Skip to main content

proto_blue_syntax/
aturi.rs

1//! AT-URI validation and types.
2//!
3//! AT-URIs follow the format: `at://authority/collection/rkey`
4//! See: <https://atproto.com/specs/at-uri-scheme>
5
6use regex::Regex;
7use std::fmt;
8use std::str::FromStr;
9
10use crate::did::Did;
11use crate::handle::Handle;
12use crate::nsid::Nsid;
13use crate::recordkey::RecordKey;
14
15/// Maximum length of an AT-URI (8 KiB).
16const MAX_ATURI_LENGTH: usize = 8 * 1024;
17
18// Case-sensitive by intent: the `at://` scheme is lowercase, DID methods
19// are lowercase, and authority case is preserved for handles. The char
20// classes already include both upper and lower where the spec allows
21// mixed case (e.g. authority percent-encoding, rkeys); a global `(?i)`
22// flag would weaken those constraints for no benefit. See issue #3.
23static ATURI_REGEX: std::sync::LazyLock<Regex> = std::sync::LazyLock::new(|| {
24    Regex::new(
25        r"^at://(?P<authority>[a-zA-Z0-9._:%-]+)(/(?P<collection>[a-zA-Z0-9-.]+)(/(?P<rkey>[a-zA-Z0-9._~:@!$&%')(*+,;=-]+))?)?(#(?P<fragment>/[a-zA-Z0-9._~:@!$&%')(*+,;=\[\]/-]*))?$"
26    )
27    .unwrap()
28});
29
30/// A validated AT-URI.
31///
32/// Format: `at://did-or-handle/collection/rkey`
33#[derive(Debug, Clone, PartialEq, Eq, Hash)]
34pub struct AtUri {
35    authority: String,
36    collection: Option<String>,
37    rkey: Option<String>,
38    fragment: Option<String>,
39}
40
41/// Error returned when an AT-URI string is invalid.
42#[derive(Debug, Clone, thiserror::Error)]
43#[error("Invalid AT-URI: {reason}")]
44pub struct InvalidAtUriError {
45    pub reason: String,
46}
47
48impl AtUri {
49    /// Parse and validate an AT-URI string.
50    pub fn new(s: &str) -> Result<Self, InvalidAtUriError> {
51        let err = |reason: &str| InvalidAtUriError {
52            reason: reason.to_string(),
53        };
54
55        if s.len() > MAX_ATURI_LENGTH {
56            return Err(err(&format!(
57                "AT-URI is too long ({} bytes, max {})",
58                s.len(),
59                MAX_ATURI_LENGTH
60            )));
61        }
62
63        if !s.starts_with("at://") {
64            return Err(err("AT-URI must start with \"at://\""));
65        }
66
67        let caps = ATURI_REGEX
68            .captures(s)
69            .ok_or_else(|| err("AT-URI format is invalid"))?;
70
71        let authority = caps
72            .name("authority")
73            .ok_or_else(|| err("AT-URI missing authority"))?
74            .as_str()
75            .to_string();
76
77        // Validate authority is a valid DID or handle
78        if authority.starts_with("did:") {
79            Did::new(&authority).map_err(|e| err(&format!("Invalid DID in AT-URI: {e}")))?;
80        } else {
81            Handle::new(&authority).map_err(|e| err(&format!("Invalid handle in AT-URI: {e}")))?;
82        }
83
84        let collection = caps.name("collection").map(|m| m.as_str().to_string());
85        let rkey = caps.name("rkey").map(|m| m.as_str().to_string());
86        let fragment = caps.name("fragment").map(|m| m.as_str().to_string());
87
88        // If collection is present, validate it's a valid NSID
89        if let Some(ref coll) = collection {
90            Nsid::new(coll).map_err(|e| err(&format!("Invalid collection NSID in AT-URI: {e}")))?;
91        }
92
93        // If rkey is present, validate it
94        if let Some(ref rk) = rkey {
95            RecordKey::new(rk).map_err(|e| err(&format!("Invalid record key in AT-URI: {e}")))?;
96        }
97
98        // Can't have rkey without collection
99        if rkey.is_some() && collection.is_none() {
100            return Err(err("AT-URI cannot have rkey without collection"));
101        }
102
103        Ok(Self {
104            authority,
105            collection,
106            rkey,
107            fragment,
108        })
109    }
110
111    /// Check whether a string is a valid AT-URI.
112    #[must_use]
113    pub fn is_valid(s: &str) -> bool {
114        Self::new(s).is_ok()
115    }
116
117    /// Build an AT-URI from individual parts. The collection and rkey
118    /// are each optional, mirroring the TS `AtUri.make(host, coll?, rkey?)`
119    /// constructor. Every part is validated in the same way as the
120    /// full-string parser — calling `make(h, None, Some(r))` (rkey
121    /// without collection) is an error.
122    pub fn make(
123        authority: &str,
124        collection: Option<&str>,
125        rkey: Option<&str>,
126    ) -> Result<Self, InvalidAtUriError> {
127        let mut s = format!("at://{authority}");
128        if let Some(c) = collection {
129            s.push('/');
130            s.push_str(c);
131            if let Some(r) = rkey {
132                s.push('/');
133                s.push_str(r);
134            }
135        } else if rkey.is_some() {
136            return Err(InvalidAtUriError {
137                reason: "AT-URI cannot have rkey without collection".to_string(),
138            });
139        }
140        Self::new(&s)
141    }
142
143    /// Return the authority (DID or handle).
144    #[must_use]
145    pub fn authority(&self) -> &str {
146        &self.authority
147    }
148
149    /// Return the collection NSID, if present.
150    #[must_use]
151    pub fn collection(&self) -> Option<&str> {
152        self.collection.as_deref()
153    }
154
155    /// Return the record key, if present.
156    #[must_use]
157    pub fn rkey(&self) -> Option<&str> {
158        self.rkey.as_deref()
159    }
160
161    /// Return the fragment, if present.
162    #[must_use]
163    pub fn fragment(&self) -> Option<&str> {
164        self.fragment.as_deref()
165    }
166
167    /// The URI protocol — always `"at:"`.
168    #[must_use]
169    pub const fn protocol(&self) -> &'static str {
170        "at:"
171    }
172
173    /// The URI origin — `at://<authority>`. Analogous to `URL.origin`
174    /// in browsers (and to the TS `AtUri.origin` getter).
175    #[must_use]
176    pub fn origin(&self) -> String {
177        format!("at://{}", self.authority)
178    }
179
180    /// Replace the authority. Validates the new value as a DID or a
181    /// handle before committing — a failed update leaves the URI
182    /// unchanged.
183    pub fn set_authority(&mut self, v: impl Into<String>) -> Result<(), InvalidAtUriError> {
184        let v = v.into();
185        if v.starts_with("did:") {
186            Did::new(&v).map_err(|e| InvalidAtUriError {
187                reason: format!("Invalid DID authority: {e}"),
188            })?;
189        } else {
190            Handle::new(&v).map_err(|e| InvalidAtUriError {
191                reason: format!("Invalid handle authority: {e}"),
192            })?;
193        }
194        self.authority = v;
195        Ok(())
196    }
197
198    /// Replace the collection NSID. Pass `None` to clear both
199    /// collection and rkey (an rkey without a collection is not a
200    /// valid AT-URI, so setting collection to `None` also drops rkey).
201    pub fn set_collection(&mut self, v: Option<&str>) -> Result<(), InvalidAtUriError> {
202        if let Some(coll) = v {
203            Nsid::new(coll).map_err(|e| InvalidAtUriError {
204                reason: format!("Invalid collection NSID: {e}"),
205            })?;
206            self.collection = Some(coll.to_string());
207        } else {
208            self.collection = None;
209            self.rkey = None;
210        }
211        Ok(())
212    }
213
214    /// Replace the record key. Setting a rkey on a URI with no
215    /// collection is rejected — the spec requires `coll/rkey` together.
216    pub fn set_rkey(&mut self, v: Option<&str>) -> Result<(), InvalidAtUriError> {
217        match v {
218            Some(rk) => {
219                if self.collection.is_none() {
220                    return Err(InvalidAtUriError {
221                        reason: "cannot set rkey on an AT-URI that has no collection".to_string(),
222                    });
223                }
224                RecordKey::new(rk).map_err(|e| InvalidAtUriError {
225                    reason: format!("Invalid record key: {e}"),
226                })?;
227                self.rkey = Some(rk.to_string());
228            }
229            None => {
230                self.rkey = None;
231            }
232        }
233        Ok(())
234    }
235
236    /// Replace the fragment. Pass `None` to clear it. Fragments must
237    /// start with `/` per the atproto syntax (they're JSON Pointer
238    /// expressions), so a non-`None` value that doesn't is rejected.
239    pub fn set_fragment(&mut self, v: Option<&str>) -> Result<(), InvalidAtUriError> {
240        match v {
241            Some(f) if !f.starts_with('/') => Err(InvalidAtUriError {
242                reason: "AT-URI fragment must start with `/` (JSON Pointer)".to_string(),
243            }),
244            Some(f) => {
245                // Round-trip through the parser to reuse its charset
246                // checks — build a throwaway URI with just this
247                // fragment and see if it parses.
248                let probe = format!("at://{}#{}", self.authority, f);
249                Self::new(&probe).map_err(|e| InvalidAtUriError {
250                    reason: format!("Invalid fragment: {e}"),
251                })?;
252                self.fragment = Some(f.to_string());
253                Ok(())
254            }
255            None => {
256                self.fragment = None;
257                Ok(())
258            }
259        }
260    }
261
262    /// Resolve a reference relative to this URI.
263    ///
264    /// Handles the two cases the atproto spec actually uses:
265    ///
266    /// - If `reference` is a full AT-URI (`at://…`), it's parsed in
267    ///   isolation and returned.
268    /// - If `reference` starts with `#`, the fragment is replaced.
269    /// - Otherwise, `reference` is treated as a path applied to this
270    ///   URI's authority: an empty / single-segment / two-segment
271    ///   reference sets just the collection / collection + rkey.
272    ///
273    /// This is a pragmatic subset of TS's relative-URI handling —
274    /// atproto doesn't use the full RFC 3986 scheme-relative / host-
275    /// relative escalation ladder, so replicating all of it would be
276    /// yak-shaving.
277    pub fn resolve(&self, reference: &str) -> Result<Self, InvalidAtUriError> {
278        if reference.starts_with("at://") {
279            return Self::new(reference);
280        }
281        if let Some(frag) = reference.strip_prefix('#') {
282            let mut cloned = self.clone();
283            cloned.set_fragment(Some(&format!(
284                "{}{frag}",
285                if frag.starts_with('/') { "" } else { "/" }
286            )))?;
287            return Ok(cloned);
288        }
289        // Otherwise treat as `coll[/rkey][#frag]`.
290        let (path_part, frag_part) = reference.find('#').map_or((reference, None), |i| {
291            (&reference[..i], Some(&reference[i + 1..]))
292        });
293        let parts: Vec<&str> = path_part.split('/').filter(|p| !p.is_empty()).collect();
294        let mut cloned = self.clone();
295        match parts.len() {
296            0 => cloned.set_collection(None)?,
297            1 => {
298                cloned.set_collection(Some(parts[0]))?;
299                cloned.set_rkey(None)?;
300            }
301            2 => {
302                cloned.set_collection(Some(parts[0]))?;
303                cloned.set_rkey(Some(parts[1]))?;
304            }
305            _ => {
306                return Err(InvalidAtUriError {
307                    reason: format!("relative reference has too many path segments: {reference:?}"),
308                });
309            }
310        }
311        cloned.set_fragment(
312            frag_part
313                .map(|f| {
314                    if f.starts_with('/') {
315                        f.to_string()
316                    } else {
317                        format!("/{f}")
318                    }
319                })
320                .as_deref(),
321        )?;
322        Ok(cloned)
323    }
324}
325
326impl fmt::Display for AtUri {
327    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
328        write!(f, "at://{}", self.authority)?;
329        if let Some(ref coll) = self.collection {
330            write!(f, "/{coll}")?;
331            if let Some(ref rk) = self.rkey {
332                write!(f, "/{rk}")?;
333            }
334        }
335        if let Some(ref frag) = self.fragment {
336            write!(f, "#{frag}")?;
337        }
338        Ok(())
339    }
340}
341
342impl FromStr for AtUri {
343    type Err = InvalidAtUriError;
344    fn from_str(s: &str) -> Result<Self, Self::Err> {
345        Self::new(s)
346    }
347}
348
349impl serde::Serialize for AtUri {
350    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
351        self.to_string().serialize(serializer)
352    }
353}
354
355impl<'de> serde::Deserialize<'de> for AtUri {
356    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
357        let s = String::deserialize(deserializer)?;
358        Self::new(&s).map_err(serde::de::Error::custom)
359    }
360}
361
362#[cfg(test)]
363mod tests {
364    use super::*;
365
366    #[test]
367    fn valid_aturis() {
368        let cases = [
369            "at://did:plc:asdf123/app.bsky.feed.post/3jui7kd54zh2y",
370            "at://did:plc:asdf123/app.bsky.feed.post",
371            "at://did:plc:asdf123",
372            "at://alice.bsky.social/app.bsky.feed.post/3jui7kd54zh2y",
373            "at://alice.bsky.social",
374        ];
375        for uri in &cases {
376            assert!(AtUri::new(uri).is_ok(), "should be valid: {uri}");
377        }
378    }
379
380    #[test]
381    fn invalid_aturis() {
382        assert!(AtUri::new("").is_err());
383        assert!(AtUri::new("http://example.com").is_err());
384        assert!(AtUri::new("at://").is_err());
385    }
386
387    #[test]
388    fn parse_components() {
389        let uri = AtUri::new("at://did:plc:asdf123/app.bsky.feed.post/3jui7kd54zh2y").unwrap();
390        assert_eq!(uri.authority(), "did:plc:asdf123");
391        assert_eq!(uri.collection(), Some("app.bsky.feed.post"));
392        assert_eq!(uri.rkey(), Some("3jui7kd54zh2y"));
393    }
394
395    #[test]
396    fn display_roundtrip() {
397        let input = "at://did:plc:asdf123/app.bsky.feed.post/3jui7kd54zh2y";
398        let uri = AtUri::new(input).unwrap();
399        assert_eq!(uri.to_string(), input);
400    }
401
402    /// Regression for issue #3: the AT-URI regex was globally case-insensitive
403    /// (`(?i)`), so `AT://...` or `at://DID:plc:...` could slip through the
404    /// regex even though `starts_with("at://")` and `Did::new` rejected them
405    /// defensively. With `(?i)` removed, the regex itself now enforces
406    /// lowercase `at://` and lowercase `did:` in the authority's method part.
407    #[test]
408    fn rejects_uppercase_scheme_via_regex() {
409        assert!(AtUri::new("AT://did:plc:asdf123").is_err());
410        assert!(AtUri::new("At://did:plc:asdf123").is_err());
411        // Uppercase DID method is rejected by Did::new.
412        assert!(AtUri::new("at://DID:plc:asdf123").is_err());
413        assert!(AtUri::new("at://did:PLC:asdf123").is_err());
414    }
415
416    // ── make ─────────────────────────────────────────────────────────
417
418    #[test]
419    fn make_with_authority_only() {
420        let uri = AtUri::make("alice.bsky.social", None, None).unwrap();
421        assert_eq!(uri.to_string(), "at://alice.bsky.social");
422        assert!(uri.collection().is_none());
423        assert!(uri.rkey().is_none());
424    }
425
426    #[test]
427    fn make_with_authority_and_collection() {
428        let uri = AtUri::make("alice.bsky.social", Some("app.bsky.feed.post"), None).unwrap();
429        assert_eq!(uri.to_string(), "at://alice.bsky.social/app.bsky.feed.post");
430    }
431
432    #[test]
433    fn make_full() {
434        let uri = AtUri::make(
435            "did:plc:abc",
436            Some("app.bsky.feed.post"),
437            Some("3jui7kd54zh2y"),
438        )
439        .unwrap();
440        assert_eq!(
441            uri.to_string(),
442            "at://did:plc:abc/app.bsky.feed.post/3jui7kd54zh2y"
443        );
444    }
445
446    #[test]
447    fn make_rkey_without_collection_is_error() {
448        let err = AtUri::make("alice.bsky.social", None, Some("rec")).unwrap_err();
449        assert!(err.reason.contains("rkey without collection"));
450    }
451
452    #[test]
453    fn make_validates_each_part() {
454        // Bad NSID.
455        assert!(AtUri::make("alice.bsky.social", Some("not an nsid"), None).is_err());
456        // Bad rkey.
457        assert!(AtUri::make("alice.bsky.social", Some("app.bsky.feed.post"), Some(".")).is_err());
458        // Bad authority.
459        assert!(AtUri::make("not a handle", None, None).is_err());
460    }
461
462    // ── origin / protocol ─────────────────────────────────────────────
463
464    #[test]
465    fn origin_is_at_authority_only() {
466        let uri = AtUri::new("at://did:plc:asdf123/app.bsky.feed.post/3jui7kd54zh2y").unwrap();
467        assert_eq!(uri.origin(), "at://did:plc:asdf123");
468        assert_eq!(uri.protocol(), "at:");
469    }
470
471    // ── setters ──────────────────────────────────────────────────────
472
473    #[test]
474    fn set_authority_accepts_valid_did_and_handle() {
475        let mut uri = AtUri::new("at://alice.bsky.social/app.bsky.feed.post").unwrap();
476        uri.set_authority("did:plc:abc").unwrap();
477        assert_eq!(uri.authority(), "did:plc:abc");
478        uri.set_authority("bob.example").unwrap();
479        assert_eq!(uri.authority(), "bob.example");
480    }
481
482    #[test]
483    fn set_authority_rejects_invalid_value_and_leaves_uri_unchanged() {
484        let mut uri = AtUri::new("at://alice.bsky.social").unwrap();
485        assert!(uri.set_authority("not an identifier").is_err());
486        assert_eq!(uri.authority(), "alice.bsky.social");
487    }
488
489    #[test]
490    fn set_collection_validates_nsid() {
491        let mut uri = AtUri::new("at://alice.bsky.social").unwrap();
492        uri.set_collection(Some("app.bsky.feed.post")).unwrap();
493        assert_eq!(uri.collection(), Some("app.bsky.feed.post"));
494        assert!(uri.set_collection(Some("not an nsid")).is_err());
495        assert_eq!(uri.collection(), Some("app.bsky.feed.post"));
496    }
497
498    #[test]
499    fn clearing_collection_also_clears_rkey() {
500        let mut uri = AtUri::new("at://alice.bsky.social/app.bsky.feed.post/abc").unwrap();
501        uri.set_collection(None).unwrap();
502        assert!(uri.collection().is_none());
503        assert!(uri.rkey().is_none());
504    }
505
506    #[test]
507    fn set_rkey_requires_collection() {
508        let mut uri = AtUri::new("at://alice.bsky.social").unwrap();
509        let err = uri.set_rkey(Some("abc")).unwrap_err();
510        assert!(err.reason.contains("no collection"));
511    }
512
513    #[test]
514    fn set_rkey_validates_record_key() {
515        let mut uri = AtUri::new("at://alice.bsky.social/app.bsky.feed.post/abc").unwrap();
516        assert!(uri.set_rkey(Some(".")).is_err());
517        // rkey unchanged after failed validation.
518        assert_eq!(uri.rkey(), Some("abc"));
519        uri.set_rkey(Some("def-123")).unwrap();
520        assert_eq!(uri.rkey(), Some("def-123"));
521        uri.set_rkey(None).unwrap();
522        assert!(uri.rkey().is_none());
523    }
524
525    #[test]
526    fn set_fragment_requires_leading_slash() {
527        let mut uri = AtUri::new("at://alice.bsky.social").unwrap();
528        assert!(uri.set_fragment(Some("no-slash")).is_err());
529        uri.set_fragment(Some("/path/to/thing")).unwrap();
530        assert_eq!(uri.fragment(), Some("/path/to/thing"));
531        uri.set_fragment(None).unwrap();
532        assert!(uri.fragment().is_none());
533    }
534
535    // ── resolve ──────────────────────────────────────────────────────
536
537    #[test]
538    fn resolve_absolute_uri_returns_that_uri() {
539        let base = AtUri::new("at://alice.bsky.social").unwrap();
540        let resolved = base
541            .resolve("at://did:plc:abc/app.bsky.feed.post/xyz")
542            .unwrap();
543        assert_eq!(
544            resolved.to_string(),
545            "at://did:plc:abc/app.bsky.feed.post/xyz"
546        );
547    }
548
549    #[test]
550    fn resolve_fragment_keeps_authority_and_path() {
551        let base = AtUri::new("at://alice.bsky.social/app.bsky.feed.post/abc").unwrap();
552        let resolved = base.resolve("#/likeCount").unwrap();
553        assert_eq!(
554            resolved.to_string(),
555            "at://alice.bsky.social/app.bsky.feed.post/abc#/likeCount"
556        );
557    }
558
559    #[test]
560    fn resolve_path_replaces_collection_and_rkey() {
561        let base = AtUri::new("at://alice.bsky.social/foo.bar.baz/old").unwrap();
562        let resolved = base.resolve("app.bsky.feed.post/new").unwrap();
563        assert_eq!(
564            resolved.to_string(),
565            "at://alice.bsky.social/app.bsky.feed.post/new"
566        );
567    }
568
569    #[test]
570    fn resolve_single_segment_replaces_only_collection() {
571        let base = AtUri::new("at://alice.bsky.social/foo.bar.baz/rec").unwrap();
572        let resolved = base.resolve("app.bsky.feed.post").unwrap();
573        // rkey cleared because we set_collection before set_rkey(None).
574        assert_eq!(
575            resolved.to_string(),
576            "at://alice.bsky.social/app.bsky.feed.post"
577        );
578    }
579
580    #[test]
581    fn resolve_rejects_too_many_segments() {
582        let base = AtUri::new("at://alice.bsky.social").unwrap();
583        let err = base.resolve("a/b/c/d").unwrap_err();
584        assert!(err.reason.contains("too many path segments"));
585    }
586}