didbot_schema/format/cid.rs
1//! The `cid` string format: a content identifier, checked by shape only.
2//!
3//! This project emits no CIDs at all — `didbot-pds`'s `RecordStore::put` says why —
4//! so the only CIDs it will ever see are ones a client wrote into a record.
5//! The check is therefore the lenient one atproto specifies: an ASCII string
6//! of plausible length in the multibase alphabets, not a decode. Decoding
7//! would mean a multibase and multihash implementation for a value nothing
8//! here dereferences.
9//!
10//! One version rule is enforced rather than left to shape. CIDv0 — 46
11//! characters of base58 opening with `Qm` — is a valid IPFS identifier and is
12//! not permitted in atproto, and it passes every length and alphabet test, so
13//! nothing but naming it would catch it.
14
15/// Shortest a CID may be.
16pub const MIN_CID_LENGTH: usize = 8;
17
18/// Longest a CID may be.
19pub const MAX_CID_LENGTH: usize = 256;
20
21/// Why a string is not a usable CID.
22#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
23pub enum CidError {
24 /// Under [`MIN_CID_LENGTH`] or over [`MAX_CID_LENGTH`] characters.
25 #[error("cid is {0} characters, outside {MIN_CID_LENGTH}..={MAX_CID_LENGTH}")]
26 BadLength(usize),
27 /// A character outside the multibase alphabets.
28 #[error("invalid character {0:?} in cid")]
29 InvalidCharacter(char),
30 /// A CIDv0, which atproto does not permit.
31 #[error("cid {0:?} is a CIDv0, which atproto does not support")]
32 VersionZero(String),
33}
34
35/// How long a CIDv0 is: `Qm` plus a base58-encoded 32-byte multihash.
36const CID_V0_LENGTH: usize = 46;
37
38/// Checks a string against the lenient CID rules.
39///
40/// ```
41/// use didbot_schema::format::cid;
42/// assert!(cid::validate("bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi").is_ok());
43/// assert!(cid::validate("12345").is_err()); // too short
44/// assert!(cid::validate("QmbWqxBEKC3P8tqsKc98xmWNzrzDtRLMiMPL8wBuTGsMnR").is_err()); // CIDv0
45/// ```
46pub fn validate(value: &str) -> Result<(), CidError> {
47 let length = value.chars().count();
48 if !(MIN_CID_LENGTH..=MAX_CID_LENGTH).contains(&length) {
49 return Err(CidError::BadLength(length));
50 }
51 if let Some(ch) = value
52 .chars()
53 .find(|ch| !(ch.is_ascii_alphanumeric() || matches!(ch, '+' | '=')))
54 {
55 return Err(CidError::InvalidCharacter(ch));
56 }
57 if length == CID_V0_LENGTH && value.starts_with("Qm") {
58 return Err(CidError::VersionZero(value.to_owned()));
59 }
60 Ok(())
61}
62
63#[cfg(test)]
64mod tests {
65 use super::*;
66
67 #[test]
68 fn a_cid_v0_shaped_string_of_the_wrong_length_is_not_a_cid_v0() {
69 // The rule is the pair, not the prefix: `Qm` is an ordinary base58
70 // opening and plenty of CIDv1 strings could begin with it.
71 assert!(validate("QmbWqxBEKC3P8tqsKc98xmWNzrzDtRLMiMPL8wBuTGsMn").is_ok());
72 }
73
74 #[test]
75 fn a_scheme_prefix_is_not_part_of_a_cid() {
76 assert_eq!(
77 validate("cid:bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi"),
78 Err(CidError::InvalidCharacter(':'))
79 );
80 }
81}