lib

Core libraries for Radroots
git clone https://radroots.dev/git/lib.git
Log | Files | Refs | README

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;