proto_blue_syntax/
handle.rs1use regex::Regex;
7use std::fmt;
8use std::str::FromStr;
9
10const MAX_HANDLE_LENGTH: usize = 253;
12
13const MAX_LABEL_LENGTH: usize = 63;
15
16pub const HANDLE_INVALID: &str = "handle.invalid";
18
19pub 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#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
42pub struct Handle(String);
43
44#[derive(Debug, Clone, thiserror::Error)]
46#[error("Invalid handle: {reason}")]
47pub struct InvalidHandleError {
48 pub reason: String,
49}
50
51impl Handle {
52 pub fn new(s: &str) -> Result<Self, InvalidHandleError> {
54 ensure_valid_handle(s)?;
55 Ok(Self(s.to_ascii_lowercase()))
56 }
57
58 #[must_use]
60 pub fn is_valid(s: &str) -> bool {
61 ensure_valid_handle(s).is_ok()
62 }
63
64 #[must_use]
66 pub fn as_str(&self) -> &str {
67 &self.0
68 }
69
70 #[must_use]
72 pub fn into_inner(self) -> String {
73 self.0
74 }
75
76 #[must_use]
78 pub fn is_invalid_handle(&self) -> bool {
79 self.0 == HANDLE_INVALID
80 }
81}
82
83pub 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#[must_use]
99pub fn normalize_handle(s: &str) -> String {
100 s.to_ascii_lowercase()
101}
102
103pub 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 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 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}