Skip to main content

proto_blue_syntax/
nsid.rs

1//! NSID (Namespaced Identifier) validation and types.
2//!
3//! NSIDs are reverse-DNS-style identifiers like `com.atproto.repo.createRecord`.
4//! See: <https://atproto.com/specs/nsid>
5
6use once_cell::sync::Lazy;
7use regex::Regex;
8use std::fmt;
9use std::str::FromStr;
10
11/// Maximum length of an NSID string (253 domain + 1 dot + 63 name).
12const MAX_NSID_LENGTH: usize = 317;
13
14/// Maximum length of a single segment.
15const MAX_SEGMENT_LENGTH: usize = 63;
16
17/// Minimum number of segments (authority has at least 2 + name = 3).
18const MIN_SEGMENTS: usize = 3;
19
20static NSID_REGEX: Lazy<Regex> = Lazy::new(|| {
21    Regex::new(
22        r"^[a-zA-Z](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+(?:\.[a-zA-Z](?:[a-zA-Z0-9]{0,62})?)$",
23    )
24    .unwrap()
25});
26
27/// A validated NSID (Namespaced Identifier).
28///
29/// Format: `authority.name` where authority is reversed domain (e.g., `com.atproto.repo.createRecord`).
30#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
31pub struct Nsid(String);
32
33/// Error returned when an NSID string is invalid.
34#[derive(Debug, Clone, thiserror::Error)]
35#[error("Invalid NSID: {reason}")]
36pub struct InvalidNsidError {
37    pub reason: String,
38}
39
40impl Nsid {
41    /// Create a new `Nsid` from a string, validating the format.
42    pub fn new(s: &str) -> Result<Self, InvalidNsidError> {
43        ensure_valid_nsid(s)?;
44        Ok(Nsid(s.to_string()))
45    }
46
47    /// Check whether a string is a valid NSID.
48    pub fn is_valid(s: &str) -> bool {
49        ensure_valid_nsid(s).is_ok()
50    }
51
52    /// Compose an NSID from an `authority` (reverse-DNS prefix, e.g.
53    /// `app.bsky.feed`) and a `name` (e.g. `post`). The inverse of
54    /// [`Nsid::authority`] + [`Nsid::name`]. Validates the result.
55    ///
56    /// Mirrors TS `NSID.create(authority, name)`.
57    pub fn create(authority: &str, name: &str) -> Result<Self, InvalidNsidError> {
58        Nsid::new(&format!("{authority}.{name}"))
59    }
60
61    /// Return the authority portion (all segments except the last).
62    ///
63    /// For `com.atproto.repo.createRecord`, returns `com.atproto.repo`.
64    pub fn authority(&self) -> &str {
65        let last_dot = self.0.rfind('.').unwrap();
66        &self.0[..last_dot]
67    }
68
69    /// Return the name portion (last segment).
70    ///
71    /// For `com.atproto.repo.createRecord`, returns `createRecord`.
72    pub fn name(&self) -> &str {
73        let last_dot = self.0.rfind('.').unwrap();
74        &self.0[last_dot + 1..]
75    }
76
77    /// Return the segments as a vector.
78    pub fn segments(&self) -> Vec<&str> {
79        self.0.split('.').collect()
80    }
81
82    /// Return the inner string.
83    pub fn as_str(&self) -> &str {
84        &self.0
85    }
86
87    /// Consume and return the inner string.
88    pub fn into_inner(self) -> String {
89        self.0
90    }
91}
92
93fn ensure_valid_nsid(s: &str) -> Result<(), InvalidNsidError> {
94    let err = |reason: &str| InvalidNsidError {
95        reason: reason.to_string(),
96    };
97
98    if s.len() > MAX_NSID_LENGTH {
99        return Err(err(&format!(
100            "NSID is too long ({} chars, max {})",
101            s.len(),
102            MAX_NSID_LENGTH
103        )));
104    }
105
106    if !s.is_ascii() {
107        return Err(err("NSID must be ASCII only"));
108    }
109
110    let segments: Vec<&str> = s.split('.').collect();
111
112    if segments.len() < MIN_SEGMENTS {
113        return Err(err(&format!(
114            "NSID must have at least {} segments, found {}",
115            MIN_SEGMENTS,
116            segments.len()
117        )));
118    }
119
120    for segment in &segments {
121        if segment.is_empty() {
122            return Err(err("NSID segments must not be empty"));
123        }
124        if segment.len() > MAX_SEGMENT_LENGTH {
125            return Err(err(&format!(
126                "NSID segment too long ({} chars, max {})",
127                segment.len(),
128                MAX_SEGMENT_LENGTH
129            )));
130        }
131    }
132
133    // The last segment (name) must start with a letter, no hyphens
134    if let Some(name) = segments.last() {
135        if name.starts_with(|c: char| c.is_ascii_digit()) {
136            return Err(err("NSID name segment must not start with a digit"));
137        }
138        if name.contains('-') {
139            return Err(err("NSID name segment must not contain hyphens"));
140        }
141    }
142
143    // First segment must not start with a digit
144    if let Some(first) = segments.first() {
145        if first.starts_with(|c: char| c.is_ascii_digit()) {
146            return Err(err("NSID first segment must not start with a digit"));
147        }
148    }
149
150    if !NSID_REGEX.is_match(s) {
151        return Err(err("NSID format is invalid"));
152    }
153
154    Ok(())
155}
156
157impl fmt::Display for Nsid {
158    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
159        f.write_str(&self.0)
160    }
161}
162
163impl FromStr for Nsid {
164    type Err = InvalidNsidError;
165    fn from_str(s: &str) -> Result<Self, Self::Err> {
166        Nsid::new(s)
167    }
168}
169
170impl AsRef<str> for Nsid {
171    fn as_ref(&self) -> &str {
172        &self.0
173    }
174}
175
176impl serde::Serialize for Nsid {
177    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
178        self.0.serialize(serializer)
179    }
180}
181
182impl<'de> serde::Deserialize<'de> for Nsid {
183    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
184        let s = String::deserialize(deserializer)?;
185        Nsid::new(&s).map_err(serde::de::Error::custom)
186    }
187}
188
189#[cfg(test)]
190mod tests {
191    use super::*;
192
193    #[test]
194    fn valid_nsids() {
195        let cases = [
196            "com.atproto.repo.createRecord",
197            "app.bsky.feed.post",
198            "com.example.fooBar",
199            "io.github.test",
200            "a.b.c",
201        ];
202        for nsid in &cases {
203            assert!(Nsid::new(nsid).is_ok(), "should be valid: {nsid}");
204        }
205    }
206
207    #[test]
208    fn invalid_nsids() {
209        assert!(Nsid::new("").is_err(), "empty");
210        assert!(Nsid::new("com.example").is_err(), "only 2 segments");
211        assert!(
212            Nsid::new("com.example.123").is_err(),
213            "name starts with digit"
214        );
215        assert!(Nsid::new("com.example.foo-bar").is_err(), "name has hyphen");
216        assert!(Nsid::new("com..example.test").is_err(), "empty segment");
217    }
218
219    #[test]
220    fn authority_and_name() {
221        let nsid = Nsid::new("com.atproto.repo.createRecord").unwrap();
222        assert_eq!(nsid.authority(), "com.atproto.repo");
223        assert_eq!(nsid.name(), "createRecord");
224    }
225
226    #[test]
227    fn segments() {
228        let nsid = Nsid::new("app.bsky.feed.post").unwrap();
229        assert_eq!(nsid.segments(), vec!["app", "bsky", "feed", "post"]);
230    }
231
232    #[test]
233    fn serde_roundtrip() {
234        let nsid = Nsid::new("com.atproto.repo.createRecord").unwrap();
235        let json = serde_json::to_string(&nsid).unwrap();
236        let parsed: Nsid = serde_json::from_str(&json).unwrap();
237        assert_eq!(parsed, nsid);
238    }
239}