Skip to main content

jacquard_common/types/
handle.rs

1use crate::bos::{Bos, DefaultStr};
2use crate::types::string::{AtStrError, StrParseKind};
3use crate::types::{DISALLOWED_TLDS, ends_with};
4use crate::{CowStr, IntoStatic};
5use alloc::string::String;
6use alloc::string::ToString;
7use core::fmt;
8use core::hash::{Hash, Hasher};
9use core::ops::Deref;
10use core::str::FromStr;
11#[cfg(all(not(target_arch = "wasm32"), feature = "std"))]
12use regex::Regex;
13#[cfg(all(not(target_arch = "wasm32"), not(feature = "std")))]
14use regex_automata::meta::Regex;
15#[cfg(target_arch = "wasm32")]
16use regex_lite::Regex;
17use serde::{Deserialize, Deserializer, Serialize, Serializer};
18use smol_str::{SmolStr, StrExt};
19
20use super::Lazy;
21
22/// AT Protocol handle (human-readable account identifier).
23///
24/// # Case semantics
25///
26/// Handle is **case-preserving but case-insensitive**: the stored string retains its
27/// original casing, but equality, hashing, serialization, and display all operate on
28/// the lowercased form. `as_str()` returns the raw stored value.
29///
30/// See: <https://atproto.com/specs/handle>
31#[derive(Clone)]
32#[repr(transparent)]
33pub struct Handle<S: Bos<str> = DefaultStr>(pub(crate) S);
34
35/// Regex for handle validation per AT Protocol spec.
36pub static HANDLE_REGEX: Lazy<Regex> = Lazy::new(|| {
37    Regex::new(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])?$").unwrap()
38});
39
40// ---------------------------------------------------------------------------
41// Shared validation
42// ---------------------------------------------------------------------------
43
44fn strip_handle_prefix(handle: &str) -> &str {
45    handle
46        .strip_prefix("at://")
47        .or_else(|| handle.strip_prefix('@'))
48        .unwrap_or(handle)
49}
50
51pub(crate) fn validate_handle(handle: &str) -> Result<(), AtStrError> {
52    if handle.len() > 253 {
53        Err(AtStrError::too_long("handle", handle, 253, handle.len()))
54    } else if !HANDLE_REGEX.is_match(handle) {
55        Err(AtStrError::regex(
56            "handle",
57            handle,
58            SmolStr::new_static("invalid"),
59        ))
60    } else if ends_with(handle, DISALLOWED_TLDS) && handle != "handle.invalid" {
61        Err(AtStrError::disallowed("handle", handle, DISALLOWED_TLDS))
62    } else {
63        Ok(())
64    }
65}
66
67// ---------------------------------------------------------------------------
68// Core methods
69// ---------------------------------------------------------------------------
70
71impl<S: Bos<str> + AsRef<str>> Handle<S> {
72    /// Get the handle as a string slice.
73    ///
74    /// Returns the raw stored value, which may contain uppercase if the handle
75    /// was deserialized from non-canonical wire data. For canonical output,
76    /// use `Display` or `Serialize`.
77    pub fn as_str(&self) -> &str {
78        self.0.as_ref()
79    }
80
81    /// Confirm that this is a (syntactically) valid handle (as we pass-through
82    /// "handle.invalid" during construction).
83    pub fn is_valid(&self) -> bool {
84        let s = self.as_str();
85        s.len() <= 253 && HANDLE_REGEX.is_match(s) && !ends_with(s, DISALLOWED_TLDS)
86    }
87}
88
89impl<S: Bos<str>> Handle<S> {
90    /// Infallible unchecked constructor.
91    ///
92    /// # Safety
93    ///
94    /// The caller must ensure the handle is valid.
95    pub unsafe fn unchecked(handle: S) -> Self {
96        Handle(handle)
97    }
98
99    /// Borrow as a `Handle<&str>`, analogous to `Uri::borrow()`.
100    pub fn borrow(&self) -> Handle<&str>
101    where
102        S: AsRef<str>,
103    {
104        // SAFETY: self is already validated.
105        unsafe { Handle::unchecked(self.0.as_ref()) }
106    }
107}
108
109// ---------------------------------------------------------------------------
110// Borrowed construction: Handle<&'h str>
111// ---------------------------------------------------------------------------
112
113impl<S: Bos<str> + AsRef<str>> Handle<S> {
114    /// Fallible constructor, validates, wraps the input directly.
115    ///
116    /// Rejects uppercase input — use `new_owned()` for case-insensitive construction.
117    /// Does NOT strip `@` or `at://` prefix — use `new_owned()` for that.
118    pub fn new(s: S) -> Result<Self, AtStrError> {
119        let r = s.as_ref();
120        if r.contains(|c: char| c.is_ascii_uppercase()) {
121            return Err(AtStrError::regex(
122                "handle",
123                r,
124                SmolStr::new_static("contains uppercase (use new_owned for normalisation)"),
125            ));
126        }
127        validate_handle(r)?;
128        Ok(Self(s))
129    }
130
131    /// Infallible constructor. Panics on invalid handles.
132    pub fn raw(s: S) -> Self {
133        Self::new(s).expect("invalid handle")
134    }
135}
136
137// ---------------------------------------------------------------------------
138// Owned construction (with prefix stripping and normalisation)
139// ---------------------------------------------------------------------------
140
141impl<S: Bos<str> + FromStr> Handle<S> {
142    /// Fallible constructor, validates, takes ownership. Normalises to lowercase.
143    ///
144    /// Accepts (and strips) preceding `@` or `at://` if present.
145    pub fn new_owned(handle: impl AsRef<str>) -> Result<Self, AtStrError> {
146        let handle = handle.as_ref();
147        let stripped = strip_handle_prefix(handle);
148        let normalized = stripped.to_lowercase_smolstr();
149        validate_handle(&normalized)?;
150        let s = S::from_str(&normalized).map_err(|_| {
151            AtStrError::new("handle", normalized.to_string(), StrParseKind::Conversion)
152        })?;
153        Ok(Self(s))
154    }
155
156    /// Fallible constructor for static strings. Normalises to lowercase.
157    pub fn new_static(handle: &'static str) -> Result<Self, AtStrError> {
158        let stripped = strip_handle_prefix(handle);
159        let normalized = if stripped.contains(|c: char| c.is_ascii_uppercase()) {
160            stripped.to_lowercase_smolstr()
161        } else {
162            SmolStr::new_static(stripped)
163        };
164        validate_handle(&normalized)?;
165        let s = S::from_str(&normalized).map_err(|_| {
166            AtStrError::new("handle", normalized.to_string(), StrParseKind::Conversion)
167        })?;
168        Ok(Self(s))
169    }
170}
171
172// ---------------------------------------------------------------------------
173// Deserialization — generic over S: Deserialize<'de>
174// ---------------------------------------------------------------------------
175
176impl<'de, S> Deserialize<'de> for Handle<S>
177where
178    S: Bos<str> + AsRef<str> + Deserialize<'de>,
179{
180    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
181    where
182        D: Deserializer<'de>,
183    {
184        let s = S::deserialize(deserializer)?;
185        validate_handle(s.as_ref()).map_err(serde::de::Error::custom)?;
186        Ok(Handle(s))
187    }
188}
189
190// ---------------------------------------------------------------------------
191// Serialization — always lowercase
192// ---------------------------------------------------------------------------
193
194impl<S: Bos<str> + AsRef<str>> Serialize for Handle<S> {
195    fn serialize<Ser>(&self, serializer: Ser) -> Result<Ser::Ok, Ser::Error>
196    where
197        Ser: Serializer,
198    {
199        let raw = self.as_str();
200        if raw.bytes().all(|b| !b.is_ascii_uppercase()) {
201            serializer.serialize_str(raw)
202        } else {
203            let lowered = raw.to_lowercase_smolstr();
204            serializer.serialize_str(&lowered)
205        }
206    }
207}
208
209// ---------------------------------------------------------------------------
210// Case-insensitive equality and hashing
211// ---------------------------------------------------------------------------
212
213impl<S: Bos<str> + AsRef<str>, T: Bos<str> + AsRef<str>> PartialEq<Handle<T>> for Handle<S> {
214    fn eq(&self, other: &Handle<T>) -> bool {
215        self.as_str().eq_ignore_ascii_case(other.as_str())
216    }
217}
218
219impl<S: Bos<str> + AsRef<str>> Eq for Handle<S> {}
220
221impl<S: Bos<str> + AsRef<str>> Hash for Handle<S> {
222    fn hash<H: Hasher>(&self, state: &mut H) {
223        for byte in self.as_str().bytes() {
224            state.write_u8(byte.to_ascii_lowercase());
225        }
226    }
227}
228
229// ---------------------------------------------------------------------------
230// Other trait impls
231// ---------------------------------------------------------------------------
232
233impl<S: Bos<str> + IntoStatic> IntoStatic for Handle<S>
234where
235    S::Output: Bos<str>,
236{
237    type Output = Handle<S::Output>;
238
239    fn into_static(self) -> Self::Output {
240        Handle(self.0.into_static())
241    }
242}
243
244impl<S: Bos<str>> Handle<S> {
245    /// Convert to a `Handle` with a different backing type.
246    pub fn convert<B: Bos<str> + From<S>>(self) -> Handle<B> {
247        Handle(B::from(self.0))
248    }
249}
250
251impl FromStr for Handle {
252    type Err = AtStrError;
253
254    fn from_str(s: &str) -> Result<Self, Self::Err> {
255        Self::new_owned(s)
256    }
257}
258
259impl FromStr for Handle<CowStr<'static>> {
260    type Err = AtStrError;
261
262    fn from_str(s: &str) -> Result<Self, Self::Err> {
263        Self::new_owned(s)
264    }
265}
266
267impl FromStr for Handle<String> {
268    type Err = AtStrError;
269
270    fn from_str(s: &str) -> Result<Self, Self::Err> {
271        Self::new_owned(s)
272    }
273}
274
275impl<S: Bos<str> + AsRef<str>> fmt::Display for Handle<S> {
276    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
277        if self.0.as_ref().contains(|c: char| c.is_ascii_uppercase()) {
278            for c in self.as_str().chars() {
279                fmt::Write::write_char(f, c.to_ascii_lowercase())?;
280            }
281        } else {
282            f.write_str(self.as_str())?;
283        }
284        Ok(())
285    }
286}
287
288impl<S: Bos<str> + AsRef<str>> fmt::Debug for Handle<S> {
289    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
290        write!(f, "at://")?;
291        if self.0.as_ref().contains(|c: char| c.is_ascii_uppercase()) {
292            for c in self.as_str().chars() {
293                fmt::Write::write_char(f, c.to_ascii_lowercase())?;
294            }
295        } else {
296            f.write_str(self.as_str())?;
297        }
298        Ok(())
299    }
300}
301
302impl<S: Bos<str> + AsRef<str>> From<Handle<S>> for String {
303    fn from(value: Handle<S>) -> Self {
304        value.as_str().to_ascii_lowercase()
305    }
306}
307
308impl<S: Bos<str> + AsRef<str>> From<Handle<S>> for SmolStr {
309    fn from(value: Handle<S>) -> Self {
310        value.as_str().to_ascii_lowercase_smolstr()
311    }
312}
313
314impl From<String> for Handle {
315    fn from(value: String) -> Self {
316        Self::new_owned(value).unwrap()
317    }
318}
319
320impl<'h> From<CowStr<'h>> for Handle<CowStr<'h>> {
321    fn from(value: CowStr<'h>) -> Self {
322        Self::new(value).unwrap()
323    }
324}
325
326impl<S: Bos<str> + AsRef<str>> AsRef<str> for Handle<S> {
327    fn as_ref(&self) -> &str {
328        self.as_str()
329    }
330}
331
332impl<S: Bos<str> + AsRef<str>> Deref for Handle<S> {
333    type Target = str;
334
335    fn deref(&self) -> &Self::Target {
336        self.as_str()
337    }
338}
339
340#[cfg(test)]
341mod tests {
342    use super::*;
343
344    #[test]
345    fn valid_handles() {
346        assert!(Handle::<&str>::new("alice.test").is_ok());
347        assert!(Handle::<&str>::new("foo.bsky.social").is_ok());
348        assert!(Handle::<&str>::new("a.b.c.d.e").is_ok());
349        assert!(Handle::<&str>::new("a1.b2.c3").is_ok());
350        assert!(Handle::<&str>::new("name-with-dash.com").is_ok());
351    }
352
353    #[test]
354    fn valid_handles_owned() {
355        assert!(Handle::<SmolStr>::new_owned("alice.test").is_ok());
356        assert!(Handle::<SmolStr>::new_owned("Alice.Test").is_ok());
357        assert!(Handle::<String>::new_owned("foo.bsky.social").is_ok());
358    }
359
360    #[test]
361    fn borrowed_rejects_uppercase() {
362        assert!(Handle::<&str>::new("Alice.Test").is_err());
363    }
364
365    #[test]
366    fn prefix_stripping() {
367        // new() does not strip — use new_owned() for that.
368        assert!(Handle::<&str>::new("@alice.test").is_err());
369        assert!(Handle::<&str>::new("at://alice.test").is_err());
370        assert_eq!(
371            Handle::<SmolStr>::new_owned("@alice.test")
372                .unwrap()
373                .as_str(),
374            "alice.test"
375        );
376        assert_eq!(
377            Handle::<SmolStr>::new_owned("at://alice.test")
378                .unwrap()
379                .as_str(),
380            "alice.test"
381        );
382        assert_eq!(
383            Handle::<&str>::new("alice.test").unwrap().as_str(),
384            "alice.test"
385        );
386    }
387
388    #[test]
389    fn prefix_stripping_owned() {
390        assert_eq!(
391            Handle::<SmolStr>::new_owned("@Alice.Test")
392                .unwrap()
393                .as_str(),
394            "alice.test"
395        );
396        assert_eq!(
397            Handle::<SmolStr>::new_owned("at://alice.test")
398                .unwrap()
399                .as_str(),
400            "alice.test"
401        );
402    }
403
404    #[test]
405    fn max_length() {
406        let s1 = format!("a{}a", "b".repeat(61));
407        let s2 = format!("c{}c", "d".repeat(61));
408        let s3 = format!("e{}e", "f".repeat(61));
409        let s4 = format!("g{}g", "h".repeat(59));
410        let valid_253 = format!("{s1}.{s2}.{s3}.{s4}");
411        assert_eq!(valid_253.len(), 253);
412        assert!(Handle::<&str>::new(&valid_253).is_ok());
413
414        let s4_long = format!("g{}g", "h".repeat(60));
415        let too_long_254 = format!("{s1}.{s2}.{s3}.{s4_long}");
416        assert_eq!(too_long_254.len(), 254);
417        assert!(Handle::<&str>::new(&too_long_254).is_err());
418    }
419
420    #[test]
421    fn segment_length_constraints() {
422        let valid = format!("{}.com", "a".repeat(63));
423        assert!(Handle::<&str>::new(&valid).is_ok());
424        let too_long = format!("{}.com", "a".repeat(64));
425        assert!(Handle::<&str>::new(&too_long).is_err());
426    }
427
428    #[test]
429    fn hyphen_placement() {
430        assert!(Handle::<&str>::new("valid-label.com").is_ok());
431        assert!(Handle::<&str>::new("-nope.com").is_err());
432        assert!(Handle::<&str>::new("nope-.com").is_err());
433    }
434
435    #[test]
436    fn tld_must_start_with_letter() {
437        assert!(Handle::<&str>::new("foo.bar").is_ok());
438        assert!(Handle::<&str>::new("foo.9bar").is_err());
439    }
440
441    #[test]
442    fn disallowed_tlds() {
443        for tld in [
444            "local",
445            "localhost",
446            "arpa",
447            "invalid",
448            "internal",
449            "example",
450            "alt",
451            "onion",
452        ] {
453            assert!(
454                Handle::<&str>::new(&format!("foo.{tld}")).is_err(),
455                "should reject .{tld}"
456            );
457        }
458    }
459
460    #[test]
461    fn minimum_segments() {
462        assert!(Handle::<&str>::new("a.b").is_ok());
463        assert!(Handle::<&str>::new("a").is_err());
464        assert!(Handle::<&str>::new("com").is_err());
465    }
466
467    #[test]
468    fn invalid_characters() {
469        assert!(Handle::<&str>::new("foo!bar.com").is_err());
470        assert!(Handle::<&str>::new("foo_bar.com").is_err());
471        assert!(Handle::<&str>::new("foo bar.com").is_err());
472        assert!(Handle::<&str>::new("foo@bar.com").is_err());
473    }
474
475    #[test]
476    fn empty_segments() {
477        assert!(Handle::<&str>::new("foo..com").is_err());
478        assert!(Handle::<&str>::new(".foo.com").is_err());
479        assert!(Handle::<&str>::new("foo.com.").is_err());
480    }
481
482    #[test]
483    fn handle_invalid_passthrough() {
484        assert!(Handle::<&str>::new("handle.invalid").is_ok());
485        assert!(Handle::<SmolStr>::new_owned("handle.invalid").is_ok());
486    }
487
488    #[test]
489    fn into_static_borrowed() {
490        let h = Handle::<&str>::new("alice.test").unwrap();
491        let owned: Handle<SmolStr> = h.into_static();
492        assert_eq!(owned.as_str(), "alice.test");
493    }
494
495    #[test]
496    fn into_static_already_owned() {
497        let h = Handle::<SmolStr>::new_owned("alice.test").unwrap();
498        let owned: Handle<SmolStr> = h.into_static();
499        assert_eq!(owned.as_str(), "alice.test");
500    }
501
502    #[test]
503    fn case_insensitive_equality() {
504        let lower = Handle::<SmolStr>::new_owned("alice.test").unwrap();
505        let upper = Handle(SmolStr::new("Alice.Test"));
506        assert_eq!(lower, upper);
507    }
508
509    #[test]
510    fn case_insensitive_hash() {
511        let a = Handle::<SmolStr>::new_owned("alice.test").unwrap();
512        let b = Handle(SmolStr::new("Alice.Test"));
513        assert_eq!(a, b);
514        #[allow(deprecated)]
515        let (mut ha, mut hb) = (core::hash::SipHasher::new(), core::hash::SipHasher::new());
516        a.hash(&mut ha);
517        b.hash(&mut hb);
518        assert_eq!(ha.finish(), hb.finish());
519    }
520
521    #[test]
522    fn display_lowercases() {
523        let h = Handle(SmolStr::new("Alice.Test"));
524        assert_eq!(format!("{h}"), "alice.test");
525    }
526
527    #[test]
528    fn serialize_lowercases() {
529        let h = Handle(SmolStr::new("Alice.Test"));
530        let json = serde_json::to_string(&h).unwrap();
531        assert_eq!(json, "\"alice.test\"");
532    }
533
534    #[test]
535    fn cross_type_equality() {
536        let borrowed = Handle::<&str>::new("alice.test").unwrap();
537        let owned = Handle::<SmolStr>::new_owned("alice.test").unwrap();
538        assert_eq!(borrowed, owned);
539    }
540}