Skip to main content

didbot_schema/
aturi.rs

1//! `at://` URIs: the names records answer to.
2//!
3//! `didbot-pds` stores records and `didbot-serve` hands out URIs for them,
4//! and this module reads one back. An AT-URI's three parts belong to three
5//! different validators, and this crate is the one that depends on all of
6//! them.
7//!
8//! | Part | Owner |
9//! | --- | --- |
10//! | authority — a DID or a handle | [`didbot_identity`] |
11//! | collection — an NSID | [`didbot_lexicon::nsid_syntax`] |
12//! | record key | [`crate::record_key::validate_record_key`] |
13//!
14//! # Only the restricted form
15//!
16//! atproto specifies a permissive AT-URI grammar and a restricted one. The
17//! restricted form drops query strings and fragments and demands that the
18//! collection be a real NSID and the record key a real record key, and it is
19//! the only form that appears in lexicons or in repository data. This parser
20//! implements that one. A fragment is meaningful when an AT-URI points into a
21//! *lexicon* rather than at a record, which nothing here does yet; when that
22//! changes it wants its own type, not a looser version of this one.
23
24use didbot_identity::{validate_did, validate_handle};
25use didbot_lexicon::nsid_syntax;
26
27use crate::record_key::{validate_record_key, RecordKeyError};
28
29/// The scheme every AT-URI starts with, lowercase and exact.
30const AT_SCHEME: &str = "at://";
31
32/// Hard cap on an AT-URI's length, from the atproto specification.
33pub const MAX_ATURI_LENGTH: usize = 8 * 1024;
34
35/// Why a string is not a usable `at://` URI.
36#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
37pub enum AtUriError {
38    /// Over [`MAX_ATURI_LENGTH`] bytes.
39    #[error("at-uri is {0} bytes, over the limit of {MAX_ATURI_LENGTH}")]
40    TooLong(usize),
41    /// Does not start with the exact lowercase `at://`.
42    #[error("at-uri does not start with `at://`")]
43    NotAtScheme,
44    /// The authority is neither a valid DID nor a valid handle.
45    #[error("at-uri authority {0:?} is neither a did nor a handle")]
46    BadAuthority(String),
47    /// The collection segment is not a valid NSID.
48    #[error("at-uri collection {collection:?} is not an nsid: {source}")]
49    BadCollection {
50        /// The segment as written.
51        collection: String,
52        /// Why it is not an NSID.
53        #[source]
54        source: nsid_syntax::NsidError,
55    },
56    /// The record key segment is not a valid record key.
57    #[error("at-uri record key {rkey:?} is not usable: {source}")]
58    BadRecordKey {
59        /// The segment as written.
60        rkey: String,
61        /// Why it is not a record key.
62        #[source]
63        source: RecordKeyError,
64    },
65    /// More path segments than `collection/rkey`, or a trailing slash, or an
66    /// empty segment. All three arrive here as an empty or extra segment.
67    #[error("at-uri has a malformed path: {0:?}")]
68    BadPath(String),
69    /// A query string or fragment, neither of which the restricted form has.
70    #[error("at-uri carries a query or fragment, which the restricted form does not allow")]
71    QueryOrFragment,
72}
73
74/// A parsed `at://` URI in its restricted form.
75///
76/// The parts are borrowed from the input, so an `AtUri` never re-renders the
77/// string it came from and cannot disagree with it.
78#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
79pub struct AtUri<'a> {
80    authority: &'a str,
81    collection: Option<&'a str>,
82    rkey: Option<&'a str>,
83}
84
85impl<'a> AtUri<'a> {
86    /// The repository this URI names: a DID or a handle, as written.
87    pub fn authority(self) -> &'a str {
88        self.authority
89    }
90
91    /// The collection, if the URI names one.
92    pub fn collection(self) -> Option<&'a str> {
93        self.collection
94    }
95
96    /// The record key, if the URI names a single record.
97    ///
98    /// A URI with a collection but no record key names the collection itself,
99    /// which is a listing rather than a record.
100    pub fn rkey(self) -> Option<&'a str> {
101        self.rkey
102    }
103
104    /// Parses an `at://` URI.
105    ///
106    /// ```
107    /// use didbot_schema::aturi::AtUri;
108    /// let uri = AtUri::parse("at://did:plc:asdf123/com.atproto.feed.post/record")?;
109    /// assert_eq!(uri.authority(), "did:plc:asdf123");
110    /// assert_eq!(uri.rkey(), Some("record"));
111    ///
112    /// assert!(AtUri::parse("at://did:plc:asdf123/").is_err()); // trailing slash
113    /// assert!(AtUri::parse("at://did:plc:asdf123#").is_err()); // fragment
114    /// # Ok::<(), didbot_schema::aturi::AtUriError>(())
115    /// ```
116    pub fn parse(uri: &'a str) -> Result<Self, AtUriError> {
117        if uri.len() > MAX_ATURI_LENGTH {
118            return Err(AtUriError::TooLong(uri.len()));
119        }
120        let rest = uri.strip_prefix(AT_SCHEME).ok_or(AtUriError::NotAtScheme)?;
121        // Checked over the whole remainder rather than per segment: a `#` in
122        // the authority and a `#` after the record key are the same mistake,
123        // and both are excluded from the restricted form outright.
124        if rest.contains('#') || rest.contains('?') {
125            return Err(AtUriError::QueryOrFragment);
126        }
127
128        let mut segments = rest.split('/');
129        let authority = segments.next().unwrap_or_default();
130        if validate_did(authority).is_err() && validate_handle(authority).is_err() {
131            return Err(AtUriError::BadAuthority(authority.to_owned()));
132        }
133
134        let collection = segments.next();
135        let rkey = segments.next();
136        if segments.next().is_some() {
137            return Err(AtUriError::BadPath(rest.to_owned()));
138        }
139        // An empty segment is a trailing slash or a `//`, and neither is a
140        // shorter URI that happens to have punctuation on the end: they are
141        // different strings naming the same thing, which is what makes a URI
142        // stop being an identifier.
143        if collection.is_some_and(str::is_empty) || rkey.is_some_and(str::is_empty) {
144            return Err(AtUriError::BadPath(rest.to_owned()));
145        }
146
147        if let Some(collection) = collection {
148            nsid_syntax::validate(collection).map_err(|source| AtUriError::BadCollection {
149                collection: collection.to_owned(),
150                source,
151            })?;
152        }
153        if let Some(rkey) = rkey {
154            validate_record_key(rkey).map_err(|source| AtUriError::BadRecordKey {
155                rkey: rkey.to_owned(),
156                source,
157            })?;
158        }
159
160        Ok(Self {
161            authority,
162            collection,
163            rkey,
164        })
165    }
166}