lib

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

profile.rs (17056B)


      1 //! Profile and account event models.
      2 
      3 #[cfg(not(feature = "std"))]
      4 use alloc::string::String;
      5 use core::{fmt, str::FromStr};
      6 
      7 use crate::media::AuthoredImage;
      8 
      9 pub const RADROOTS_PROFILE_TYPE_TAG_KEY: &str = "t";
     10 pub const RADROOTS_PROFILE_TYPE_TAG_INDIVIDUAL: &str = "radroots:type:individual";
     11 pub const RADROOTS_PROFILE_TYPE_TAG_FARM: &str = "radroots:type:farm";
     12 pub const RADROOTS_PROFILE_TYPE_TAG_COOP: &str = "radroots:type:coop";
     13 pub const RADROOTS_PROFILE_TYPE_TAG_ANY: &str = "radroots:type:any";
     14 pub const RADROOTS_PROFILE_TYPE_TAG_RADROOTSD: &str = "radroots:type:radrootsd";
     15 pub const RADROOTS_PROFILE_METADATA_MAX_CONTENT_BYTES: usize =
     16     crate::wire::v1::DEFAULT_CONTENT_MAX_BYTES;
     17 
     18 #[cfg_attr(all(test, feature = "std"), derive(dto_bindgen::Dto))]
     19 #[cfg_attr(all(test, feature = "std"), dto(export))]
     20 #[cfg_attr(
     21     any(feature = "serde", test),
     22     derive(serde::Serialize, serde::Deserialize)
     23 )]
     24 #[cfg_attr(any(feature = "serde", test), serde(rename_all = "snake_case"))]
     25 #[derive(Clone, Debug, PartialEq, Eq, Copy)]
     26 pub enum ProfileType {
     27     #[cfg_attr(any(feature = "serde", test), serde(rename = "individual"))]
     28     Individual,
     29     #[cfg_attr(any(feature = "serde", test), serde(rename = "farm"))]
     30     Farm,
     31     #[cfg_attr(any(feature = "serde", test), serde(rename = "coop"))]
     32     Coop,
     33     #[cfg_attr(any(feature = "serde", test), serde(rename = "any"))]
     34     Any,
     35     #[cfg_attr(any(feature = "serde", test), serde(rename = "radrootsd"))]
     36     Radrootsd,
     37 }
     38 
     39 pub fn radroots_profile_type_tag_value(profile_type: ProfileType) -> &'static str {
     40     match profile_type {
     41         ProfileType::Individual => RADROOTS_PROFILE_TYPE_TAG_INDIVIDUAL,
     42         ProfileType::Farm => RADROOTS_PROFILE_TYPE_TAG_FARM,
     43         ProfileType::Coop => RADROOTS_PROFILE_TYPE_TAG_COOP,
     44         ProfileType::Any => RADROOTS_PROFILE_TYPE_TAG_ANY,
     45         ProfileType::Radrootsd => RADROOTS_PROFILE_TYPE_TAG_RADROOTSD,
     46     }
     47 }
     48 
     49 pub fn radroots_profile_type_from_tag_value(value: &str) -> Option<ProfileType> {
     50     match value {
     51         RADROOTS_PROFILE_TYPE_TAG_INDIVIDUAL => Some(ProfileType::Individual),
     52         RADROOTS_PROFILE_TYPE_TAG_FARM => Some(ProfileType::Farm),
     53         RADROOTS_PROFILE_TYPE_TAG_COOP => Some(ProfileType::Coop),
     54         RADROOTS_PROFILE_TYPE_TAG_ANY => Some(ProfileType::Any),
     55         RADROOTS_PROFILE_TYPE_TAG_RADROOTSD => Some(ProfileType::Radrootsd),
     56         _ => None,
     57     }
     58 }
     59 
     60 #[non_exhaustive]
     61 #[derive(Clone, Debug, PartialEq, Eq)]
     62 pub enum Nip05IdentifierError {
     63     MissingSeparator,
     64     MultipleSeparators,
     65     InvalidLocalPart,
     66     InvalidDomain,
     67 }
     68 
     69 impl Nip05IdentifierError {
     70     pub const fn code(&self) -> &'static str {
     71         match self {
     72             Self::MissingSeparator => "missing_separator",
     73             Self::MultipleSeparators => "multiple_separators",
     74             Self::InvalidLocalPart => "invalid_local_part",
     75             Self::InvalidDomain => "invalid_domain",
     76         }
     77     }
     78 }
     79 
     80 impl fmt::Display for Nip05IdentifierError {
     81     fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
     82         match self {
     83             Self::MissingSeparator => f.write_str("NIP-05 identifier must contain one @ separator"),
     84             Self::MultipleSeparators => {
     85                 f.write_str("NIP-05 identifier must not contain multiple @ separators")
     86             }
     87             Self::InvalidLocalPart => f.write_str("NIP-05 identifier local part is invalid"),
     88             Self::InvalidDomain => f.write_str("NIP-05 identifier domain is invalid"),
     89         }
     90     }
     91 }
     92 
     93 #[cfg(feature = "std")]
     94 impl std::error::Error for Nip05IdentifierError {}
     95 
     96 /// A syntax-checked NIP-05 internet identifier.
     97 ///
     98 /// The DNS domain is canonicalized to lowercase. This type performs no network
     99 /// resolution and makes no identity-verification claim.
    100 #[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
    101 pub struct Nip05Identifier(String);
    102 
    103 impl Nip05Identifier {
    104     pub fn parse(value: &str) -> Result<Self, Nip05IdentifierError> {
    105         let Some((local_part, domain)) = value.split_once('@') else {
    106             return Err(Nip05IdentifierError::MissingSeparator);
    107         };
    108         if domain.contains('@') {
    109             return Err(Nip05IdentifierError::MultipleSeparators);
    110         }
    111         if local_part.is_empty()
    112             || !local_part.bytes().all(|byte| {
    113                 byte.is_ascii_lowercase()
    114                     || byte.is_ascii_digit()
    115                     || matches!(byte, b'-' | b'_' | b'.')
    116             })
    117         {
    118             return Err(Nip05IdentifierError::InvalidLocalPart);
    119         }
    120         let domain = domain.to_ascii_lowercase();
    121         if !valid_nip05_domain(&domain) {
    122             return Err(Nip05IdentifierError::InvalidDomain);
    123         }
    124         let mut canonical = String::with_capacity(value.len());
    125         canonical.push_str(local_part);
    126         canonical.push('@');
    127         canonical.push_str(&domain);
    128         Ok(Self(canonical))
    129     }
    130 
    131     pub fn as_str(&self) -> &str {
    132         &self.0
    133     }
    134 
    135     pub fn local_part(&self) -> &str {
    136         self.0
    137             .split_once('@')
    138             .expect("validated NIP-05 identifiers always contain one separator")
    139             .0
    140     }
    141 
    142     pub fn domain(&self) -> &str {
    143         self.0
    144             .split_once('@')
    145             .expect("validated NIP-05 identifiers always contain one separator")
    146             .1
    147     }
    148 }
    149 
    150 impl fmt::Display for Nip05Identifier {
    151     fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
    152         f.write_str(self.as_str())
    153     }
    154 }
    155 
    156 impl FromStr for Nip05Identifier {
    157     type Err = Nip05IdentifierError;
    158 
    159     fn from_str(value: &str) -> Result<Self, Self::Err> {
    160         Self::parse(value)
    161     }
    162 }
    163 
    164 fn valid_nip05_domain(domain: &str) -> bool {
    165     !domain.is_empty()
    166         && domain.len() <= 253
    167         && domain.split('.').all(|label| {
    168             !label.is_empty()
    169                 && label.len() <= 63
    170                 && label
    171                     .bytes()
    172                     .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-')
    173                 && label
    174                     .as_bytes()
    175                     .first()
    176                     .is_some_and(u8::is_ascii_alphanumeric)
    177                 && label
    178                     .as_bytes()
    179                     .last()
    180                     .is_some_and(u8::is_ascii_alphanumeric)
    181         })
    182 }
    183 
    184 #[non_exhaustive]
    185 #[derive(Clone, Debug, PartialEq, Eq)]
    186 pub enum AuthoredProfileError {
    187     InvalidName,
    188 }
    189 
    190 impl AuthoredProfileError {
    191     pub const fn code(&self) -> &'static str {
    192         match self {
    193             Self::InvalidName => "invalid_name",
    194         }
    195     }
    196 }
    197 
    198 impl fmt::Display for AuthoredProfileError {
    199     fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
    200         match self {
    201             Self::InvalidName => {
    202                 f.write_str("authored Profile name must be non-whitespace and control-free")
    203             }
    204         }
    205     }
    206 }
    207 
    208 #[cfg(feature = "std")]
    209 impl std::error::Error for AuthoredProfileError {}
    210 
    211 /// Metadata accepted by the strict authored kind-0 Profile operation.
    212 ///
    213 /// A non-whitespace, control-free name is required. Media values can only enter
    214 /// through image-only, byte-verified Blossom descriptors. That state proves
    215 /// descriptor/byte agreement, not successful network upload.
    216 ///
    217 /// This model represents a complete kind-0 replacement snapshot, not a patch.
    218 /// Omitting an existing field removes it from the authored replacement.
    219 ///
    220 /// ```compile_fail
    221 /// let _: radroots_event::profile::AuthoredProfile =
    222 ///     serde_json::from_str(r#"{"name":"alice"}"#).unwrap();
    223 /// ```
    224 #[derive(Clone, Debug, PartialEq, Eq)]
    225 pub struct AuthoredProfile {
    226     name: String,
    227     display_name: Option<String>,
    228     about: Option<String>,
    229     picture: Option<AuthoredImage>,
    230     banner: Option<AuthoredImage>,
    231     nip05: Option<Nip05Identifier>,
    232     bot: Option<bool>,
    233 }
    234 
    235 impl AuthoredProfile {
    236     pub fn new(name: impl Into<String>) -> Result<Self, AuthoredProfileError> {
    237         let name = name.into();
    238         if name.trim().is_empty() || name.chars().any(char::is_control) {
    239             return Err(AuthoredProfileError::InvalidName);
    240         }
    241         Ok(Self {
    242             name,
    243             display_name: None,
    244             about: None,
    245             picture: None,
    246             banner: None,
    247             nip05: None,
    248             bot: None,
    249         })
    250     }
    251 
    252     #[must_use]
    253     pub fn with_display_name(mut self, value: impl Into<String>) -> Self {
    254         self.display_name = Some(value.into());
    255         self
    256     }
    257 
    258     #[must_use]
    259     pub fn with_about(mut self, value: impl Into<String>) -> Self {
    260         self.about = Some(value.into());
    261         self
    262     }
    263 
    264     #[must_use]
    265     pub fn with_picture(mut self, value: AuthoredImage) -> Self {
    266         self.picture = Some(value);
    267         self
    268     }
    269 
    270     #[must_use]
    271     pub fn with_banner(mut self, value: AuthoredImage) -> Self {
    272         self.banner = Some(value);
    273         self
    274     }
    275 
    276     #[must_use]
    277     pub fn with_nip05(mut self, value: Nip05Identifier) -> Self {
    278         self.nip05 = Some(value);
    279         self
    280     }
    281 
    282     #[must_use]
    283     pub const fn with_bot(mut self, value: bool) -> Self {
    284         self.bot = Some(value);
    285         self
    286     }
    287 
    288     pub fn name(&self) -> &str {
    289         &self.name
    290     }
    291 
    292     pub fn display_name(&self) -> Option<&str> {
    293         self.display_name.as_deref()
    294     }
    295 
    296     pub fn about(&self) -> Option<&str> {
    297         self.about.as_deref()
    298     }
    299 
    300     pub fn picture(&self) -> Option<&AuthoredImage> {
    301         self.picture.as_ref()
    302     }
    303 
    304     pub fn banner(&self) -> Option<&AuthoredImage> {
    305         self.banner.as_ref()
    306     }
    307 
    308     pub fn nip05(&self) -> Option<&Nip05Identifier> {
    309         self.nip05.as_ref()
    310     }
    311 
    312     pub const fn bot(&self) -> Option<bool> {
    313         self.bot
    314     }
    315 }
    316 
    317 #[cfg(test)]
    318 #[cfg_attr(coverage_nightly, coverage(off))]
    319 mod tests {
    320     use super::*;
    321     use radroots_blossom::{BlobDescriptor, BlobUrl, ByteVerifiedDescriptor, MediaType, Sha256};
    322 
    323     #[test]
    324     fn maps_profile_type_to_tag_value() {
    325         assert_eq!(
    326             radroots_profile_type_tag_value(ProfileType::Individual),
    327             RADROOTS_PROFILE_TYPE_TAG_INDIVIDUAL
    328         );
    329         assert_eq!(
    330             radroots_profile_type_tag_value(ProfileType::Farm),
    331             RADROOTS_PROFILE_TYPE_TAG_FARM
    332         );
    333         assert_eq!(
    334             radroots_profile_type_tag_value(ProfileType::Coop),
    335             RADROOTS_PROFILE_TYPE_TAG_COOP
    336         );
    337         assert_eq!(
    338             radroots_profile_type_tag_value(ProfileType::Any),
    339             RADROOTS_PROFILE_TYPE_TAG_ANY
    340         );
    341         assert_eq!(
    342             radroots_profile_type_tag_value(ProfileType::Radrootsd),
    343             RADROOTS_PROFILE_TYPE_TAG_RADROOTSD
    344         );
    345     }
    346 
    347     #[test]
    348     fn maps_tag_value_to_profile_type() {
    349         assert_eq!(
    350             radroots_profile_type_from_tag_value(RADROOTS_PROFILE_TYPE_TAG_INDIVIDUAL),
    351             Some(ProfileType::Individual)
    352         );
    353         assert_eq!(
    354             radroots_profile_type_from_tag_value(RADROOTS_PROFILE_TYPE_TAG_FARM),
    355             Some(ProfileType::Farm)
    356         );
    357         assert_eq!(
    358             radroots_profile_type_from_tag_value(RADROOTS_PROFILE_TYPE_TAG_COOP),
    359             Some(ProfileType::Coop)
    360         );
    361         assert_eq!(
    362             radroots_profile_type_from_tag_value(RADROOTS_PROFILE_TYPE_TAG_ANY),
    363             Some(ProfileType::Any)
    364         );
    365         assert_eq!(
    366             radroots_profile_type_from_tag_value(RADROOTS_PROFILE_TYPE_TAG_RADROOTSD),
    367             Some(ProfileType::Radrootsd)
    368         );
    369         assert_eq!(radroots_profile_type_from_tag_value("unknown"), None);
    370     }
    371 
    372     #[test]
    373     fn nip05_identifier_accepts_the_pinned_nip05_syntax_without_claiming_trust() {
    374         for (value, canonical) in [
    375             ("alice@example.com", "alice@example.com"),
    376             ("_@example.com", "_@example.com"),
    377             (
    378                 "farm.stand-1@markets.example",
    379                 "farm.stand-1@markets.example",
    380             ),
    381             ("alice@farm2.example", "alice@farm2.example"),
    382             ("alice@xn--bcher-kva.example", "alice@xn--bcher-kva.example"),
    383             ("alice@Example.COM", "alice@example.com"),
    384         ] {
    385             let identifier = Nip05Identifier::parse(value).unwrap();
    386             assert_eq!(identifier.as_str(), canonical);
    387             assert_eq!(identifier.to_string(), canonical);
    388             assert_eq!(canonical.parse::<Nip05Identifier>().unwrap(), identifier);
    389         }
    390 
    391         let identifier = Nip05Identifier::parse("alice@example.com").unwrap();
    392         assert_eq!(identifier.local_part(), "alice");
    393         assert_eq!(identifier.domain(), "example.com");
    394     }
    395 
    396     #[test]
    397     fn nip05_identifier_rejects_noncanonical_or_ambiguous_syntax() {
    398         let cases = [
    399             ("alice", Nip05IdentifierError::MissingSeparator),
    400             (
    401                 "alice@example.com@other.example",
    402                 Nip05IdentifierError::MultipleSeparators,
    403             ),
    404             ("@example.com", Nip05IdentifierError::InvalidLocalPart),
    405             ("Alice@example.com", Nip05IdentifierError::InvalidLocalPart),
    406             (
    407                 "alice+farm@example.com",
    408                 Nip05IdentifierError::InvalidLocalPart,
    409             ),
    410             ("alice@", Nip05IdentifierError::InvalidDomain),
    411             ("alice@-example.com", Nip05IdentifierError::InvalidDomain),
    412             ("alice@example-.com", Nip05IdentifierError::InvalidDomain),
    413             ("alice@example..com", Nip05IdentifierError::InvalidDomain),
    414             ("alice@example.com.", Nip05IdentifierError::InvalidDomain),
    415             ("alice@example_com", Nip05IdentifierError::InvalidDomain),
    416         ];
    417 
    418         for (value, expected) in cases {
    419             let error = Nip05Identifier::parse(value).unwrap_err();
    420             assert_eq!(error, expected, "{value}");
    421             assert!(!error.code().is_empty());
    422             assert!(!error.to_string().is_empty());
    423         }
    424 
    425         let long_label = "a".repeat(64);
    426         assert_eq!(
    427             Nip05Identifier::parse(&format!("alice@{long_label}.example")),
    428             Err(Nip05IdentifierError::InvalidDomain)
    429         );
    430         let long_domain = (0..43).map(|_| "aaaaa").collect::<Vec<_>>().join(".");
    431         assert!(long_domain.len() > 253);
    432         assert_eq!(
    433             Nip05Identifier::parse(&format!("alice@{long_domain}")),
    434             Err(Nip05IdentifierError::InvalidDomain)
    435         );
    436     }
    437 
    438     #[test]
    439     fn authored_profile_requires_name_and_image_only_verified_media() {
    440         let media = AuthoredImage::try_from(verified_descriptor("image/webp", "webp")).unwrap();
    441         let profile = AuthoredProfile::new("alice")
    442             .unwrap()
    443             .with_display_name("Alice")
    444             .with_about("Victoria grower")
    445             .with_picture(media.clone())
    446             .with_banner(media)
    447             .with_nip05(Nip05Identifier::parse("alice@example.com").unwrap())
    448             .with_bot(false);
    449 
    450         assert_eq!(profile.name(), "alice");
    451         assert_eq!(profile.display_name(), Some("Alice"));
    452         assert_eq!(profile.about(), Some("Victoria grower"));
    453         assert_eq!(
    454             profile.nip05().map(Nip05Identifier::as_str),
    455             Some("alice@example.com")
    456         );
    457         assert_eq!(profile.bot(), Some(false));
    458         assert_eq!(
    459             profile
    460                 .picture()
    461                 .map(|value| value.descriptor().url().as_str()),
    462             Some(
    463                 "https://media.example/2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824.webp"
    464             )
    465         );
    466         assert_eq!(
    467             profile
    468                 .banner()
    469                 .map(|value| value.descriptor().url().as_str()),
    470             profile
    471                 .picture()
    472                 .map(|value| value.descriptor().url().as_str())
    473         );
    474 
    475         let error = AuthoredImage::try_from(verified_descriptor("text/plain", "txt")).unwrap_err();
    476         assert_eq!(error, crate::media::AuthoredImageError::MediaTypeNotImage);
    477         assert_eq!(error.code(), "media_type_not_image");
    478         assert!(!error.to_string().is_empty());
    479 
    480         for invalid_name in ["", "   ", "alice\n"] {
    481             let error = AuthoredProfile::new(invalid_name).unwrap_err();
    482             assert_eq!(error, AuthoredProfileError::InvalidName);
    483             assert_eq!(error.code(), "invalid_name");
    484             assert!(!error.to_string().is_empty());
    485         }
    486     }
    487 
    488     fn verified_descriptor(media_type: &str, extension: &str) -> ByteVerifiedDescriptor {
    489         let bytes = b"hello";
    490         let hash = Sha256::digest(bytes);
    491         let media_type = MediaType::parse(media_type).unwrap();
    492         BlobDescriptor::new(
    493             BlobUrl::parse(&format!("https://media.example/{hash}.{extension}")).unwrap(),
    494             hash,
    495             bytes.len() as u64,
    496             media_type.clone(),
    497             1_784_347_200,
    498         )
    499         .unwrap()
    500         .approve_reference()
    501         .unwrap()
    502         .verify_bytes(bytes, &media_type)
    503         .unwrap()
    504     }
    505 }
    506 #[path = "account.rs"]
    507 pub mod account;