proto_blue_syntax/
handle.rs1use once_cell::sync::Lazy;
7use regex::Regex;
8use std::fmt;
9use std::str::FromStr;
10
11const MAX_HANDLE_LENGTH: usize = 253;
13
14const MAX_LABEL_LENGTH: usize = 63;
16
17pub const HANDLE_INVALID: &str = "handle.invalid";
19
20pub 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#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
43pub struct Handle(String);
44
45#[derive(Debug, Clone, thiserror::Error)]
47#[error("Invalid handle: {reason}")]
48pub struct InvalidHandleError {
49 pub reason: String,
50}
51
52impl Handle {
53 pub fn new(s: &str) -> Result<Self, InvalidHandleError> {
55 ensure_valid_handle(s)?;
56 Ok(Handle(s.to_ascii_lowercase()))
57 }
58
59 pub fn is_valid(s: &str) -> bool {
61 ensure_valid_handle(s).is_ok()
62 }
63
64 pub fn as_str(&self) -> &str {
66 &self.0
67 }
68
69 pub fn into_inner(self) -> String {
71 self.0
72 }
73
74 pub fn is_invalid_handle(&self) -> bool {
76 self.0 == HANDLE_INVALID
77 }
78}
79
80pub 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
94pub fn normalize_handle(s: &str) -> String {
98 s.to_ascii_lowercase()
99}
100
101pub 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 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 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}