didbot_schema/record_key.rs
1//! Record keys: the last segment of an `at://` URI, and the name a record is
2//! stored under in its collection.
3
4/// Longest a record key may be.
5pub const MAX_RECORD_KEY_LENGTH: usize = 512;
6
7/// Why a string is not a usable record key.
8#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
9pub enum RecordKeyError {
10 /// Empty, which would make the record unaddressable.
11 #[error("record key is empty")]
12 Empty,
13 /// Over [`MAX_RECORD_KEY_LENGTH`] characters.
14 #[error("record key is {0} characters, over the limit of {MAX_RECORD_KEY_LENGTH}")]
15 TooLong(usize),
16 /// A character outside `A-Za-z0-9._:~-`.
17 #[error("invalid character {0:?} in record key")]
18 InvalidCharacter(char),
19 /// The key is `.` or `..`.
20 ///
21 /// Both are legal under the character rule and both are path traversal
22 /// waiting to happen, because a record key is the last segment of an
23 /// `at://` URI and of many implementations' file layouts.
24 #[error("record key {0:?} is a relative path segment")]
25 RelativePath(String),
26}
27
28/// Checks a string against the record key rules.
29///
30/// This is the *generic* rule, which is far looser than what this server
31/// mints: every key `didbot-pds`'s record store produces is a TID, and a TID is
32/// a much narrower thing (see [`crate::tid`]). Both matter. Keys arriving from
33/// elsewhere — in an `at://` URI naming somebody else's record — follow only
34/// this rule, and treating them as TIDs would reject `self`, which is the key
35/// every singleton record in atproto uses.
36///
37/// ```
38/// use didbot_schema::validate_record_key;
39/// assert!(validate_record_key("self").is_ok());
40/// assert!(validate_record_key("3jzfcijpj2z2a").is_ok());
41/// assert!(validate_record_key("..").is_err());
42/// assert!(validate_record_key("alpha/beta").is_err());
43/// ```
44pub fn validate_record_key(rkey: &str) -> Result<(), RecordKeyError> {
45 if rkey.is_empty() {
46 return Err(RecordKeyError::Empty);
47 }
48 if rkey == "." || rkey == ".." {
49 return Err(RecordKeyError::RelativePath(rkey.to_owned()));
50 }
51 let length = rkey.chars().count();
52 if length > MAX_RECORD_KEY_LENGTH {
53 return Err(RecordKeyError::TooLong(length));
54 }
55 if let Some(ch) = rkey
56 .chars()
57 .find(|ch| !(ch.is_ascii_alphanumeric() || matches!(ch, '.' | '-' | '_' | ':' | '~')))
58 {
59 return Err(RecordKeyError::InvalidCharacter(ch));
60 }
61 Ok(())
62}