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