lib

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

commit f67175bcf8d08464f354606c2fa3227d8ba1dffa
parent f431ef3a91938612079ce49cd405a5566d1d0eea
Author: triesap <tyson@radroots.org>
Date:   Mon, 27 Jul 2026 18:22:44 +0000

identity: move username and public profile models

- replace helper functions with a normalized validated Username newtype
- add private-field Profile and invariant-matched PublicIdentity values
- keep canonical profile metadata transport-neutral and event-free
- enforce checked serde, no-std, wasm, and architecture behavior

Diffstat:
Mcrates/identity/src/account.rs | 1+
Mcrates/identity/src/error.rs | 16++++++++++++++++
Mcrates/identity/src/key.rs | 4++++
Mcrates/identity/src/lib.rs | 6++----
Mcrates/identity/src/profile.rs | 185+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Mcrates/identity/src/username.rs | 227+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------
6 files changed, 373 insertions(+), 66 deletions(-)

diff --git a/crates/identity/src/account.rs b/crates/identity/src/account.rs @@ -23,6 +23,7 @@ impl From<IdentityId> for AccountId { #[cfg(test)] mod tests { + use alloc::format; use core::str::FromStr; use super::*; diff --git a/crates/identity/src/error.rs b/crates/identity/src/error.rs @@ -22,6 +22,22 @@ pub enum Error { #[error("public key bytes are not a valid secp256k1 x-only public key")] InvalidPublicKeyBytes, + + #[error("public identity identifier does not match its public key")] + IdentityIdMismatch, + + #[error("username length must be between {min} and {max} ASCII bytes, but was {actual}")] + InvalidUsernameLength { + min: usize, + max: usize, + actual: usize, + }, + + #[error("username contains an invalid character at byte {index}")] + InvalidUsernameCharacter { index: usize }, + + #[error("username dots cannot be leading, trailing, or consecutive")] + InvalidUsernameDotPlacement, } /// Transitional errors from the legacy secret and filesystem identity API. diff --git a/crates/identity/src/key.rs b/crates/identity/src/key.rs @@ -276,6 +276,10 @@ impl From<PublicKey> for IdentityId { #[cfg(test)] mod tests { + use alloc::{ + format, + string::{String, ToString}, + }; use core::str::FromStr; use super::*; diff --git a/crates/identity/src/lib.rs b/crates/identity/src/lib.rs @@ -28,6 +28,7 @@ pub use identity::{ RadrootsIdentityEncryptedSecretKeyOptions, RadrootsIdentityEncryptedSecretKeySecurity, }; pub use key::{IdentityId, PublicKey}; +pub use profile::{Profile, PublicIdentity}; #[cfg(all(feature = "std", feature = "serde"))] pub use storage::{ RADROOTS_ENCRYPTED_IDENTITY_DEFAULT_KEY_SLOT, RADROOTS_ENCRYPTED_IDENTITY_KEY_SUFFIX, @@ -36,7 +37,4 @@ pub use storage::{ rotate_encrypted_identity_with_key_slot, store_encrypted_identity, store_encrypted_identity_with_key_slot, store_identity_profile, }; -pub use username::{ - RADROOTS_USERNAME_MAX_LEN, RADROOTS_USERNAME_MIN_LEN, RADROOTS_USERNAME_REGEX, - radroots_username_is_valid, radroots_username_normalize, -}; +pub use username::Username; diff --git a/crates/identity/src/profile.rs b/crates/identity/src/profile.rs @@ -1,4 +1,181 @@ -//! Public, transport-neutral profile value types. -//! -//! The canonical profile definitions are introduced by the ordered identity -//! migration steps. +//! Public, transport-neutral identity profiles. + +use crate::{Error, IdentityId, PublicKey, Username}; + +/// Public identity metadata that is independent of any transport event. +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "serde", serde(deny_unknown_fields))] +#[derive(Clone, Debug, Default, PartialEq, Eq, Hash)] +pub struct Profile { + #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))] + username: Option<Username>, +} + +impl Profile { + /// Creates an empty public profile. + #[must_use] + pub const fn new() -> Self { + Self { username: None } + } + + /// Returns a profile with its canonical username set. + #[must_use] + pub fn with_username(mut self, username: Username) -> Self { + self.username = Some(username); + self + } + + /// Borrows the canonical username, when present. + #[must_use] + pub fn username(&self) -> Option<&Username> { + self.username.as_ref() + } + + /// Reports whether the profile contains no public metadata. + #[must_use] + pub const fn is_empty(&self) -> bool { + self.username.is_none() + } +} + +/// A public identity with an invariant-matched identifier and public key. +#[cfg_attr(feature = "serde", derive(serde::Serialize))] +#[cfg_attr(feature = "serde", serde(deny_unknown_fields))] +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct PublicIdentity { + id: IdentityId, + public_key: PublicKey, + #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))] + profile: Option<Profile>, +} + +impl PublicIdentity { + /// Creates a public identity whose identifier is derived from its key. + #[must_use] + pub const fn new(public_key: PublicKey) -> Self { + Self { + id: IdentityId::from_public_key(public_key), + public_key, + profile: None, + } + } + + /// Validates an identity assembled from separately decoded parts. + pub fn try_from_parts( + id: IdentityId, + public_key: PublicKey, + profile: Option<Profile>, + ) -> Result<Self, Error> { + if id != IdentityId::from_public_key(public_key) { + return Err(Error::IdentityIdMismatch); + } + Ok(Self { + id, + public_key, + profile: profile.filter(|value| !value.is_empty()), + }) + } + + /// Returns a public identity with non-empty profile metadata attached. + #[must_use] + pub fn with_profile(mut self, profile: Profile) -> Self { + self.profile = (!profile.is_empty()).then_some(profile); + self + } + + /// Returns the canonical identity identifier. + #[must_use] + pub const fn id(&self) -> IdentityId { + self.id + } + + /// Returns the canonical public key. + #[must_use] + pub const fn public_key(&self) -> PublicKey { + self.public_key + } + + /// Borrows public profile metadata, when present. + #[must_use] + pub fn profile(&self) -> Option<&Profile> { + self.profile.as_ref() + } +} + +#[cfg(feature = "serde")] +impl<'de> serde::Deserialize<'de> for PublicIdentity { + fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> + where + D: serde::Deserializer<'de>, + { + #[derive(serde::Deserialize)] + #[serde(deny_unknown_fields)] + struct PublicIdentityRepr { + id: IdentityId, + public_key: PublicKey, + #[serde(default)] + profile: Option<Profile>, + } + + let value = PublicIdentityRepr::deserialize(deserializer)?; + Self::try_from_parts(value.id, value.public_key, value.profile) + .map_err(serde::de::Error::custom) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const ALICE: &str = "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df"; + const BOB: &str = "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af"; + + #[test] + fn public_identity_derives_and_protects_its_identifier() { + let public_key = PublicKey::from_hex(ALICE).unwrap(); + let identity = PublicIdentity::new(public_key); + + assert_eq!(identity.id(), IdentityId::from(public_key)); + assert_eq!(identity.public_key(), public_key); + assert!(identity.profile().is_none()); + + let mismatched_id = IdentityId::from_hex(BOB).unwrap(); + assert!(matches!( + PublicIdentity::try_from_parts(mismatched_id, public_key, None), + Err(Error::IdentityIdMismatch) + )); + } + + #[test] + fn public_identity_discards_empty_profiles_and_retains_usernames() { + let public_key = PublicKey::from_hex(ALICE).unwrap(); + let empty = PublicIdentity::new(public_key).with_profile(Profile::new()); + assert!(empty.profile().is_none()); + + let username = Username::parse("Alice.Farm").unwrap(); + let identity = PublicIdentity::new(public_key) + .with_profile(Profile::new().with_username(username.clone())); + assert_eq!( + identity.profile().and_then(Profile::username), + Some(&username) + ); + } + + #[cfg(feature = "serde")] + #[test] + fn public_identity_serde_revalidates_key_identity_and_profile() { + let public_key = PublicKey::from_hex(ALICE).unwrap(); + let identity = PublicIdentity::new(public_key) + .with_profile(Profile::new().with_username(Username::parse("Alice.Farm").unwrap())); + let encoded = serde_json::to_string(&identity).unwrap(); + assert_eq!( + serde_json::from_str::<PublicIdentity>(&encoded).unwrap(), + identity + ); + + let mismatched = encoded.replace(ALICE, BOB).replacen(BOB, ALICE, 1); + assert!(serde_json::from_str::<PublicIdentity>(&mismatched).is_err()); + assert!(!encoded.contains("metadata")); + assert!(!encoded.contains("application_handler")); + } +} diff --git a/crates/identity/src/username.rs b/crates/identity/src/username.rs @@ -1,91 +1,202 @@ -#![forbid(unsafe_code)] +//! Canonical public usernames. -#[cfg(not(feature = "std"))] use alloc::string::String; +use core::{fmt, str::FromStr}; -pub const RADROOTS_USERNAME_MIN_LEN: usize = 3; -pub const RADROOTS_USERNAME_MAX_LEN: usize = 30; -pub const RADROOTS_USERNAME_REGEX: &str = r"^(?!.*\.\.)(?!\.)(?!.*\.$)[a-z0-9._-]{3,30}$"; +use crate::Error; -pub fn radroots_username_is_valid(username: &str) -> bool { - if !username.is_ascii() { - return false; +/// Minimum canonical username length in ASCII bytes. +pub const MIN_LENGTH: usize = 3; + +/// Maximum canonical username length in ASCII bytes. +pub const MAX_LENGTH: usize = 30; + +/// A normalized public Radroots username. +/// +/// Usernames are lowercase ASCII and may contain letters, digits, `.`, `_`, +/// and `-`. A dot cannot occur first, last, or consecutively. Parsing trims +/// surrounding whitespace and canonicalizes ASCII uppercase letters. +#[repr(transparent)] +#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct Username(String); + +impl Username { + /// Parses and normalizes a public username. + pub fn parse(value: &str) -> Result<Self, Error> { + let canonical = value.trim().to_ascii_lowercase(); + validate(&canonical)?; + Ok(Self(canonical)) } - let len = username.len(); - if !(RADROOTS_USERNAME_MIN_LEN..=RADROOTS_USERNAME_MAX_LEN).contains(&len) { - return false; + + /// Borrows the canonical username text. + #[must_use] + pub fn as_str(&self) -> &str { + &self.0 + } + + /// Returns the canonical username text. + #[must_use] + pub fn into_string(self) -> String { + self.0 } - let bytes = username.as_bytes(); +} + +fn validate(value: &str) -> Result<(), Error> { + let length = value.len(); + if !(MIN_LENGTH..=MAX_LENGTH).contains(&length) { + return Err(Error::InvalidUsernameLength { + min: MIN_LENGTH, + max: MAX_LENGTH, + actual: length, + }); + } + + let bytes = value.as_bytes(); if bytes.first() == Some(&b'.') || bytes.last() == Some(&b'.') { - return false; + return Err(Error::InvalidUsernameDotPlacement); } - let mut prev_dot = false; - for &byte in bytes { + + let mut previous_dot = false; + for (index, byte) in bytes.iter().copied().enumerate() { if byte == b'.' { - if prev_dot { - return false; + if previous_dot { + return Err(Error::InvalidUsernameDotPlacement); } - prev_dot = true; + previous_dot = true; continue; } - prev_dot = false; - let is_alpha = byte.is_ascii_lowercase(); - let is_digit = byte.is_ascii_digit(); - let is_allowed = is_alpha || is_digit || byte == b'_' || byte == b'-'; - if !is_allowed { - return false; + previous_dot = false; + if !(byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'_' | b'-')) { + return Err(Error::InvalidUsernameCharacter { index }); } } - true + Ok(()) } -pub fn radroots_username_normalize(input: &str) -> Option<String> { - let trimmed = input.trim(); - if trimmed.is_empty() { - return None; +impl fmt::Debug for Username { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_tuple("Username") + .field(&self.as_str()) + .finish() } - let normalized = trimmed.to_ascii_lowercase(); - if radroots_username_is_valid(&normalized) { - Some(normalized) - } else { - None +} + +impl fmt::Display for Username { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(self.as_str()) + } +} + +impl FromStr for Username { + type Err = Error; + + fn from_str(value: &str) -> Result<Self, Self::Err> { + Self::parse(value) } } -#[cfg(all(test, feature = "std"))] +impl TryFrom<&str> for Username { + type Error = Error; + + fn try_from(value: &str) -> Result<Self, Self::Error> { + Self::parse(value) + } +} + +impl TryFrom<String> for Username { + type Error = Error; + + fn try_from(value: String) -> Result<Self, Self::Error> { + Self::parse(&value) + } +} + +impl AsRef<str> for Username { + fn as_ref(&self) -> &str { + self.as_str() + } +} + +#[cfg(feature = "serde")] +impl serde::Serialize for Username { + fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error> + where + S: serde::Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +#[cfg(feature = "serde")] +impl<'de> serde::Deserialize<'de> for Username { + fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> + where + D: serde::Deserializer<'de>, + { + struct UsernameVisitor; + + impl serde::de::Visitor<'_> for UsernameVisitor { + type Value = Username; + + fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("a valid public Radroots username") + } + + fn visit_str<E>(self, value: &str) -> Result<Self::Value, E> + where + E: serde::de::Error, + { + Username::parse(value).map_err(E::custom) + } + } + + deserializer.deserialize_str(UsernameVisitor) + } +} + +#[cfg(test)] mod tests { + use alloc::string::ToString; + use super::*; #[test] - fn valid_usernames() { - for name in ["radroots", "radroots_1", "radroots.test", "rr-01"] { - assert!(radroots_username_is_valid(name)); - } + fn usernames_normalize_to_one_canonical_form() { + let username = Username::parse(" RadRoots.Test ").unwrap(); + assert_eq!(username.as_str(), "radroots.test"); + assert_eq!(username.to_string(), "radroots.test"); + assert_eq!(Username::from_str("radroots.test").unwrap(), username); } #[test] - fn invalid_usernames() { - for name in [ - "ra", - ".radroots", - "radroots.", - "radroots..test", - "radroots!", - "RADROOTS", - "rädroots", - "radroots-radroots-radroots-radroots", - ] { - assert!(!radroots_username_is_valid(name)); + fn usernames_reject_invalid_lengths_characters_and_dots() { + assert!(matches!( + Username::parse("rr"), + Err(Error::InvalidUsernameLength { actual: 2, .. }) + )); + assert!(matches!( + Username::parse("rad roots"), + Err(Error::InvalidUsernameCharacter { index: 3 }) + )); + for value in [".radroots", "radroots.", "radroots..test"] { + assert!(matches!( + Username::parse(value), + Err(Error::InvalidUsernameDotPlacement) + )); } + assert!(matches!( + Username::parse("rädroots"), + Err(Error::InvalidUsernameCharacter { index: 1 }) + )); } + #[cfg(feature = "serde")] #[test] - fn normalize_usernames() { - assert_eq!( - radroots_username_normalize(" RadRoots "), - Some("radroots".to_string()) - ); - assert_eq!(radroots_username_normalize("ra"), None); - assert_eq!(radroots_username_normalize(" "), None); + fn username_serde_is_checked_and_canonical() { + let username: Username = serde_json::from_str("\" RadRoots \"").unwrap(); + assert_eq!(username.as_str(), "radroots"); + assert_eq!(serde_json::to_string(&username).unwrap(), "\"radroots\""); + assert!(serde_json::from_str::<Username>("\"rr\"").is_err()); } }