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}