Skip to main content

content_addressable/
raw_id.rs

1//! [`RawContentId`] — the identity of an **opaque byte string** (the *raw*
2//! profile), sibling to [`ContentId`] (the *dag-cbor* profile).
3//!
4//! # Two profiles, one algebra
5//!
6//! This crate mints exactly two kinds of identifier, and the **type** says which:
7//!
8//! | Type | CID profile | Names |
9//! |------|-------------|-------|
10//! | [`ContentId`] | CIDv1 · `dag-cbor` (`0x71`) · BLAKE3-256 (`0x1e`) · 32 bytes | a canonical structured **value** |
11//! | [`RawContentId`] | CIDv1 · `raw` (`0x55`) · BLAKE3-256 (`0x1e`) · 32 bytes | an opaque **byte string** — a file, a chunk, a binary, a payload |
12//!
13//! Both are real IPLD CIDs and both share the same multihash tail; they differ
14//! *only* in the codec — and that difference is **semantic, not cosmetic**
15//! (decision record `docs/adr/0003`, law 6). `RawContentId(x)` and
16//! `ContentId(x)` are different identities even when their 32 digest bytes are
17//! identical: one names *these bytes*, the other names *the value whose
18//! canonical dag-cbor is these bytes*. The type system enforces that — there is
19//! deliberately **no** `PartialEq` between the two, no `From` in either
20//! direction, and neither ingress path accepts the other's codec.
21//!
22//! # Why not wrap bytes in a dag-cbor node?
23//!
24//! `CID(dag-cbor, blake3(encode(ChunkLeaf { data: bytes })))` is the identity
25//! of a *representation containing* the bytes, not of the bytes. When a
26//! capability system says "authority X permits artifact Y", Y must identify the
27//! artifact itself. That is what `RawContentId` is for; it also makes every
28//! BLAKE3-native identifier already in the wild (kyln's raw CIDs, bare
29//! `blake3::hash` digests, `blake3:<hex>` digests) a **byte-identical**
30//! `RawContentId` with no re-hash — see [`RawContentId::from_blake3_digest`] and
31//! the `legacy` adapters.
32//!
33//! # Presentation contract
34//!
35//! Mirrors [`ContentId`]'s exactly (the same accessor names mean the same
36//! things): `Display`/`FromStr` are the multibase base32-lower `b…` string,
37//! [`to_bytes`](RawContentId::to_bytes)/[`from_bytes`](RawContentId::from_bytes)
38//! are the CID binary envelope, [`digest_bytes`](RawContentId::digest_bytes)/
39//! [`digest_hex`](RawContentId::digest_hex) are the bare 32-byte digest. The
40//! serde form is the same as [`ContentId`]'s: a tag-42 link in binary/IPLD
41//! formats, the base32-lower string in human-readable ones. Every ingress path
42//! validates the raw profile, so a `RawContentId` (however it entered) always
43//! carries it and every accessor is total.
44//!
45//! The bytes are fixed by the CID specification, not by this crate — a
46//! CIDv1(raw, blake3-256) is the same octets everywhere. `tests/raw_vectors.json`
47//! pins them across the Rust and Python faces.
48
49use crate::content_id::{BLAKE3_DIGEST_LEN, BLAKE3_HASH_CODE};
50use crate::error::ContentError;
51use core::fmt;
52use core::str::FromStr;
53use ipld_core::cid::multihash::Multihash;
54use ipld_core::cid::{Cid, Version};
55use serde::{Deserialize, Deserializer, Serialize, Serializer};
56
57// Keep `ContentId` in the doc-link namespace of this module.
58#[allow(unused_imports)]
59use crate::content_id::ContentId;
60
61/// Multicodec code for `raw` (`0x55`): the codec field of every
62/// [`RawContentId`], declaring "these are opaque bytes; do not decode".
63pub const RAW_CODEC: u64 = 0x55;
64
65/// The identity of an opaque byte string: a CIDv1 whose codec is `raw`
66/// (`0x55`) and whose multihash is BLAKE3-256 (`0x1e`) over the bytes.
67///
68/// See the [module docs](self) for the two-profile picture and why the codec
69/// is part of the identity. Construct with
70/// [`from_content`](Self::from_content) (hash bytes you hold) or
71/// [`from_blake3_digest`](Self::from_blake3_digest) (wrap a digest you already
72/// have, no re-hash); parse with `FromStr` / [`from_bytes`](Self::from_bytes) /
73/// `TryFrom<Cid>`.
74#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
75pub struct RawContentId(Cid);
76
77impl RawContentId {
78    /// The identity of `content`: `CIDv1(raw, BLAKE3(content))`.
79    ///
80    /// This is the door for bytes you hold. There is no canonicalization step
81    /// and nothing to get wrong: the bytes *are* the content, so the id names
82    /// exactly what was hashed. (Contrast [`ContentId::from_canonical_bytes`],
83    /// whose "MUST be canonical dag-cbor" precondition exists because a
84    /// dag-cbor id names a *value*, not the bytes.)
85    ///
86    /// Named [`from_content`](RawContentId::from_content) rather than [`from_bytes`](RawContentId::from_bytes) on purpose: across both
87    /// profiles [`from_bytes`](RawContentId::from_bytes)/[`to_bytes`](RawContentId::to_bytes) always mean the **CID binary envelope**
88    /// (the presentation contract), never "hash these bytes".
89    #[must_use]
90    pub fn from_content(content: &[u8]) -> Self {
91        let digest = blake3::hash(content);
92        Self::wrap_blake3_digest(*digest.as_bytes())
93    }
94
95    /// Wrap a precomputed 32-byte BLAKE3 digest **of some content** as a
96    /// `RawContentId`, with no re-hash.
97    ///
98    /// This is the honest home of the "no-rehash bridge": a system that already
99    /// hashed content with BLAKE3 (kyln-lore revision signatures, a bare
100    /// `blake3::hash`, a `blake3:<hex>` digest) gets a byte-identical
101    /// `RawContentId` by wrapping — and the `raw` codec tells the truth about
102    /// what was hashed (opaque bytes). This replaces the deprecated
103    /// [`ContentId::from_blake3_content_digest`], which stamped `dag-cbor` on a
104    /// digest it could not know came from dag-cbor.
105    ///
106    /// **The digest must really be BLAKE3 over the content you mean.** This
107    /// function cannot check that; a wrong digest mints an id that names
108    /// nothing anyone hashed. Verify against real bytes with
109    /// [`verify`](Self::verify) when you can.
110    ///
111    /// # Panics
112    ///
113    /// Does not panic: a 32-byte digest always fits the multihash's 64-byte
114    /// capacity.
115    #[must_use]
116    pub fn from_blake3_digest(digest: [u8; BLAKE3_DIGEST_LEN]) -> Self {
117        Self::wrap_blake3_digest(digest)
118    }
119
120    /// The single construction point shared by [`from_content`](Self::from_content)
121    /// and [`from_blake3_digest`](Self::from_blake3_digest), so both doors emit
122    /// byte-identical ids for the same digest.
123    fn wrap_blake3_digest(digest: [u8; BLAKE3_DIGEST_LEN]) -> Self {
124        let mh = Multihash::wrap(BLAKE3_HASH_CODE, &digest)
125            .expect("BLAKE3 digest is 32 bytes and always fits a 64-byte multihash");
126        RawContentId(Cid::new_v1(RAW_CODEC, mh))
127    }
128
129    /// `true` iff `content` hashes to this id.
130    ///
131    /// Infallible: a mismatch is `false`, never an error (the same contract as
132    /// [`ContentAddressable::verify`](crate::ContentAddressable::verify)).
133    #[must_use]
134    pub fn verify(&self, content: &[u8]) -> bool {
135        Self::from_content(content) == *self
136    }
137
138    /// Borrow the underlying IPLD [`Cid`].
139    #[must_use]
140    pub fn as_cid(&self) -> &Cid {
141        &self.0
142    }
143
144    /// The raw 32-byte BLAKE3 digest, copied out of the multihash. Total: every
145    /// ingress path validated the profile.
146    #[must_use]
147    pub fn digest_bytes(&self) -> [u8; BLAKE3_DIGEST_LEN] {
148        let mut out = [0u8; BLAKE3_DIGEST_LEN];
149        out.copy_from_slice(self.0.hash().digest());
150        out
151    }
152
153    /// Lowercase hex of the raw 32-byte digest: 64 chars, **no** prefix, no
154    /// envelope — the "bare-digest-hex" presentation form. Note that this is
155    /// identical for `RawContentId(x)` and `ContentId(x)` when the digests
156    /// match; it is a *digest* accessor, not an identity, and must never be
157    /// compared as one (law 6).
158    #[must_use]
159    pub fn digest_hex(&self) -> String {
160        let mut s = String::with_capacity(BLAKE3_DIGEST_LEN * 2);
161        for b in self.digest_bytes() {
162            s.push_str(&format!("{b:02x}"));
163        }
164        s
165    }
166
167    /// The full CID binary envelope (version + codec + multihash + digest).
168    #[must_use]
169    pub fn to_bytes(&self) -> Vec<u8> {
170        self.0.to_bytes()
171    }
172
173    /// Parse an id from its CID binary form, accepting **only** the raw profile.
174    ///
175    /// # Errors
176    ///
177    /// [`ContentError::InvalidCid`] if the bytes are not a CID at all;
178    /// [`ContentError::InvalidCidProfile`] if they are a CID of any other profile
179    /// (including a `dag-cbor` [`ContentId`]).
180    pub fn from_bytes(bytes: &[u8]) -> Result<Self, ContentError> {
181        let cid = Cid::read_bytes(bytes).map_err(|e| ContentError::InvalidCid {
182            reason: e.to_string(),
183            source: Box::new(e),
184        })?;
185        validate_raw_profile(&cid)?;
186        Ok(RawContentId(cid))
187    }
188}
189
190/// Reject any [`Cid`] that is not the raw profile: CIDv1 + `raw` (`0x55`) +
191/// BLAKE3 (`0x1e`) + a 32-byte digest. Every ingress path routes through this.
192pub(crate) fn validate_raw_profile(cid: &Cid) -> Result<(), ContentError> {
193    if cid.version() != Version::V1 {
194        return Err(ContentError::InvalidCidProfile {
195            reason: format!("expected CIDv1, got {:?}", cid.version()),
196        });
197    }
198    if cid.codec() != RAW_CODEC {
199        return Err(ContentError::InvalidCidProfile {
200            reason: format!(
201                "expected raw codec 0x{RAW_CODEC:x}, got 0x{:x}",
202                cid.codec()
203            ),
204        });
205    }
206    if cid.hash().code() != BLAKE3_HASH_CODE {
207        return Err(ContentError::InvalidCidProfile {
208            reason: format!(
209                "expected BLAKE3 multihash 0x{BLAKE3_HASH_CODE:x}, got 0x{:x}",
210                cid.hash().code()
211            ),
212        });
213    }
214    let digest_len = cid.hash().digest().len();
215    if digest_len != BLAKE3_DIGEST_LEN {
216        return Err(ContentError::InvalidCidProfile {
217            reason: format!("expected a {BLAKE3_DIGEST_LEN}-byte digest, got {digest_len} bytes"),
218        });
219    }
220    Ok(())
221}
222
223impl TryFrom<Cid> for RawContentId {
224    type Error = ContentError;
225
226    /// Wrap a [`Cid`] **iff** it is the raw profile; anything else — including a
227    /// `dag-cbor` [`ContentId`]'s CID — is rejected with
228    /// [`ContentError::InvalidCidProfile`].
229    fn try_from(cid: Cid) -> Result<Self, Self::Error> {
230        validate_raw_profile(&cid)?;
231        Ok(RawContentId(cid))
232    }
233}
234
235impl From<RawContentId> for Cid {
236    fn from(id: RawContentId) -> Self {
237        id.0
238    }
239}
240
241impl fmt::Display for RawContentId {
242    /// Multibase **base32-lower** (`b…`) — the canonical text form, the inverse
243    /// of `FromStr` for that form.
244    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
245        fmt::Display::fmt(&self.0, f)
246    }
247}
248
249impl FromStr for RawContentId {
250    type Err = ContentError;
251
252    /// Parse a multibase CID string, accepting **only** the raw profile. As with
253    /// [`ContentId`], base32-lower is the contract; other multibases are a
254    /// convenience of the inner parser, not a promise. Legacy dialects (kyln
255    /// envelope-hex, `blake3:<hex>`, bare digest hex) are deliberately **not**
256    /// accepted here — they live behind the explicit `legacy` adapters.
257    fn from_str(s: &str) -> Result<Self, Self::Err> {
258        let cid = Cid::from_str(s).map_err(|e| ContentError::InvalidCid {
259            reason: e.to_string(),
260            source: Box::new(e),
261        })?;
262        validate_raw_profile(&cid)?;
263        Ok(RawContentId(cid))
264    }
265}
266
267impl Serialize for RawContentId {
268    /// Same shape as [`ContentId`]: the base32-lower string in human-readable
269    /// formats, the inner [`Cid`] (a dag-cbor tag-42 link) in binary/IPLD ones.
270    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
271        if serializer.is_human_readable() {
272            serializer.serialize_str(&self.to_string())
273        } else {
274            self.0.serialize(serializer)
275        }
276    }
277}
278
279impl<'de> Deserialize<'de> for RawContentId {
280    /// Inverse of [`Serialize`]; a link of any other profile is rejected at the
281    /// boundary, never later in an accessor.
282    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
283        if deserializer.is_human_readable() {
284            let s = String::deserialize(deserializer)?;
285            s.parse::<RawContentId>()
286                .map_err(<D::Error as serde::de::Error>::custom)
287        } else {
288            let cid = Cid::deserialize(deserializer)?;
289            RawContentId::try_from(cid).map_err(<D::Error as serde::de::Error>::custom)
290        }
291    }
292}
293
294#[cfg(test)]
295mod tests {
296    use super::*;
297    use crate::content_id::ContentId;
298
299    #[test]
300    fn profile_bytes_are_cidv1_raw_blake3() {
301        let id = RawContentId::from_content(b"");
302        let bytes = id.to_bytes();
303        // 0x01 = CIDv1, 0x55 = raw, 0x1e = blake3, 0x20 = 32-byte digest.
304        assert_eq!(&bytes[..4], &[0x01, 0x55, 0x1e, 0x20]);
305        assert_eq!(bytes.len(), 4 + 32);
306        assert_eq!(id.as_cid().codec(), RAW_CODEC);
307        assert_eq!(id.as_cid().hash().code(), BLAKE3_HASH_CODE);
308    }
309
310    #[test]
311    fn from_content_and_from_digest_converge() {
312        let content = b"hello, raw world";
313        let via_content = RawContentId::from_content(content);
314        let via_digest = RawContentId::from_blake3_digest(*blake3::hash(content).as_bytes());
315        assert_eq!(via_content, via_digest);
316        assert!(via_content.verify(content));
317        assert!(!via_content.verify(b"other"));
318    }
319
320    #[test]
321    fn display_is_base32_lower_and_roundtrips() {
322        let id = RawContentId::from_content(b"abc");
323        let s = id.to_string();
324        assert!(
325            s.starts_with('b'),
326            "base32-lower CIDv1 strings start with 'b': {s}"
327        );
328        assert_eq!(s.parse::<RawContentId>().unwrap(), id);
329        assert_eq!(RawContentId::from_bytes(&id.to_bytes()).unwrap(), id);
330    }
331
332    /// Law 6: the codec is part of the identity. Same digest, different profile,
333    /// different identity — different bytes, different strings, and each
334    /// profile's ingress rejects the other's CID.
335    #[test]
336    fn same_digest_different_profile_is_a_different_identity() {
337        let digest = *blake3::hash(b"same bytes").as_bytes();
338        let raw = RawContentId::from_blake3_digest(digest);
339        let dag = ContentId::from_dag_cbor_digest(digest);
340        assert_eq!(raw.digest_bytes(), dag.digest_bytes());
341        assert_eq!(raw.digest_hex(), dag.digest_hex());
342        assert_ne!(raw.to_bytes(), dag.to_bytes());
343        assert_ne!(raw.to_string(), dag.to_string());
344        // Cross-profile ingress fails closed in both directions.
345        assert!(matches!(
346            RawContentId::from_bytes(&dag.to_bytes()),
347            Err(ContentError::InvalidCidProfile { .. })
348        ));
349        assert!(matches!(
350            ContentId::from_bytes(&raw.to_bytes()),
351            Err(ContentError::InvalidCidProfile { .. })
352        ));
353        assert!(matches!(
354            dag.to_string().parse::<RawContentId>(),
355            Err(ContentError::InvalidCidProfile { .. })
356        ));
357        assert!(RawContentId::try_from(*dag.as_cid()).is_err());
358        assert!(ContentId::try_from(*raw.as_cid()).is_err());
359    }
360
361    #[test]
362    fn foreign_cids_are_rejected() {
363        // A CIDv1 raw/sha2-256 (what a REAPI digest would wrap as).
364        let mh = Multihash::wrap(0x12, &[0u8; 32]).unwrap();
365        let sha = Cid::new_v1(RAW_CODEC, mh);
366        assert!(matches!(
367            RawContentId::try_from(sha),
368            Err(ContentError::InvalidCidProfile { .. })
369        ));
370        assert!(matches!(
371            RawContentId::from_bytes(b"not a cid"),
372            Err(ContentError::InvalidCid { .. })
373        ));
374        assert!(matches!(
375            "not a cid".parse::<RawContentId>(),
376            Err(ContentError::InvalidCid { .. })
377        ));
378    }
379
380    #[test]
381    fn serde_human_readable_is_string_and_binary_is_link() {
382        let id = RawContentId::from_content(b"serde");
383        let json = serde_json::to_string(&id).unwrap();
384        assert_eq!(json, format!("\"{id}\""));
385        assert_eq!(serde_json::from_str::<RawContentId>(&json).unwrap(), id);
386        let cbor = crate::canonical::to_canonical_dagcbor(&id).unwrap();
387        // dag-cbor tag 42 = 0xd8 0x2a, then a byte string with the 0x00 prefix.
388        assert_eq!(&cbor[..2], &[0xd8, 0x2a]);
389        let back: RawContentId = crate::canonical::from_canonical_dagcbor_checked(&cbor).unwrap();
390        assert_eq!(back, id);
391        // A dag-cbor link to a *ContentId* does not deserialize as a RawContentId.
392        //
393        // The VARIANT is asserted, not merely `is_err()`. `RawContentId` stores
394        // the profile in its `Cid`, so a mutant `Deserialize` that dropped the
395        // profile gate and rebuilt the id from the bare digest would re-serialize
396        // with the raw codec `0x55` instead of the input's dag-cbor `0x71` — and
397        // the checked door's stage-3 byte comparison would refuse it with
398        // `LossyDecode`. `is_err()` would stay green on a genuine reintroduction
399        // of profile aliasing at the serde boundary; `DecodingError` is the
400        // boundary refusal this test exists to prove, and only that.
401        let dag = ContentId::from_dag_cbor_digest(id.digest_bytes());
402        let cbor_dag = crate::canonical::to_canonical_dagcbor(&dag).unwrap();
403        let err = crate::canonical::from_canonical_dagcbor_checked::<RawContentId>(&cbor_dag)
404            .expect_err("a dag-cbor link must not deserialize as a RawContentId");
405        assert!(
406            matches!(err, ContentError::DecodingError { .. }),
407            "the profile must be refused at the serde BOUNDARY (DecodingError), not \
408             caught later by the re-encode comparison, got {err:?}"
409        );
410    }
411}