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:
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());
}
}