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}