Skip to main content

didbot_identity/
handle.rs

1//! Handle syntax, as atproto specifies it.
2//!
3//! A handle is a domain name, and this validator says only whether a string
4//! could be one. It is deliberately looser than [`crate::did`]'s hostname
5//! rules in two ways that matter:
6//!
7//! * **Case is allowed.** `A.ISI.EDU` is a syntactically valid handle: the
8//!   spec's answer to case is to lowercase before comparing, not to refuse. A
9//!   `did:web` cannot do that, because the DID is compared byte for byte
10//!   across the network and two spellings would be two identities.
11//! * **`.arpa` is allowed.** The atproto handle spec keeps "is this a legal
12//!   handle string" separate from "is this a top-level domain handles may be
13//!   registered under", and puts `.arpa`, `.local`, `.invalid` and the rest in
14//!   the second list. `SRI-NIC.ARPA` is in the upstream *valid* vectors for
15//!   exactly that reason, and a caller that needs the second question asks it
16//!   separately.
17//!
18//! Nothing here touches DNS. A syntactically valid handle is one that could be
19//! resolved, not one that resolves.
20
21/// Longest a handle may be, matching a DNS name.
22pub const MAX_HANDLE_LENGTH: usize = 253;
23
24/// Longest any one segment may be, matching a DNS label.
25pub const MAX_SEGMENT_LENGTH: usize = 63;
26
27/// Why a string is not a syntactically valid handle.
28#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
29pub enum HandleError {
30    /// Over [`MAX_HANDLE_LENGTH`] characters.
31    #[error("handle is {0} characters, over the limit of {MAX_HANDLE_LENGTH}")]
32    TooLong(usize),
33    /// Fewer than two segments: a bare `com` is not a handle.
34    #[error("handle needs at least two segments, found {0}")]
35    TooFewSegments(usize),
36    /// A segment was empty: a leading dot, a trailing dot, or a `..`.
37    #[error("handle has an empty segment")]
38    EmptySegment,
39    /// A segment was over [`MAX_SEGMENT_LENGTH`] characters.
40    #[error("handle segment {0:?} is over {MAX_SEGMENT_LENGTH} characters")]
41    SegmentTooLong(String),
42    /// A character outside ASCII letters, digits, `-` and `.`.
43    #[error("invalid character {0:?} in handle")]
44    InvalidCharacter(char),
45    /// A segment started or ended with a hyphen.
46    #[error("handle segment {0:?} starts or ends with a hyphen")]
47    HyphenEdge(String),
48    /// The last segment is the top-level domain, which may not start with a
49    /// digit. This is what keeps `127.0.0.1` from being a handle.
50    #[error("handle top-level domain {0:?} starts with a digit")]
51    NumericTld(String),
52    /// A port was supplied for a handle that is not a bare loopback name.
53    ///
54    /// Never produced by [`validate`], which answers about a string on its
55    /// own. It belongs here because [`atproto_did_url`] is the one place a
56    /// handle turns into something fetchable, and a port outside development
57    /// is the same mistake atproto refuses for a `did:web`.
58    #[error("handle {0:?} is not a bare loopback name, so it cannot carry a port")]
59    PortOffLoopback(String),
60}
61
62/// Checks a string against the handle syntax rules.
63///
64/// ```
65/// use didbot_identity::handle::validate;
66/// assert!(validate("john.test").is_ok());
67/// assert!(validate("A.ISI.EDU").is_ok());  // case is a normalization concern
68/// assert!(validate("127.0.0.1").is_err()); // numeric top-level domain
69/// assert!(validate("org").is_err());       // dotless
70/// ```
71pub fn validate(handle: &str) -> Result<(), HandleError> {
72    if handle.len() > MAX_HANDLE_LENGTH {
73        return Err(HandleError::TooLong(handle.len()));
74    }
75    // Before the segment walk, so a multi-byte character is reported as
76    // itself rather than as a byte count.
77    if let Some(ch) = handle
78        .chars()
79        .find(|ch| !(ch.is_ascii_alphanumeric() || *ch == '-' || *ch == '.'))
80    {
81        return Err(HandleError::InvalidCharacter(ch));
82    }
83
84    let segments: Vec<&str> = handle.split('.').collect();
85    if segments.len() < 2 {
86        return Err(HandleError::TooFewSegments(segments.len()));
87    }
88    for segment in &segments {
89        if segment.is_empty() {
90            return Err(HandleError::EmptySegment);
91        }
92        if segment.len() > MAX_SEGMENT_LENGTH {
93            return Err(HandleError::SegmentTooLong((*segment).to_owned()));
94        }
95        if segment.starts_with('-') || segment.ends_with('-') {
96            return Err(HandleError::HyphenEdge((*segment).to_owned()));
97        }
98    }
99    let tld = segments[segments.len() - 1];
100    if tld.starts_with(|ch: char| ch.is_ascii_digit()) {
101        return Err(HandleError::NumericTld(tld.to_owned()));
102    }
103    Ok(())
104}
105
106/// Path an atproto handle resolves through over HTTPS.
107pub const ATPROTO_DID_PATH: &str = "/.well-known/atproto-did";
108
109/// Where `handle` is resolved over HTTPS, or why it cannot be.
110///
111/// `port` follows the same rule [`crate::AccountDid`] applies to a
112/// `did:web`: allowed on a bare loopback hostname, refused anywhere else.
113/// The scheme is `https` either way — every server this project runs
114/// terminates TLS.
115pub fn atproto_did_url(handle: &str, port: Option<u16>) -> Result<String, HandleError> {
116    validate(handle)?;
117    if port.is_some() && !crate::did::is_loopback_literal(handle) {
118        return Err(HandleError::PortOffLoopback(handle.to_owned()));
119    }
120    Ok(match port.or_else(|| crate::did::resolve_port(handle)) {
121        Some(port) => format!("https://{handle}:{port}{ATPROTO_DID_PATH}"),
122        None => format!("https://{handle}{ATPROTO_DID_PATH}"),
123    })
124}
125
126/// Reads the body of an `/.well-known/atproto-did` response.
127///
128/// The response is "the DID and nothing else", but a text file served off a
129/// web server acquires a trailing newline the moment anybody edits it by
130/// hand, so surrounding whitespace is trimmed before the string is checked.
131/// Nothing else is repaired: a body carrying a second line, JSON, or an HTML
132/// error page a proxy substituted with a 200 is not a DID and is refused
133/// here rather than fetched.
134///
135/// The generic DID validator, not [`crate::AccountDid`]: the account on the
136/// other end of a handle may perfectly well be a `did:plc`, and narrowing
137/// that here would make this crate's own strictness into a rule about
138/// everybody else's identifiers.
139pub fn parse_atproto_did(body: &str) -> Result<&str, crate::SyntaxError> {
140    let did = body.trim();
141    crate::validate_did(did)?;
142    Ok(did)
143}
144
145/// Top-level domains the atproto handle spec reserves: a handle under one
146/// is syntactically valid and never resolves.
147pub const DISALLOWED_TLDS: &[&str] = &[
148    "alt",
149    "arpa",
150    "example",
151    "internal",
152    "invalid",
153    "local",
154    "localhost",
155    "onion",
156];
157
158/// Whether `handle` sits under one of [`DISALLOWED_TLDS`], compared without
159/// regard to case.
160#[must_use]
161pub fn has_disallowed_tld(handle: &str) -> bool {
162    let tld = handle.rsplit('.').next().unwrap_or(handle);
163    DISALLOWED_TLDS
164        .iter()
165        .any(|disallowed| disallowed.eq_ignore_ascii_case(tld))
166}