Skip to main content

didbot_lexicon/
nsid_syntax.rs

1//! Namespace identifier syntax, as atproto specifies it.
2//!
3//! This is the generic validator, not a check against [`crate::nsid::ALL`].
4//! The two answer different questions: this one says whether a string could
5//! name a lexicon anywhere in atproto, and `ALL` says whether this deployment
6//! defines it. A collection arriving over the wire needs the second; a lexicon
7//! document's own `id`, or the collection segment of an `at://` URI pointing
8//! at somebody else's repository, needs the first.
9//!
10//! # The authority is reversed, and that is the whole trick
11//!
12//! An NSID is a domain authority written backwards with a name segment on the
13//! end, so `com.example.fooBar` belongs to `example.com`. Reversal is why the
14//! *first* segment is the one that may not start with a digit: it is the
15//! top-level domain. `org.4chan.lex.getThing` is fine and `1.0.0.127.record`
16//! is not, for exactly the reason `4chan.org` is a domain and `127.0.0.1` is
17//! not.
18
19/// Longest an NSID may be: a 253-character domain authority, a dot, and a
20/// 63-character name.
21pub const MAX_NSID_LENGTH: usize = 317;
22
23/// Longest the reversed domain authority may be, matching DNS.
24pub const MAX_AUTHORITY_LENGTH: usize = 253;
25
26/// Longest any one segment may be, matching a DNS label.
27pub const MAX_SEGMENT_LENGTH: usize = 63;
28
29/// Why a string is not a syntactically valid NSID.
30#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
31pub enum NsidError {
32    /// Over [`MAX_NSID_LENGTH`] characters.
33    #[error("nsid is {0} characters, over the limit of {MAX_NSID_LENGTH}")]
34    TooLong(usize),
35    /// The reversed domain authority is over [`MAX_AUTHORITY_LENGTH`].
36    #[error("nsid authority is {0} characters, over the limit of {MAX_AUTHORITY_LENGTH}")]
37    AuthorityTooLong(usize),
38    /// Fewer than three dot-separated segments, so there is no name segment
39    /// under a two-part authority.
40    #[error("nsid needs at least three segments, found {0}")]
41    TooFewSegments(usize),
42    /// A segment was empty: a leading dot, a trailing dot, or a `..`.
43    #[error("nsid has an empty segment")]
44    EmptySegment,
45    /// A segment was over [`MAX_SEGMENT_LENGTH`] characters.
46    #[error("nsid segment {0:?} is over {MAX_SEGMENT_LENGTH} characters")]
47    SegmentTooLong(String),
48    /// A character outside the allowed set appeared.
49    #[error("invalid character {0:?} in nsid")]
50    InvalidCharacter(char),
51    /// An authority segment started or ended with a hyphen.
52    #[error("nsid segment {0:?} starts or ends with a hyphen")]
53    HyphenEdge(String),
54    /// The first segment is the top-level domain, which may not start with a
55    /// digit.
56    #[error("nsid top-level segment {0:?} starts with a digit")]
57    NumericTld(String),
58    /// The name segment held something other than letters and digits, or
59    /// started with a digit.
60    #[error("nsid name segment {0:?} must start with a letter and hold only letters and digits")]
61    BadName(String),
62}
63
64/// Checks a string against the NSID syntax rules.
65///
66/// ```
67/// use didbot_lexicon::nsid_syntax::validate;
68/// assert!(validate("com.example.fooBar").is_ok());
69/// assert!(validate("com.example").is_err());     // no name segment
70/// assert!(validate("1.0.0.127.record").is_err()); // numeric top-level domain
71/// ```
72pub fn validate(nsid: &str) -> Result<(), NsidError> {
73    if nsid.len() > MAX_NSID_LENGTH {
74        return Err(NsidError::TooLong(nsid.len()));
75    }
76    // Checked before anything else so that a multi-byte character is reported
77    // as itself rather than as a segment length in bytes.
78    if let Some(ch) = nsid
79        .chars()
80        .find(|ch| !(ch.is_ascii_alphanumeric() || *ch == '-' || *ch == '.'))
81    {
82        return Err(NsidError::InvalidCharacter(ch));
83    }
84
85    let segments: Vec<&str> = nsid.split('.').collect();
86    if segments.len() < 3 {
87        return Err(NsidError::TooFewSegments(segments.len()));
88    }
89    let (name, authority) = segments
90        .split_last()
91        .expect("split on '.' yields at least one segment");
92    // +1 for each separating dot, minus the one before the name.
93    let authority_length = nsid.len() - name.len() - 1;
94    if authority_length > MAX_AUTHORITY_LENGTH {
95        return Err(NsidError::AuthorityTooLong(authority_length));
96    }
97
98    for segment in authority {
99        if segment.is_empty() {
100            return Err(NsidError::EmptySegment);
101        }
102        if segment.len() > MAX_SEGMENT_LENGTH {
103            return Err(NsidError::SegmentTooLong((*segment).to_owned()));
104        }
105        if segment.starts_with('-') || segment.ends_with('-') {
106            return Err(NsidError::HyphenEdge((*segment).to_owned()));
107        }
108    }
109    // Segment zero is the top-level domain, because the authority is reversed.
110    let tld = authority[0];
111    if tld.starts_with(|ch: char| ch.is_ascii_digit()) {
112        return Err(NsidError::NumericTld(tld.to_owned()));
113    }
114
115    // The name segment is not a DNS label and does not follow DNS rules: no
116    // hyphens at all, and it must start with a letter. `com.example.fooBar.2`
117    // is invalid for the same reason a Rust identifier cannot start with a
118    // digit — the name is a program identifier, not a host.
119    if name.is_empty() {
120        return Err(NsidError::EmptySegment);
121    }
122    if name.len() > MAX_SEGMENT_LENGTH {
123        return Err(NsidError::SegmentTooLong((*name).to_owned()));
124    }
125    if !name.starts_with(|ch: char| ch.is_ascii_alphabetic())
126        || !name.chars().all(|ch| ch.is_ascii_alphanumeric())
127    {
128        return Err(NsidError::BadName((*name).to_owned()));
129    }
130    Ok(())
131}