Skip to main content

codec_cbor/
cid.rs

1// SPDX-FileCopyrightText: 2026 ReallyMe LLC
2//
3// SPDX-License-Identifier: MIT OR Apache-2.0
4
5use cid::multibase::{decode as multibase_decode, Base};
6use cid::{Cid, Version};
7use multihash::Multihash;
8use multihash_codetable::{Code, MultihashDigest};
9use sha2::{Digest, Sha256};
10use zeroize::Zeroizing;
11
12use crate::{decode_dag_cbor, CborError};
13
14/// dag-cbor multicodec code (IPLD)
15pub const DAG_CBOR_CODEC: u64 = 0x71;
16
17/// Maximum CID string size accepted before multibase decoding.
18///
19/// A CID with the supported 64-byte digest and four u64 varints occupies at
20/// most 104 binary bytes. This budget accommodates even base2 and the UTF-8
21/// base256emoji representation, while bounding quadratic base conversions.
22pub const MAX_CID_STRING_LEN: usize = 1024;
23
24const CID_V0_STRING_LEN: usize = 46;
25
26/// Hash output for sha2-256
27pub type ContentHash = [u8; 32];
28
29/// SHA-256 multihash for a DAG-CBOR block.
30///
31/// The wrapper keeps the upstream multihash representation out of the public
32/// API while preserving the canonical wire bytes.
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34pub struct DagCborMultihash(Multihash<64>);
35
36impl DagCborMultihash {
37    /// Return the multihash algorithm code.
38    pub fn code(&self) -> u64 {
39        self.0.code()
40    }
41
42    /// Return the digest length.
43    pub fn size(&self) -> u8 {
44        self.0.size()
45    }
46
47    /// Borrow the digest bytes.
48    pub fn digest(&self) -> &[u8] {
49        self.0.digest()
50    }
51
52    /// Return canonical multihash wire bytes.
53    pub fn to_bytes(&self) -> Vec<u8> {
54        self.0.to_bytes()
55    }
56}
57
58/// Validated CID, independent of the upstream CID type.
59#[derive(Debug, Clone, PartialEq, Eq)]
60pub struct ParsedCid(Cid);
61
62impl ParsedCid {
63    /// Return the validated CID's canonical binary representation.
64    pub fn to_bytes(&self) -> Vec<u8> {
65        self.0.to_bytes()
66    }
67}
68
69impl core::fmt::Display for ParsedCid {
70    fn fmt(&self, formatter: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
71        self.0.fmt(formatter)
72    }
73}
74
75/// Returns the raw sha2-256 digest of `bytes`.
76pub fn sha2_256_content_hash(bytes: &[u8]) -> ContentHash {
77    Sha256::digest(bytes).into()
78}
79
80/// Returns a sha2-256 multihash of `bytes` for use in a CID.
81pub fn dag_cbor_multihash(bytes: &[u8]) -> DagCborMultihash {
82    DagCborMultihash(Code::Sha2_256.digest(bytes))
83}
84
85/// Computes the CIDv1 (dag-cbor, sha2-256) of `bytes` in canonical
86/// base32-lower string form.
87///
88/// Hashes the supplied bytes as-is, without parsing CBOR or applying the
89/// encoder/decoder size limit. Encode a value first to obtain a canonical block.
90pub fn compute_cid_dag_cbor(bytes: &[u8]) -> String {
91    let hash = dag_cbor_multihash(bytes);
92    let cid = Cid::new_v1(DAG_CBOR_CODEC, hash.0);
93    cid.to_string()
94}
95
96/// Outcome of comparing a canonical DAG-CBOR block with a CID string.
97#[derive(Debug, Clone, Copy, PartialEq, Eq)]
98pub enum CidVerificationStatus {
99    /// The canonical CID matches the validated block.
100    Match,
101    /// A canonical CID identifies different bytes.
102    Mismatch,
103    /// The CID parses but its textual representation is not canonical.
104    NonCanonical,
105    /// The supplied text is not a valid CID.
106    InvalidCid,
107}
108
109/// Validated CID comparison with canonical, input-independent diagnostics.
110#[must_use]
111pub struct DagCborCidVerification {
112    status: CidVerificationStatus,
113    expected_cid: String,
114    actual_cid: String,
115}
116
117impl DagCborCidVerification {
118    /// Return the exact verification outcome.
119    pub const fn status(&self) -> CidVerificationStatus {
120        self.status
121    }
122
123    /// Return the canonical CID computed from the block.
124    pub fn expected_cid(&self) -> &str {
125        self.expected_cid.as_str()
126    }
127
128    /// Return the parsed CID in canonical form, or empty for invalid text.
129    pub fn actual_cid(&self) -> &str {
130        self.actual_cid.as_str()
131    }
132
133    /// Transfer the canonical strings to a transport adapter without copying.
134    pub fn into_parts(self) -> (CidVerificationStatus, String, String) {
135        (self.status, self.expected_cid, self.actual_cid)
136    }
137}
138
139/// Validate one canonical DAG-CBOR block and compare its CID text.
140///
141/// # Errors
142///
143/// Returns a typed DAG-CBOR error when the payload is malformed, noncanonical,
144/// or exceeds the parser's resource limits. Use [`compute_cid_dag_cbor`] when
145/// intentionally hashing opaque bytes without validating a DAG-CBOR block.
146pub fn verify_dag_cbor_cid(
147    cid_str: &str,
148    bytes: &[u8],
149) -> Result<DagCborCidVerification, CborError> {
150    let _validated = Zeroizing::new(decode_dag_cbor(bytes)?);
151    let expected_hash = dag_cbor_multihash(bytes);
152    let expected_cid = Cid::new_v1(DAG_CBOR_CODEC, expected_hash.0);
153    let expected = expected_cid.to_string();
154    let Some((actual_cid, _base)) = parse_cid_string(cid_str) else {
155        return Ok(DagCborCidVerification {
156            status: CidVerificationStatus::InvalidCid,
157            expected_cid: expected,
158            actual_cid: String::new(),
159        });
160    };
161    let actual = actual_cid.to_string();
162    let status = if cid_str != actual {
163        CidVerificationStatus::NonCanonical
164    } else if expected_cid == actual_cid {
165        CidVerificationStatus::Match
166    } else {
167        CidVerificationStatus::Mismatch
168    };
169    Ok(DagCborCidVerification {
170        status,
171        expected_cid: expected,
172        actual_cid: actual,
173    })
174}
175
176/// Returns whether `s` parses as a valid CID string.
177pub fn is_valid_cid_string(s: &str) -> bool {
178    try_parse_cid(s).is_some()
179}
180
181/// Parses `s` as a CID, returning `None` if it is not a valid CID string.
182///
183/// Accepts CIDv0 and multibase CID strings up to [`MAX_CID_STRING_LEN`]. Paths,
184/// non-minimal binary encodings, and trailing decoded bytes are rejected.
185pub fn try_parse_cid(s: &str) -> Option<ParsedCid> {
186    parse_cid_string(s).map(|(cid, _base)| ParsedCid(cid))
187}
188
189fn parse_cid_string(s: &str) -> Option<(Cid, Option<Base>)> {
190    if s.len() > MAX_CID_STRING_LEN {
191        return None;
192    }
193    // The upstream string convenience parser also extracts CIDs from paths.
194    // Decode the entire identifier ourselves so no prefix can be discarded.
195    let (base, decoded) = if s.len() == CID_V0_STRING_LEN && s.starts_with("Qm") {
196        (None, Base::Base58Btc.decode(s).ok()?)
197    } else {
198        let (base, bytes) = multibase_decode(s).ok()?;
199        (Some(base), bytes)
200    };
201    let mut remaining = decoded.as_slice();
202    let cid = Cid::read_bytes(&mut remaining).ok()?;
203    // CIDv0 has exactly one canonical textual representation: bare base58btc.
204    if base.is_some() && cid.version() == Version::V0 {
205        return None;
206    }
207    // read_bytes is a stream parser. Exhaustion is essential for validating
208    // an identifier, and byte equality also enforces minimal varint forms.
209    if !remaining.is_empty() || cid.to_bytes() != decoded {
210        return None;
211    }
212    Some((cid, base))
213}