Skip to main content

content_addressable/
content_id.rs

1//! [`ContentId`] — the self-certifying identity of a value.
2//!
3//! A `ContentId` is a newtype over an IPLD [`Cid`] (content identifier, v1).
4//! It is computed directly from a value's canonical bytes, so it *is* a proof
5//! of integrity: anyone holding the bytes can recompute the id and confirm the
6//! bytes are exactly what the id names. Nothing trusted needs to vouch for the
7//! association — it is intrinsic.
8
9use std::fmt;
10use std::str::FromStr;
11
12use ipld_core::cid::multihash::Multihash;
13use ipld_core::cid::{Cid, Version};
14use serde::{Deserialize, Deserializer, Serialize, Serializer};
15
16use crate::error::ContentError;
17
18/// Multicodec code for the `dag-cbor` content type (`0x71`).
19///
20/// This is the codec field of every [`ContentId`]'s CID: it declares that the
21/// bytes the id names are canonical dag-cbor. **Frozen** for the `0.1.x` line —
22/// see the [`ContentId` CID-parameters contract](ContentId#cid-parameters-frozen-at-010).
23pub const DAG_CBOR_CODEC: u64 = 0x71;
24
25/// Multihash code for BLAKE3 (`0x1e`).
26///
27/// This is the hash function field of every [`ContentId`]'s multihash.
28/// **Frozen** for the `0.1.x` line — see the
29/// [`ContentId` CID-parameters contract](ContentId#cid-parameters-frozen-at-010).
30pub const BLAKE3_HASH_CODE: u64 = 0x1e;
31
32/// Length of a BLAKE3 digest in bytes (256 bits).
33///
34/// Part of the **frozen** v1 CID-parameter contract: every [`ContentId`]'s
35/// multihash carries exactly 32 digest bytes. Kept private (no new public
36/// surface, gate item 9); the value is asserted by the CID-shape tests and is
37/// implied on the wire by the `0x20` multihash length prefix. Changing it is a
38/// major version bump — see the
39/// [`ContentId` CID-parameters contract](ContentId#cid-parameters-frozen-at-010).
40pub(crate) const BLAKE3_DIGEST_LEN: usize = 32;
41
42/// The self-certifying identity of a value.
43///
44/// A `ContentId` wraps an IPLD CIDv1 whose codec is `dag-cbor` (`0x71`) and
45/// whose multihash is BLAKE3 (`0x1e`) over the value's canonical dag-cbor
46/// bytes. See [`from_canonical_bytes`](Self::from_canonical_bytes).
47///
48/// Ordering is defined by the CID's binary representation, which makes
49/// `ContentId` usable as a key in ordered maps and sets with a stable,
50/// content-derived sort.
51///
52/// # CID parameters (FROZEN at 0.1.0)
53///
54/// The three fields that determine every address this crate emits are a
55/// **fixed v1 contract**, not configuration (gate items 3, 4, 5 — see
56/// `README.md`):
57///
58/// - **CID version: `V1`** (`Cid::new_v1`). There is no v0 / agility path.
59/// - **Multicodec: `dag-cbor` (`0x71`)** — see [`DAG_CBOR_CODEC`].
60/// - **Multihash: `BLAKE3` (`0x1e`)** — see [`BLAKE3_HASH_CODE`] — over a
61///   **fixed 32-byte digest** (`BLAKE3_DIGEST_LEN`, 256 bits).
62///
63/// These values are **not selectable**. [`from_canonical_bytes`](Self::from_canonical_bytes)
64/// is the single mint site and always produces this shape. Changing the
65/// version, codec, hash function, or digest length is a **new representation =
66/// major version bump**, never a patch — every previously emitted address would
67/// become unreachable. CIDs are self-describing, so a future hash/codec can
68/// ship as a *new* representation under a *new* major without retrofitting
69/// agility now. A raw [`Cid`] is admitted only if it matches this profile —
70/// [`TryFrom<Cid>`](#impl-TryFrom%3CCid%3E-for-ContentId) validates it — and the
71/// inner CID is readable via [`as_cid`](Self::as_cid).
72///
73/// # Serde representation (FROZEN at 0.1.0)
74///
75/// `ContentId` has two serde forms, chosen by the serializer via
76/// `is_human_readable`, and **both are a stability contract across the entire
77/// `0.1.x` line** — changing either is a **major version bump**. The committed
78/// golden vectors (`tests/vectors.json`) plus the in-crate golden test pin
79/// these exact bytes so a dependency bump cannot silently perturb them:
80///
81/// - **Binary / IPLD form (e.g. dag-cbor): a tag-42 link.** Through
82///   [`serde_ipld_dagcbor`] a `ContentId` is encoded as a CBOR **tag-42 link**
83///   (major-type-6 head `0xd8 0x2a`, then a byte string carrying the `0x00`
84///   multibase-identity prefix followed by the CID binary form). This is the
85///   natural IPLD encoding and makes `ContentId` fields inside other
86///   content-addressed structures behave as real links — the crate's
87///   IPLD-native thesis. See [`canonical`](crate::canonical).
88/// - **Human-readable form (e.g. `serde_json`, TOML): the multibase base32
89///   string.** In a human-readable serializer a `ContentId` is the
90///   base32-lower `b…` CID string — the same text
91///   [`Display`](fmt::Display)/[`FromStr`] produce — so JSON and config files
92///   carry a readable, portable CID rather than a raw byte array. The crate
93///   adds a thin `is_human_readable` branch over the inner `Cid` to guarantee
94///   this; the IPLD/binary path is unchanged.
95///
96/// # Presentation contract (FROZEN at 0.1.0)
97///
98/// Whatever string and byte forms this type emits become a **byte/wire
99/// contract** at `0.1.0`: changing any of them afterward is a **major version
100/// bump**, never a patch (issue #6, gate item 2). The full presentation
101/// surface is therefore named and frozen explicitly, with conformance vectors
102/// (`tests/vectors.json` + the in-crate goldens) pinning the exact bytes. There
103/// are **four** distinct presentation forms, each load-bearing for a different
104/// reason — do not confuse them:
105///
106/// | Form | Method | What it is |
107/// |------|--------|------------|
108/// | **Canonical text** | [`Display`](fmt::Display) / [`to_string`](ToString::to_string) | multibase **base32-lower** (`b…`), the IPLD-canonical CID string |
109/// | **Binary envelope** | [`to_bytes`](Self::to_bytes) / [`from_bytes`](Self::from_bytes) | the full **CID binary** form (version + codec + multihash + digest) |
110/// | **Bare digest** | [`digest_bytes`](Self::digest_bytes) | the raw **32-byte BLAKE3** hash (no envelope) |
111/// | **Bare-digest-hex** | [`digest_hex`](Self::digest_hex) | lowercase **hex of the 32-byte digest** (64 chars, no prefix) |
112///
113/// **Three incompatible "hex" conventions** exist in the wild for a CID; an
114/// adopter must pick one, so the crate names them to end the ambiguity:
115///
116/// 1. **bare-digest-hex** — hex of the raw 32-byte BLAKE3 digest. *This is what
117///    [`digest_hex`](Self::digest_hex) returns* (the "swarm" / kyln `to_hex()`
118///    convention: shortest, hash-only).
119/// 2. **full-CID-bytes-hex** — hex of [`to_bytes`](Self::to_bytes) (the whole
120///    CID envelope as base16). *Deliberately **not** a method.* Convention #2 is
121///    just `hex::encode(id.to_bytes())`; blessing it as `cid_hex()` would add a
122///    third "hex" accessor that invites exactly the confusion this contract
123///    exists to end. A caller who genuinely needs CID-bytes-as-hex hex-encodes
124///    [`to_bytes`](Self::to_bytes) explicitly and owns that choice. (This
125///    non-decision is recorded so it is not re-litigated; it can be added later
126///    additively without breaking the frozen surface.)
127/// 3. **multibase base32-lower** — the [`Display`](fmt::Display) string. Use
128///    this, not a hex form, as the canonical text representation.
129///
130/// `Display` emits base32-lower and is the **inverse of [`FromStr`] for that
131/// form**: the `Display`→`FromStr` round-trip is frozen and tested. `FromStr`
132/// *also* tolerates other multibases (base58, base16, …) as a **convenience,
133/// not a contract** — that tolerance may be tightened later without breaking
134/// the frozen base32-lower round-trip.
135#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
136pub struct ContentId(Cid);
137
138impl ContentId {
139    /// Compute the content id of bytes that **are already canonical dag-cbor**.
140    ///
141    /// This is the crate's fast, unchecked minting primitive — the hot path that
142    /// [`ContentAddressable::content_id`](crate::ContentAddressable::content_id)
143    /// leans on. The pipeline is, end to end:
144    ///
145    /// 1. `digest = BLAKE3(bytes)` — a 32-byte hash.
146    /// 2. `mh = Multihash::wrap(0x1e, digest)` — tag the digest with the
147    ///    BLAKE3 multihash code.
148    /// 3. `cid = Cid::new_v1(0x71, mh)` — a CIDv1 declaring the bytes are
149    ///    dag-cbor.
150    ///
151    /// # Precondition — CALLER MUST PASS CANONICAL DAG-CBOR
152    ///
153    /// **The bytes you pass MUST already be canonical dag-cbor** — typically the
154    /// output of
155    /// [`to_canonical_dagcbor`](crate::canonical::to_canonical_dagcbor) or, for a
156    /// whole value, [`ContentAddressable::canonical_form`](crate::ContentAddressable::canonical_form).
157    /// This function **does not re-canonicalize and does not validate**; it only
158    /// hashes whatever it is handed and stamps the result with the `0x71`
159    /// (dag-cbor) codec.
160    ///
161    /// Passing **non-canonical** CBOR (wrong map-key order, indefinite-length
162    /// items, non-smallest integers), hand-rolled CBOR, or arbitrary non-dag-cbor
163    /// bytes is a **logic error, not handled at runtime**: you get back a
164    /// perfectly valid-looking [`ContentId`] whose `0x71` codec is a **lie** —
165    /// the bytes it names are not canonical dag-cbor. A second party who
166    /// canonicalizes "the same value" will compute a **different** id, and
167    /// nothing here will have told you. This is silent and by design (validation
168    /// is not free, so it is opt-in — see below), so honor the precondition.
169    ///
170    /// # Which door to use
171    ///
172    /// - **Have a value?** Use
173    ///   [`ContentAddressable::content_id`](crate::ContentAddressable::content_id)
174    ///   (the documented default). It canonicalizes *then* hashes, so it can
175    ///   never hand this function non-canonical bytes — the precondition holds by
176    ///   construction and there is no per-call validation tax on the safe path.
177    /// - **Have bytes you canonicalized yourself** (via
178    ///   [`to_canonical_dagcbor`](crate::canonical::to_canonical_dagcbor))?
179    ///   This primitive is correct and fastest.
180    /// - **Have foreign / untrusted bytes you did *not* canonicalize?** Use the
181    ///   checked sibling
182    ///   [`from_canonical_bytes_checked`](Self::from_canonical_bytes_checked),
183    ///   which re-encodes and rejects non-canonical input with a typed error.
184    ///
185    /// # Naming (FROZEN at 0.1.0)
186    ///
187    /// This door keeps the name [`from_canonical_bytes`](Self::from_canonical_bytes) (the fast primitive) and
188    /// is **not** renamed to `_unchecked`; the checked variant is the explicitly
189    /// suffixed [`from_canonical_bytes_checked`](Self::from_canonical_bytes_checked).
190    /// This pairing is a frozen `0.1.0` decision (README gate item #6) — it
191    /// cannot change cheaply after `0.1.0`.
192    ///
193    /// # Panics
194    ///
195    /// Does not panic. BLAKE3 always produces a 32-byte digest, which always
196    /// fits the multihash's 64-byte capacity, so the `wrap` call cannot fail.
197    #[must_use]
198    pub fn from_canonical_bytes(bytes: &[u8]) -> Self {
199        let digest = blake3::hash(bytes);
200        Self::wrap_blake3_digest(*digest.as_bytes())
201    }
202
203    /// Compute the content id of bytes, **first verifying they are canonical
204    /// dag-cbor**.
205    ///
206    /// This is the opt-in, *checked* sibling of
207    /// [`from_canonical_bytes`](Self::from_canonical_bytes). It is for callers
208    /// minting an id from bytes they did **not** canonicalize themselves
209    /// (foreign, untrusted, hand-rolled, or stored input) and who therefore
210    /// cannot rely on the precondition by construction. It closes the silent
211    /// integrity hole the fast primitive leaves open, at the cost of a decode +
212    /// re-encode + compare on every call — pay it only when byte provenance is
213    /// not yours.
214    ///
215    /// The check is a **round-trip**: decode the bytes as an
216    /// [`Ipld`](ipld_core::ipld::Ipld) value, re-encode that value via
217    /// [`to_canonical_dagcbor`](crate::canonical::to_canonical_dagcbor), and
218    /// require the re-encoding to equal the input byte-for-byte. Because the
219    /// dag-cbor codec is canonical by construction (strict key order,
220    /// definite-length, smallest-form integers — see
221    /// [`canonical`](crate::canonical)), equality proves the input was *already*
222    /// the unique canonical encoding of that value. On success the id is computed
223    /// over the (canonical) input bytes, so it is **byte-identical** to what
224    /// [`from_canonical_bytes`](Self::from_canonical_bytes) would return for the
225    /// same bytes — the check changes the fallibility, never the emitted id.
226    ///
227    /// # Errors
228    ///
229    /// - [`ContentError::DecodingError`] if `bytes` are not dag-cbor at all.
230    /// - [`ContentError::NonCanonical`] if `bytes` decode but are *not* the
231    ///   canonical encoding (wrong map-key order, indefinite-length items,
232    ///   non-smallest integers, …) — i.e. re-encoding differs from the input.
233    /// - [`ContentError::EncodingError`] in the unlikely event the decoded value
234    ///   cannot be re-encoded.
235    pub fn from_canonical_bytes_checked(bytes: &[u8]) -> Result<Self, ContentError> {
236        // Decode to the generic Ipld value, re-encode canonically, require byte
237        // equality. That gate lives in ONE place (issue #90) so this door and
238        // `canonical::from_canonical_dagcbor_checked` cannot drift apart in what
239        // they call canonical; the behavior here is unchanged.
240        crate::canonical::ensure_canonical(bytes)?;
241        // The bytes are proven canonical: minting over them is identical to the
242        // unchecked primitive, so reuse it (no second decode/encode).
243        Ok(Self::from_canonical_bytes(bytes))
244    }
245
246    /// Wrap an **already-computed** 32-byte BLAKE3 content digest as a
247    /// `ContentId` — **without hashing**.
248    ///
249    /// This is a deliberately narrow, *unchecked* escape hatch for
250    /// **BLAKE3-native upstreams** that have already hashed their content and
251    /// hold only the 32-byte digest (a signature, an address) — not the
252    /// original canonical bytes. For them,
253    /// [`from_canonical_bytes`](Self::from_canonical_bytes) is not just
254    /// wasteful, it is *impossible*: there are no bytes left to hash. This
255    /// constructor takes the digest they already have and wraps it directly:
256    /// `Multihash::wrap(0x1e, digest)` → `Cid::new_v1(0x71, mh)`. No BLAKE3
257    /// step runs, and the result is byte-identical to what
258    /// [`from_canonical_bytes`](Self::from_canonical_bytes) would have produced
259    /// *had the caller hashed the real content correctly*.
260    ///
261    /// The `[u8; 32]` argument makes the 32-byte length a **compile-time**
262    /// guarantee: it is impossible to call with a wrong-length digest, so no
263    /// runtime length check, no fallible signature, and no new error variant
264    /// are needed. Callers holding a `&[u8]` convert with `try_into()` and own
265    /// that fallibility.
266    ///
267    /// # ⚠️ UNCHECKED ESCAPE HATCH — CALLER ASSERTS THE PRECONDITION ⚠️
268    ///
269    /// This constructor **does not hash and does not canonicalize**. It trusts
270    /// the caller completely. By calling it you **assert** that `digest` is
271    /// exactly `BLAKE3` over the value's **canonical dag-cbor** bytes — the same
272    /// input domain [`from_canonical_bytes`](Self::from_canonical_bytes) would
273    /// hash.
274    ///
275    /// If that assertion is false — the digest was computed over non-canonical
276    /// bytes, over a different encoding, with a different hash, or is simply
277    /// arbitrary — the result is a `ContentId` that **names content nothing
278    /// actually hashed**. Such an id is silently wrong: there is no error, and
279    /// verifying it against the real bytes returns `Ok(false)`, never an `Err`.
280    ///
281    /// **If you have the content bytes, use
282    /// [`from_canonical_bytes`](Self::from_canonical_bytes) instead** — it
283    /// hashes them for you and cannot be wrong this way. Do *not* reach for
284    /// this constructor merely to avoid a hash; reach for it only when you
285    /// genuinely hold a precomputed, canonical-dag-cbor BLAKE3 digest and the
286    /// content itself is gone.
287    ///
288    /// # Motivation
289    ///
290    /// The kyln-lore `address.rs` bridge (`blake3_digest_to_content_id` /
291    /// `signature_to_content_id`) does exactly this: a lore revision signature
292    /// *is* a raw 32-byte BLAKE3 digest of the content, so it becomes a kyln
293    /// `ContentId` by wrapping it — minus the `blake3::hash` step, because lore
294    /// already hashed the content. That bridge is what makes a projected git
295    /// commit's provenance note [`content_id`](crate::ContentAddressable::content_id) *equal* the originating lore
296    /// revision signature. kyln's adoption (kyln #303) and any other
297    /// BLAKE3-native source needs this primitive so a system that already
298    /// hashed with BLAKE3 gets an *exact* `ContentId` with no re-hash.
299    ///
300    /// # Panics
301    ///
302    /// Does not panic. A 32-byte digest always fits the multihash's 64-byte
303    /// capacity, so the `wrap` call cannot fail.
304    ///
305    /// # Deprecated — the codec was a guess
306    ///
307    /// This door stamps the `dag-cbor` (`0x71`) codec on a digest it **cannot
308    /// know** came from canonical dag-cbor. Every real caller (kyln-lore
309    /// signatures, bare `blake3::hash` outputs, `blake3:<hex>` digests) held a
310    /// digest of *opaque bytes*, for which the honest identity is
311    /// [`RawContentId::from_blake3_digest`](crate::RawContentId::from_blake3_digest)
312    /// (CIDv1 · `raw` · BLAKE3) — byte-identical to those systems' existing raw
313    /// CIDs. If you genuinely hold a digest of canonical dag-cbor and the value
314    /// is gone, say so explicitly with
315    /// [`from_dag_cbor_digest`](Self::from_dag_cbor_digest). See decision record
316    /// `docs/adr/0003` (question 5).
317    #[deprecated(
318        since = "0.1.1",
319        note = "use RawContentId::from_blake3_digest for a digest of opaque bytes (the honest \
320                profile), or ContentId::from_dag_cbor_digest when the digest is known to be \
321                over canonical dag-cbor"
322    )]
323    #[must_use]
324    pub fn from_blake3_content_digest(digest: [u8; BLAKE3_DIGEST_LEN]) -> Self {
325        Self::wrap_blake3_digest(digest)
326    }
327
328    /// Wrap a precomputed 32-byte BLAKE3 digest **of canonical dag-cbor bytes**
329    /// as a `ContentId`, with no re-hash.
330    ///
331    /// The name asserts the precondition: the digest MUST be BLAKE3 over the
332    /// canonical dag-cbor encoding of a value — the same bytes
333    /// [`from_canonical_bytes`](Self::from_canonical_bytes) would have hashed.
334    /// This function cannot check that; a digest of anything else mints an id
335    /// whose `dag-cbor` codec is a lie (see the precondition discussion on
336    /// [`from_canonical_bytes`](Self::from_canonical_bytes)). If your digest is
337    /// over opaque bytes, you want
338    /// [`RawContentId::from_blake3_digest`](crate::RawContentId::from_blake3_digest).
339    ///
340    /// Emits exactly the bytes [`from_canonical_bytes`](Self::from_canonical_bytes)
341    /// emits for the same digest (one shared construction point), so
342    /// `from_dag_cbor_digest(blake3(c)) == from_canonical_bytes(c)`.
343    ///
344    /// # Panics
345    ///
346    /// Does not panic: a 32-byte digest always fits the multihash's 64-byte
347    /// capacity.
348    #[must_use]
349    pub fn from_dag_cbor_digest(digest: [u8; BLAKE3_DIGEST_LEN]) -> Self {
350        Self::wrap_blake3_digest(digest)
351    }
352
353    /// Wrap a 32-byte BLAKE3 digest as a [`ContentId`]: the frozen tail shared
354    /// by [`from_canonical_bytes`] (which hashes first) and
355    /// [`from_blake3_content_digest`] (which does not). Keeping the
356    /// `Multihash::wrap(0x1e, ..)` + `Cid::new_v1(0x71, ..)` construction in
357    /// exactly one place guarantees both doors emit **byte-identical** CIDs for
358    /// the same digest — the property the no-rehash bridge depends on.
359    fn wrap_blake3_digest(digest: [u8; BLAKE3_DIGEST_LEN]) -> Self {
360        let mh = Multihash::wrap(BLAKE3_HASH_CODE, &digest)
361            .expect("BLAKE3 digest is 32 bytes and always fits a 64-byte multihash");
362        ContentId(Cid::new_v1(DAG_CBOR_CODEC, mh))
363    }
364
365    /// Borrow the underlying IPLD [`Cid`].
366    #[must_use]
367    pub fn as_cid(&self) -> &Cid {
368        &self.0
369    }
370
371    /// The raw 32-byte BLAKE3 content digest, copied out of the multihash.
372    ///
373    /// This is the **bare hash** — *not* the CID envelope. It is the sovereign
374    /// hash an adopter joins on across systems (the same digest a BLAKE3-native
375    /// upstream would hand to [`from_blake3_content_digest`](Self::from_blake3_content_digest)).
376    ///
377    /// The length is a **frozen, enforced invariant**: every `ContentId` carries
378    /// exactly `BLAKE3_DIGEST_LEN` (32) digest bytes (see the
379    /// [CID-parameters contract](ContentId#cid-parameters-frozen-at-010)), so
380    /// this accessor is infallible and returns a fixed-size array. The copy
381    /// (`try_into`) can never fail; the `expect` is unreachable for *any*
382    /// `ContentId` — every ingress path ([`from_bytes`](Self::from_bytes), `FromStr`, `TryFrom<Cid>`,
383    /// binary `Deserialize`) validates the profile, so an off-profile CID can never
384    /// become a `ContentId` and reach this method.
385    ///
386    /// Part of the **frozen presentation contract** — see the
387    /// [presentation contract](ContentId#presentation-contract-frozen-at-010).
388    #[must_use]
389    pub fn digest_bytes(&self) -> [u8; BLAKE3_DIGEST_LEN] {
390        self.0
391            .hash()
392            .digest()
393            .try_into()
394            .expect("a ContentId multihash always carries a 32-byte BLAKE3 digest")
395    }
396
397    /// Lowercase hex of the raw 32-byte BLAKE3 digest: 64 hex chars, **no**
398    /// `0x` or multibase prefix.
399    ///
400    /// This is the **"bare-digest-hex"** convention (the shortest, hash-only
401    /// "hex" form an adopter reaches for, e.g. kyln's `to_hex()`). It is hex of
402    /// [`digest_bytes`](Self::digest_bytes) — *not* hex of the full CID. The
403    /// three "hex" forms in the wild are mutually incompatible; this crate names
404    /// each so they cannot be confused:
405    ///
406    /// - **bare-digest-hex** = this method = `digest_hex()` (64 chars, hash
407    ///   only).
408    /// - **full-CID-bytes-hex** = `hex::encode(id.to_bytes())` — the whole CID
409    ///   envelope (version + codec + multihash header + digest) as base16.
410    ///   *Deliberately not provided as a method* (see the
411    ///   [presentation contract](ContentId#presentation-contract-frozen-at-010));
412    ///   a caller who truly needs it hex-encodes [`to_bytes`](Self::to_bytes)
413    ///   and owns that choice.
414    /// - **multibase base32-lower** = the [`Display`](fmt::Display) /
415    ///   [`to_string`](ToString::to_string) string (`b…`), the IPLD-canonical
416    ///   text form.
417    ///
418    /// Part of the **frozen presentation contract** — see the
419    /// [presentation contract](ContentId#presentation-contract-frozen-at-010).
420    #[must_use]
421    pub fn digest_hex(&self) -> String {
422        let digest = self.digest_bytes();
423        let mut s = String::with_capacity(digest.len() * 2);
424        for b in digest {
425            // Hand-rolled 32->64 lowercase hex: avoids taking a `hex` crate
426            // dependency for a byte-frozen surface (issue #6: "hand-roll
427            // 32->64 hex to avoid the dep — implementer's call").
428            s.push(char::from_digit((b >> 4) as u32, 16).expect("nibble < 16 is a hex digit"));
429            s.push(char::from_digit((b & 0x0f) as u32, 16).expect("nibble < 16 is a hex digit"));
430        }
431        s
432    }
433
434    /// Encode this id as its canonical CID binary form.
435    ///
436    /// This is the full **CID envelope**: version + codec (`0x71`) + multihash
437    /// header + digest — *not* the bare hash. It round-trips through
438    /// [`from_bytes`](Self::from_bytes). For the hex of *this* form ("full-CID-
439    /// bytes-hex"), a caller hex-encodes the result explicitly; the crate
440    /// deliberately does not bless a `cid_hex()` method (see the
441    /// [presentation contract](ContentId#presentation-contract-frozen-at-010)).
442    ///
443    /// Part of the **frozen presentation contract** — see the
444    /// [presentation contract](ContentId#presentation-contract-frozen-at-010).
445    #[must_use]
446    pub fn to_bytes(&self) -> Vec<u8> {
447        self.0.to_bytes()
448    }
449
450    /// Parse an id from its canonical CID binary form.
451    ///
452    /// # Errors
453    ///
454    /// Returns [`ContentError::InvalidCid`] if the bytes are not a valid CID.
455    pub fn from_bytes(bytes: &[u8]) -> Result<Self, ContentError> {
456        let cid = Cid::read_bytes(bytes).map_err(|e| ContentError::InvalidCid {
457            reason: e.to_string(),
458            source: Box::new(e),
459        })?;
460        validate_profile(&cid)?;
461        Ok(ContentId(cid))
462    }
463}
464
465/// Reject any [`Cid`] that is not this crate's frozen profile: CIDv1 + dag-cbor
466/// (`0x71`) + BLAKE3 (`0x1e`) + a 32-byte digest.
467///
468/// **Every** ingress path — [`from_bytes`](ContentId::from_bytes), the
469/// [`FromStr`] impl, [`TryFrom<Cid>`], and the binary/IPLD [`Deserialize`] —
470/// routes through this, so a parsed / deserialized / converted `ContentId` carries
471/// the same invariant a *minted* one does. That is what makes the presentation
472/// accessors ([`digest_bytes`](ContentId::digest_bytes) etc.) total: a foreign CID
473/// (a CIDv0, a non-dag-cbor codec, a SHA-256 hash, a non-32-byte digest) can never
474/// become a `ContentId`, so it can never reach an accessor and panic.
475fn validate_profile(cid: &Cid) -> Result<(), ContentError> {
476    if cid.version() != Version::V1 {
477        return Err(ContentError::InvalidCidProfile {
478            reason: format!("expected CIDv1, got {:?}", cid.version()),
479        });
480    }
481    if cid.codec() != DAG_CBOR_CODEC {
482        return Err(ContentError::InvalidCidProfile {
483            reason: format!(
484                "expected dag-cbor codec 0x{DAG_CBOR_CODEC:x}, got 0x{:x}",
485                cid.codec()
486            ),
487        });
488    }
489    if cid.hash().code() != BLAKE3_HASH_CODE {
490        return Err(ContentError::InvalidCidProfile {
491            reason: format!(
492                "expected BLAKE3 multihash 0x{BLAKE3_HASH_CODE:x}, got 0x{:x}",
493                cid.hash().code()
494            ),
495        });
496    }
497    let digest_len = cid.hash().digest().len();
498    if digest_len != BLAKE3_DIGEST_LEN {
499        return Err(ContentError::InvalidCidProfile {
500            reason: format!("expected a {BLAKE3_DIGEST_LEN}-byte digest, got {digest_len} bytes"),
501        });
502    }
503    Ok(())
504}
505
506impl TryFrom<Cid> for ContentId {
507    type Error = ContentError;
508
509    /// Wrap a [`Cid`] **iff** it is this crate's frozen profile; a foreign CID is
510    /// rejected with [`ContentError::InvalidCidProfile`].
511    ///
512    /// This is `TryFrom`, not `From`, on purpose: an infallible `From<Cid>` would
513    /// let an off-profile CID become a `ContentId` and later panic in a
514    /// presentation accessor. (The outward [`From<ContentId>`] for [`Cid`] stays
515    /// infallible — dropping the newtype is always safe.)
516    fn try_from(cid: Cid) -> Result<Self, Self::Error> {
517        validate_profile(&cid)?;
518        Ok(ContentId(cid))
519    }
520}
521
522impl From<ContentId> for Cid {
523    fn from(id: ContentId) -> Self {
524        id.0
525    }
526}
527
528impl fmt::Display for ContentId {
529    /// Render as multibase **base32-lower** (the `b…` CIDv1 string).
530    ///
531    /// This is the crate's **canonical text form** and is **frozen** for the
532    /// `0.1.x` line — see the
533    /// [presentation contract](ContentId#presentation-contract-frozen-at-010).
534    /// It is the IPLD-canonical CID string and the same text the human-readable
535    /// serde form emits. It is the inverse of [`FromStr`] for base32-lower:
536    /// `id.to_string().parse() == Ok(id)` is a tested, frozen round-trip. A unit
537    /// test and the conformance vectors pin the exact string for fixed inputs,
538    /// so any drift in the inner CID's rendering fails loudly. Changing this
539    /// form is a major version bump.
540    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
541        fmt::Display::fmt(&self.0, f)
542    }
543}
544
545impl FromStr for ContentId {
546    type Err = ContentError;
547
548    /// Parse an id from a multibase CID string.
549    ///
550    /// The frozen contract is narrow: `FromStr` is the **inverse of
551    /// [`Display`](fmt::Display)** for the base32-lower form, and that round-trip
552    /// (`id.to_string().parse() == Ok(id)`) is the part pinned by the
553    /// [presentation contract](ContentId#presentation-contract-frozen-at-010).
554    /// As a **convenience (not a contract)** this also accepts other valid
555    /// multibases (base58, base16, …), since the inner [`Cid::from_str`] does;
556    /// that tolerance is *not* frozen and may be tightened later without
557    /// breaking the frozen base32-lower round-trip — so do not depend on parsing
558    /// non-base32 CID strings.
559    fn from_str(s: &str) -> Result<Self, Self::Err> {
560        let cid = Cid::from_str(s).map_err(|e| ContentError::InvalidCid {
561            reason: e.to_string(),
562            source: Box::new(e),
563        })?;
564        validate_profile(&cid)?;
565        Ok(ContentId(cid))
566    }
567}
568
569impl Serialize for ContentId {
570    /// Serialize per the frozen serde contract: the multibase base32 string in
571    /// human-readable formats (e.g. `serde_json`, TOML), the inner [`Cid`] (a
572    /// dag-cbor tag-42 link) in binary/IPLD formats.
573    ///
574    /// This representation is **frozen** for the `0.1.x` line; see the
575    /// [type-level serde contract](ContentId#serde-representation-frozen-at-010).
576    /// Changing it is a major version bump.
577    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
578        if serializer.is_human_readable() {
579            serializer.serialize_str(&self.to_string())
580        } else {
581            self.0.serialize(serializer)
582        }
583    }
584}
585
586impl<'de> Deserialize<'de> for ContentId {
587    /// Deserialize per the frozen serde contract: a multibase base32 string from
588    /// human-readable formats, the inner [`Cid`] (a dag-cbor tag-42 link) from
589    /// binary/IPLD formats.
590    ///
591    /// The accepted representation is **frozen** for the `0.1.x` line; see the
592    /// [type-level serde contract](ContentId#serde-representation-frozen-at-010).
593    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
594        if deserializer.is_human_readable() {
595            let s = String::deserialize(deserializer)?;
596            s.parse::<ContentId>()
597                .map_err(<D::Error as serde::de::Error>::custom)
598        } else {
599            // Binary/IPLD: a decoded tag-42 link is an arbitrary CID until proven
600            // on-profile — reject a foreign link here, not later in an accessor.
601            let cid = Cid::deserialize(deserializer)?;
602            validate_profile(&cid).map_err(<D::Error as serde::de::Error>::custom)?;
603            Ok(ContentId(cid))
604        }
605    }
606}
607
608#[cfg(test)]
609mod cid_param_lock_tests {
610    //! Locks the frozen v1 CID parameters (issue #4) so they cannot drift
611    //! silently. These assertions live in the [`content_id`](crate::ContentAddressable::content_id) module itself, next
612    //! to the definitions, so the freeze is self-evident at the source. They use
613    //! **literal** values (`0x71`, `0x1e`, `V1`, `32`), not the crate constants,
614    //! so an accidental edit to a `const` is caught by a failing test rather
615    //! than silently followed.
616
617    use super::{ContentId, BLAKE3_DIGEST_LEN, BLAKE3_HASH_CODE, DAG_CBOR_CODEC};
618    use ipld_core::cid::Version;
619
620    /// A freshly produced id, from the empty-map canonical dag-cbor (`0xa0`).
621    /// This is the documented fixed input also used by the embedded-link golden
622    /// and the `empty_map` conformance vector, so the three pins agree.
623    fn empty_map_id() -> ContentId {
624        ContentId::from_canonical_bytes(&[0xa0])
625    }
626
627    #[test]
628    fn cid_params_are_literally_v1_dagcbor_blake3_32() {
629        // Assert against LITERALS, not the constants: editing a `const` must
630        // break this test, not be silently followed.
631        let id = empty_map_id();
632        let cid = id.as_cid();
633        assert_eq!(cid.version(), Version::V1, "CID version is frozen at V1");
634        assert_eq!(cid.codec(), 0x71, "codec is frozen at dag-cbor (0x71)");
635        assert_eq!(
636            cid.hash().code(),
637            0x1e,
638            "multihash is frozen at BLAKE3 (0x1e)"
639        );
640        assert_eq!(
641            cid.hash().digest().len(),
642            32,
643            "BLAKE3 digest length is frozen at 32 bytes"
644        );
645    }
646
647    #[test]
648    fn frozen_constants_hold_their_documented_values() {
649        // The public/private constants must equal the frozen literals, so the
650        // doc and the re-exported surface cannot drift from the contract.
651        assert_eq!(DAG_CBOR_CODEC, 0x71);
652        assert_eq!(BLAKE3_HASH_CODE, 0x1e);
653        assert_eq!(BLAKE3_DIGEST_LEN, 32);
654    }
655
656    #[test]
657    fn cid_binary_prefix_is_frozen() {
658        // The leading bytes of the CIDv1 binary form encode the frozen shape:
659        //   0x01 = CIDv1
660        //   0x71 = multicodec dag-cbor
661        //   0x1e = multihash code BLAKE3
662        //   0x20 = digest length 32 (varint; 0x20 == 32)
663        // Pinning the prefix means the on-wire identifier head cannot drift even
664        // if the digest payload changes.
665        let bytes = empty_map_id().to_bytes();
666        assert_eq!(
667            &bytes[..4],
668            &[0x01, 0x71, 0x1e, 0x20],
669            "CIDv1/dag-cbor/BLAKE3/len-32 binary prefix is frozen"
670        );
671        assert_eq!(
672            bytes.len(),
673            4 + BLAKE3_DIGEST_LEN,
674            "CID binary form is the 4-byte prefix plus the 32-byte digest"
675        );
676    }
677}
678
679#[cfg(test)]
680#[allow(deprecated)] // pins the deprecated door's behavior until it is removed
681mod no_rehash_digest_tests {
682    //! Tests for [`ContentId::from_blake3_content_digest`] (issue #10) and its
683    //! explicit successor [`ContentId::from_dag_cbor_digest`] (#84): the
684    //! guarded, no-rehash escape hatch that wraps an already-computed BLAKE3
685    //! content digest as a `ContentId` **without hashing it again**. These pin
686    //! both the produced CID shape and — critically — the *no-rehash invariant*
687    //! that distinguishes this door from [`from_canonical_bytes`](Self::from_canonical_bytes) (which hashes).
688
689    use super::{ContentId, BLAKE3_DIGEST_LEN};
690
691    /// #84: [`from_dag_cbor_digest`](Self::from_dag_cbor_digest) is the explicitly-named successor and emits
692    /// exactly the bytes the deprecated door emits (one shared construction
693    /// point), so migrating callers changes no ids.
694    #[test]
695    fn from_dag_cbor_digest_is_byte_identical_to_deprecated_door() {
696        let d = *blake3::hash(b"any canonical dag-cbor").as_bytes();
697        assert_eq!(
698            ContentId::from_dag_cbor_digest(d),
699            ContentId::from_blake3_content_digest(d)
700        );
701        assert_eq!(
702            ContentId::from_dag_cbor_digest(d),
703            ContentId::from_canonical_bytes(b"any canonical dag-cbor")
704        );
705    }
706    use ipld_core::cid::Version;
707
708    /// A non-trivial, fixed 32-byte digest (`[1, 2, 3, ..., 32]`). Deliberately
709    /// not all-zero so that the no-rehash invariant below is a meaningful test.
710    fn sample_digest() -> [u8; BLAKE3_DIGEST_LEN] {
711        let mut d = [0u8; BLAKE3_DIGEST_LEN];
712        for (i, b) in d.iter_mut().enumerate() {
713            *b = (i as u8) + 1;
714        }
715        d
716    }
717
718    #[test]
719    fn produces_v1_dagcbor_blake3_cid_carrying_the_exact_digest() {
720        // Assert against LITERALS, matching the frozen-parameter lock tests.
721        let d = sample_digest();
722        let id = ContentId::from_blake3_content_digest(d);
723        let cid = id.as_cid();
724        assert_eq!(cid.version(), Version::V1, "must be a CIDv1");
725        assert_eq!(cid.codec(), 0x71, "codec must be dag-cbor (0x71)");
726        assert_eq!(cid.hash().code(), 0x1e, "multihash must be BLAKE3 (0x1e)");
727        assert_eq!(
728            cid.hash().digest().len(),
729            BLAKE3_DIGEST_LEN,
730            "digest must be 32 bytes"
731        );
732        // The wrapped digest bytes must be EXACTLY the input — the whole point
733        // of a no-rehash door is that the digest passes through untouched.
734        assert_eq!(
735            cid.hash().digest(),
736            &d,
737            "the CID must carry the caller's digest verbatim"
738        );
739    }
740
741    #[test]
742    fn does_not_rehash_the_digest() {
743        // THE CRITICAL INVARIANT (must-fix gate): for a non-trivial digest `d`,
744        // wrapping it must NOT equal hashing it. `from_canonical_bytes(&d)`
745        // computes BLAKE3(d) — a second hash — whereas
746        // `from_blake3_content_digest(d)` wraps `d` as-is. If these were ever
747        // equal, the constructor would secretly be re-hashing.
748        let d = sample_digest();
749        let wrapped = ContentId::from_blake3_content_digest(d);
750        let hashed_again = ContentId::from_canonical_bytes(&d);
751        assert_ne!(
752            wrapped, hashed_again,
753            "from_blake3_content_digest must NOT re-hash: wrapping d must differ \
754             from BLAKE3(d)"
755        );
756        // Tighter: the wrapped id's digest is `d` itself, while the re-hashed
757        // id's digest is BLAKE3(d) — explicitly different bytes.
758        assert_eq!(wrapped.as_cid().hash().digest(), &d);
759        assert_eq!(
760            hashed_again.as_cid().hash().digest(),
761            blake3::hash(&d).as_bytes(),
762        );
763    }
764
765    #[test]
766    fn digest_survives_a_byte_roundtrip() {
767        // Building from a digest, serializing to CID bytes, and reading the
768        // digest back must recover the original `d` exactly.
769        let d = sample_digest();
770        let id = ContentId::from_blake3_content_digest(d);
771
772        // Via the live CID handle.
773        assert_eq!(id.as_cid().hash().digest(), &d);
774
775        // Via the full CID binary form: tail 32 bytes are the digest, and a
776        // from_bytes parse lands on the same id.
777        let bytes = id.to_bytes();
778        assert_eq!(
779            &bytes[bytes.len() - BLAKE3_DIGEST_LEN..],
780            &d,
781            "the trailing 32 bytes of the CID binary form are the digest"
782        );
783        let reparsed = ContentId::from_bytes(&bytes).expect("CID bytes must reparse");
784        assert_eq!(reparsed, id);
785        assert_eq!(reparsed.as_cid().hash().digest(), &d);
786    }
787
788    #[test]
789    fn converges_with_from_canonical_bytes_when_caller_hashes_correctly() {
790        // The two doors meet in the middle: if a caller hashes the real content
791        // with BLAKE3 and feeds the digest here, they get the SAME id as
792        // hashing the content via from_canonical_bytes. This is the bridge
793        // property the no-rehash primitive exists to provide.
794        for content in [
795            &b""[..],
796            &[0xa0][..], // empty-map canonical dag-cbor
797            b"the quick brown fox",
798            b"\x00\x01\x02\x03 arbitrary canonical bytes",
799        ] {
800            let digest: [u8; BLAKE3_DIGEST_LEN] = blake3::hash(content).into();
801            let via_digest = ContentId::from_blake3_content_digest(digest);
802            let via_hash = ContentId::from_canonical_bytes(content);
803            assert_eq!(
804                via_digest, via_hash,
805                "from_blake3_content_digest(blake3(x)) must equal \
806                 from_canonical_bytes(x)"
807            );
808        }
809    }
810}
811
812#[cfg(test)]
813mod presentation_tests {
814    //! Tests for the **frozen presentation contract** (issue #6): the named,
815    //! separately-frozen string/byte forms a `ContentId` emits. These pin the
816    //! exact values for fixed inputs so any drift in the inner CID's rendering,
817    //! the multihash digest, or the hex encoder fails loudly. The cross-language
818    //! `tests/vectors.json` gate additionally pins [`digest_hex`](Self::digest_hex) (and the base32
819    //! string + CID bytes) so Python parity is enforced too; these in-crate
820    //! tests assert the *accessor semantics* that the vectors cannot (the array
821    //! return, the no-prefix rule, the Display/FromStr round-trip).
822
823    use super::{ContentId, BLAKE3_DIGEST_LEN};
824
825    /// The documented fixed input: the empty-map canonical dag-cbor (`0xa0`).
826    /// This is the same id as the `empty_map` conformance vector and the
827    /// embedded-link golden, so all the pins agree.
828    fn empty_map_id() -> ContentId {
829        ContentId::from_canonical_bytes(&[0xa0])
830    }
831
832    /// The frozen `empty_map` digest (the BLAKE3 of `0xa0`), as bare-digest-hex.
833    /// Equals the tail of the `empty_map` vector's `content_id_bytes_hex`
834    /// (`01711e20` CID prefix, then this 64-char digest).
835    const EMPTY_MAP_DIGEST_HEX: &str =
836        "1f94cbf313b3ce23257a7251ea0fc95a24556ea611e4f8f475e549971baedb02";
837
838    #[test]
839    fn display_is_frozen_base32_lower() {
840        // The canonical text form is multibase base32-lower (`b…`), frozen.
841        let id = empty_map_id();
842        let s = id.to_string();
843        assert_eq!(
844            s, "bafyr4ia7stf7ge5tzyrsk6tskhva7sk2erkw5jqr4t4pi5pfjglrxlw3ai",
845            "Display must be the frozen base32-lower CID string"
846        );
847        assert!(s.starts_with('b'), "base32-lower multibase prefix is 'b'");
848    }
849
850    #[test]
851    fn display_fromstr_roundtrip_is_frozen() {
852        // The frozen guarantee: Display -> FromStr lands back on the same id.
853        let id = empty_map_id();
854        let parsed: ContentId = id.to_string().parse().expect("base32 string must reparse");
855        assert_eq!(parsed, id, "Display -> FromStr must round-trip");
856    }
857
858    #[test]
859    fn digest_bytes_is_the_raw_32_byte_blake3_hash() {
860        let id = empty_map_id();
861        let d = id.digest_bytes();
862        // It is a fixed-size 32-byte array...
863        assert_eq!(d.len(), BLAKE3_DIGEST_LEN);
864        // ...equal to the multihash's digest (the bare hash, no envelope)...
865        assert_eq!(&d[..], id.as_cid().hash().digest());
866        // ...and equal to an independent BLAKE3 of the input bytes.
867        assert_eq!(d, *blake3::hash(&[0xa0]).as_bytes());
868    }
869
870    #[test]
871    fn digest_hex_is_frozen_64_char_lower_hex_no_prefix() {
872        let id = empty_map_id();
873        let h = id.digest_hex();
874        // Exactly 64 lowercase hex chars, no `0x` prefix. (A hex digest may
875        // legitimately begin with the digit `b`; that is NOT the base32
876        // multibase prefix, which belongs only to the Display string.)
877        assert_eq!(h.len(), 64, "digest_hex is 64 chars (32 bytes)");
878        assert!(!h.starts_with("0x"), "digest_hex carries no 0x prefix");
879        assert!(
880            h.chars()
881                .all(|c| c.is_ascii_digit() || ('a'..='f').contains(&c)),
882            "digest_hex is lowercase hex only"
883        );
884        // The exact frozen value for the empty map.
885        assert_eq!(
886            h, EMPTY_MAP_DIGEST_HEX,
887            "frozen empty-map digest_hex drifted"
888        );
889    }
890
891    #[test]
892    fn digest_hex_is_hex_of_digest_bytes_not_of_the_cid() {
893        // digest_hex names convention #1 (bare-digest-hex): it is hex of the
894        // bare digest, and is STRICTLY SHORTER than full-CID-bytes-hex
895        // (`hex::encode(to_bytes())`), which carries the 4-byte CID prefix too.
896        let id = empty_map_id();
897        let digest = id.digest_bytes();
898        let mut expected = String::new();
899        for b in digest {
900            expected.push_str(&format!("{b:02x}"));
901        }
902        assert_eq!(id.digest_hex(), expected);
903
904        // The full-CID-bytes-hex (the deliberately-unmethodd convention #2) is
905        // longer and ENDS WITH the digest hex, after the CID envelope prefix.
906        let cid_bytes_hex: String = id.to_bytes().iter().map(|b| format!("{b:02x}")).collect();
907        assert!(
908            cid_bytes_hex.ends_with(&id.digest_hex()),
909            "the full-CID-bytes-hex ends with the bare-digest-hex"
910        );
911        assert!(
912            cid_bytes_hex.len() > id.digest_hex().len(),
913            "full-CID-bytes-hex is longer than bare-digest-hex (it has the envelope)"
914        );
915        // Concretely: the 4-byte CIDv1/dag-cbor/BLAKE3/len-32 prefix, then digest.
916        assert_eq!(cid_bytes_hex, format!("01711e20{}", id.digest_hex()));
917    }
918}
919
920#[cfg(test)]
921mod checked_input_tests {
922    //! Tests for [`ContentId::from_canonical_bytes_checked`] (issue #5): the
923    //! opt-in, round-trip-validating sibling of the fast [`from_canonical_bytes`](Self::from_canonical_bytes)
924    //! primitive. They pin the four behaviors the issue's AC names: (a) canonical
925    //! bytes accepted and yielding the *same* id as the unchecked door, (b)
926    //! non-canonical-but-valid CBOR rejected, (c) non-dag-cbor garbage rejected,
927    //! and (d) a regression test documenting that the *unchecked* primitive still
928    //! happily mints an id for non-canonical bytes (the precondition is real and
929    //! unenforced by design).
930
931    use super::ContentId;
932    use crate::canonical::to_canonical_dagcbor;
933    use crate::error::ContentError;
934
935    #[test]
936    fn checked_accepts_canonical_bytes_and_matches_the_unchecked_door() {
937        // The always-canonical path: canonicalize a value, then both doors must
938        // agree on the id (the check changes fallibility, never the bytes).
939        for value in [
940            &serde_json::json!({}),
941            &serde_json::json!({"alpha": 1, "zeta": 26}),
942            &serde_json::json!({"name": "hello", "n": [1, 2, 3], "nested": {"a": 1}}),
943            &serde_json::json!([1, "two", [3, 4], null]),
944        ] {
945            // Drive canonical bytes through Ipld so the encoding is the codec's
946            // canonical form, then assert the two doors converge.
947            let ipld: ipld_core::ipld::Ipld =
948                serde_json::from_value((*value).clone()).expect("json -> ipld");
949            let canonical = to_canonical_dagcbor(&ipld).expect("encode canonical");
950
951            let via_checked =
952                ContentId::from_canonical_bytes_checked(&canonical).expect("canonical accepted");
953            let via_unchecked = ContentId::from_canonical_bytes(&canonical);
954            assert_eq!(
955                via_checked, via_unchecked,
956                "checked(canonical) must equal the unchecked id for the same bytes"
957            );
958        }
959    }
960
961    #[test]
962    fn always_canonical_path_equals_from_canonical_bytes_of_to_canonical_dagcbor() {
963        // The exact identity the issue calls out: the always-canonical path
964        // (to_canonical_dagcbor then mint) and the checked door produce the same
965        // id, and that id is from_canonical_bytes(to_canonical_dagcbor(x)).
966        let ipld: ipld_core::ipld::Ipld = serde_json::from_value(serde_json::json!(
967            {"alpha": 1, "zeta": 26, "list": [true, false, null]}
968        ))
969        .expect("json -> ipld");
970
971        let canonical = to_canonical_dagcbor(&ipld).expect("encode canonical");
972        let from_unchecked = ContentId::from_canonical_bytes(&canonical);
973        let from_checked =
974            ContentId::from_canonical_bytes_checked(&canonical).expect("canonical accepted");
975
976        assert_eq!(
977            from_checked, from_unchecked,
978            "from_canonical_bytes_checked(to_canonical_dagcbor(x)) == \
979             from_canonical_bytes(to_canonical_dagcbor(x))"
980        );
981    }
982
983    #[test]
984    fn checked_rejects_non_canonical_but_valid_cbor() {
985        // A map with two string keys encoded in NON-canonical (descending) key
986        // order. dag-cbor canonical order is by length-then-bytewise, so {"a",
987        // "bb"} canonical is a2 [a] .. [bb]; we hand-build the reverse order.
988        // Hand-built CBOR:
989        //   a2                      map(2)
990        //   62 6262                 text(2) "bb"   <- emitted FIRST (non-canonical)
991        //   01                      1
992        //   61 61                   text(1) "a"
993        //   02                      2
994        let non_canonical = [0xa2, 0x62, 0x62, 0x62, 0x01, 0x61, 0x61, 0x02];
995
996        // Sanity: it IS valid CBOR (decodes fine), so this exercises the
997        // re-encode-compare path, not the decode path. The UNVERIFIED door is the
998        // point of the probe — it is what shows the refusal below belongs to the
999        // check and not to the codec — so its deprecation (issue #90) is allowed
1000        // here rather than routed around.
1001        #[allow(deprecated)]
1002        let decoded: ipld_core::ipld::Ipld =
1003            crate::canonical::from_canonical_dagcbor(&non_canonical)
1004                .expect("non-canonical bytes are still valid CBOR and decode");
1005        // And its canonical re-encoding genuinely differs (proving our fixture is
1006        // really non-canonical, not an accidental canonical form).
1007        let recanon = to_canonical_dagcbor(&decoded).expect("re-encode");
1008        assert_ne!(
1009            &recanon[..],
1010            &non_canonical[..],
1011            "fixture must actually be non-canonical"
1012        );
1013
1014        let err = ContentId::from_canonical_bytes_checked(&non_canonical)
1015            .expect_err("non-canonical CBOR must be rejected");
1016        assert!(
1017            matches!(err, ContentError::NonCanonical),
1018            "non-canonical valid CBOR must map to ContentError::NonCanonical, got {err:?}"
1019        );
1020    }
1021
1022    #[test]
1023    fn checked_rejects_indefinite_length_cbor() {
1024        // An indefinite-length CBOR array (0x9f .. 0xff) is valid CBOR but
1025        // forbidden in canonical dag-cbor (definite-length only). dag-cbor's
1026        // decoder may already refuse it; either way the checked door must error
1027        // (decode failure OR non-canonical), never silently mint an id.
1028        //   9f        array(*)  (indefinite)
1029        //   01 02 03  1, 2, 3
1030        //   ff        break
1031        let indefinite = [0x9f, 0x01, 0x02, 0x03, 0xff];
1032        let err = ContentId::from_canonical_bytes_checked(&indefinite)
1033            .expect_err("indefinite-length CBOR must be rejected");
1034        assert!(
1035            matches!(
1036                err,
1037                ContentError::NonCanonical | ContentError::DecodingError { .. }
1038            ),
1039            "indefinite-length CBOR must be rejected (NonCanonical or DecodingError), got {err:?}"
1040        );
1041    }
1042
1043    #[test]
1044    fn checked_rejects_non_dagcbor_garbage() {
1045        // Arbitrary non-CBOR bytes must fail at the decode step.
1046        let garbage = [0xff, 0xff, 0xff, 0xff];
1047        let err = ContentId::from_canonical_bytes_checked(&garbage)
1048            .expect_err("non-dag-cbor garbage must be rejected");
1049        assert!(
1050            matches!(err, ContentError::DecodingError { .. }),
1051            "non-dag-cbor garbage must map to ContentError::DecodingError, got {err:?}"
1052        );
1053    }
1054
1055    #[test]
1056    fn unchecked_still_mints_an_id_for_non_canonical_bytes() {
1057        // REGRESSION / documentation test (issue #5 AC item d): the FAST,
1058        // unchecked primitive does NOT validate — it mints a (misleading) id even
1059        // for non-canonical bytes. This pins that the precondition is real and
1060        // unenforced by design: the unchecked door succeeds where the checked one
1061        // refuses, and the two ids necessarily differ (the unchecked id hashes
1062        // the non-canonical bytes; the canonical bytes hash to something else).
1063        let non_canonical = [0xa2, 0x62, 0x62, 0x62, 0x01, 0x61, 0x61, 0x02];
1064
1065        // Unchecked: succeeds, no error, no panic — mints over the raw bytes.
1066        let misleading = ContentId::from_canonical_bytes(&non_canonical);
1067        assert_eq!(
1068            misleading.digest_bytes(),
1069            *blake3::hash(&non_canonical).as_bytes(),
1070            "the unchecked door hashes exactly the bytes it was given"
1071        );
1072
1073        // Checked: refuses.
1074        assert!(ContentId::from_canonical_bytes_checked(&non_canonical).is_err());
1075
1076        // The misleading id differs from the id of the *canonical* form of the
1077        // same value — the silent integrity hole the precondition warns about.
1078        // Same probe, same reason: the unverified door is the subject here.
1079        #[allow(deprecated)]
1080        let decoded: ipld_core::ipld::Ipld =
1081            crate::canonical::from_canonical_dagcbor(&non_canonical).expect("valid CBOR");
1082        let canonical = to_canonical_dagcbor(&decoded).expect("re-encode");
1083        let honest = ContentId::from_canonical_bytes(&canonical);
1084        assert_ne!(
1085            misleading, honest,
1086            "minting over non-canonical bytes names a different id than the \
1087             canonical form would — exactly the hazard from_canonical_bytes warns of"
1088        );
1089    }
1090}
1091
1092#[cfg(test)]
1093mod profile_validation_tests {
1094    //! Every ingress path ([`from_bytes`](Self::from_bytes), `FromStr`, `TryFrom<Cid>`, binary
1095    //! `Deserialize`) admits ONLY the frozen profile — CIDv1 + dag-cbor (`0x71`) +
1096    //! BLAKE3 (`0x1e`) + 32-byte digest. A foreign CID is rejected with
1097    //! [`ContentError::InvalidCidProfile`] rather than smuggled in to panic later in
1098    //! a presentation accessor. (Closes the ContentId profile-validation hole.)
1099    use super::*;
1100    use ipld_core::cid::multihash::Multihash;
1101    use ipld_core::cid::Cid;
1102
1103    const SHA2_256: u64 = 0x12;
1104    const RAW_CODEC: u64 = 0x55;
1105
1106    fn blake3_mh(digest: &[u8]) -> Multihash<64> {
1107        Multihash::wrap(BLAKE3_HASH_CODE, digest).expect("wrap blake3")
1108    }
1109
1110    // Well-formed CIDs that are NOT this crate's profile, one per parameter.
1111    fn cidv0_sha256() -> Cid {
1112        Cid::new_v0(Multihash::wrap(SHA2_256, &[7u8; 32]).expect("wrap sha256")).expect("v0")
1113    }
1114    fn v1_non_dagcbor_codec() -> Cid {
1115        Cid::new_v1(RAW_CODEC, blake3_mh(&[7u8; 32]))
1116    }
1117    fn v1_sha256_hash() -> Cid {
1118        Cid::new_v1(
1119            DAG_CBOR_CODEC,
1120            Multihash::wrap(SHA2_256, &[7u8; 32]).expect("wrap sha256"),
1121        )
1122    }
1123    fn v1_short_digest() -> Cid {
1124        Cid::new_v1(DAG_CBOR_CODEC, blake3_mh(&[7u8; 16]))
1125    }
1126
1127    /// A foreign CID is rejected identically by TryFrom, from_bytes, and FromStr.
1128    fn assert_all_ingress_reject(cid: Cid) {
1129        assert!(
1130            matches!(
1131                ContentId::try_from(cid),
1132                Err(ContentError::InvalidCidProfile { .. })
1133            ),
1134            "TryFrom<Cid> must reject a foreign CID"
1135        );
1136        assert!(
1137            matches!(
1138                ContentId::from_bytes(&cid.to_bytes()),
1139                Err(ContentError::InvalidCidProfile { .. })
1140            ),
1141            "from_bytes must reject a foreign CID envelope"
1142        );
1143        assert!(
1144            matches!(
1145                cid.to_string().parse::<ContentId>(),
1146                Err(ContentError::InvalidCidProfile { .. })
1147            ),
1148            "FromStr must reject a foreign CID string"
1149        );
1150    }
1151
1152    #[test]
1153    fn cidv0_sha256_is_rejected() {
1154        assert_all_ingress_reject(cidv0_sha256());
1155    }
1156
1157    #[test]
1158    fn v1_non_dagcbor_codec_is_rejected() {
1159        assert_all_ingress_reject(v1_non_dagcbor_codec());
1160    }
1161
1162    #[test]
1163    fn v1_sha256_hash_is_rejected() {
1164        assert_all_ingress_reject(v1_sha256_hash());
1165    }
1166
1167    #[test]
1168    fn v1_short_digest_is_rejected() {
1169        assert_all_ingress_reject(v1_short_digest());
1170    }
1171
1172    #[test]
1173    fn a_foreign_cid_link_in_dagcbor_is_rejected_on_deserialize() {
1174        // A tag-42 link to a foreign (sha256) CID, encoded as dag-cbor, must NOT
1175        // deserialize as a ContentId through the binary/IPLD path.
1176        let foreign = v1_sha256_hash();
1177        let bytes = serde_ipld_dagcbor::to_vec(&foreign).expect("encode a tag-42 link");
1178        let decoded: Result<ContentId, _> = serde_ipld_dagcbor::from_slice(&bytes);
1179        assert!(
1180            decoded.is_err(),
1181            "a foreign CID link must not deserialize as a ContentId"
1182        );
1183    }
1184
1185    #[test]
1186    fn an_on_profile_id_passes_every_ingress_path() {
1187        // A genuine, minted id round-trips through all validated ingress paths.
1188        let id = ContentId::from_canonical_bytes(&[0xa0]); // canonical empty map
1189        assert_eq!(ContentId::try_from(*id.as_cid()).expect("try_from"), id);
1190        assert_eq!(
1191            ContentId::from_bytes(&id.to_bytes()).expect("from_bytes"),
1192            id
1193        );
1194        assert_eq!(id.to_string().parse::<ContentId>().expect("parse"), id);
1195        // And the presentation accessor is now provably total.
1196        assert_eq!(id.digest_bytes().len(), BLAKE3_DIGEST_LEN);
1197    }
1198}