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}