lib

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

authored.rs (6224B)


      1 #[cfg(not(feature = "std"))]
      2 use alloc::vec::Vec;
      3 use core::fmt;
      4 
      5 use radroots_event::{
      6     envelope::kind::KIND_PROFILE,
      7     profile::{AuthoredProfile, RADROOTS_PROFILE_METADATA_MAX_CONTENT_BYTES},
      8     wire::Nip01EventWireParts,
      9 };
     10 use serde::Serialize;
     11 
     12 #[non_exhaustive]
     13 #[derive(Clone, Debug, PartialEq, Eq)]
     14 pub enum RadrootsAuthoredProfileEncodeError {
     15     ContentTooLarge { max: usize, actual: usize },
     16 }
     17 
     18 impl RadrootsAuthoredProfileEncodeError {
     19     pub const fn code(&self) -> &'static str {
     20         match self {
     21             Self::ContentTooLarge { .. } => "content_too_large",
     22         }
     23     }
     24 }
     25 
     26 impl fmt::Display for RadrootsAuthoredProfileEncodeError {
     27     fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
     28         match self {
     29             Self::ContentTooLarge { max, actual } => {
     30                 write!(
     31                     f,
     32                     "authored Profile metadata is {actual} bytes; max is {max}"
     33                 )
     34             }
     35         }
     36     }
     37 }
     38 
     39 #[cfg(feature = "std")]
     40 impl std::error::Error for RadrootsAuthoredProfileEncodeError {}
     41 
     42 #[derive(Serialize)]
     43 struct AuthoredProfileMetadata<'a> {
     44     name: &'a str,
     45     #[serde(skip_serializing_if = "Option::is_none")]
     46     display_name: Option<&'a str>,
     47     #[serde(skip_serializing_if = "Option::is_none")]
     48     about: Option<&'a str>,
     49     #[serde(skip_serializing_if = "Option::is_none")]
     50     picture: Option<&'a str>,
     51     #[serde(skip_serializing_if = "Option::is_none")]
     52     banner: Option<&'a str>,
     53     #[serde(skip_serializing_if = "Option::is_none")]
     54     nip05: Option<&'a str>,
     55     #[serde(skip_serializing_if = "Option::is_none")]
     56     bot: Option<bool>,
     57 }
     58 
     59 /// Builds deterministic unsigned kind-0 wire parts from strict authored metadata.
     60 ///
     61 /// The output is a complete replacement snapshot, not a merge or patch. The
     62 /// caller must not use it for an existing Profile edit unless omitted fields
     63 /// are intentionally removed. Retaining existing media requires the caller to
     64 /// re-establish its byte-verified descriptor state.
     65 ///
     66 /// The caller must separately prove successful BUD-02 upload completion before
     67 /// passing media-bearing output to a signing boundary.
     68 pub fn authored_profile_to_wire_parts(
     69     profile: &AuthoredProfile,
     70 ) -> Result<Nip01EventWireParts, RadrootsAuthoredProfileEncodeError> {
     71     let metadata = AuthoredProfileMetadata {
     72         name: profile.name(),
     73         display_name: profile.display_name(),
     74         about: profile.about(),
     75         picture: profile
     76             .picture()
     77             .map(|image| image.descriptor().url().as_str()),
     78         banner: profile
     79             .banner()
     80             .map(|image| image.descriptor().url().as_str()),
     81         nip05: profile.nip05().map(|identifier| identifier.as_str()),
     82         bot: profile.bot(),
     83     };
     84     let expected_len = authored_profile_metadata_len(&metadata);
     85     if expected_len > RADROOTS_PROFILE_METADATA_MAX_CONTENT_BYTES {
     86         return Err(RadrootsAuthoredProfileEncodeError::ContentTooLarge {
     87             max: RADROOTS_PROFILE_METADATA_MAX_CONTENT_BYTES,
     88             actual: expected_len,
     89         });
     90     }
     91     let content = serde_json::to_string(&metadata)
     92         .expect("authored Profile metadata contains only infallible JSON scalar types");
     93     Ok(Nip01EventWireParts {
     94         kind: KIND_PROFILE,
     95         content,
     96         tags: Vec::new(),
     97     })
     98 }
     99 
    100 fn authored_profile_metadata_len(metadata: &AuthoredProfileMetadata<'_>) -> usize {
    101     let mut len = 2usize;
    102     let mut fields = 0usize;
    103     add_string_field_len(&mut len, &mut fields, "name", metadata.name);
    104     if let Some(value) = metadata.display_name {
    105         add_string_field_len(&mut len, &mut fields, "display_name", value);
    106     }
    107     if let Some(value) = metadata.about {
    108         add_string_field_len(&mut len, &mut fields, "about", value);
    109     }
    110     if let Some(value) = metadata.picture {
    111         add_string_field_len(&mut len, &mut fields, "picture", value);
    112     }
    113     if let Some(value) = metadata.banner {
    114         add_string_field_len(&mut len, &mut fields, "banner", value);
    115     }
    116     if let Some(value) = metadata.nip05 {
    117         add_string_field_len(&mut len, &mut fields, "nip05", value);
    118     }
    119     if let Some(value) = metadata.bot {
    120         add_field_prefix_len(&mut len, &mut fields, "bot");
    121         len = len.saturating_add(if value { 4 } else { 5 });
    122     }
    123     len
    124 }
    125 
    126 fn add_string_field_len(len: &mut usize, fields: &mut usize, key: &str, value: &str) {
    127     add_field_prefix_len(len, fields, key);
    128     *len = len.saturating_add(json_string_encoded_len(value));
    129 }
    130 
    131 fn add_field_prefix_len(len: &mut usize, fields: &mut usize, key: &str) {
    132     if *fields > 0 {
    133         *len = len.saturating_add(1);
    134     }
    135     *fields = fields.saturating_add(1);
    136     *len = len.saturating_add(key.len().saturating_add(3));
    137 }
    138 
    139 fn json_string_encoded_len(value: &str) -> usize {
    140     value.bytes().fold(2usize, |len, byte| {
    141         let encoded = match byte {
    142             b'"' | b'\\' | b'\x08' | b'\t' | b'\n' | b'\x0c' | b'\r' => 2,
    143             0x00..=0x1f => 6,
    144             _ => 1,
    145         };
    146         len.saturating_add(encoded)
    147     })
    148 }
    149 
    150 #[cfg(test)]
    151 mod tests {
    152     use super::*;
    153     use radroots_event::profile::Nip05Identifier;
    154 
    155     #[test]
    156     fn preflight_length_matches_compact_json_escaping() {
    157         let escaped = "\0\u{8}\t\n\u{c}\r\"\\e\u{301}";
    158         assert_eq!(
    159             json_string_encoded_len(escaped),
    160             serde_json::to_string(escaped).unwrap().len()
    161         );
    162 
    163         let profile = AuthoredProfile::new("farm \\\"one\\\"")
    164             .unwrap()
    165             .with_display_name(escaped)
    166             .with_about("Victoria \u{e9}")
    167             .with_nip05(Nip05Identifier::parse("farm@example.com").unwrap())
    168             .with_bot(true);
    169         let wire = authored_profile_to_wire_parts(&profile).unwrap();
    170         let metadata = AuthoredProfileMetadata {
    171             name: profile.name(),
    172             display_name: profile.display_name(),
    173             about: profile.about(),
    174             picture: None,
    175             banner: None,
    176             nip05: profile.nip05().map(|identifier| identifier.as_str()),
    177             bot: profile.bot(),
    178         };
    179         assert_eq!(authored_profile_metadata_len(&metadata), wire.content.len());
    180     }
    181 }