lib

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

registry_v7.rs (10347B)


      1 //! Frozen profile inbound semantics for event-contract registry v7.
      2 
      3 #[cfg(not(feature = "std"))]
      4 use alloc::{
      5     collections::BTreeMap,
      6     string::{String, ToString},
      7 };
      8 #[cfg(feature = "std")]
      9 use std::{collections::BTreeMap, string::String};
     10 
     11 use core::fmt;
     12 
     13 use radroots_event::profile::{Nip05Identifier, RADROOTS_PROFILE_METADATA_MAX_CONTENT_BYTES};
     14 use serde::de::{IgnoredAny, MapAccess, SeqAccess, Visitor};
     15 use serde::{Deserialize, Deserializer};
     16 use serde_json::Value;
     17 
     18 #[non_exhaustive]
     19 #[derive(Clone, Debug, PartialEq, Eq)]
     20 pub enum RadrootsProfileMetadataParseError {
     21     ContentTooLarge { max: usize, actual: usize },
     22     InvalidJson,
     23     RootNotObject,
     24     DuplicateField(String),
     25 }
     26 
     27 impl RadrootsProfileMetadataParseError {
     28     pub const fn code(&self) -> &'static str {
     29         match self {
     30             Self::ContentTooLarge { .. } => "content_too_large",
     31             Self::InvalidJson => "invalid_json",
     32             Self::RootNotObject => "root_not_object",
     33             Self::DuplicateField(_) => "duplicate_field",
     34         }
     35     }
     36 }
     37 
     38 impl fmt::Display for RadrootsProfileMetadataParseError {
     39     fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
     40         match self {
     41             Self::ContentTooLarge { max, actual } => {
     42                 write!(f, "Profile metadata is {actual} bytes; max is {max}")
     43             }
     44             Self::InvalidJson => f.write_str("Profile metadata is invalid JSON"),
     45             Self::RootNotObject => f.write_str("Profile metadata root must be a JSON object"),
     46             Self::DuplicateField(field) => {
     47                 write!(f, "Profile metadata contains duplicate field {field:?}")
     48             }
     49         }
     50     }
     51 }
     52 
     53 #[cfg(feature = "std")]
     54 impl std::error::Error for RadrootsProfileMetadataParseError {}
     55 
     56 /// A string media reference observed in inbound Profile metadata.
     57 ///
     58 /// Even a structurally valid Blossom URL remains unverified in this state.
     59 #[derive(Clone, Debug, PartialEq, Eq)]
     60 pub struct RadrootsUnverifiedProfileMediaReference(String);
     61 
     62 impl RadrootsUnverifiedProfileMediaReference {
     63     fn from_inbound(value: String) -> Self {
     64         Self(value)
     65     }
     66 
     67     pub fn as_str(&self) -> &str {
     68         &self.0
     69     }
     70 }
     71 
     72 /// The content parser never performs NIP-05 network identity resolution.
     73 ///
     74 /// A future resolved identity must use a separate verified result type.
     75 #[non_exhaustive]
     76 #[derive(Clone, Copy, Debug, PartialEq, Eq)]
     77 pub enum RadrootsNip05IdentityVerification {
     78     NotPerformed,
     79 }
     80 
     81 impl RadrootsNip05IdentityVerification {
     82     pub const fn code(self) -> &'static str {
     83         match self {
     84             Self::NotPerformed => "not_performed",
     85         }
     86     }
     87 }
     88 
     89 impl fmt::Display for RadrootsUnverifiedProfileMediaReference {
     90     fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
     91         f.write_str(self.as_str())
     92     }
     93 }
     94 
     95 /// Tolerant inbound Profile metadata with lossless raw and residual views.
     96 ///
     97 /// Correctly typed known fields are projected below. Unknown fields, known
     98 /// fields with the wrong JSON type, and syntactically invalid NIP-05 strings
     99 /// remain in `residual_fields` and in the complete raw object. This type does
    100 /// not attest event kind, identifier, signature, or author.
    101 #[derive(Clone, Debug, PartialEq)]
    102 pub struct RadrootsInboundProfileMetadata {
    103     raw_content: String,
    104     raw_fields: BTreeMap<String, Value>,
    105     residual_fields: BTreeMap<String, Value>,
    106     name: Option<String>,
    107     display_name: Option<String>,
    108     about: Option<String>,
    109     picture: Option<RadrootsUnverifiedProfileMediaReference>,
    110     banner: Option<RadrootsUnverifiedProfileMediaReference>,
    111     nip05: Option<Nip05Identifier>,
    112     bot: Option<bool>,
    113 }
    114 
    115 impl RadrootsInboundProfileMetadata {
    116     pub fn raw_content(&self) -> &str {
    117         &self.raw_content
    118     }
    119 
    120     pub fn raw_fields(&self) -> &BTreeMap<String, Value> {
    121         &self.raw_fields
    122     }
    123 
    124     pub fn residual_fields(&self) -> &BTreeMap<String, Value> {
    125         &self.residual_fields
    126     }
    127 
    128     pub fn name(&self) -> Option<&str> {
    129         self.name.as_deref()
    130     }
    131 
    132     pub fn display_name(&self) -> Option<&str> {
    133         self.display_name.as_deref()
    134     }
    135 
    136     pub fn about(&self) -> Option<&str> {
    137         self.about.as_deref()
    138     }
    139 
    140     pub fn picture(&self) -> Option<&RadrootsUnverifiedProfileMediaReference> {
    141         self.picture.as_ref()
    142     }
    143 
    144     pub fn banner(&self) -> Option<&RadrootsUnverifiedProfileMediaReference> {
    145         self.banner.as_ref()
    146     }
    147 
    148     /// Returns only a syntax-checked identifier; no NIP-05 resolution occurred.
    149     pub fn nip05(&self) -> Option<&Nip05Identifier> {
    150         self.nip05.as_ref()
    151     }
    152 
    153     pub const fn nip05_identity_verification(&self) -> RadrootsNip05IdentityVerification {
    154         RadrootsNip05IdentityVerification::NotPerformed
    155     }
    156 
    157     pub const fn bot(&self) -> Option<bool> {
    158         self.bot
    159     }
    160 }
    161 
    162 /// Parses bounded, untrusted kind-0 metadata content after event verification.
    163 ///
    164 /// Successful parsing is not event admission. The caller must first require an
    165 /// exact kind-0 event with a verified identifier and signature. Content larger
    166 /// than [`RADROOTS_PROFILE_METADATA_MAX_CONTENT_BYTES`] is rejected before JSON
    167 /// parsing.
    168 pub fn parse_inbound_profile_metadata(
    169     content: &str,
    170 ) -> Result<RadrootsInboundProfileMetadata, RadrootsProfileMetadataParseError> {
    171     parse_inbound_profile_metadata_registry_v7(content)
    172 }
    173 
    174 /// Parses Profile metadata with the behavior frozen for contract registry v7.
    175 pub fn parse_inbound_profile_metadata_registry_v7(
    176     content: &str,
    177 ) -> Result<RadrootsInboundProfileMetadata, RadrootsProfileMetadataParseError> {
    178     if content.len() > RADROOTS_PROFILE_METADATA_MAX_CONTENT_BYTES {
    179         return Err(RadrootsProfileMetadataParseError::ContentTooLarge {
    180             max: RADROOTS_PROFILE_METADATA_MAX_CONTENT_BYTES,
    181             actual: content.len(),
    182         });
    183     }
    184 
    185     let root: ProfileMetadataRoot = serde_json::from_str(content)
    186         .map_err(|_| RadrootsProfileMetadataParseError::InvalidJson)?;
    187     let ProfileMetadataRoot::Object(unique) = root else {
    188         return Err(RadrootsProfileMetadataParseError::RootNotObject);
    189     };
    190     if let Some(field) = unique.duplicate {
    191         return Err(RadrootsProfileMetadataParseError::DuplicateField(field));
    192     }
    193 
    194     let raw_fields = unique.fields;
    195     let mut residual_fields = raw_fields.clone();
    196     let name = project_string(&raw_fields, &mut residual_fields, "name");
    197     let display_name = project_string(&raw_fields, &mut residual_fields, "display_name");
    198     let about = project_string(&raw_fields, &mut residual_fields, "about");
    199     let picture = project_string(&raw_fields, &mut residual_fields, "picture")
    200         .map(RadrootsUnverifiedProfileMediaReference::from_inbound);
    201     let banner = project_string(&raw_fields, &mut residual_fields, "banner")
    202         .map(RadrootsUnverifiedProfileMediaReference::from_inbound);
    203     let nip05 = project_nip05(&raw_fields, &mut residual_fields);
    204     let bot = project_bool(&raw_fields, &mut residual_fields, "bot");
    205 
    206     Ok(RadrootsInboundProfileMetadata {
    207         raw_content: content.to_string(),
    208         raw_fields,
    209         residual_fields,
    210         name,
    211         display_name,
    212         about,
    213         picture,
    214         banner,
    215         nip05,
    216         bot,
    217     })
    218 }
    219 
    220 fn project_string(
    221     raw_fields: &BTreeMap<String, Value>,
    222     residual_fields: &mut BTreeMap<String, Value>,
    223     key: &'static str,
    224 ) -> Option<String> {
    225     let value = raw_fields.get(key)?.as_str()?.to_string();
    226     residual_fields.remove(key);
    227     Some(value)
    228 }
    229 
    230 fn project_bool(
    231     raw_fields: &BTreeMap<String, Value>,
    232     residual_fields: &mut BTreeMap<String, Value>,
    233     key: &'static str,
    234 ) -> Option<bool> {
    235     let value = raw_fields.get(key)?.as_bool()?;
    236     residual_fields.remove(key);
    237     Some(value)
    238 }
    239 
    240 fn project_nip05(
    241     raw_fields: &BTreeMap<String, Value>,
    242     residual_fields: &mut BTreeMap<String, Value>,
    243 ) -> Option<Nip05Identifier> {
    244     let value = raw_fields.get("nip05")?.as_str()?;
    245     let identifier = Nip05Identifier::parse(value).ok()?;
    246     residual_fields.remove("nip05");
    247     Some(identifier)
    248 }
    249 
    250 struct UniqueMetadataObject {
    251     fields: BTreeMap<String, Value>,
    252     duplicate: Option<String>,
    253 }
    254 
    255 enum ProfileMetadataRoot {
    256     Object(UniqueMetadataObject),
    257     NonObject,
    258 }
    259 
    260 impl<'de> Deserialize<'de> for ProfileMetadataRoot {
    261     fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
    262     where
    263         D: Deserializer<'de>,
    264     {
    265         deserializer.deserialize_any(ProfileMetadataRootVisitor)
    266     }
    267 }
    268 
    269 struct ProfileMetadataRootVisitor;
    270 
    271 impl<'de> Visitor<'de> for ProfileMetadataRootVisitor {
    272     type Value = ProfileMetadataRoot;
    273 
    274     fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
    275         formatter.write_str("Profile metadata JSON")
    276     }
    277 
    278     fn visit_bool<E>(self, _value: bool) -> Result<Self::Value, E> {
    279         Ok(ProfileMetadataRoot::NonObject)
    280     }
    281 
    282     fn visit_i64<E>(self, _value: i64) -> Result<Self::Value, E> {
    283         Ok(ProfileMetadataRoot::NonObject)
    284     }
    285 
    286     fn visit_u64<E>(self, _value: u64) -> Result<Self::Value, E> {
    287         Ok(ProfileMetadataRoot::NonObject)
    288     }
    289 
    290     fn visit_f64<E>(self, _value: f64) -> Result<Self::Value, E> {
    291         Ok(ProfileMetadataRoot::NonObject)
    292     }
    293 
    294     fn visit_str<E>(self, _value: &str) -> Result<Self::Value, E> {
    295         Ok(ProfileMetadataRoot::NonObject)
    296     }
    297 
    298     fn visit_unit<E>(self) -> Result<Self::Value, E> {
    299         Ok(ProfileMetadataRoot::NonObject)
    300     }
    301 
    302     fn visit_seq<A>(self, mut sequence: A) -> Result<Self::Value, A::Error>
    303     where
    304         A: SeqAccess<'de>,
    305     {
    306         loop {
    307             if sequence.next_element::<IgnoredAny>()?.is_none() {
    308                 break;
    309             }
    310         }
    311         Ok(ProfileMetadataRoot::NonObject)
    312     }
    313 
    314     fn visit_map<A>(self, mut map: A) -> Result<Self::Value, A::Error>
    315     where
    316         A: MapAccess<'de>,
    317     {
    318         let mut fields = BTreeMap::new();
    319         let mut duplicate = None;
    320         while let Some((key, value)) = map.next_entry::<String, Value>()? {
    321             if fields.insert(key.clone(), value).is_some() && duplicate.is_none() {
    322                 duplicate = Some(key);
    323             }
    324         }
    325         Ok(ProfileMetadataRoot::Object(UniqueMetadataObject {
    326             fields,
    327             duplicate,
    328         }))
    329     }
    330 }
    331 
    332 #[cfg(test)]
    333 mod tests;