lib

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

commit 71a1508d8a746dfb2bdabce7a0f3ab05cd10dc8b
parent 45ccb940ca7100ed8e9a2ec87310d1e9fcd5d769
Author: triesap <tyson@radroots.org>
Date:   Sun,  2 Aug 2026 18:10:26 +0000

core(domain): model kind-zero profile metadata

- bound and normalize public Nostr profile fields
- model canonical event identifiers and authors
- select replaceable metadata by timestamp and event ID
- cover invalid fields, empty values, and deterministic ties

Diffstat:
Mcrates/studio_domain/src/error.rs | 1+
Mcrates/studio_domain/src/lib.rs | 1+
Mcrates/studio_domain/src/profile.rs | 294+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
3 files changed, 296 insertions(+), 0 deletions(-)

diff --git a/crates/studio_domain/src/error.rs b/crates/studio_domain/src/error.rs @@ -7,6 +7,7 @@ pub enum SafeErrorCode { InvalidPublicKey, InvalidSecretKey, InvalidAccountMetadata, + InvalidProfileMetadata, AccountAlreadyExists, AccountNotFound, KeyringUnavailable, diff --git a/crates/studio_domain/src/lib.rs b/crates/studio_domain/src/lib.rs @@ -10,5 +10,6 @@ pub mod time; pub use account::{AccountCreatedAt, AccountLabel, AccountSummary, KeyAvailability, SignerKind}; pub use error::{SafeError, SafeErrorCode, SafeMessage}; pub use key::{Npub, Nsec, PublicKey, SecretKeyInput, SecretKeyInputKind}; +pub use profile::{EventId, Kind0ProfileCandidate, ProfileMetadata, select_latest_kind0}; pub use relay::{RelayUrl, normalize_relay_urls}; pub use time::UnixTimestamp; diff --git a/crates/studio_domain/src/profile.rs b/crates/studio_domain/src/profile.rs @@ -1 +1,295 @@ //! Public Nostr profile metadata values. + +use crate::{PublicKey, SafeError, SafeErrorCode, SafeMessage, UnixTimestamp}; + +const EVENT_ID_BYTES: usize = 32; +const EVENT_ID_HEX: usize = EVENT_ID_BYTES * 2; +const MAX_NAME_CHARS: usize = 128; +const MAX_NIP05_CHARS: usize = 320; +const MAX_ABOUT_CHARS: usize = 4_096; +const MAX_PICTURE_CHARS: usize = 2_048; + +#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +pub struct EventId([u8; EVENT_ID_BYTES]); + +impl EventId { + /// Parses a canonical lowercase hexadecimal Nostr event ID. + /// + /// # Errors + /// + /// Returns a safe profile error for malformed input. + pub fn from_hex(value: &str) -> Result<Self, SafeError> { + if value.len() != EVENT_ID_HEX + || !value + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + { + return Err(invalid_profile_metadata()); + } + + let mut bytes = [0_u8; EVENT_ID_BYTES]; + for (index, pair) in value.as_bytes().chunks_exact(2).enumerate() { + let high = decode_hex(pair[0]).ok_or_else(invalid_profile_metadata)?; + let low = decode_hex(pair[1]).ok_or_else(invalid_profile_metadata)?; + bytes[index] = (high << 4) | low; + } + Ok(Self(bytes)) + } + + #[must_use] + pub const fn from_bytes(bytes: [u8; EVENT_ID_BYTES]) -> Self { + Self(bytes) + } + + #[must_use] + pub const fn as_bytes(self) -> [u8; EVENT_ID_BYTES] { + self.0 + } + + #[must_use] + pub fn to_hex(self) -> String { + const HEX: &[u8; 16] = b"0123456789abcdef"; + let mut output = String::with_capacity(EVENT_ID_HEX); + for byte in self.0 { + output.push(char::from(HEX[usize::from(byte >> 4)])); + output.push(char::from(HEX[usize::from(byte & 0x0f)])); + } + output + } +} + +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct ProfileMetadata { + name: Option<String>, + display_name: Option<String>, + nip05: Option<String>, + about: Option<String>, + picture: Option<String>, +} + +impl ProfileMetadata { + /// Normalizes and bounds public kind-0 profile fields. + /// + /// # Errors + /// + /// Returns a safe profile error when a field exceeds its limit or contains + /// a forbidden control character. + pub fn new( + name: Option<String>, + display_name: Option<String>, + nip05: Option<String>, + about: Option<String>, + picture: Option<String>, + ) -> Result<Self, SafeError> { + Ok(Self { + name: normalize_field(name, MAX_NAME_CHARS, false)?, + display_name: normalize_field(display_name, MAX_NAME_CHARS, false)?, + nip05: normalize_field(nip05, MAX_NIP05_CHARS, false)?, + about: normalize_field(about, MAX_ABOUT_CHARS, true)?, + picture: normalize_field(picture, MAX_PICTURE_CHARS, false)?, + }) + } + + #[must_use] + pub fn name(&self) -> Option<&str> { + self.name.as_deref() + } + + #[must_use] + pub fn display_name(&self) -> Option<&str> { + self.display_name.as_deref() + } + + #[must_use] + pub fn nip05(&self) -> Option<&str> { + self.nip05.as_deref() + } + + #[must_use] + pub fn about(&self) -> Option<&str> { + self.about.as_deref() + } + + #[must_use] + pub fn picture(&self) -> Option<&str> { + self.picture.as_deref() + } + + #[must_use] + pub fn preferred_name(&self) -> Option<&str> { + self.display_name().or_else(|| self.name()) + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Kind0ProfileCandidate { + event_id: EventId, + author: PublicKey, + created_at: UnixTimestamp, + metadata: ProfileMetadata, +} + +impl Kind0ProfileCandidate { + #[must_use] + pub const fn new( + event_id: EventId, + author: PublicKey, + created_at: UnixTimestamp, + metadata: ProfileMetadata, + ) -> Self { + Self { + event_id, + author, + created_at, + metadata, + } + } + + #[must_use] + pub const fn event_id(&self) -> EventId { + self.event_id + } + + #[must_use] + pub const fn author(&self) -> PublicKey { + self.author + } + + #[must_use] + pub const fn created_at(&self) -> UnixTimestamp { + self.created_at + } + + #[must_use] + pub const fn metadata(&self) -> &ProfileMetadata { + &self.metadata + } +} + +#[must_use] +pub fn select_latest_kind0( + candidates: impl IntoIterator<Item = Kind0ProfileCandidate>, +) -> Option<Kind0ProfileCandidate> { + candidates.into_iter().reduce(|selected, candidate| { + if candidate.created_at > selected.created_at + || (candidate.created_at == selected.created_at + && candidate.event_id < selected.event_id) + { + candidate + } else { + selected + } + }) +} + +fn normalize_field( + value: Option<String>, + max_chars: usize, + allow_layout_controls: bool, +) -> Result<Option<String>, SafeError> { + let Some(value) = value else { + return Ok(None); + }; + let normalized = value.trim(); + if normalized.is_empty() { + return Ok(None); + } + if normalized.chars().count() > max_chars + || normalized.chars().any(|character| { + character.is_control() + && !(allow_layout_controls && matches!(character, '\n' | '\r' | '\t')) + }) + { + return Err(invalid_profile_metadata()); + } + Ok(Some(normalized.to_owned())) +} + +const fn invalid_profile_metadata() -> SafeError { + SafeError::new( + SafeErrorCode::InvalidProfileMetadata, + SafeMessage::new("The Nostr profile metadata is invalid."), + ) +} + +const fn decode_hex(byte: u8) -> Option<u8> { + match byte { + b'0'..=b'9' => Some(byte - b'0'), + b'a'..=b'f' => Some(byte - b'a' + 10), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use crate::{PublicKey, UnixTimestamp}; + + use super::{EventId, Kind0ProfileCandidate, ProfileMetadata, select_latest_kind0}; + + fn profile(name: &str) -> ProfileMetadata { + ProfileMetadata::new(Some(name.to_owned()), None, None, None, None).expect("valid profile") + } + + fn candidate(id_byte: u8, created_at: i64, name: &str) -> Kind0ProfileCandidate { + Kind0ProfileCandidate::new( + EventId::from_bytes([id_byte; 32]), + PublicKey::from_bytes([9_u8; 32]), + UnixTimestamp::from_seconds(created_at).expect("valid timestamp"), + profile(name), + ) + } + + #[test] + fn profile_fields_are_trimmed_bounded_and_public() { + let metadata = ProfileMetadata::new( + Some(" farmer ".to_owned()), + Some(" Farm Account ".to_owned()), + Some("farmer@example.test".to_owned()), + Some("First line\nSecond line".to_owned()), + Some("https://images.example.test/profile.png".to_owned()), + ) + .expect("valid profile"); + + assert_eq!(metadata.name(), Some("farmer")); + assert_eq!(metadata.display_name(), Some("Farm Account")); + assert_eq!(metadata.preferred_name(), Some("Farm Account")); + assert_eq!(metadata.nip05(), Some("farmer@example.test")); + assert_eq!(metadata.about(), Some("First line\nSecond line")); + assert_eq!( + metadata.picture(), + Some("https://images.example.test/profile.png") + ); + } + + #[test] + fn profile_fields_reject_forbidden_controls_and_oversize_values() { + assert!( + ProfileMetadata::new(Some("bad\0name".to_owned()), None, None, None, None).is_err() + ); + assert!(ProfileMetadata::new(Some("x".repeat(129)), None, None, None, None).is_err()); + } + + #[test] + fn latest_kind0_uses_timestamp_then_lowest_event_id() { + let older = candidate(0, 10, "older"); + let equal_high_id = candidate(9, 20, "high-id"); + let equal_low_id = candidate(1, 20, "low-id"); + + let selected = + select_latest_kind0([older, equal_high_id, equal_low_id]).expect("selected profile"); + + assert_eq!(selected.metadata().name(), Some("low-id")); + assert_eq!(selected.event_id().as_bytes(), [1_u8; 32]); + assert_eq!(selected.author(), PublicKey::from_bytes([9_u8; 32])); + assert_eq!(selected.created_at().as_seconds(), 20); + } + + #[test] + fn event_id_rejects_noncanonical_hex_and_round_trips() { + let hex = "12".repeat(32); + let event_id = EventId::from_hex(&hex).expect("valid event id"); + + assert_eq!(event_id.to_hex(), hex); + assert!(EventId::from_hex(&"GG".repeat(32)).is_err()); + } +}