use std::fmt;
use std::str::FromStr;
use serde::{Deserialize, Serialize};
use unicode_normalization::UnicodeNormalization;
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum UsernameError {
#[error("username is empty or whitespace after normalization")]
Empty,
#[error("username is {len} bytes after normalization, maximum is {max}")]
TooLong {
len: usize,
max: usize,
},
#[error("username has the shape of an address, which is not claimable")]
AddressShaped,
#[error("username contains a control or format character")]
ControlCharacter,
}
fn is_format_character(c: char) -> bool {
matches!(c,
'\u{00AD}' | '\u{0600}'..='\u{0605}'
| '\u{061C}' | '\u{06DD}' | '\u{070F}' | '\u{0890}'..='\u{0891}'
| '\u{08E2}' | '\u{180E}' | '\u{200B}'..='\u{200F}' | '\u{202A}'..='\u{202E}' | '\u{2060}'..='\u{2064}'
| '\u{2066}'..='\u{206F}' | '\u{FEFF}' | '\u{FFF9}'..='\u{FFFB}'
| '\u{110BD}'
| '\u{110CD}'
| '\u{13430}'..='\u{1343F}'
| '\u{1BCA0}'..='\u{1BCA3}'
| '\u{1D173}'..='\u{1D17A}'
| '\u{E0001}' | '\u{E0020}'..='\u{E007F}' )
}
pub fn contains_control_or_format(s: &str) -> bool {
s.chars().any(|c| c.is_control() || is_format_character(c))
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct Username {
normalized: String,
}
impl Username {
pub const MAX_BYTES: usize = 64;
const ADDRESS_LEN: usize = 44;
const ADDRESS_PREFIX: &'static str = "off1";
fn normalize(raw: &str) -> String {
raw.to_lowercase().nfc().collect()
}
fn looks_like_address(candidate: &str) -> bool {
candidate.len() == Self::ADDRESS_LEN
&& candidate.starts_with(Self::ADDRESS_PREFIX)
&& candidate
.bytes()
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit())
}
pub fn as_str(&self) -> &str {
&self.normalized
}
pub fn into_string(self) -> String {
self.normalized
}
}
impl FromStr for Username {
type Err = UsernameError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let normalized = Self::normalize(s);
if normalized.is_empty() || normalized.chars().all(char::is_whitespace) {
return Err(UsernameError::Empty);
}
if normalized.len() > Self::MAX_BYTES {
return Err(UsernameError::TooLong {
len: normalized.len(),
max: Self::MAX_BYTES,
});
}
if contains_control_or_format(&normalized) {
return Err(UsernameError::ControlCharacter);
}
if Self::looks_like_address(&normalized) {
return Err(UsernameError::AddressShaped);
}
Ok(Self { normalized })
}
}
impl fmt::Display for Username {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.normalized)
}
}
impl Serialize for Username {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_str(&self.normalized)
}
}
impl<'de> Deserialize<'de> for Username {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let s = String::deserialize(deserializer)?;
let parsed: Self = s.parse().map_err(serde::de::Error::custom)?;
if parsed.as_str() != s {
return Err(serde::de::Error::custom(
"username is not in normalized form",
));
}
Ok(parsed)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn parse(s: &str) -> Username {
s.parse().expect("username should parse")
}
#[test]
fn username_lowercases_ascii() {
assert_eq!(parse("Alice").as_str(), "alice");
assert_eq!(parse("ALICE").as_str(), "alice");
}
#[test]
fn username_lowercases_with_the_full_language_insensitive_mapping() {
let parsed = parse("\u{0130}");
assert_eq!(
parsed.as_str(),
"i\u{0307}",
"the full mapping expands İ to i + COMBINING DOT ABOVE; a bare 'i' \
means the simple mapping was used and every tag for such a name \
will disagree with a conforming implementation"
);
assert_eq!(parsed.as_str().len(), 3, "i + U+0307 is 3 UTF-8 bytes");
assert_eq!(parse("I").as_str(), "i");
}
#[test]
fn username_applies_nfc() {
let decomposed = "cafe\u{0301}";
let composed = "caf\u{00e9}";
assert_ne!(decomposed, composed, "fixture must start decomposed");
assert_eq!(parse(decomposed).as_str(), composed);
assert_eq!(parse(decomposed), parse(composed));
}
#[test]
fn username_normalization_is_idempotent() {
for raw in [
"Alice",
"cafe\u{0301}",
"\u{1e9e}", "İ", "Džungla", ] {
let once = parse(raw);
let twice = parse(once.as_str());
assert_eq!(
once, twice,
"normalizing {:?} twice must equal normalizing it once",
raw
);
}
}
#[test]
fn username_rejects_empty_and_whitespace_only() {
assert_eq!("".parse::<Username>(), Err(UsernameError::Empty));
for blank in [" ", " ", "\u{00A0}", "\u{3000}", " \u{2009}"] {
assert_eq!(
blank.parse::<Username>(),
Err(UsernameError::Empty),
"whitespace-only {:?} must not be a claimable name",
blank
);
}
}
#[test]
fn username_rejects_over_length() {
let long = "a".repeat(Username::MAX_BYTES + 1);
assert_eq!(
long.parse::<Username>(),
Err(UsernameError::TooLong {
len: Username::MAX_BYTES + 1,
max: Username::MAX_BYTES,
})
);
let at_limit = "a".repeat(Username::MAX_BYTES);
assert!(at_limit.parse::<Username>().is_ok());
}
#[test]
fn username_length_is_measured_after_normalization() {
let raw = "\u{1e9e}".repeat(Username::MAX_BYTES / 2 + 1);
assert!(matches!(
raw.parse::<Username>(),
Err(UsernameError::TooLong { .. })
));
}
#[test]
fn username_rejects_control_characters() {
assert_eq!(
"ali\nce".parse::<Username>(),
Err(UsernameError::ControlCharacter)
);
}
#[test]
fn username_rejects_format_characters_that_control_check_alone_misses() {
for (label, raw) in [
("right-to-left override", "ali\u{202E}ce"),
("zero-width joiner", "ali\u{200D}ce"),
("zero-width non-joiner", "ali\u{200C}ce"),
("zero-width space", "ali\u{200B}ce"),
("left-to-right mark", "ali\u{200E}ce"),
("soft hyphen", "ali\u{00AD}ce"),
("word joiner", "ali\u{2060}ce"),
("byte-order mark", "ali\u{FEFF}ce"),
("tag character", "alice\u{E0041}"),
] {
let parsed = raw.parse::<Username>();
assert!(
!raw.chars().any(char::is_control),
"{label} must not be Cc, or this test proves nothing new"
);
assert_eq!(
parsed,
Err(UsernameError::ControlCharacter),
"a username carrying a {label} must be refused"
);
}
}
#[test]
fn username_rejects_every_format_character_range() {
for c in [
'\u{00AD}',
'\u{0600}',
'\u{061C}',
'\u{06DD}',
'\u{070F}',
'\u{0890}',
'\u{08E2}',
'\u{180E}',
'\u{200B}',
'\u{202A}',
'\u{2060}',
'\u{2066}',
'\u{FEFF}',
'\u{FFF9}',
'\u{110BD}',
'\u{110CD}',
'\u{13430}',
'\u{1BCA0}',
'\u{1D173}',
'\u{E0001}',
'\u{E0020}',
] {
assert!(
is_format_character(c),
"U+{:04X} must be screened as a format character",
c as u32
);
assert_eq!(
format!("ali{c}ce").parse::<Username>(),
Err(UsernameError::ControlCharacter),
"U+{:04X} must be refused in a username",
c as u32
);
}
}
#[test]
fn username_allows_ordinary_international_names() {
for raw in [
"alice",
"josé",
"мария",
"上田",
"أحمد",
"ali ce",
"a_b-c.d",
] {
assert!(
raw.parse::<Username>().is_ok(),
"{raw:?} is a legitimate name and must parse"
);
}
}
#[test]
fn username_rejects_address_shaped_names() {
let address = "off1qysluvwl5922yctzd0u9gpr06gn3k7ldfvgtwgvn";
assert_eq!(address.len(), Username::ADDRESS_LEN);
assert_eq!(
address.parse::<Username>(),
Err(UsernameError::AddressShaped)
);
assert_eq!(
address.to_uppercase().parse::<Username>(),
Err(UsernameError::AddressShaped)
);
}
#[test]
fn username_rejects_address_shaped_names_with_bad_checksums() {
let mangled = "off1qysluvwl5922yctzd0u9gpr06gn3k7ldfvgtwgqq";
assert_eq!(mangled.len(), Username::ADDRESS_LEN);
assert_eq!(
mangled.parse::<Username>(),
Err(UsernameError::AddressShaped)
);
}
#[test]
fn username_address_shape_matches_real_addresses() {
use crate::Address;
assert_eq!(Username::ADDRESS_LEN, Address::ENCODED_LEN);
let address = Address::from_hash_bytes([0u8; Address::HASH_LEN]).to_string();
assert!(
Username::looks_like_address(&address),
"a real address must be refused as a username: {}",
address
);
}
#[test]
fn username_allows_names_that_merely_start_with_the_address_prefix() {
assert_eq!(parse("off1ce").as_str(), "off1ce");
}
#[test]
fn username_round_trips_through_serde() {
let username = parse("alice");
let json = serde_json::to_string(&username).expect("serialize");
assert_eq!(json, "\"alice\"");
let back: Username = serde_json::from_str(&json).expect("deserialize");
assert_eq!(back, username);
}
#[test]
fn username_deserialization_refuses_unnormalized_input() {
for raw in ["\"Alice\"", "\"cafe\\u0301\""] {
assert!(
serde_json::from_str::<Username>(raw).is_err(),
"unnormalized {} must not deserialize",
raw
);
}
}
}