Skip to main content

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}