app

Local-first trade for farms and co-ops
git clone https://radroots.dev/git/app.git
Log | Files | Refs | README | LICENSE

profile.rs (13083B)


      1 //! Public Nostr profile metadata values.
      2 
      3 use crate::{PublicKey, SafeError, SafeErrorCode, SafeMessage, UnixTimestamp};
      4 
      5 const EVENT_ID_BYTES: usize = 32;
      6 const EVENT_ID_HEX: usize = EVENT_ID_BYTES * 2;
      7 const MAX_NAME_UTF8_BYTES: usize = 128;
      8 const MAX_NIP05_UTF8_BYTES: usize = 320;
      9 const MAX_ABOUT_UTF8_BYTES: usize = 4_096;
     10 const MAX_PICTURE_UTF8_BYTES: usize = 2_048;
     11 
     12 #[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
     13 pub struct EventId([u8; EVENT_ID_BYTES]);
     14 
     15 impl EventId {
     16     /// Parses a canonical lowercase hexadecimal Nostr event ID.
     17     ///
     18     /// # Errors
     19     ///
     20     /// Returns a safe profile error for malformed input.
     21     pub fn from_hex(value: &str) -> Result<Self, SafeError> {
     22         if value.len() != EVENT_ID_HEX
     23             || !value
     24                 .bytes()
     25                 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
     26         {
     27             return Err(invalid_profile_metadata());
     28         }
     29 
     30         let mut bytes = [0_u8; EVENT_ID_BYTES];
     31         for (index, pair) in value.as_bytes().as_chunks::<2>().0.iter().enumerate() {
     32             let high = decode_hex(pair[0]).ok_or_else(invalid_profile_metadata)?;
     33             let low = decode_hex(pair[1]).ok_or_else(invalid_profile_metadata)?;
     34             bytes[index] = (high << 4) | low;
     35         }
     36         Ok(Self(bytes))
     37     }
     38 
     39     #[must_use]
     40     pub const fn from_bytes(bytes: [u8; EVENT_ID_BYTES]) -> Self {
     41         Self(bytes)
     42     }
     43 
     44     #[must_use]
     45     pub const fn as_bytes(self) -> [u8; EVENT_ID_BYTES] {
     46         self.0
     47     }
     48 
     49     #[must_use]
     50     pub fn to_hex(self) -> String {
     51         const HEX: &[u8; 16] = b"0123456789abcdef";
     52         let mut output = String::with_capacity(EVENT_ID_HEX);
     53         for byte in self.0 {
     54             output.push(char::from(HEX[usize::from(byte >> 4)]));
     55             output.push(char::from(HEX[usize::from(byte & 0x0f)]));
     56         }
     57         output
     58     }
     59 }
     60 
     61 #[derive(Clone, Debug, Default, Eq, PartialEq)]
     62 pub struct ProfileMetadata {
     63     name: Option<String>,
     64     display_name: Option<String>,
     65     nip05: Option<String>,
     66     about: Option<String>,
     67     picture: Option<String>,
     68 }
     69 
     70 impl ProfileMetadata {
     71     /// Normalizes and bounds public kind-0 profile fields.
     72     ///
     73     /// # Errors
     74     ///
     75     /// Returns a safe profile error when a field exceeds its limit or contains
     76     /// a forbidden control character.
     77     pub fn new(
     78         name: Option<String>,
     79         display_name: Option<String>,
     80         nip05: Option<String>,
     81         about: Option<String>,
     82         picture: Option<String>,
     83     ) -> Result<Self, SafeError> {
     84         Ok(Self {
     85             name: normalize_field(name, MAX_NAME_UTF8_BYTES, false)?,
     86             display_name: normalize_field(display_name, MAX_NAME_UTF8_BYTES, false)?,
     87             nip05: normalize_field(nip05, MAX_NIP05_UTF8_BYTES, false)?,
     88             about: normalize_field(about, MAX_ABOUT_UTF8_BYTES, true)?,
     89             picture: normalize_field(picture, MAX_PICTURE_UTF8_BYTES, false)?,
     90         })
     91     }
     92 
     93     #[must_use]
     94     pub fn name(&self) -> Option<&str> {
     95         self.name.as_deref()
     96     }
     97 
     98     #[must_use]
     99     pub fn display_name(&self) -> Option<&str> {
    100         self.display_name.as_deref()
    101     }
    102 
    103     #[must_use]
    104     pub fn nip05(&self) -> Option<&str> {
    105         self.nip05.as_deref()
    106     }
    107 
    108     #[must_use]
    109     pub fn about(&self) -> Option<&str> {
    110         self.about.as_deref()
    111     }
    112 
    113     #[must_use]
    114     pub fn picture(&self) -> Option<&str> {
    115         self.picture.as_deref()
    116     }
    117 
    118     #[must_use]
    119     pub fn preferred_name(&self) -> Option<&str> {
    120         self.display_name().or_else(|| self.name())
    121     }
    122 }
    123 
    124 #[derive(Clone, Debug, Eq, PartialEq)]
    125 pub struct Kind0ProfileCandidate {
    126     event_id: EventId,
    127     author: PublicKey,
    128     created_at: UnixTimestamp,
    129     metadata: ProfileMetadata,
    130 }
    131 
    132 impl Kind0ProfileCandidate {
    133     #[must_use]
    134     pub const fn new(
    135         event_id: EventId,
    136         author: PublicKey,
    137         created_at: UnixTimestamp,
    138         metadata: ProfileMetadata,
    139     ) -> Self {
    140         Self {
    141             event_id,
    142             author,
    143             created_at,
    144             metadata,
    145         }
    146     }
    147 
    148     #[must_use]
    149     pub const fn event_id(&self) -> EventId {
    150         self.event_id
    151     }
    152 
    153     #[must_use]
    154     pub const fn author(&self) -> PublicKey {
    155         self.author
    156     }
    157 
    158     #[must_use]
    159     pub const fn created_at(&self) -> UnixTimestamp {
    160         self.created_at
    161     }
    162 
    163     #[must_use]
    164     pub const fn metadata(&self) -> &ProfileMetadata {
    165         &self.metadata
    166     }
    167 }
    168 
    169 #[must_use]
    170 pub fn select_latest_kind0(
    171     candidates: impl IntoIterator<Item = Kind0ProfileCandidate>,
    172 ) -> Option<Kind0ProfileCandidate> {
    173     candidates.into_iter().reduce(|selected, candidate| {
    174         if candidate.created_at > selected.created_at
    175             || (candidate.created_at == selected.created_at
    176                 && candidate.event_id < selected.event_id)
    177         {
    178             candidate
    179         } else {
    180             selected
    181         }
    182     })
    183 }
    184 
    185 fn normalize_field(
    186     value: Option<String>,
    187     max_utf8_bytes: usize,
    188     allow_layout_controls: bool,
    189 ) -> Result<Option<String>, SafeError> {
    190     let Some(value) = value else {
    191         return Ok(None);
    192     };
    193     let normalized = value.trim();
    194     if normalized.is_empty() {
    195         return Ok(None);
    196     }
    197     if normalized.len() > max_utf8_bytes
    198         || normalized.chars().any(|character| {
    199             character.is_control()
    200                 && !(allow_layout_controls && matches!(character, '\n' | '\r' | '\t'))
    201         })
    202     {
    203         return Err(invalid_profile_metadata());
    204     }
    205     Ok(Some(normalized.to_owned()))
    206 }
    207 
    208 const fn invalid_profile_metadata() -> SafeError {
    209     SafeError::new(
    210         SafeErrorCode::InvalidProfileMetadata,
    211         SafeMessage::new("The Nostr profile metadata is invalid."),
    212     )
    213 }
    214 
    215 const fn decode_hex(byte: u8) -> Option<u8> {
    216     match byte {
    217         b'0'..=b'9' => Some(byte - b'0'),
    218         b'a'..=b'f' => Some(byte - b'a' + 10),
    219         _ => None,
    220     }
    221 }
    222 
    223 #[cfg(test)]
    224 mod tests {
    225     use crate::{PublicKey, UnixTimestamp};
    226 
    227     use super::{EventId, Kind0ProfileCandidate, ProfileMetadata, select_latest_kind0};
    228 
    229     fn profile(name: &str) -> ProfileMetadata {
    230         ProfileMetadata::new(Some(name.to_owned()), None, None, None, None).expect("valid profile")
    231     }
    232 
    233     fn candidate(id_byte: u8, created_at: i64, name: &str) -> Kind0ProfileCandidate {
    234         Kind0ProfileCandidate::new(
    235             EventId::from_bytes([id_byte; 32]),
    236             PublicKey::from_bytes([7_u8; 32]).expect("valid public key"),
    237             UnixTimestamp::from_seconds(created_at).expect("valid timestamp"),
    238             profile(name),
    239         )
    240     }
    241 
    242     fn text_at_utf8_limit(unit: &str, maximum: usize) -> String {
    243         unit.repeat(maximum / unit.len()) + &"x".repeat(maximum % unit.len())
    244     }
    245 
    246     fn metadata_with_field(
    247         index: usize,
    248         value: String,
    249     ) -> Result<ProfileMetadata, crate::SafeError> {
    250         let mut fields: [Option<String>; 5] = std::array::from_fn(|_| None);
    251         fields[index] = Some(value);
    252         let [name, display_name, nip05, about, picture] = fields;
    253         ProfileMetadata::new(name, display_name, nip05, about, picture)
    254     }
    255 
    256     fn metadata_field(metadata: &ProfileMetadata, index: usize) -> Option<&str> {
    257         match index {
    258             0 => metadata.name(),
    259             1 => metadata.display_name(),
    260             2 => metadata.nip05(),
    261             3 => metadata.about(),
    262             4 => metadata.picture(),
    263             _ => unreachable!("test field index"),
    264         }
    265     }
    266 
    267     fn assert_one_byte_over_limit_is_rejected(index: usize, maximum: usize) {
    268         for unit in ["x", "é", "🥕", "e\u{301}"] {
    269             let value = text_at_utf8_limit(unit, maximum) + "x";
    270             assert_eq!(value.len(), maximum + 1);
    271             let error = metadata_with_field(index, format!(" {value} "))
    272                 .expect_err("normalized UTF-8 field must fail before storage");
    273             assert_eq!(error.code(), crate::SafeErrorCode::InvalidProfileMetadata);
    274         }
    275     }
    276 
    277     #[test]
    278     fn profile_fields_accept_exact_utf8_boundaries_after_trimming() {
    279         for (index, maximum) in [128, 128, 320, 4_096, 2_048].into_iter().enumerate() {
    280             for unit in ["x", "é", "🥕", "e\u{301}"] {
    281                 let value = text_at_utf8_limit(unit, maximum);
    282                 assert_eq!(value.len(), maximum);
    283                 let metadata = metadata_with_field(index, format!(" \u{2003}{value}\u{2003} "))
    284                     .expect("exact normalized UTF-8 boundary");
    285                 assert_eq!(metadata_field(&metadata, index), Some(value.as_str()));
    286             }
    287         }
    288     }
    289 
    290     #[test]
    291     fn profile_name_rejects_one_byte_over_utf8_limit() {
    292         assert_one_byte_over_limit_is_rejected(0, 128);
    293     }
    294 
    295     #[test]
    296     fn profile_display_name_rejects_one_byte_over_utf8_limit() {
    297         assert_one_byte_over_limit_is_rejected(1, 128);
    298     }
    299 
    300     #[test]
    301     fn profile_nip05_rejects_one_byte_over_utf8_limit() {
    302         assert_one_byte_over_limit_is_rejected(2, 320);
    303     }
    304 
    305     #[test]
    306     fn profile_about_rejects_one_byte_over_utf8_limit() {
    307         assert_one_byte_over_limit_is_rejected(3, 4_096);
    308     }
    309 
    310     #[test]
    311     fn profile_picture_rejects_one_byte_over_utf8_limit() {
    312         assert_one_byte_over_limit_is_rejected(4, 2_048);
    313     }
    314 
    315     #[test]
    316     fn profile_fields_preserve_blank_and_about_layout_control_policy() {
    317         for index in 0..5 {
    318             for blank in ["", " \u{2003}\t\r\n "] {
    319                 assert_eq!(
    320                     metadata_with_field(index, blank.to_owned()).expect("blank optional metadata"),
    321                     ProfileMetadata::default()
    322                 );
    323             }
    324             for control in ['\0', '\u{1b}', '\u{7f}', '\u{85}'] {
    325                 assert_eq!(
    326                     metadata_with_field(index, format!("a{control}b"))
    327                         .expect_err("forbidden embedded control")
    328                         .code(),
    329                     crate::SafeErrorCode::InvalidProfileMetadata
    330                 );
    331             }
    332             for control in ['\n', '\r', '\t'] {
    333                 let value = format!("a{control}b");
    334                 let result = metadata_with_field(index, value.clone());
    335                 if index == 3 {
    336                     assert_eq!(result.expect("about layout").about(), Some(value.as_str()));
    337                 } else {
    338                     assert_eq!(
    339                         result.expect_err("layout outside about").code(),
    340                         crate::SafeErrorCode::InvalidProfileMetadata
    341                     );
    342                 }
    343             }
    344         }
    345         let about = "First\nSecond\rThird\tFourth";
    346         assert_eq!(
    347             metadata_with_field(3, format!(" \n{about}\t "))
    348                 .expect("normalized about layout")
    349                 .about(),
    350             Some(about)
    351         );
    352     }
    353 
    354     #[test]
    355     fn profile_fields_are_trimmed_bounded_and_public() {
    356         let metadata = ProfileMetadata::new(
    357             Some("  farmer  ".to_owned()),
    358             Some("  Farm Identity  ".to_owned()),
    359             Some("farmer@example.test".to_owned()),
    360             Some("First line\nSecond line".to_owned()),
    361             Some("https://images.example.test/profile.png".to_owned()),
    362         )
    363         .expect("valid profile");
    364 
    365         assert_eq!(metadata.name(), Some("farmer"));
    366         assert_eq!(metadata.display_name(), Some("Farm Identity"));
    367         assert_eq!(metadata.preferred_name(), Some("Farm Identity"));
    368         assert_eq!(metadata.nip05(), Some("farmer@example.test"));
    369         assert_eq!(metadata.about(), Some("First line\nSecond line"));
    370         assert_eq!(
    371             metadata.picture(),
    372             Some("https://images.example.test/profile.png")
    373         );
    374     }
    375 
    376     #[test]
    377     fn profile_fields_reject_forbidden_controls_and_oversize_values() {
    378         assert!(
    379             ProfileMetadata::new(Some("bad\0name".to_owned()), None, None, None, None).is_err()
    380         );
    381         assert!(ProfileMetadata::new(Some("x".repeat(129)), None, None, None, None).is_err());
    382     }
    383 
    384     #[test]
    385     fn latest_kind0_uses_timestamp_then_lowest_event_id() {
    386         let older = candidate(0, 10, "older");
    387         let equal_high_id = candidate(9, 20, "high-id");
    388         let equal_low_id = candidate(1, 20, "low-id");
    389 
    390         let selected =
    391             select_latest_kind0([older, equal_high_id, equal_low_id]).expect("selected profile");
    392 
    393         assert_eq!(selected.metadata().name(), Some("low-id"));
    394         assert_eq!(selected.event_id().as_bytes(), [1_u8; 32]);
    395         assert_eq!(
    396             selected.author(),
    397             PublicKey::from_bytes([7_u8; 32]).expect("valid public key")
    398         );
    399         assert_eq!(selected.created_at().as_seconds(), 20);
    400     }
    401 
    402     #[test]
    403     fn event_id_rejects_noncanonical_hex_and_round_trips() {
    404         let hex = "12".repeat(32);
    405         let event_id = EventId::from_hex(&hex).expect("valid event id");
    406 
    407         assert_eq!(event_id.to_hex(), hex);
    408         assert!(EventId::from_hex(&"GG".repeat(32)).is_err());
    409     }
    410 }