post.rs (34478B)
1 //! Authored posts and related public social-content event models. 2 3 #[cfg(not(feature = "std"))] 4 use alloc::{format, string::String, vec::Vec}; 5 use core::fmt; 6 7 use radroots_blossom::url::ApprovedBlobUrl; 8 use url_nostd::Url; 9 10 use crate::media::AuthoredImage; 11 use crate::tag::name::TAG_IMETA; 12 13 pub const RADROOTS_POST_CONTENT_MAX_BYTES: usize = crate::wire::v1::DEFAULT_CONTENT_MAX_BYTES; 14 pub const RADROOTS_POST_IMETA_MAX_COUNT: usize = 64; 15 pub const RADROOTS_POST_EVENT_WIRE_MAX_BYTES: usize = crate::wire::v1::DEFAULT_RAW_JSON_MAX_BYTES; 16 pub const RADROOTS_POST_TAG_ELEMENT_MAX_BYTES: usize = 17 crate::wire::v1::DEFAULT_TAG_ELEMENT_MAX_BYTES; 18 pub const RADROOTS_POST_TAG_TOTAL_MAX_BYTES: usize = crate::wire::v1::DEFAULT_TAG_TOTAL_MAX_BYTES; 19 pub const RADROOTS_POST_ALT_MAX_BYTES: usize = RADROOTS_POST_TAG_ELEMENT_MAX_BYTES - "alt ".len(); 20 pub const RADROOTS_ASK_MARKER_TAG_KEY: &str = "t"; 21 pub const RADROOTS_ASK_MARKER_TAG_VALUE: &str = "radroots-ask"; 22 23 const RADROOTS_ASK_MARKER_TAG_BYTES: usize = 24 RADROOTS_ASK_MARKER_TAG_KEY.len() + RADROOTS_ASK_MARKER_TAG_VALUE.len(); 25 const RADROOTS_POST_SIGNED_EVENT_FIXED_MAX_BYTES: usize = "{\"id\":\"".len() 26 + 64 27 + "\",\"pubkey\":\"".len() 28 + 64 29 + "\",\"created_at\":".len() 30 + 20 31 + ",\"kind\":1,\"tags\":".len() 32 + ",\"content\":".len() 33 + ",\"sig\":\"".len() 34 + 128 35 + "\"}".len(); 36 37 #[non_exhaustive] 38 #[derive(Clone, Debug, PartialEq, Eq)] 39 pub enum AuthoredPostError { 40 ContentMissing, 41 ContentTooLarge { max: usize, actual: usize }, 42 ImageMissing, 43 ImageCountExceeded { max: usize, actual: usize }, 44 ImageUrlOccurrenceCount { expected: usize, actual: usize }, 45 DuplicateImageUrl, 46 ImageMediaTypeInvalid, 47 ImageSizeInvalid, 48 ImageDimensionsInvalid, 49 ImageAltInvalid, 50 ImageAltTooLarge { max: usize, actual: usize }, 51 ImageFallbackHashMismatch, 52 TagElementTooLarge { max: usize, actual: usize }, 53 TagBytesExceeded { max: usize, actual: usize }, 54 EventWireTooLarge { max: usize, actual: usize }, 55 ImageUrlOverlap, 56 } 57 58 impl AuthoredPostError { 59 pub const fn code(&self) -> &'static str { 60 match self { 61 Self::ContentMissing => "post_content_missing", 62 Self::ContentTooLarge { .. } => "post_content_too_large", 63 Self::ImageMissing => "photo_imeta_missing", 64 Self::ImageCountExceeded { .. } => "imeta_count_exceeded", 65 Self::ImageUrlOccurrenceCount { .. } => "imeta_url_occurrence_count", 66 Self::ImageUrlOverlap => "imeta_url_overlap", 67 Self::DuplicateImageUrl => "duplicate_imeta_url", 68 Self::ImageMediaTypeInvalid => "imeta_mime_invalid", 69 Self::ImageSizeInvalid => "imeta_size_invalid", 70 Self::ImageDimensionsInvalid => "imeta_dimensions_invalid", 71 Self::ImageAltInvalid => "imeta_alt_invalid", 72 Self::ImageAltTooLarge { .. } => "imeta_alt_too_large", 73 Self::ImageFallbackHashMismatch => "imeta_fallback_hash_mismatch", 74 Self::TagElementTooLarge { .. } => "post_tag_element_too_large", 75 Self::TagBytesExceeded { .. } => "post_tag_bytes_exceeded", 76 Self::EventWireTooLarge { .. } => "post_event_wire_too_large", 77 } 78 } 79 } 80 81 impl fmt::Display for AuthoredPostError { 82 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { 83 match self { 84 Self::ContentMissing => { 85 formatter.write_str("authored post content must be non-whitespace") 86 } 87 Self::ContentTooLarge { max, actual } => { 88 write!( 89 formatter, 90 "authored post content is {actual} bytes; max is {max}" 91 ) 92 } 93 Self::ImageMissing => { 94 formatter.write_str("authored PhotoUpdate requires at least one image") 95 } 96 Self::ImageCountExceeded { max, actual } => { 97 write!(formatter, "authored post has {actual} images; max is {max}") 98 } 99 Self::ImageUrlOccurrenceCount { expected, actual } => write!( 100 formatter, 101 "authored image URL occurrence count is {actual}; expected {expected}" 102 ), 103 Self::ImageUrlOverlap => formatter.write_str( 104 "authored image URL occurrences must not overlap another image URL occurrence", 105 ), 106 Self::DuplicateImageUrl => { 107 formatter.write_str("authored post image URLs must be unique") 108 } 109 Self::ImageMediaTypeInvalid => formatter.write_str( 110 "authored post image media type must be parameter-free canonical lowercase image/*", 111 ), 112 Self::ImageSizeInvalid => { 113 formatter.write_str("authored post image size must be nonzero") 114 } 115 Self::ImageDimensionsInvalid => { 116 formatter.write_str("authored post image dimensions must be nonzero u32 values") 117 } 118 Self::ImageAltInvalid => { 119 formatter.write_str("authored post image alt text must be non-whitespace") 120 } 121 Self::ImageAltTooLarge { max, actual } => { 122 write!( 123 formatter, 124 "authored post image alt text is {actual} bytes; max is {max}" 125 ) 126 } 127 Self::ImageFallbackHashMismatch => formatter.write_str( 128 "authored post image fallback URL must contain the primary image digest", 129 ), 130 Self::TagElementTooLarge { max, actual } => { 131 write!( 132 formatter, 133 "authored post tag element is {actual} bytes; max is {max}" 134 ) 135 } 136 Self::TagBytesExceeded { max, actual } => { 137 write!( 138 formatter, 139 "authored post tag bytes are {actual}; max is {max}" 140 ) 141 } 142 Self::EventWireTooLarge { max, actual } => write!( 143 formatter, 144 "authored post canonical signed event is at most {actual} bytes; max is {max}" 145 ), 146 } 147 } 148 } 149 150 #[cfg(feature = "std")] 151 impl std::error::Error for AuthoredPostError {} 152 153 /// Nonzero pixel dimensions for one strict authored NIP-92 image. 154 #[derive(Clone, Copy, Debug, PartialEq, Eq)] 155 pub struct PostImageDimensions { 156 width: u32, 157 height: u32, 158 } 159 160 impl PostImageDimensions { 161 pub const fn new(width: u32, height: u32) -> Result<Self, AuthoredPostError> { 162 if width == 0 || height == 0 { 163 return Err(AuthoredPostError::ImageDimensionsInvalid); 164 } 165 Ok(Self { width, height }) 166 } 167 168 pub const fn width(self) -> u32 { 169 self.width 170 } 171 172 pub const fn height(self) -> u32 { 173 self.height 174 } 175 } 176 177 /// Strict authored NIP-92 image metadata. 178 /// 179 /// The primary image can only enter through a byte-verified Blossom 180 /// descriptor. This proves descriptor/byte agreement, not upload completion or 181 /// network availability. Publication runtimes must separately require a 182 /// successful BUD-02 upload before signing. 183 #[derive(Clone, Debug, PartialEq, Eq)] 184 pub struct AuthoredPostImage { 185 image: AuthoredImage, 186 dimensions: PostImageDimensions, 187 alt: String, 188 fallbacks: Vec<ApprovedBlobUrl>, 189 imeta_tag: Vec<String>, 190 } 191 192 impl AuthoredPostImage { 193 pub fn new( 194 image: AuthoredImage, 195 dimensions: PostImageDimensions, 196 alt: impl Into<String>, 197 ) -> Result<Self, AuthoredPostError> { 198 let descriptor = image.descriptor(); 199 if descriptor.size() == 0 { 200 return Err(AuthoredPostError::ImageSizeInvalid); 201 } 202 if !post_image_media_type_is_valid(descriptor.media_type().as_str()) { 203 return Err(AuthoredPostError::ImageMediaTypeInvalid); 204 } 205 let alt = alt.into(); 206 if alt.trim().is_empty() { 207 return Err(AuthoredPostError::ImageAltInvalid); 208 } 209 if alt.len() > RADROOTS_POST_ALT_MAX_BYTES { 210 return Err(AuthoredPostError::ImageAltTooLarge { 211 max: RADROOTS_POST_ALT_MAX_BYTES, 212 actual: alt.len(), 213 }); 214 } 215 let fallbacks = Vec::new(); 216 let imeta_tag = derive_imeta_tag(&image, dimensions, &alt, &fallbacks)?; 217 Ok(Self { 218 image, 219 dimensions, 220 alt, 221 fallbacks, 222 imeta_tag, 223 }) 224 } 225 226 pub fn try_with_fallback( 227 mut self, 228 fallback: ApprovedBlobUrl, 229 ) -> Result<Self, AuthoredPostError> { 230 if fallback.as_blob_url().hash_path().hash() != self.image.descriptor().sha256() { 231 return Err(AuthoredPostError::ImageFallbackHashMismatch); 232 } 233 let fallback_element = format!("fallback {fallback}"); 234 validate_tag_element(&fallback_element)?; 235 validate_tag_bytes( 236 imeta_tag_bytes(&self.imeta_tag).saturating_add(fallback_element.len()), 237 )?; 238 self.fallbacks.push(fallback); 239 self.imeta_tag.push(fallback_element); 240 Ok(self) 241 } 242 243 pub fn image(&self) -> &AuthoredImage { 244 &self.image 245 } 246 247 pub const fn dimensions(&self) -> PostImageDimensions { 248 self.dimensions 249 } 250 251 pub fn alt(&self) -> &str { 252 &self.alt 253 } 254 255 pub fn fallbacks(&self) -> &[ApprovedBlobUrl] { 256 &self.fallbacks 257 } 258 259 /// Returns the exact validated NIP-92 `imeta` tag emitted for this image. 260 pub fn imeta_tag(&self) -> &[String] { 261 &self.imeta_tag 262 } 263 264 pub fn url(&self) -> &str { 265 self.image.descriptor().url().as_str() 266 } 267 } 268 269 /// Strict authored root kind-1 Update without Ask or media tags. 270 /// 271 /// ```compile_fail 272 /// let _: radroots_event::post::AuthoredUpdate = 273 /// serde_json::from_str(r#"{"content":"harvest"}"#).unwrap(); 274 /// ``` 275 #[derive(Clone, Debug, PartialEq, Eq)] 276 pub struct AuthoredUpdate { 277 content: String, 278 } 279 280 impl AuthoredUpdate { 281 pub fn new(content: impl Into<String>) -> Result<Self, AuthoredPostError> { 282 let content = content.into(); 283 validate_authored_root_content(&content)?; 284 validate_post_event_wire_size(&content, false, &[])?; 285 Ok(Self { content }) 286 } 287 288 pub fn content(&self) -> &str { 289 &self.content 290 } 291 } 292 293 /// Strict authored root kind-1 PhotoUpdate with deterministic NIP-92 tags. 294 #[derive(Clone, Debug, PartialEq, Eq)] 295 pub struct AuthoredPhotoUpdate { 296 content: String, 297 images: Vec<AuthoredPostImage>, 298 } 299 300 impl AuthoredPhotoUpdate { 301 pub fn new( 302 content: impl Into<String>, 303 images: Vec<AuthoredPostImage>, 304 ) -> Result<Self, AuthoredPostError> { 305 let content = content.into(); 306 validate_content_size(&content)?; 307 validate_authored_images(&content, &images, 0)?; 308 validate_post_event_wire_size(&content, false, &images)?; 309 Ok(Self { content, images }) 310 } 311 312 pub fn content(&self) -> &str { 313 &self.content 314 } 315 316 pub fn images(&self) -> &[AuthoredPostImage] { 317 &self.images 318 } 319 } 320 321 /// Strict authored root kind-1 Ask with its exact product marker. 322 #[derive(Clone, Debug, PartialEq, Eq)] 323 pub struct AuthoredAsk { 324 content: String, 325 images: Vec<AuthoredPostImage>, 326 } 327 328 impl AuthoredAsk { 329 pub fn new( 330 content: impl Into<String>, 331 images: Vec<AuthoredPostImage>, 332 ) -> Result<Self, AuthoredPostError> { 333 let content = content.into(); 334 validate_authored_root_content(&content)?; 335 if images.len() > RADROOTS_POST_IMETA_MAX_COUNT { 336 return Err(AuthoredPostError::ImageCountExceeded { 337 max: RADROOTS_POST_IMETA_MAX_COUNT, 338 actual: images.len(), 339 }); 340 } 341 if !images.is_empty() { 342 validate_authored_images(&content, &images, RADROOTS_ASK_MARKER_TAG_BYTES)?; 343 } 344 validate_post_event_wire_size(&content, true, &images)?; 345 Ok(Self { content, images }) 346 } 347 348 pub fn content(&self) -> &str { 349 &self.content 350 } 351 352 pub fn images(&self) -> &[AuthoredPostImage] { 353 &self.images 354 } 355 } 356 357 fn validate_authored_root_content(content: &str) -> Result<(), AuthoredPostError> { 358 validate_content_size(content)?; 359 if content.trim().is_empty() { 360 return Err(AuthoredPostError::ContentMissing); 361 } 362 Ok(()) 363 } 364 365 fn validate_content_size(content: &str) -> Result<(), AuthoredPostError> { 366 if content.len() > RADROOTS_POST_CONTENT_MAX_BYTES { 367 return Err(AuthoredPostError::ContentTooLarge { 368 max: RADROOTS_POST_CONTENT_MAX_BYTES, 369 actual: content.len(), 370 }); 371 } 372 Ok(()) 373 } 374 375 fn validate_authored_images( 376 content: &str, 377 images: &[AuthoredPostImage], 378 initial_tag_bytes: usize, 379 ) -> Result<(), AuthoredPostError> { 380 if images.is_empty() { 381 return Err(AuthoredPostError::ImageMissing); 382 } 383 if images.len() > RADROOTS_POST_IMETA_MAX_COUNT { 384 return Err(AuthoredPostError::ImageCountExceeded { 385 max: RADROOTS_POST_IMETA_MAX_COUNT, 386 actual: images.len(), 387 }); 388 } 389 let mut occurrences = Vec::with_capacity(images.len()); 390 for (index, image) in images.iter().enumerate() { 391 if images[..index] 392 .iter() 393 .any(|candidate| candidate.url() == image.url()) 394 { 395 return Err(AuthoredPostError::DuplicateImageUrl); 396 } 397 let mut matches = content.match_indices(image.url()); 398 let first = matches.next(); 399 let actual = usize::from(first.is_some()).saturating_add(matches.count()); 400 if actual != 1 { 401 return Err(AuthoredPostError::ImageUrlOccurrenceCount { 402 expected: 1, 403 actual, 404 }); 405 } 406 let (start, matched) = first.expect("exactly one occurrence was established"); 407 occurrences.push((start, start.saturating_add(matched.len()))); 408 } 409 occurrences.sort_unstable(); 410 if occurrences.windows(2).any(|pair| pair[0].1 > pair[1].0) { 411 return Err(AuthoredPostError::ImageUrlOverlap); 412 } 413 let total_tag_bytes = images.iter().fold(initial_tag_bytes, |total, image| { 414 total.saturating_add(imeta_tag_bytes(image.imeta_tag())) 415 }); 416 validate_tag_bytes(total_tag_bytes)?; 417 Ok(()) 418 } 419 420 fn derive_imeta_tag( 421 image: &AuthoredImage, 422 dimensions: PostImageDimensions, 423 alt: &str, 424 fallbacks: &[ApprovedBlobUrl], 425 ) -> Result<Vec<String>, AuthoredPostError> { 426 let descriptor = image.descriptor(); 427 let mut tag = Vec::with_capacity(7 + fallbacks.len()); 428 tag.push(TAG_IMETA.into()); 429 tag.push(format!("url {}", descriptor.url())); 430 tag.push(format!("x {}", descriptor.sha256())); 431 tag.push(format!("m {}", descriptor.media_type())); 432 tag.push(format!( 433 "dim {}x{}", 434 dimensions.width(), 435 dimensions.height() 436 )); 437 tag.push(format!("size {}", descriptor.size())); 438 tag.push(format!("alt {alt}")); 439 tag.extend( 440 fallbacks 441 .iter() 442 .map(|fallback| format!("fallback {fallback}")), 443 ); 444 for element in &tag { 445 validate_tag_element(element)?; 446 } 447 validate_tag_bytes(imeta_tag_bytes(&tag))?; 448 Ok(tag) 449 } 450 451 fn validate_tag_element(element: &str) -> Result<(), AuthoredPostError> { 452 if element.len() > RADROOTS_POST_TAG_ELEMENT_MAX_BYTES { 453 return Err(AuthoredPostError::TagElementTooLarge { 454 max: RADROOTS_POST_TAG_ELEMENT_MAX_BYTES, 455 actual: element.len(), 456 }); 457 } 458 Ok(()) 459 } 460 461 fn validate_tag_bytes(actual: usize) -> Result<(), AuthoredPostError> { 462 if actual > RADROOTS_POST_TAG_TOTAL_MAX_BYTES { 463 return Err(AuthoredPostError::TagBytesExceeded { 464 max: RADROOTS_POST_TAG_TOTAL_MAX_BYTES, 465 actual, 466 }); 467 } 468 Ok(()) 469 } 470 471 fn imeta_tag_bytes(tag: &[String]) -> usize { 472 tag.iter() 473 .fold(0, |total, element| total.saturating_add(element.len())) 474 } 475 476 fn validate_post_event_wire_size( 477 content: &str, 478 ask_marker: bool, 479 images: &[AuthoredPostImage], 480 ) -> Result<(), AuthoredPostError> { 481 let mut tags_json_bytes = 2usize; 482 let mut tag_count = 0usize; 483 if ask_marker { 484 add_tag_json_bytes( 485 &mut tags_json_bytes, 486 &mut tag_count, 487 [RADROOTS_ASK_MARKER_TAG_KEY, RADROOTS_ASK_MARKER_TAG_VALUE], 488 ); 489 } 490 for image in images { 491 add_tag_json_bytes( 492 &mut tags_json_bytes, 493 &mut tag_count, 494 image.imeta_tag().iter().map(String::as_str), 495 ); 496 } 497 let actual = RADROOTS_POST_SIGNED_EVENT_FIXED_MAX_BYTES 498 .saturating_add(tags_json_bytes) 499 .saturating_add(canonical_json_string_bytes(content)); 500 if actual > RADROOTS_POST_EVENT_WIRE_MAX_BYTES { 501 return Err(AuthoredPostError::EventWireTooLarge { 502 max: RADROOTS_POST_EVENT_WIRE_MAX_BYTES, 503 actual, 504 }); 505 } 506 Ok(()) 507 } 508 509 fn add_tag_json_bytes<'a>( 510 total: &mut usize, 511 tag_count: &mut usize, 512 elements: impl IntoIterator<Item = &'a str>, 513 ) { 514 if *tag_count > 0 { 515 *total = total.saturating_add(1); 516 } 517 *total = total.saturating_add(2); 518 let mut element_count = 0usize; 519 for element in elements { 520 if element_count > 0 { 521 *total = total.saturating_add(1); 522 } 523 *total = total.saturating_add(canonical_json_string_bytes(element)); 524 element_count = element_count.saturating_add(1); 525 } 526 *tag_count = tag_count.saturating_add(1); 527 } 528 529 fn canonical_json_string_bytes(value: &str) -> usize { 530 value.chars().fold(2usize, |total, character| { 531 total.saturating_add(match character { 532 '"' | '\\' | '\u{0008}' | '\t' | '\n' | '\u{000c}' | '\r' => 2, 533 '\u{0000}'..='\u{001f}' => 6, 534 _ => character.len_utf8(), 535 }) 536 }) 537 } 538 539 pub fn post_image_media_type_is_valid(value: &str) -> bool { 540 let Some(subtype) = value.strip_prefix("image/") else { 541 return false; 542 }; 543 let mut bytes = subtype.bytes(); 544 bytes 545 .next() 546 .is_some_and(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit()) 547 && bytes.all(|byte| { 548 byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'.' | b'+' | b'-') 549 }) 550 } 551 552 /// Returns whether an inbound media reference is a structural HTTP(S) URL. 553 /// 554 /// This is intentionally broader than strict authored Blossom policy and does 555 /// not make a reachability, byte-verification, or upload claim. 556 pub fn post_media_http_url_is_valid(value: &str) -> bool { 557 if value.is_empty() 558 || value 559 .chars() 560 .any(|character| character.is_control() || character.is_whitespace()) 561 { 562 return false; 563 } 564 let Some((scheme, remainder)) = value.split_once("://") else { 565 return false; 566 }; 567 if !(scheme.eq_ignore_ascii_case("http") || scheme.eq_ignore_ascii_case("https")) { 568 return false; 569 } 570 let authority_end = remainder.find(['/', '?', '#']).unwrap_or(remainder.len()); 571 let authority = &remainder[..authority_end]; 572 if authority.is_empty() { 573 return false; 574 } 575 let raw_path = remainder[authority_end..] 576 .split(['?', '#']) 577 .next() 578 .unwrap_or_default(); 579 let Ok(parsed) = Url::parse(value) else { 580 return false; 581 }; 582 matches!(parsed.scheme(), "http" | "https") 583 && parsed.host_str().is_some_and(|host| !host.is_empty()) 584 && parsed.username().is_empty() 585 && parsed.password().is_none() 586 && !authority.contains('@') 587 && raw_path.starts_with('/') 588 && !raw_path.is_empty() 589 } 590 591 #[cfg(all(test, feature = "std", feature = "serde"))] 592 #[cfg_attr(coverage_nightly, coverage(off))] 593 mod tests { 594 use super::*; 595 use radroots_blossom::{BlobDescriptor, BlobUrl, ByteVerifiedDescriptor, MediaType, Sha256}; 596 597 #[test] 598 fn post_image_media_type_uses_exact_product_grammar() { 599 for valid in [ 600 "image/png", 601 "image/1", 602 "image/a0", 603 "image/vnd.radroots+png", 604 "image/x-radroots.photo", 605 ] { 606 assert!(post_image_media_type_is_valid(valid), "{valid}"); 607 } 608 609 for invalid in [ 610 "image/", 611 "image/PNG", 612 "IMAGE/png", 613 "image/_png", 614 "image/p_ng", 615 "image/p!ng", 616 "image/p#ng", 617 "image/p$ng", 618 "image/p&ng", 619 "image/p^ng", 620 "image/p%ng", 621 "image/p*ng", 622 "image/p'ng", 623 "image/png;quality=90", 624 "text/png", 625 ] { 626 assert!(!post_image_media_type_is_valid(invalid), "{invalid}"); 627 } 628 } 629 630 #[test] 631 fn authored_post_models_preserve_validated_content_and_image_metadata() { 632 let image = authored_image(b"photo", "image/webp", "webp", "media.example"); 633 let dimensions = PostImageDimensions::new(640, 480).unwrap(); 634 let primary_url = image.descriptor().url().as_str().to_owned(); 635 let post_image = AuthoredPostImage::new(image, dimensions, "market basket").unwrap(); 636 let fallback = BlobUrl::parse(&format!( 637 "https://fallback.example/{}.webp", 638 post_image.image().descriptor().sha256() 639 )) 640 .unwrap() 641 .approve() 642 .unwrap(); 643 let post_image = post_image.try_with_fallback(fallback.clone()).unwrap(); 644 645 assert_eq!(dimensions.width(), 640); 646 assert_eq!(dimensions.height(), 480); 647 assert_eq!(post_image.dimensions(), dimensions); 648 assert_eq!(post_image.alt(), "market basket"); 649 assert_eq!(post_image.url(), primary_url); 650 assert_eq!(post_image.fallbacks(), &[fallback]); 651 assert_eq!(post_image.imeta_tag()[0], TAG_IMETA); 652 assert!( 653 post_image 654 .imeta_tag() 655 .iter() 656 .any(|value| value == "dim 640x480") 657 ); 658 659 let update = AuthoredUpdate::new("harvest update").unwrap(); 660 assert_eq!(update.content(), "harvest update"); 661 662 let content = format!("available today {primary_url}"); 663 let photo = AuthoredPhotoUpdate::new(content.clone(), vec![post_image.clone()]).unwrap(); 664 assert_eq!(photo.content(), content); 665 assert_eq!(photo.images(), std::slice::from_ref(&post_image)); 666 667 let ask = AuthoredAsk::new(content.clone(), vec![post_image]).unwrap(); 668 assert_eq!(ask.content(), content); 669 assert_eq!(ask.images().len(), 1); 670 assert!(AuthoredAsk::new("where can I buy this?", Vec::new()).is_ok()); 671 } 672 673 #[test] 674 fn authored_post_rejects_invalid_content_and_image_shapes() { 675 assert_eq!( 676 PostImageDimensions::new(0, 1), 677 Err(AuthoredPostError::ImageDimensionsInvalid) 678 ); 679 assert_eq!( 680 PostImageDimensions::new(1, 0), 681 Err(AuthoredPostError::ImageDimensionsInvalid) 682 ); 683 assert_eq!( 684 AuthoredUpdate::new(" \n").unwrap_err(), 685 AuthoredPostError::ContentMissing 686 ); 687 let oversized = "x".repeat(RADROOTS_POST_CONTENT_MAX_BYTES + 1); 688 assert_eq!( 689 AuthoredUpdate::new(oversized).unwrap_err(), 690 AuthoredPostError::ContentTooLarge { 691 max: RADROOTS_POST_CONTENT_MAX_BYTES, 692 actual: RADROOTS_POST_CONTENT_MAX_BYTES + 1, 693 } 694 ); 695 assert_eq!( 696 AuthoredPhotoUpdate::new("photo", Vec::new()).unwrap_err(), 697 AuthoredPostError::ImageMissing 698 ); 699 700 let image = AuthoredPostImage::new( 701 authored_image(b"photo", "image/png", "png", "media.example"), 702 PostImageDimensions::new(1, 1).unwrap(), 703 "photo", 704 ) 705 .unwrap(); 706 assert_eq!( 707 AuthoredPhotoUpdate::new("missing URL", vec![image.clone()]).unwrap_err(), 708 AuthoredPostError::ImageUrlOccurrenceCount { 709 expected: 1, 710 actual: 0, 711 } 712 ); 713 let content = image.url().to_owned(); 714 assert_eq!( 715 AuthoredPhotoUpdate::new(content, vec![image.clone(), image]).unwrap_err(), 716 AuthoredPostError::DuplicateImageUrl 717 ); 718 } 719 720 #[test] 721 fn authored_post_requires_one_non_overlapping_utf8_url_occurrence() { 722 let image = AuthoredPostImage::new( 723 authored_image(b"photo", "image/png", "png", "media.example"), 724 PostImageDimensions::new(1, 1).unwrap(), 725 "photo", 726 ) 727 .unwrap(); 728 let repeated = format!("{} 🍓 {}", image.url(), image.url()); 729 assert_eq!( 730 AuthoredPhotoUpdate::new(repeated, vec![image.clone()]).unwrap_err(), 731 AuthoredPostError::ImageUrlOccurrenceCount { 732 expected: 1, 733 actual: 2, 734 } 735 ); 736 assert!( 737 AuthoredPhotoUpdate::new(format!("苗 {} 🍓", image.url()), vec![image]).is_ok(), 738 "UTF-8 surrounding text must not disturb byte-boundary occurrence counting" 739 ); 740 741 let bytes = b"shared-prefix"; 742 let hash = Sha256::digest(bytes); 743 let short_url = format!("https://media.example/{hash}.webp"); 744 let long_url = format!("{short_url}2"); 745 let short = AuthoredPostImage::new( 746 authored_image_at_url(bytes, "image/webp", &short_url), 747 PostImageDimensions::new(1, 1).unwrap(), 748 "short", 749 ) 750 .unwrap(); 751 let long = AuthoredPostImage::new( 752 authored_image_at_url(bytes, "image/webp", &long_url), 753 PostImageDimensions::new(1, 1).unwrap(), 754 "long", 755 ) 756 .unwrap(); 757 assert_eq!( 758 AuthoredPhotoUpdate::new(long_url, vec![short, long]).unwrap_err(), 759 AuthoredPostError::ImageUrlOverlap 760 ); 761 } 762 763 #[test] 764 fn authored_image_rejects_invalid_descriptor_metadata_and_bounds() { 765 let dimensions = PostImageDimensions::new(1, 1).unwrap(); 766 let empty = authored_image(b"", "image/png", "png", "media.example"); 767 assert_eq!( 768 AuthoredPostImage::new(empty, dimensions, "empty").unwrap_err(), 769 AuthoredPostError::ImageSizeInvalid 770 ); 771 let noncanonical_media = authored_image(b"x", "image/p_ng", "png", "media.example"); 772 assert_eq!( 773 AuthoredPostImage::new(noncanonical_media, dimensions, "photo").unwrap_err(), 774 AuthoredPostError::ImageMediaTypeInvalid 775 ); 776 let valid = authored_image(b"x", "image/png", "png", "media.example"); 777 assert_eq!( 778 AuthoredPostImage::new(valid.clone(), dimensions, " \t").unwrap_err(), 779 AuthoredPostError::ImageAltInvalid 780 ); 781 let long_alt = "a".repeat(RADROOTS_POST_ALT_MAX_BYTES + 1); 782 assert_eq!( 783 AuthoredPostImage::new(valid, dimensions, long_alt).unwrap_err(), 784 AuthoredPostError::ImageAltTooLarge { 785 max: RADROOTS_POST_ALT_MAX_BYTES, 786 actual: RADROOTS_POST_ALT_MAX_BYTES + 1, 787 } 788 ); 789 790 let primary = AuthoredPostImage::new( 791 authored_image(b"primary", "image/png", "png", "media.example"), 792 dimensions, 793 "primary", 794 ) 795 .unwrap(); 796 let other_hash = Sha256::digest(b"other"); 797 let fallback = BlobUrl::parse(&format!("https://fallback.example/{other_hash}.png")) 798 .unwrap() 799 .approve() 800 .unwrap(); 801 assert_eq!( 802 primary.try_with_fallback(fallback).unwrap_err(), 803 AuthoredPostError::ImageFallbackHashMismatch 804 ); 805 } 806 807 #[test] 808 fn authored_post_enforces_collection_and_wire_accounting_bounds() { 809 let image = AuthoredPostImage::new( 810 authored_image(b"same", "image/png", "png", "media.example"), 811 PostImageDimensions::new(1, 1).unwrap(), 812 "a".repeat(RADROOTS_POST_ALT_MAX_BYTES), 813 ) 814 .unwrap(); 815 let too_many = vec![image.clone(); RADROOTS_POST_IMETA_MAX_COUNT + 1]; 816 assert_eq!( 817 AuthoredPhotoUpdate::new("photo", too_many.clone()).unwrap_err(), 818 AuthoredPostError::ImageCountExceeded { 819 max: RADROOTS_POST_IMETA_MAX_COUNT, 820 actual: RADROOTS_POST_IMETA_MAX_COUNT + 1, 821 } 822 ); 823 assert_eq!( 824 AuthoredAsk::new("ask", too_many).unwrap_err(), 825 AuthoredPostError::ImageCountExceeded { 826 max: RADROOTS_POST_IMETA_MAX_COUNT, 827 actual: RADROOTS_POST_IMETA_MAX_COUNT + 1, 828 } 829 ); 830 831 let unique_images = (0..RADROOTS_POST_IMETA_MAX_COUNT) 832 .map(|index| { 833 AuthoredPostImage::new( 834 authored_image( 835 format!("image-{index}").as_bytes(), 836 "image/png", 837 "png", 838 "media.example", 839 ), 840 PostImageDimensions::new(1, 1).unwrap(), 841 "a".repeat(RADROOTS_POST_ALT_MAX_BYTES), 842 ) 843 .unwrap() 844 }) 845 .collect::<Vec<_>>(); 846 let content = unique_images 847 .iter() 848 .map(|image| image.url()) 849 .collect::<Vec<_>>() 850 .join(" "); 851 assert!(matches!( 852 AuthoredPhotoUpdate::new(content, unique_images), 853 Err(AuthoredPostError::TagBytesExceeded { .. }) 854 )); 855 856 assert!(matches!( 857 validate_tag_element(&"x".repeat(RADROOTS_POST_TAG_ELEMENT_MAX_BYTES + 1)), 858 Err(AuthoredPostError::TagElementTooLarge { .. }) 859 )); 860 assert!(matches!( 861 validate_post_event_wire_size( 862 &"\u{001f}".repeat(RADROOTS_POST_CONTENT_MAX_BYTES), 863 true, 864 &[] 865 ), 866 Err(AuthoredPostError::EventWireTooLarge { .. }) 867 )); 868 } 869 870 #[test] 871 fn authored_post_errors_expose_stable_codes_and_messages() { 872 let errors = [ 873 AuthoredPostError::ContentMissing, 874 AuthoredPostError::ContentTooLarge { max: 1, actual: 2 }, 875 AuthoredPostError::ImageMissing, 876 AuthoredPostError::ImageCountExceeded { max: 1, actual: 2 }, 877 AuthoredPostError::ImageUrlOccurrenceCount { 878 expected: 1, 879 actual: 0, 880 }, 881 AuthoredPostError::ImageUrlOverlap, 882 AuthoredPostError::DuplicateImageUrl, 883 AuthoredPostError::ImageMediaTypeInvalid, 884 AuthoredPostError::ImageSizeInvalid, 885 AuthoredPostError::ImageDimensionsInvalid, 886 AuthoredPostError::ImageAltInvalid, 887 AuthoredPostError::ImageAltTooLarge { max: 1, actual: 2 }, 888 AuthoredPostError::ImageFallbackHashMismatch, 889 AuthoredPostError::TagElementTooLarge { max: 1, actual: 2 }, 890 AuthoredPostError::TagBytesExceeded { max: 1, actual: 2 }, 891 AuthoredPostError::EventWireTooLarge { max: 1, actual: 2 }, 892 ]; 893 for error in errors { 894 assert!(!error.code().is_empty()); 895 assert!(!error.to_string().is_empty()); 896 } 897 } 898 899 #[test] 900 fn inbound_media_url_validation_rejects_ambiguous_authorities_and_paths() { 901 for valid in [ 902 "https://media.example/path", 903 "HTTP://localhost/path?size=large", 904 "https://[::1]/hash#preview", 905 ] { 906 assert!(post_media_http_url_is_valid(valid), "{valid}"); 907 } 908 for invalid in [ 909 "", 910 " https://media.example/path", 911 "\nhttps://media.example/path", 912 "media.example/path", 913 "ftp://media.example/path", 914 "https://user@media.example/path", 915 "https://user:password@media.example/path", 916 "https://media.example", 917 "https:///path", 918 "https://[invalid]/path", 919 "not a URL://media.example/path", 920 ] { 921 assert!(!post_media_http_url_is_valid(invalid), "{invalid}"); 922 } 923 } 924 925 #[test] 926 fn canonical_json_size_accounts_for_every_escape_class() { 927 assert_eq!(canonical_json_string_bytes("plain"), 7); 928 for escaped in ['"', '\\', '\u{0008}', '\t', '\n', '\u{000c}', '\r'] { 929 assert_eq!(canonical_json_string_bytes(&escaped.to_string()), 4); 930 } 931 assert_eq!(canonical_json_string_bytes("\u{0001}"), 8); 932 assert_eq!(canonical_json_string_bytes("é"), 4); 933 } 934 935 fn authored_image( 936 bytes: &[u8], 937 media_type: &str, 938 extension: &str, 939 host: &str, 940 ) -> AuthoredImage { 941 AuthoredImage::try_from(verified_descriptor(bytes, media_type, extension, host)).unwrap() 942 } 943 944 fn authored_image_at_url(bytes: &[u8], media_type: &str, url: &str) -> AuthoredImage { 945 let hash = Sha256::digest(bytes); 946 let media_type = MediaType::parse(media_type).unwrap(); 947 let descriptor = BlobDescriptor::new( 948 BlobUrl::parse(url).unwrap(), 949 hash, 950 bytes.len() as u64, 951 media_type.clone(), 952 1_784_347_200, 953 ) 954 .unwrap() 955 .approve_reference() 956 .unwrap() 957 .verify_bytes(bytes, &media_type) 958 .unwrap(); 959 AuthoredImage::try_from(descriptor).unwrap() 960 } 961 962 fn verified_descriptor( 963 bytes: &[u8], 964 media_type: &str, 965 extension: &str, 966 host: &str, 967 ) -> ByteVerifiedDescriptor { 968 let hash = Sha256::digest(bytes); 969 let media_type = MediaType::parse(media_type).unwrap(); 970 BlobDescriptor::new( 971 BlobUrl::parse(&format!("https://{host}/{hash}.{extension}")).unwrap(), 972 hash, 973 bytes.len() as u64, 974 media_type.clone(), 975 1_784_347_200, 976 ) 977 .unwrap() 978 .approve_reference() 979 .unwrap() 980 .verify_bytes(bytes, &media_type) 981 .unwrap() 982 } 983 } 984 #[path = "article.rs"] 985 pub mod article; 986 #[path = "comment.rs"] 987 pub mod comment; 988 #[path = "deletion.rs"] 989 pub mod deletion; 990 #[path = "document.rs"] 991 pub mod document; 992 #[path = "reaction.rs"] 993 pub mod reaction; 994 #[path = "reply.rs"] 995 pub mod reply; 996 #[path = "report.rs"] 997 pub mod report; 998 #[path = "repost.rs"] 999 pub mod repost;