lib

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

username.rs (6542B)


      1 //! Canonical public usernames.
      2 //!
      3 //! [`Username`] trims surrounding whitespace, lowercases ASCII input, enforces
      4 //! [`MIN_LENGTH`] and [`MAX_LENGTH`], and accepts only the documented public
      5 //! character set and dot placement. Parsing and serde decoding use the same
      6 //! validator and always produce canonical text.
      7 
      8 use alloc::string::String;
      9 use core::{fmt, str::FromStr};
     10 
     11 use crate::Error;
     12 
     13 /// Minimum canonical username length in ASCII bytes.
     14 pub const MIN_LENGTH: usize = 3;
     15 
     16 /// Maximum canonical username length in ASCII bytes.
     17 pub const MAX_LENGTH: usize = 30;
     18 
     19 /// A normalized public Radroots username.
     20 ///
     21 /// Usernames are lowercase ASCII and may contain letters, digits, `.`, `_`,
     22 /// and `-`. A dot cannot occur first, last, or consecutively. Parsing trims
     23 /// surrounding whitespace and canonicalizes ASCII uppercase letters.
     24 #[repr(transparent)]
     25 #[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
     26 pub struct Username(String);
     27 
     28 impl Username {
     29     /// Parses and normalizes a public username.
     30     pub fn parse(value: &str) -> Result<Self, Error> {
     31         let canonical = value.trim().to_ascii_lowercase();
     32         validate(&canonical)?;
     33         Ok(Self(canonical))
     34     }
     35 
     36     /// Borrows the canonical username text.
     37     #[must_use]
     38     pub fn as_str(&self) -> &str {
     39         &self.0
     40     }
     41 
     42     /// Returns the canonical username text.
     43     #[must_use]
     44     pub fn into_string(self) -> String {
     45         self.0
     46     }
     47 }
     48 
     49 fn validate(value: &str) -> Result<(), Error> {
     50     let length = value.len();
     51     if !(MIN_LENGTH..=MAX_LENGTH).contains(&length) {
     52         return Err(Error::InvalidUsernameLength {
     53             min: MIN_LENGTH,
     54             max: MAX_LENGTH,
     55             actual: length,
     56         });
     57     }
     58 
     59     let bytes = value.as_bytes();
     60     if bytes.first() == Some(&b'.') || bytes.last() == Some(&b'.') {
     61         return Err(Error::InvalidUsernameDotPlacement);
     62     }
     63 
     64     let mut previous_dot = false;
     65     for (index, byte) in bytes.iter().copied().enumerate() {
     66         if byte == b'.' {
     67             if previous_dot {
     68                 return Err(Error::InvalidUsernameDotPlacement);
     69             }
     70             previous_dot = true;
     71             continue;
     72         }
     73         previous_dot = false;
     74         if !(byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'_' | b'-')) {
     75             return Err(Error::InvalidUsernameCharacter { index });
     76         }
     77     }
     78     Ok(())
     79 }
     80 
     81 impl fmt::Debug for Username {
     82     fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
     83         formatter
     84             .debug_tuple("Username")
     85             .field(&self.as_str())
     86             .finish()
     87     }
     88 }
     89 
     90 impl fmt::Display for Username {
     91     fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
     92         formatter.write_str(self.as_str())
     93     }
     94 }
     95 
     96 impl FromStr for Username {
     97     type Err = Error;
     98 
     99     fn from_str(value: &str) -> Result<Self, Self::Err> {
    100         Self::parse(value)
    101     }
    102 }
    103 
    104 impl TryFrom<&str> for Username {
    105     type Error = Error;
    106 
    107     fn try_from(value: &str) -> Result<Self, Self::Error> {
    108         Self::parse(value)
    109     }
    110 }
    111 
    112 impl TryFrom<String> for Username {
    113     type Error = Error;
    114 
    115     fn try_from(value: String) -> Result<Self, Self::Error> {
    116         Self::parse(&value)
    117     }
    118 }
    119 
    120 impl AsRef<str> for Username {
    121     fn as_ref(&self) -> &str {
    122         self.as_str()
    123     }
    124 }
    125 
    126 #[cfg(feature = "serde")]
    127 impl serde::Serialize for Username {
    128     fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
    129     where
    130         S: serde::Serializer,
    131     {
    132         serializer.serialize_str(self.as_str())
    133     }
    134 }
    135 
    136 #[cfg(feature = "serde")]
    137 impl<'de> serde::Deserialize<'de> for Username {
    138     fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
    139     where
    140         D: serde::Deserializer<'de>,
    141     {
    142         struct UsernameVisitor;
    143 
    144         impl serde::de::Visitor<'_> for UsernameVisitor {
    145             type Value = Username;
    146 
    147             fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
    148                 formatter.write_str("a valid public Radroots username")
    149             }
    150 
    151             fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
    152             where
    153                 E: serde::de::Error,
    154             {
    155                 Username::parse(value).map_err(E::custom)
    156             }
    157         }
    158 
    159         deserializer.deserialize_str(UsernameVisitor)
    160     }
    161 }
    162 
    163 #[cfg(test)]
    164 mod tests {
    165     use alloc::{format, string::ToString};
    166 
    167     use super::*;
    168 
    169     #[test]
    170     fn usernames_normalize_to_one_canonical_form() {
    171         let username = Username::parse("  RadRoots.Test  ").unwrap();
    172         assert_eq!(username.as_str(), "radroots.test");
    173         assert_eq!(username.as_ref(), "radroots.test");
    174         assert_eq!(username.to_string(), "radroots.test");
    175         assert_eq!(format!("{username:?}"), "Username(\"radroots.test\")");
    176         assert_eq!(Username::from_str("radroots.test").unwrap(), username);
    177         assert_eq!(Username::try_from("radroots.test").unwrap(), username);
    178         assert_eq!(
    179             Username::try_from(String::from("radroots.test")).unwrap(),
    180             username
    181         );
    182         assert_eq!(username.clone().into_string(), "radroots.test");
    183     }
    184 
    185     #[test]
    186     fn usernames_reject_invalid_lengths_characters_and_dots() {
    187         assert!(matches!(
    188             Username::parse("rr"),
    189             Err(Error::InvalidUsernameLength { actual: 2, .. })
    190         ));
    191         assert!(matches!(
    192             Username::parse(&"r".repeat(MAX_LENGTH + 1)),
    193             Err(Error::InvalidUsernameLength { actual, .. }) if actual == MAX_LENGTH + 1
    194         ));
    195         assert!(matches!(
    196             Username::parse("rad roots"),
    197             Err(Error::InvalidUsernameCharacter { index: 3 })
    198         ));
    199         for value in [".radroots", "radroots.", "radroots..test"] {
    200             assert!(matches!(
    201                 Username::parse(value),
    202                 Err(Error::InvalidUsernameDotPlacement)
    203             ));
    204         }
    205         assert!(matches!(
    206             Username::parse("rädroots"),
    207             Err(Error::InvalidUsernameCharacter { index: 1 })
    208         ));
    209     }
    210 
    211     #[cfg(feature = "serde")]
    212     #[test]
    213     fn username_serde_is_checked_and_canonical() {
    214         let username: Username = serde_json::from_str("\" RadRoots \"").unwrap();
    215         assert_eq!(username.as_str(), "radroots");
    216         assert_eq!(serde_json::to_string(&username).unwrap(), "\"radroots\"");
    217         assert!(serde_json::from_str::<Username>("\"rr\"").is_err());
    218         assert!(serde_json::from_str::<Username>("42").is_err());
    219     }
    220 }