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