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