Skip to main content

proto_blue_syntax/
handle.rs

1//! Handle validation and types.
2//!
3//! Handles are domain-name-like identifiers (e.g., `alice.bsky.social`).
4//! See: <https://atproto.com/specs/handle>
5
6use regex::Regex;
7use std::fmt;
8use std::str::FromStr;
9
10/// Maximum length of a handle.
11const MAX_HANDLE_LENGTH: usize = 253;
12
13/// Maximum length of a single label (domain segment).
14const MAX_LABEL_LENGTH: usize = 63;
15
16/// The canonical invalid handle value.
17pub const HANDLE_INVALID: &str = "handle.invalid";
18
19/// TLDs that are disallowed for handles.
20pub const DISALLOWED_TLDS: &[&str] = &[
21    ".local",
22    ".arpa",
23    ".invalid",
24    ".localhost",
25    ".internal",
26    ".example",
27    ".alt",
28    ".onion",
29];
30
31static HANDLE_REGEX: std::sync::LazyLock<Regex> = std::sync::LazyLock::new(|| {
32    Regex::new(
33        r"^([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$",
34    )
35    .unwrap()
36});
37
38/// A validated AT Protocol handle.
39///
40/// Handles are domain-name-like identifiers such as `alice.bsky.social`.
41#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
42pub struct Handle(String);
43
44/// Error returned when a handle string is invalid.
45#[derive(Debug, Clone, thiserror::Error)]
46#[error("Invalid handle: {reason}")]
47pub struct InvalidHandleError {
48    pub reason: String,
49}
50
51impl Handle {
52    /// Create a new `Handle` from a string, validating the format.
53    pub fn new(s: &str) -> Result<Self, InvalidHandleError> {
54        ensure_valid_handle(s)?;
55        Ok(Self(s.to_ascii_lowercase()))
56    }
57
58    /// Check whether a string is a valid handle without allocating.
59    #[must_use]
60    pub fn is_valid(s: &str) -> bool {
61        ensure_valid_handle(s).is_ok()
62    }
63
64    /// Return the inner string (always lowercase).
65    #[must_use]
66    pub fn as_str(&self) -> &str {
67        &self.0
68    }
69
70    /// Consume and return the inner string.
71    #[must_use]
72    pub fn into_inner(self) -> String {
73        self.0
74    }
75
76    /// Check if this is the canonical invalid handle.
77    #[must_use]
78    pub fn is_invalid_handle(&self) -> bool {
79        self.0 == HANDLE_INVALID
80    }
81}
82
83/// `true` if the handle ends in a TLD that AT Protocol forbids for
84/// handles (`.local`, `.arpa`, `.onion`, etc.). Mirrors TS
85/// `isValidTld` in `@atproto/syntax`.
86///
87/// Accepts either a raw string or anything that implements
88/// `AsRef<str>` so callers can pass `&Handle` directly.
89pub fn is_valid_tld(handle: impl AsRef<str>) -> bool {
90    let h = handle.as_ref();
91    let lower = h.to_ascii_lowercase();
92    !DISALLOWED_TLDS.iter().any(|suffix| lower.ends_with(suffix))
93}
94
95/// Normalise a raw handle string to its canonical form (ASCII
96/// lowercase). Does **not** validate the handle — use [`Handle::new`]
97/// if you want validation. Mirrors TS `normalizeHandle`.
98#[must_use]
99pub fn normalize_handle(s: &str) -> String {
100    s.to_ascii_lowercase()
101}
102
103/// Normalise + validate in one step. Returns the canonical lowercase
104/// form on success. Mirrors TS `normalizeAndEnsureValidHandle`.
105pub fn normalize_and_ensure_valid_handle(s: &str) -> Result<String, InvalidHandleError> {
106    let n = normalize_handle(s);
107    Handle::new(&n)?;
108    Ok(n)
109}
110
111fn ensure_valid_handle(s: &str) -> Result<(), InvalidHandleError> {
112    let err = |reason: &str| InvalidHandleError {
113        reason: reason.to_string(),
114    };
115
116    if !s.is_ascii() {
117        return Err(err("Handle must be ASCII"));
118    }
119
120    if s.len() > MAX_HANDLE_LENGTH {
121        return Err(err(&format!(
122            "Handle is too long ({} chars, max {})",
123            s.len(),
124            MAX_HANDLE_LENGTH
125        )));
126    }
127
128    let labels: Vec<&str> = s.split('.').collect();
129    if labels.len() < 2 {
130        return Err(err("Handle must have at least two parts separated by '.'"));
131    }
132
133    for label in &labels {
134        if label.is_empty() {
135            return Err(err("Handle labels must not be empty"));
136        }
137        if label.len() > MAX_LABEL_LENGTH {
138            return Err(err(&format!(
139                "Handle label too long ({} chars, max {})",
140                label.len(),
141                MAX_LABEL_LENGTH
142            )));
143        }
144    }
145
146    // TLD must start with a letter
147    if let Some(tld) = labels.last()
148        && !tld.starts_with(|c: char| c.is_ascii_alphabetic())
149    {
150        return Err(err("Handle TLD must start with an ASCII letter"));
151    }
152
153    if !HANDLE_REGEX.is_match(s) {
154        return Err(err("Handle contains invalid characters or format"));
155    }
156
157    Ok(())
158}
159
160impl fmt::Display for Handle {
161    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
162        f.write_str(&self.0)
163    }
164}
165
166impl FromStr for Handle {
167    type Err = InvalidHandleError;
168    fn from_str(s: &str) -> Result<Self, Self::Err> {
169        Self::new(s)
170    }
171}
172
173impl AsRef<str> for Handle {
174    fn as_ref(&self) -> &str {
175        &self.0
176    }
177}
178
179impl serde::Serialize for Handle {
180    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
181        self.0.serialize(serializer)
182    }
183}
184
185impl<'de> serde::Deserialize<'de> for Handle {
186    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
187        let s = String::deserialize(deserializer)?;
188        Self::new(&s).map_err(serde::de::Error::custom)
189    }
190}
191
192#[cfg(test)]
193mod tests {
194    use super::*;
195
196    #[test]
197    fn valid_handles() {
198        assert!(Handle::new("alice.bsky.social").is_ok());
199        assert!(Handle::new("john.test").is_ok());
200        assert!(Handle::new("a.b").is_ok());
201        assert!(Handle::new("xn--nxasmq6b.com").is_ok());
202        assert!(Handle::new("example.t").is_ok());
203    }
204
205    #[test]
206    fn normalizes_to_lowercase() {
207        let h = Handle::new("Alice.Bsky.Social").unwrap();
208        assert_eq!(h.as_str(), "alice.bsky.social");
209    }
210
211    #[test]
212    fn invalid_handles() {
213        assert!(Handle::new("").is_err(), "empty");
214        assert!(Handle::new("noperiod").is_err(), "no period");
215        assert!(Handle::new(".leading-dot").is_err(), "leading dot");
216        assert!(Handle::new("trailing-dot.").is_err(), "trailing dot");
217        assert!(Handle::new("double..dot").is_err(), "double dot");
218        assert!(
219            Handle::new("-start.test").is_err(),
220            "leading hyphen in label"
221        );
222        assert!(
223            Handle::new("end-.test").is_err(),
224            "trailing hyphen in label"
225        );
226        assert!(Handle::new("john.0test").is_err(), "TLD starts with digit");
227        assert!(Handle::new("john.123").is_err(), "numeric TLD");
228    }
229
230    #[test]
231    fn max_length() {
232        let label = "a".repeat(63);
233        // 63 + 1 + 63 + 1 + 63 + 1 + 63 = 255 > 253
234        let long = format!("{label}.{label}.{label}.{label}");
235        if long.len() > MAX_HANDLE_LENGTH {
236            assert!(Handle::new(&long).is_err());
237        }
238    }
239
240    #[test]
241    fn serde_roundtrip() {
242        let h = Handle::new("alice.bsky.social").unwrap();
243        let json = serde_json::to_string(&h).unwrap();
244        assert_eq!(json, "\"alice.bsky.social\"");
245        let parsed: Handle = serde_json::from_str(&json).unwrap();
246        assert_eq!(parsed, h);
247    }
248}