Skip to main content

adler_core/
username.rs

1//! Validated username target for site checks.
2
3use std::fmt;
4use std::str::FromStr;
5
6use crate::error::{Error, Result};
7
8/// Maximum username length we accept.
9///
10/// Sites in the wild generally cap usernames around 30 characters; 64 leaves
11/// headroom for less common services while keeping URLs sane.
12const MAX_LEN: usize = 64;
13
14/// A validated username.
15///
16/// The character set is restricted to ASCII letters, digits, `_`, `-`, and
17/// `.`. This keeps URL substitution naive (no percent-encoding needed) and
18/// guards against accidental cross-site normalisation differences. Usernames
19/// containing characters outside this set are rejected at construction.
20#[derive(Debug, Clone, PartialEq, Eq, Hash)]
21pub struct Username(String);
22
23impl Username {
24    /// Construct a `Username`, validating its character set and length.
25    pub fn new(input: impl Into<String>) -> Result<Self> {
26        let input = input.into();
27        if let Some(reason) = invalid_reason(&input) {
28            return Err(Error::InvalidUsername { input, reason });
29        }
30        Ok(Self(input))
31    }
32
33    /// Borrow the inner string.
34    #[inline]
35    pub fn as_str(&self) -> &str {
36        &self.0
37    }
38}
39
40impl fmt::Display for Username {
41    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
42        f.write_str(&self.0)
43    }
44}
45
46impl FromStr for Username {
47    type Err = Error;
48
49    fn from_str(s: &str) -> Result<Self> {
50        Self::new(s)
51    }
52}
53
54impl AsRef<str> for Username {
55    fn as_ref(&self) -> &str {
56        &self.0
57    }
58}
59
60impl serde::Serialize for Username {
61    fn serialize<S: serde::Serializer>(
62        &self,
63        serializer: S,
64    ) -> std::result::Result<S::Ok, S::Error> {
65        self.0.serialize(serializer)
66    }
67}
68
69impl<'de> serde::Deserialize<'de> for Username {
70    fn deserialize<D: serde::Deserializer<'de>>(
71        deserializer: D,
72    ) -> std::result::Result<Self, D::Error> {
73        let raw = String::deserialize(deserializer)?;
74        Self::new(raw).map_err(serde::de::Error::custom)
75    }
76}
77
78fn invalid_reason(s: &str) -> Option<String> {
79    if s.is_empty() {
80        return Some(String::from("username is empty"));
81    }
82    if s.len() > MAX_LEN {
83        return Some(format!("username exceeds {MAX_LEN} characters"));
84    }
85    s.chars()
86        .find(|c| !is_allowed(*c))
87        .map(|c| format!("contains invalid character {c:?}"))
88}
89
90#[inline]
91const fn is_allowed(c: char) -> bool {
92    c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '.'
93}
94
95#[cfg(test)]
96mod tests {
97    use super::*;
98
99    #[test]
100    fn accepts_common_usernames() {
101        for ok in ["alice", "bob_doe", "user-name", "a.b", "1234", "A_b-c.d"] {
102            assert!(Username::new(ok).is_ok(), "{ok:?} should be accepted");
103        }
104    }
105
106    #[test]
107    fn rejects_empty() {
108        let err = Username::new("").unwrap_err();
109        assert!(matches!(err, Error::InvalidUsername { .. }));
110        assert!(err.to_string().contains("empty"));
111    }
112
113    #[test]
114    fn rejects_too_long() {
115        let long = "a".repeat(MAX_LEN + 1);
116        assert!(Username::new(long).is_err());
117        let edge = "a".repeat(MAX_LEN);
118        assert!(
119            Username::new(edge).is_ok(),
120            "exactly {MAX_LEN} chars is allowed"
121        );
122    }
123
124    #[test]
125    fn rejects_disallowed_characters() {
126        for bad in [
127            " alice", "alice ", "ali ce", "a/b", "a?b", "a#b", "ali@ce", "café",
128        ] {
129            assert!(Username::new(bad).is_err(), "{bad:?} should be rejected");
130        }
131    }
132
133    #[test]
134    fn display_and_as_str_roundtrip() {
135        let u = Username::new("alice").unwrap();
136        assert_eq!(u.as_str(), "alice");
137        assert_eq!(u.to_string(), "alice");
138        assert_eq!(<Username as AsRef<str>>::as_ref(&u), "alice");
139    }
140
141    #[test]
142    fn from_str_works() {
143        let u: Username = "carol".parse().unwrap();
144        assert_eq!(u.as_str(), "carol");
145    }
146
147    #[test]
148    fn serde_roundtrip_via_json() {
149        let u = Username::new("dave_42").unwrap();
150        let json = serde_json::to_string(&u).unwrap();
151        assert_eq!(json, "\"dave_42\"");
152        let back: Username = serde_json::from_str(&json).unwrap();
153        assert_eq!(back, u);
154    }
155
156    #[test]
157    fn serde_deserialize_validates() {
158        let err = serde_json::from_str::<Username>("\"bad space\"").unwrap_err();
159        assert!(err.to_string().contains("invalid character"));
160    }
161
162    proptest::proptest! {
163        /// Validation must never panic on arbitrary input — only Ok/Err.
164        #[test]
165        fn new_never_panics_on_arbitrary_input(s in ".*") {
166            let _ = Username::new(s);
167        }
168
169        /// Any string matching the allowed charset/length is accepted and
170        /// round-trips losslessly through `as_str` and serde.
171        #[test]
172        fn valid_usernames_round_trip(s in "[A-Za-z0-9._-]{1,64}") {
173            let u = Username::new(s.clone()).expect("matches the username charset");
174            proptest::prop_assert_eq!(u.as_str(), s.as_str());
175            let json = serde_json::to_string(&u).unwrap();
176            let back: Username = serde_json::from_str(&json).unwrap();
177            proptest::prop_assert_eq!(back, u);
178        }
179
180        /// Any string containing a disallowed character is rejected.
181        #[test]
182        fn strings_with_disallowed_chars_are_rejected(s in "[A-Za-z0-9._-]{0,20}[^A-Za-z0-9._-][A-Za-z0-9._-]{0,20}") {
183            proptest::prop_assert!(Username::new(s).is_err());
184        }
185    }
186}