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;