lib

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

registry_v7.rs (15985B)


      1 //! Frozen post inbound semantics for event-contract registry v7.
      2 
      3 #[cfg(not(feature = "std"))]
      4 use alloc::{
      5     collections::{BTreeMap, BTreeSet},
      6     string::{String, ToString},
      7     vec,
      8     vec::Vec,
      9 };
     10 use core::fmt;
     11 #[cfg(feature = "std")]
     12 use std::collections::{BTreeMap, BTreeSet};
     13 
     14 use radroots_event::{
     15     envelope::kind::KIND_POST,
     16     post::{
     17         PostImageDimensions, RADROOTS_ASK_MARKER_TAG_VALUE, RADROOTS_POST_ALT_MAX_BYTES,
     18         RADROOTS_POST_CONTENT_MAX_BYTES, RADROOTS_POST_IMETA_MAX_COUNT,
     19         post_image_media_type_is_valid, post_media_http_url_is_valid,
     20     },
     21 };
     22 
     23 use crate::verification::v1::RadrootsSignatureVerifiedEvent;
     24 
     25 const REQUIRED_IMETA_FIELDS: [&str; 6] = ["url", "x", "m", "dim", "size", "alt"];
     26 
     27 #[non_exhaustive]
     28 #[derive(Clone, Copy, Debug, PartialEq, Eq)]
     29 pub enum RadrootsPostDiagnostic {
     30     AskMarkerShape,
     31     ImetaCountExceeded,
     32     ImetaFieldInvalid,
     33     ImetaUrlMissing,
     34     ImetaMetadataMissing,
     35     ImetaSingletonDuplicate,
     36     ImetaUrlMissingFromContent,
     37     DuplicateImetaUrl,
     38     ImetaUrlInvalid,
     39     ImetaHashInvalid,
     40     ImetaMimeInvalid,
     41     ImetaDimensionsInvalid,
     42     ImetaSizeInvalid,
     43     ImetaAltInvalid,
     44     ImetaAltTooLarge,
     45     ImetaFallbackUrlInvalid,
     46 }
     47 
     48 impl RadrootsPostDiagnostic {
     49     pub const fn code(self) -> &'static str {
     50         match self {
     51             Self::AskMarkerShape => "ask_marker_shape",
     52             Self::ImetaCountExceeded => "imeta_count_exceeded",
     53             Self::ImetaFieldInvalid => "imeta_field_invalid",
     54             Self::ImetaUrlMissing => "imeta_url_missing",
     55             Self::ImetaMetadataMissing => "imeta_metadata_missing",
     56             Self::ImetaSingletonDuplicate => "imeta_singleton_duplicate",
     57             Self::ImetaUrlMissingFromContent => "imeta_url_missing_from_content",
     58             Self::DuplicateImetaUrl => "duplicate_imeta_url",
     59             Self::ImetaUrlInvalid => "imeta_url_invalid",
     60             Self::ImetaHashInvalid => "imeta_hash_invalid",
     61             Self::ImetaMimeInvalid => "imeta_mime_invalid",
     62             Self::ImetaDimensionsInvalid => "imeta_dimensions_invalid",
     63             Self::ImetaSizeInvalid => "imeta_size_invalid",
     64             Self::ImetaAltInvalid => "imeta_alt_invalid",
     65             Self::ImetaAltTooLarge => "imeta_alt_too_large",
     66             Self::ImetaFallbackUrlInvalid => "imeta_fallback_url_invalid",
     67         }
     68     }
     69 }
     70 
     71 impl fmt::Display for RadrootsPostDiagnostic {
     72     fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
     73         formatter.write_str(self.code())
     74     }
     75 }
     76 
     77 /// Product projection for a verified kind-1 event.
     78 ///
     79 /// ThreadExcluded is an exclusion classification only; strict NIP-10 parsing
     80 /// remains a separate contract. Update, PhotoUpdate, and Ask are root-card
     81 /// profiles.
     82 #[non_exhaustive]
     83 #[derive(Clone, Copy, Debug, PartialEq, Eq)]
     84 pub enum RadrootsPostClassification {
     85     ThreadExcluded,
     86     Update,
     87     PhotoUpdate,
     88     Ask,
     89 }
     90 
     91 impl RadrootsPostClassification {
     92     pub const fn contract_id(self) -> &'static str {
     93         match self {
     94             Self::ThreadExcluded => "radroots.social.post.v1",
     95             Self::Update => "radroots.social.update.v1",
     96             Self::PhotoUpdate => "radroots.social.photo_update.v1",
     97             Self::Ask => "radroots.social.ask.v1",
     98         }
     99     }
    100 
    101     pub const fn is_root_card(self) -> bool {
    102         !matches!(self, Self::ThreadExcluded)
    103     }
    104 }
    105 
    106 /// One raw inbound NIP-92 `imeta` projection.
    107 ///
    108 /// URLs and metadata remain unverified even when the entry qualifies for
    109 /// PhotoUpdate classification. Classification is structural and performs no
    110 /// network request or blob verification.
    111 #[derive(Clone, Debug, PartialEq, Eq)]
    112 pub struct RadrootsInboundPostImeta {
    113     raw_fields: Vec<String>,
    114     url: Option<String>,
    115     sha256: Option<String>,
    116     media_type: Option<String>,
    117     dimensions: Option<PostImageDimensions>,
    118     size: Option<u64>,
    119     alt: Option<String>,
    120     fallbacks: Vec<String>,
    121     unknown_fields: Vec<String>,
    122     diagnostics: Vec<RadrootsPostDiagnostic>,
    123 }
    124 
    125 impl RadrootsInboundPostImeta {
    126     pub fn raw_fields(&self) -> &[String] {
    127         &self.raw_fields
    128     }
    129 
    130     pub fn url(&self) -> Option<&str> {
    131         self.url.as_deref()
    132     }
    133 
    134     pub fn sha256(&self) -> Option<&str> {
    135         self.sha256.as_deref()
    136     }
    137 
    138     pub fn media_type(&self) -> Option<&str> {
    139         self.media_type.as_deref()
    140     }
    141 
    142     pub const fn dimensions(&self) -> Option<PostImageDimensions> {
    143         self.dimensions
    144     }
    145 
    146     pub const fn size(&self) -> Option<u64> {
    147         self.size
    148     }
    149 
    150     pub fn alt(&self) -> Option<&str> {
    151         self.alt.as_deref()
    152     }
    153 
    154     pub fn fallbacks(&self) -> &[String] {
    155         &self.fallbacks
    156     }
    157 
    158     pub fn unknown_fields(&self) -> &[String] {
    159         &self.unknown_fields
    160     }
    161 
    162     pub fn diagnostics(&self) -> &[RadrootsPostDiagnostic] {
    163         &self.diagnostics
    164     }
    165 
    166     pub fn qualifies_photo(&self) -> bool {
    167         self.diagnostics.is_empty()
    168     }
    169 }
    170 
    171 /// Tolerant, ordered product projection of one verified kind-1 event.
    172 #[derive(Clone, Debug, PartialEq, Eq)]
    173 pub struct RadrootsInboundPostProjection {
    174     classification: RadrootsPostClassification,
    175     ask_marker: Option<Vec<String>>,
    176     imeta: Vec<RadrootsInboundPostImeta>,
    177     diagnostics: Vec<RadrootsPostDiagnostic>,
    178 }
    179 
    180 impl RadrootsInboundPostProjection {
    181     pub const fn classification(&self) -> RadrootsPostClassification {
    182         self.classification
    183     }
    184 
    185     pub fn ask_marker(&self) -> Option<&[String]> {
    186         self.ask_marker.as_deref()
    187     }
    188 
    189     pub fn imeta(&self) -> &[RadrootsInboundPostImeta] {
    190         &self.imeta
    191     }
    192 
    193     pub fn diagnostics(&self) -> &[RadrootsPostDiagnostic] {
    194         &self.diagnostics
    195     }
    196 }
    197 
    198 #[non_exhaustive]
    199 #[derive(Clone, Debug, PartialEq, Eq)]
    200 pub enum RadrootsPostProjectionError {
    201     InvalidKind { expected: u32, actual: u32 },
    202     ContentTooLarge { max: usize, actual: usize },
    203     AskMarkerCount,
    204 }
    205 
    206 impl RadrootsPostProjectionError {
    207     pub const fn code(&self) -> &'static str {
    208         match self {
    209             Self::InvalidKind { .. } => "invalid_kind",
    210             Self::ContentTooLarge { .. } => "post_content_too_large",
    211             Self::AskMarkerCount => "ask_marker_count",
    212         }
    213     }
    214 }
    215 
    216 impl fmt::Display for RadrootsPostProjectionError {
    217     fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
    218         match self {
    219             Self::InvalidKind { expected, actual } => {
    220                 write!(
    221                     formatter,
    222                     "post event kind must be {expected}, got {actual}"
    223                 )
    224             }
    225             Self::ContentTooLarge { max, actual } => {
    226                 write!(formatter, "post content is {actual} bytes; max is {max}")
    227             }
    228             Self::AskMarkerCount => {
    229                 formatter.write_str("post event must not contain multiple normalized Ask markers")
    230             }
    231         }
    232     }
    233 }
    234 
    235 #[cfg(feature = "std")]
    236 impl std::error::Error for RadrootsPostProjectionError {}
    237 
    238 /// Projects a signature-and-id verified kind-1 event without admitting it to a
    239 /// relay or claiming media verification.
    240 ///
    241 /// Any `e` tag excludes the event before Ask or media inspection. This function
    242 /// does not claim that the event is a valid NIP-10 reply; that belongs to the
    243 /// dedicated reply contract.
    244 pub fn project_verified_post_event(
    245     verified_event: &RadrootsSignatureVerifiedEvent,
    246 ) -> Result<RadrootsInboundPostProjection, RadrootsPostProjectionError> {
    247     project_verified_post_event_registry_v7(verified_event)
    248 }
    249 
    250 /// Projects a verified kind-1 event with contract-registry-v7 semantics.
    251 pub fn project_verified_post_event_registry_v7(
    252     verified_event: &RadrootsSignatureVerifiedEvent,
    253 ) -> Result<RadrootsInboundPostProjection, RadrootsPostProjectionError> {
    254     let event = verified_event.event();
    255     project_inbound_post_parts(event.kind_u32(), &event.tags_as_vec(), event.content())
    256 }
    257 
    258 pub(crate) fn project_inbound_post_parts(
    259     kind: u32,
    260     tags: &[Vec<String>],
    261     content: &str,
    262 ) -> Result<RadrootsInboundPostProjection, RadrootsPostProjectionError> {
    263     if kind != KIND_POST {
    264         return Err(RadrootsPostProjectionError::InvalidKind {
    265             expected: KIND_POST,
    266             actual: kind,
    267         });
    268     }
    269     if content.len() > RADROOTS_POST_CONTENT_MAX_BYTES {
    270         return Err(RadrootsPostProjectionError::ContentTooLarge {
    271             max: RADROOTS_POST_CONTENT_MAX_BYTES,
    272             actual: content.len(),
    273         });
    274     }
    275     if tags
    276         .iter()
    277         .any(|tag| tag.first().is_some_and(|key| key == "e"))
    278     {
    279         return Ok(RadrootsInboundPostProjection {
    280             classification: RadrootsPostClassification::ThreadExcluded,
    281             ask_marker: None,
    282             imeta: Vec::new(),
    283             diagnostics: Vec::new(),
    284         });
    285     }
    286 
    287     let (ask_marker, marker_diagnostics) = project_ask_marker(tags)?;
    288     let imeta_tags = tags
    289         .iter()
    290         .filter(|tag| tag.first().is_some_and(|key| key == "imeta"))
    291         .collect::<Vec<_>>();
    292     let mut diagnostics = marker_diagnostics;
    293     if imeta_tags.len() > RADROOTS_POST_IMETA_MAX_COUNT {
    294         diagnostics.push(RadrootsPostDiagnostic::ImetaCountExceeded);
    295     }
    296     let imeta = project_imeta(imeta_tags, content);
    297     diagnostics.extend(
    298         imeta
    299             .iter()
    300             .flat_map(|item| item.diagnostics.iter().copied()),
    301     );
    302     let classification = if ask_marker.is_some() {
    303         RadrootsPostClassification::Ask
    304     } else if !imeta.is_empty() && diagnostics.is_empty() {
    305         RadrootsPostClassification::PhotoUpdate
    306     } else {
    307         RadrootsPostClassification::Update
    308     };
    309     Ok(RadrootsInboundPostProjection {
    310         classification,
    311         ask_marker,
    312         imeta,
    313         diagnostics,
    314     })
    315 }
    316 
    317 fn project_ask_marker(
    318     tags: &[Vec<String>],
    319 ) -> Result<(Option<Vec<String>>, Vec<RadrootsPostDiagnostic>), RadrootsPostProjectionError> {
    320     let candidates = tags
    321         .iter()
    322         .filter(|tag| {
    323             tag.first().is_some_and(|key| key == "t")
    324                 && tag.get(1).is_some_and(|value| normalized_ask_marker(value))
    325         })
    326         .collect::<Vec<_>>();
    327     if candidates.len() > 1 {
    328         return Err(RadrootsPostProjectionError::AskMarkerCount);
    329     }
    330     let Some(candidate) = candidates.first() else {
    331         return Ok((None, Vec::new()));
    332     };
    333     if candidate.len() != 2 {
    334         return Ok((None, vec![RadrootsPostDiagnostic::AskMarkerShape]));
    335     }
    336     Ok((Some((*candidate).clone()), Vec::new()))
    337 }
    338 
    339 fn normalized_ask_marker(value: &str) -> bool {
    340     value
    341         .trim_matches(|character| {
    342             matches!(
    343                 character,
    344                 ' ' | '\t' | '\n' | '\r' | '\u{000b}' | '\u{000c}'
    345             )
    346         })
    347         .eq_ignore_ascii_case(RADROOTS_ASK_MARKER_TAG_VALUE)
    348 }
    349 
    350 fn project_imeta(tags: Vec<&Vec<String>>, content: &str) -> Vec<RadrootsInboundPostImeta> {
    351     let mut projections = Vec::with_capacity(tags.len());
    352     let mut seen_urls = BTreeSet::new();
    353     for tag in tags {
    354         let raw_fields = tag[1..].to_vec();
    355         let mut fields = BTreeMap::new();
    356         let mut fallbacks = Vec::new();
    357         let mut unknown_fields = Vec::new();
    358         let mut diagnostics = Vec::new();
    359 
    360         for raw_field in &raw_fields {
    361             let Some((key, value)) = raw_field.split_once(' ') else {
    362                 diagnostics.push(RadrootsPostDiagnostic::ImetaFieldInvalid);
    363                 continue;
    364             };
    365             if key.is_empty() || value.is_empty() {
    366                 diagnostics.push(RadrootsPostDiagnostic::ImetaFieldInvalid);
    367             } else if key == "fallback" {
    368                 fallbacks.push(value.to_string());
    369             } else if imeta_singleton_field(key) {
    370                 if fields.contains_key(key) {
    371                     diagnostics.push(RadrootsPostDiagnostic::ImetaSingletonDuplicate);
    372                 } else {
    373                     fields.insert(key.to_string(), value.to_string());
    374                 }
    375             } else {
    376                 unknown_fields.push(raw_field.clone());
    377             }
    378         }
    379 
    380         let url = fields.get("url").cloned();
    381         if url.is_none() {
    382             diagnostics.push(RadrootsPostDiagnostic::ImetaUrlMissing);
    383         } else if REQUIRED_IMETA_FIELDS
    384             .iter()
    385             .any(|required| !fields.contains_key(*required))
    386         {
    387             diagnostics.push(RadrootsPostDiagnostic::ImetaMetadataMissing);
    388         }
    389         if let Some(url) = &url {
    390             if !content.contains(url) {
    391                 diagnostics.push(RadrootsPostDiagnostic::ImetaUrlMissingFromContent);
    392             }
    393             if !seen_urls.insert(url.clone()) {
    394                 diagnostics.push(RadrootsPostDiagnostic::DuplicateImetaUrl);
    395             }
    396             if !post_media_http_url_is_valid(url) {
    397                 diagnostics.push(RadrootsPostDiagnostic::ImetaUrlInvalid);
    398             }
    399         }
    400 
    401         let sha256 = fields.get("x").cloned();
    402         if sha256.as_deref().is_some_and(|value| !lower_hex_64(value)) {
    403             diagnostics.push(RadrootsPostDiagnostic::ImetaHashInvalid);
    404         }
    405         let media_type = fields.get("m").cloned();
    406         if media_type
    407             .as_deref()
    408             .is_some_and(|value| !post_image_media_type_is_valid(value))
    409         {
    410             diagnostics.push(RadrootsPostDiagnostic::ImetaMimeInvalid);
    411         }
    412         let dimensions = fields.get("dim").and_then(|value| {
    413             parse_dimensions(value).or_else(|| {
    414                 diagnostics.push(RadrootsPostDiagnostic::ImetaDimensionsInvalid);
    415                 None
    416             })
    417         });
    418         let size = fields.get("size").and_then(|value| {
    419             parse_nonzero_u64(value).or_else(|| {
    420                 diagnostics.push(RadrootsPostDiagnostic::ImetaSizeInvalid);
    421                 None
    422             })
    423         });
    424         let alt = fields.get("alt").cloned();
    425         if let Some(alt) = &alt {
    426             if alt.trim().is_empty() {
    427                 diagnostics.push(RadrootsPostDiagnostic::ImetaAltInvalid);
    428             } else if alt.len() > RADROOTS_POST_ALT_MAX_BYTES {
    429                 diagnostics.push(RadrootsPostDiagnostic::ImetaAltTooLarge);
    430             }
    431         }
    432         for fallback in &fallbacks {
    433             if !post_media_http_url_is_valid(fallback) {
    434                 diagnostics.push(RadrootsPostDiagnostic::ImetaFallbackUrlInvalid);
    435             }
    436         }
    437 
    438         projections.push(RadrootsInboundPostImeta {
    439             raw_fields,
    440             url,
    441             sha256,
    442             media_type,
    443             dimensions,
    444             size,
    445             alt,
    446             fallbacks,
    447             unknown_fields,
    448             diagnostics,
    449         });
    450     }
    451     projections
    452 }
    453 
    454 fn imeta_singleton_field(value: &str) -> bool {
    455     matches!(
    456         value,
    457         "url"
    458             | "m"
    459             | "x"
    460             | "ox"
    461             | "size"
    462             | "dim"
    463             | "magnet"
    464             | "i"
    465             | "blurhash"
    466             | "thumb"
    467             | "image"
    468             | "summary"
    469             | "alt"
    470             | "service"
    471     )
    472 }
    473 
    474 fn lower_hex_64(value: &str) -> bool {
    475     value.len() == 64
    476         && value
    477             .bytes()
    478             .all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f'))
    479 }
    480 
    481 fn parse_dimensions(value: &str) -> Option<PostImageDimensions> {
    482     let (width, height) = value.split_once('x')?;
    483     if !canonical_nonzero_decimal(width) || !canonical_nonzero_decimal(height) {
    484         return None;
    485     }
    486     PostImageDimensions::new(width.parse().ok()?, height.parse().ok()?).ok()
    487 }
    488 
    489 fn parse_nonzero_u64(value: &str) -> Option<u64> {
    490     canonical_nonzero_decimal(value)
    491         .then(|| value.parse().ok())
    492         .flatten()
    493 }
    494 
    495 fn canonical_nonzero_decimal(value: &str) -> bool {
    496     value
    497         .as_bytes()
    498         .first()
    499         .is_some_and(|byte| matches!(byte, b'1'..=b'9'))
    500         && value.bytes().all(|byte| byte.is_ascii_digit())
    501 }
    502 
    503 #[cfg(test)]
    504 mod tests;