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}