post.rs (19607B)
1 use radroots_blossom::{BlobDescriptor, BlobUrl, ByteVerifiedDescriptor, MediaType, Sha256}; 2 use radroots_event::{ 3 media::AuthoredImage, 4 post::{ 5 AuthoredAsk, AuthoredPhotoUpdate, AuthoredPostError, AuthoredPostImage, AuthoredUpdate, 6 PostImageDimensions, RADROOTS_ASK_MARKER_TAG_VALUE, RADROOTS_POST_ALT_MAX_BYTES, 7 RADROOTS_POST_CONTENT_MAX_BYTES, RADROOTS_POST_EVENT_WIRE_MAX_BYTES, 8 RADROOTS_POST_IMETA_MAX_COUNT, RADROOTS_POST_TAG_ELEMENT_MAX_BYTES, 9 RADROOTS_POST_TAG_TOTAL_MAX_BYTES, post_image_media_type_is_valid, 10 }, 11 }; 12 use radroots_event_codec::encode::post::{ 13 authored_ask_to_wire_parts, authored_photo_update_to_wire_parts, authored_update_to_wire_parts, 14 }; 15 16 #[test] 17 fn authored_update_emits_only_bounded_nonblank_content() { 18 let update = AuthoredUpdate::new("The first strawberries are ready.").unwrap(); 19 let wire = authored_update_to_wire_parts(&update); 20 21 assert_eq!(wire.kind, 1); 22 assert_eq!(wire.content, update.content()); 23 assert!(wire.tags.is_empty()); 24 assert_eq!( 25 AuthoredUpdate::new(" \t").unwrap_err().code(), 26 "post_content_missing" 27 ); 28 } 29 30 #[test] 31 fn authored_update_enforces_the_utf8_byte_limit() { 32 let maximum = "x".repeat(RADROOTS_POST_CONTENT_MAX_BYTES); 33 assert!(AuthoredUpdate::new(maximum).is_ok()); 34 35 let over = "x".repeat(RADROOTS_POST_CONTENT_MAX_BYTES + 1); 36 assert_eq!( 37 AuthoredUpdate::new(over).unwrap_err(), 38 AuthoredPostError::ContentTooLarge { 39 max: RADROOTS_POST_CONTENT_MAX_BYTES, 40 actual: RADROOTS_POST_CONTENT_MAX_BYTES + 1, 41 } 42 ); 43 } 44 45 #[test] 46 fn authored_photo_emits_exact_nip92_order_and_repeatable_fallbacks() { 47 let image = authored_image(b"strawberries", "image/webp", "webp") 48 .try_with_fallback(fallback_url(b"strawberries", "cache-one.example", "webp")) 49 .unwrap() 50 .try_with_fallback(fallback_url(b"strawberries", "cache-two.example", "webp")) 51 .unwrap(); 52 let content = format!("Today's harvest {}", image.url()); 53 let photo = AuthoredPhotoUpdate::new(content.clone(), vec![image]).unwrap(); 54 let wire = authored_photo_update_to_wire_parts(&photo); 55 56 assert_eq!(wire.kind, 1); 57 assert_eq!(wire.content, content); 58 assert_eq!(wire.tags.len(), 1); 59 assert_eq!(wire.tags[0].as_slice(), photo.images()[0].imeta_tag()); 60 assert_eq!( 61 wire.tags[0] 62 .iter() 63 .map(|field| field.split_once(' ').map_or(field.as_str(), |part| part.0)) 64 .collect::<Vec<_>>(), 65 [ 66 "imeta", "url", "x", "m", "dim", "size", "alt", "fallback", "fallback" 67 ] 68 ); 69 } 70 71 #[test] 72 fn authored_ask_precedes_optional_media_with_one_exact_marker() { 73 let image = authored_image(b"leaf", "image/jpeg", "jpg"); 74 let ask = AuthoredAsk::new( 75 format!("Is this leaf healthy? {}", image.url()), 76 vec![image], 77 ) 78 .unwrap(); 79 let wire = authored_ask_to_wire_parts(&ask); 80 81 assert_eq!( 82 wire.tags[0], 83 ["t".to_string(), RADROOTS_ASK_MARKER_TAG_VALUE.to_string()] 84 ); 85 assert_eq!(wire.tags[1][0], "imeta"); 86 } 87 88 #[test] 89 fn authored_photo_requires_exact_disjoint_content_url_occurrences() { 90 let image = authored_image(b"leaf", "image/jpeg", "jpg"); 91 assert_eq!( 92 AuthoredPhotoUpdate::new("photo", Vec::new()).unwrap_err(), 93 AuthoredPostError::ImageMissing 94 ); 95 assert_eq!( 96 AuthoredPhotoUpdate::new("photo", vec![image.clone()]) 97 .unwrap_err() 98 .code(), 99 "imeta_url_occurrence_count" 100 ); 101 let content = image.url().to_string(); 102 assert_eq!( 103 AuthoredPhotoUpdate::new(content, vec![image.clone(), image]).unwrap_err(), 104 AuthoredPostError::DuplicateImageUrl 105 ); 106 107 let repeated = authored_image(b"repeated", "image/jpeg", "jpg"); 108 assert_eq!( 109 AuthoredPhotoUpdate::new( 110 format!("{} then {}", repeated.url(), repeated.url()), 111 vec![repeated], 112 ) 113 .unwrap_err(), 114 AuthoredPostError::ImageUrlOccurrenceCount { 115 expected: 1, 116 actual: 2, 117 } 118 ); 119 120 let unicode = authored_image(b"unicode", "image/webp", "webp"); 121 assert!(AuthoredPhotoUpdate::new(format!("収穫 {} 🍓", unicode.url()), vec![unicode]).is_ok()); 122 123 let bytes = b"prefix"; 124 let hash = Sha256::digest(bytes); 125 let short_url = format!("https://media.example/{hash}.webp"); 126 let long_url = format!("{short_url}2"); 127 let short = authored_image_with_url(bytes, "image/webp", &short_url); 128 let long = authored_image_with_url(bytes, "image/webp", &long_url); 129 assert_eq!( 130 AuthoredPhotoUpdate::new(long_url, vec![short, long]).unwrap_err(), 131 AuthoredPostError::ImageUrlOverlap 132 ); 133 } 134 135 #[test] 136 fn authored_photo_and_ask_enforce_the_imeta_count_limit() { 137 let image = authored_image(b"leaf", "image/jpeg", "jpg"); 138 let images = vec![image.clone(); RADROOTS_POST_IMETA_MAX_COUNT + 1]; 139 let expected = AuthoredPostError::ImageCountExceeded { 140 max: RADROOTS_POST_IMETA_MAX_COUNT, 141 actual: RADROOTS_POST_IMETA_MAX_COUNT + 1, 142 }; 143 144 assert_eq!( 145 AuthoredPhotoUpdate::new(image.url(), images.clone()).unwrap_err(), 146 expected 147 ); 148 assert_eq!(AuthoredAsk::new("Question", images).unwrap_err(), expected); 149 } 150 151 #[test] 152 fn authored_image_rejects_parameterized_mime_zero_dimensions_and_wrong_fallback_hash() { 153 let parameterized = AuthoredImage::try_from(verified_descriptor( 154 b"leaf", 155 "image/webp; charset=binary", 156 "webp", 157 )) 158 .unwrap(); 159 assert_eq!( 160 AuthoredPostImage::new( 161 parameterized, 162 PostImageDimensions::new(1, 1).unwrap(), 163 "Leaf", 164 ) 165 .unwrap_err() 166 .code(), 167 "imeta_mime_invalid" 168 ); 169 assert_eq!( 170 PostImageDimensions::new(0, 1).unwrap_err().code(), 171 "imeta_dimensions_invalid" 172 ); 173 174 let image = authored_image(b"leaf", "image/webp", "webp"); 175 assert_eq!( 176 image 177 .try_with_fallback(fallback_url(b"other", "cache.example", "webp")) 178 .unwrap_err() 179 .code(), 180 "imeta_fallback_hash_mismatch" 181 ); 182 } 183 184 #[test] 185 fn post_image_mime_profile_uses_canonical_parameter_free_media_types() { 186 assert!(post_image_media_type_is_valid("image/webp")); 187 assert!(post_image_media_type_is_valid("image/svg+xml")); 188 assert!(post_image_media_type_is_valid("image/vnd.microsoft.icon")); 189 assert!(!post_image_media_type_is_valid("IMAGE/WEBP")); 190 assert!(!post_image_media_type_is_valid("image/webp;quality=90")); 191 assert!(!post_image_media_type_is_valid("text/plain")); 192 } 193 194 #[test] 195 fn authored_image_rejects_zero_size_and_invalid_alt_text() { 196 let empty = AuthoredImage::try_from(verified_descriptor(b"", "image/webp", "webp")).unwrap(); 197 assert_eq!( 198 AuthoredPostImage::new( 199 empty, 200 PostImageDimensions::new(1, 1).unwrap(), 201 "Empty image", 202 ) 203 .unwrap_err(), 204 AuthoredPostError::ImageSizeInvalid 205 ); 206 207 let blank_alt = 208 AuthoredImage::try_from(verified_descriptor(b"leaf", "image/webp", "webp")).unwrap(); 209 assert_eq!( 210 AuthoredPostImage::new(blank_alt, PostImageDimensions::new(1, 1).unwrap(), " \t",) 211 .unwrap_err(), 212 AuthoredPostError::ImageAltInvalid 213 ); 214 215 let maximum_alt = "a".repeat(RADROOTS_POST_ALT_MAX_BYTES); 216 let maximum = 217 AuthoredImage::try_from(verified_descriptor(b"maximum", "image/webp", "webp")).unwrap(); 218 assert!( 219 AuthoredPostImage::new( 220 maximum, 221 PostImageDimensions::new(1, 1).unwrap(), 222 maximum_alt, 223 ) 224 .is_ok() 225 ); 226 227 let oversized_alt = "a".repeat(RADROOTS_POST_ALT_MAX_BYTES + 1); 228 let oversized = 229 AuthoredImage::try_from(verified_descriptor(b"oversized", "image/webp", "webp")).unwrap(); 230 assert_eq!( 231 AuthoredPostImage::new( 232 oversized, 233 PostImageDimensions::new(1, 1).unwrap(), 234 oversized_alt, 235 ) 236 .unwrap_err(), 237 AuthoredPostError::ImageAltTooLarge { 238 max: RADROOTS_POST_ALT_MAX_BYTES, 239 actual: RADROOTS_POST_ALT_MAX_BYTES + 1, 240 } 241 ); 242 } 243 244 #[test] 245 fn authored_image_enforces_the_exact_tag_element_limit() { 246 let maximum = AuthoredImage::try_from(verified_descriptor_with_url_element_bytes( 247 b"maximum-url", 248 RADROOTS_POST_TAG_ELEMENT_MAX_BYTES, 249 )) 250 .unwrap(); 251 let maximum = AuthoredPostImage::new( 252 maximum, 253 PostImageDimensions::new(1, 1).unwrap(), 254 "Maximum URL", 255 ) 256 .unwrap(); 257 assert_eq!( 258 maximum.imeta_tag()[1].len(), 259 RADROOTS_POST_TAG_ELEMENT_MAX_BYTES 260 ); 261 262 let oversized = AuthoredImage::try_from(verified_descriptor_with_url_element_bytes( 263 b"oversized-url", 264 RADROOTS_POST_TAG_ELEMENT_MAX_BYTES + 1, 265 )) 266 .unwrap(); 267 assert_eq!( 268 AuthoredPostImage::new( 269 oversized, 270 PostImageDimensions::new(1, 1).unwrap(), 271 "Oversized URL", 272 ) 273 .unwrap_err(), 274 AuthoredPostError::TagElementTooLarge { 275 max: RADROOTS_POST_TAG_ELEMENT_MAX_BYTES, 276 actual: RADROOTS_POST_TAG_ELEMENT_MAX_BYTES + 1, 277 } 278 ); 279 280 let maximum_fallback = authored_image(b"maximum-fallback", "image/webp", "webp") 281 .try_with_fallback(fallback_url_with_element_bytes( 282 b"maximum-fallback", 283 RADROOTS_POST_TAG_ELEMENT_MAX_BYTES, 284 )) 285 .unwrap(); 286 assert_eq!( 287 maximum_fallback.imeta_tag().last().unwrap().len(), 288 RADROOTS_POST_TAG_ELEMENT_MAX_BYTES 289 ); 290 assert_eq!( 291 authored_image(b"oversized-fallback", "image/webp", "webp") 292 .try_with_fallback(fallback_url_with_element_bytes( 293 b"oversized-fallback", 294 RADROOTS_POST_TAG_ELEMENT_MAX_BYTES + 1, 295 )) 296 .unwrap_err(), 297 AuthoredPostError::TagElementTooLarge { 298 max: RADROOTS_POST_TAG_ELEMENT_MAX_BYTES, 299 actual: RADROOTS_POST_TAG_ELEMENT_MAX_BYTES + 1, 300 } 301 ); 302 } 303 304 #[test] 305 fn authored_posts_enforce_the_exact_aggregate_tag_byte_limit() { 306 let second = authored_image(b"second-image", "image/webp", "webp"); 307 let second_tag_bytes = tag_bytes(second.imeta_tag()); 308 let first_target = RADROOTS_POST_TAG_TOTAL_MAX_BYTES - second_tag_bytes; 309 let first = fill_image_tag_bytes( 310 authored_image(b"first-image", "image/webp", "webp"), 311 b"first-image", 312 first_target, 313 ); 314 let content = format!("{} {}", first.url(), second.url()); 315 let photo = 316 AuthoredPhotoUpdate::new(content.clone(), vec![first.clone(), second.clone()]).unwrap(); 317 let wire = authored_photo_update_to_wire_parts(&photo); 318 assert_eq!( 319 wire.tags.iter().map(|tag| tag_bytes(tag)).sum::<usize>(), 320 RADROOTS_POST_TAG_TOTAL_MAX_BYTES 321 ); 322 323 let first_over = fill_image_tag_bytes( 324 authored_image(b"first-over", "image/webp", "webp"), 325 b"first-over", 326 first_target + 1, 327 ); 328 let over_content = format!("{} {}", first_over.url(), second.url()); 329 assert_eq!( 330 AuthoredPhotoUpdate::new(over_content, vec![first_over, second]).unwrap_err(), 331 AuthoredPostError::TagBytesExceeded { 332 max: RADROOTS_POST_TAG_TOTAL_MAX_BYTES, 333 actual: RADROOTS_POST_TAG_TOTAL_MAX_BYTES + 1, 334 } 335 ); 336 337 let full = fill_image_tag_bytes( 338 authored_image(b"full-image", "image/webp", "webp"), 339 b"full-image", 340 RADROOTS_POST_TAG_TOTAL_MAX_BYTES, 341 ); 342 assert!( 343 AuthoredPhotoUpdate::new(full.url(), vec![full.clone()]).is_ok(), 344 "PhotoUpdate must accept the exact aggregate maximum" 345 ); 346 assert_eq!( 347 AuthoredAsk::new(full.url(), vec![full.clone()]).unwrap_err(), 348 AuthoredPostError::TagBytesExceeded { 349 max: RADROOTS_POST_TAG_TOTAL_MAX_BYTES, 350 actual: RADROOTS_POST_TAG_TOTAL_MAX_BYTES 351 + "t".len() 352 + RADROOTS_ASK_MARKER_TAG_VALUE.len(), 353 } 354 ); 355 356 let minimum_fallback_bytes = fallback_element_prefix(b"full-image").len() + 1; 357 assert_eq!( 358 full.try_with_fallback(fallback_url_with_element_bytes( 359 b"full-image", 360 minimum_fallback_bytes, 361 )) 362 .unwrap_err(), 363 AuthoredPostError::TagBytesExceeded { 364 max: RADROOTS_POST_TAG_TOTAL_MAX_BYTES, 365 actual: RADROOTS_POST_TAG_TOTAL_MAX_BYTES + minimum_fallback_bytes, 366 } 367 ); 368 } 369 370 #[test] 371 fn authored_posts_enforce_the_exact_canonical_signed_event_budget() { 372 let image = fill_image_tag_bytes( 373 authored_image(b"wire-budget", "image/webp", "webp"), 374 b"wire-budget", 375 RADROOTS_POST_TAG_TOTAL_MAX_BYTES, 376 ); 377 let tags = vec![image.imeta_tag().to_vec()]; 378 let minimum_content = image.url(); 379 let minimum_wire_bytes = canonical_signed_post_json_bytes(minimum_content, &tags); 380 let padding_bytes = RADROOTS_POST_EVENT_WIRE_MAX_BYTES - minimum_wire_bytes; 381 let maximum_content = format!("{minimum_content}{}", "x".repeat(padding_bytes)); 382 383 let maximum = AuthoredPhotoUpdate::new(maximum_content.clone(), vec![image.clone()]).unwrap(); 384 let maximum_wire = authored_photo_update_to_wire_parts(&maximum); 385 assert_eq!( 386 canonical_signed_post_json_bytes(&maximum_wire.content, &maximum_wire.tags), 387 RADROOTS_POST_EVENT_WIRE_MAX_BYTES 388 ); 389 390 let oversized_content = format!("{maximum_content}x"); 391 assert_eq!( 392 AuthoredPhotoUpdate::new(oversized_content, vec![image.clone()]).unwrap_err(), 393 AuthoredPostError::EventWireTooLarge { 394 max: RADROOTS_POST_EVENT_WIRE_MAX_BYTES, 395 actual: RADROOTS_POST_EVENT_WIRE_MAX_BYTES + 1, 396 } 397 ); 398 399 let mut escaped_content = maximum_content; 400 escaped_content.pop(); 401 escaped_content.push('"'); 402 assert_eq!( 403 AuthoredPhotoUpdate::new(escaped_content, vec![image]).unwrap_err(), 404 AuthoredPostError::EventWireTooLarge { 405 max: RADROOTS_POST_EVENT_WIRE_MAX_BYTES, 406 actual: RADROOTS_POST_EVENT_WIRE_MAX_BYTES + 1, 407 } 408 ); 409 } 410 411 #[test] 412 fn authored_update_counts_json_control_character_expansion() { 413 let content = format!( 414 "a{}", 415 "\u{0001}".repeat(RADROOTS_POST_CONTENT_MAX_BYTES - 1) 416 ); 417 let actual = canonical_signed_post_json_bytes(&content, &[]); 418 419 assert_eq!( 420 AuthoredUpdate::new(content).unwrap_err(), 421 AuthoredPostError::EventWireTooLarge { 422 max: RADROOTS_POST_EVENT_WIRE_MAX_BYTES, 423 actual, 424 } 425 ); 426 assert!(actual > RADROOTS_POST_EVENT_WIRE_MAX_BYTES); 427 } 428 429 fn authored_image(bytes: &[u8], media_type: &str, extension: &str) -> AuthoredPostImage { 430 AuthoredPostImage::new( 431 AuthoredImage::try_from(verified_descriptor(bytes, media_type, extension)).unwrap(), 432 PostImageDimensions::new(1200, 900).unwrap(), 433 "Harvest", 434 ) 435 .unwrap() 436 } 437 438 fn authored_image_with_url(bytes: &[u8], media_type: &str, url: &str) -> AuthoredPostImage { 439 let hash = Sha256::digest(bytes); 440 let media_type = MediaType::parse(media_type).unwrap(); 441 let descriptor = BlobDescriptor::new( 442 BlobUrl::parse(url).unwrap(), 443 hash, 444 bytes.len() as u64, 445 media_type.clone(), 446 1_784_347_200, 447 ) 448 .unwrap() 449 .approve_reference() 450 .unwrap() 451 .verify_bytes(bytes, &media_type) 452 .unwrap(); 453 AuthoredPostImage::new( 454 AuthoredImage::try_from(descriptor).unwrap(), 455 PostImageDimensions::new(1200, 900).unwrap(), 456 "Harvest", 457 ) 458 .unwrap() 459 } 460 461 fn verified_descriptor(bytes: &[u8], media_type: &str, extension: &str) -> ByteVerifiedDescriptor { 462 let hash = Sha256::digest(bytes); 463 let media_type = MediaType::parse(media_type).unwrap(); 464 BlobDescriptor::new( 465 BlobUrl::parse(&format!("https://media.example/{hash}.{extension}")).unwrap(), 466 hash, 467 bytes.len() as u64, 468 media_type.clone(), 469 1_784_347_200, 470 ) 471 .unwrap() 472 .approve_reference() 473 .unwrap() 474 .verify_bytes(bytes, &media_type) 475 .unwrap() 476 } 477 478 fn verified_descriptor_with_url_element_bytes( 479 bytes: &[u8], 480 element_bytes: usize, 481 ) -> ByteVerifiedDescriptor { 482 let hash = Sha256::digest(bytes); 483 let prefix = format!("https://media.example/{hash}."); 484 let extension_bytes = element_bytes 485 .checked_sub("url ".len() + prefix.len()) 486 .expect("requested URL tag element is large enough"); 487 assert!(extension_bytes > 0); 488 let media_type = MediaType::parse("image/webp").unwrap(); 489 let descriptor = BlobDescriptor::new( 490 BlobUrl::parse(&format!("{prefix}{}", "x".repeat(extension_bytes))).unwrap(), 491 hash, 492 bytes.len() as u64, 493 media_type.clone(), 494 1_784_347_200, 495 ) 496 .unwrap() 497 .approve_reference() 498 .unwrap() 499 .verify_bytes(bytes, &media_type) 500 .unwrap(); 501 assert_eq!( 502 "url ".len() + descriptor.url().as_str().len(), 503 element_bytes 504 ); 505 descriptor 506 } 507 508 fn fallback_url( 509 bytes: &[u8], 510 host: &str, 511 extension: &str, 512 ) -> radroots_blossom::url::ApprovedBlobUrl { 513 let hash = Sha256::digest(bytes); 514 BlobUrl::parse(&format!("https://{host}/{hash}.{extension}")) 515 .unwrap() 516 .approve() 517 .unwrap() 518 } 519 520 fn fallback_url_with_element_bytes( 521 bytes: &[u8], 522 element_bytes: usize, 523 ) -> radroots_blossom::url::ApprovedBlobUrl { 524 let prefix = fallback_element_prefix(bytes); 525 let extension_bytes = element_bytes 526 .checked_sub(prefix.len()) 527 .expect("requested fallback tag element is large enough"); 528 assert!(extension_bytes > 0); 529 let fallback = BlobUrl::parse(&format!( 530 "{}{}", 531 prefix.strip_prefix("fallback ").unwrap(), 532 "x".repeat(extension_bytes) 533 )) 534 .unwrap() 535 .approve() 536 .unwrap(); 537 assert_eq!(format!("fallback {fallback}").len(), element_bytes); 538 fallback 539 } 540 541 fn fallback_element_prefix(bytes: &[u8]) -> String { 542 let hash = Sha256::digest(bytes); 543 format!("fallback https://cache.example/{hash}.") 544 } 545 546 fn tag_bytes(tag: &[String]) -> usize { 547 tag.iter().map(String::len).sum() 548 } 549 550 fn canonical_signed_post_json_bytes(content: &str, tags: &[Vec<String>]) -> usize { 551 serde_json::json!({ 552 "id": "0".repeat(64), 553 "pubkey": "0".repeat(64), 554 "created_at": u64::MAX, 555 "kind": 1, 556 "tags": tags, 557 "content": content, 558 "sig": "0".repeat(128), 559 }) 560 .to_string() 561 .len() 562 } 563 564 fn fill_image_tag_bytes( 565 mut image: AuthoredPostImage, 566 bytes: &[u8], 567 target: usize, 568 ) -> AuthoredPostImage { 569 let minimum_fallback_bytes = fallback_element_prefix(bytes).len() + 1; 570 while tag_bytes(image.imeta_tag()) < target { 571 let remaining = target - tag_bytes(image.imeta_tag()); 572 assert!(remaining >= minimum_fallback_bytes); 573 let mut next = remaining.min(RADROOTS_POST_TAG_ELEMENT_MAX_BYTES); 574 if remaining > RADROOTS_POST_TAG_ELEMENT_MAX_BYTES 575 && remaining - RADROOTS_POST_TAG_ELEMENT_MAX_BYTES < minimum_fallback_bytes 576 { 577 next = remaining - minimum_fallback_bytes; 578 } 579 assert!((minimum_fallback_bytes..=RADROOTS_POST_TAG_ELEMENT_MAX_BYTES).contains(&next)); 580 image = image 581 .try_with_fallback(fallback_url_with_element_bytes(bytes, next)) 582 .unwrap(); 583 } 584 assert_eq!(tag_bytes(image.imeta_tag()), target); 585 image 586 }