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 }