content-addressable 0.1.2

IPLD-native content addressing: data that carries its own proof of integrity, intrinsically (BLAKE3 + CIDv1 + canonical dag-cbor).
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
//! [`ContentId`] — the self-certifying identity of a value.
//!
//! A `ContentId` is a newtype over an IPLD [`Cid`] (content identifier, v1).
//! It is computed directly from a value's canonical bytes, so it *is* a proof
//! of integrity: anyone holding the bytes can recompute the id and confirm the
//! bytes are exactly what the id names. Nothing trusted needs to vouch for the
//! association — it is intrinsic.

use std::fmt;
use std::str::FromStr;

use ipld_core::cid::multihash::Multihash;
use ipld_core::cid::{Cid, Version};
use serde::{Deserialize, Deserializer, Serialize, Serializer};

use crate::error::ContentError;

/// Multicodec code for the `dag-cbor` content type (`0x71`).
///
/// This is the codec field of every [`ContentId`]'s CID: it declares that the
/// bytes the id names are canonical dag-cbor. **Frozen** for the `0.1.x` line —
/// see the [`ContentId` CID-parameters contract](ContentId#cid-parameters-frozen-at-010).
pub const DAG_CBOR_CODEC: u64 = 0x71;

/// Multihash code for BLAKE3 (`0x1e`).
///
/// This is the hash function field of every [`ContentId`]'s multihash.
/// **Frozen** for the `0.1.x` line — see the
/// [`ContentId` CID-parameters contract](ContentId#cid-parameters-frozen-at-010).
pub const BLAKE3_HASH_CODE: u64 = 0x1e;

/// Length of a BLAKE3 digest in bytes (256 bits).
///
/// Part of the **frozen** v1 CID-parameter contract: every [`ContentId`]'s
/// multihash carries exactly 32 digest bytes. Kept private (no new public
/// surface, gate item 9); the value is asserted by the CID-shape tests and is
/// implied on the wire by the `0x20` multihash length prefix. Changing it is a
/// major version bump — see the
/// [`ContentId` CID-parameters contract](ContentId#cid-parameters-frozen-at-010).
pub(crate) const BLAKE3_DIGEST_LEN: usize = 32;

/// The self-certifying identity of a value.
///
/// A `ContentId` wraps an IPLD CIDv1 whose codec is `dag-cbor` (`0x71`) and
/// whose multihash is BLAKE3 (`0x1e`) over the value's canonical dag-cbor
/// bytes. See [`from_canonical_bytes`](Self::from_canonical_bytes).
///
/// Ordering is defined by the CID's binary representation, which makes
/// `ContentId` usable as a key in ordered maps and sets with a stable,
/// content-derived sort.
///
/// # CID parameters (FROZEN at 0.1.0)
///
/// The three fields that determine every address this crate emits are a
/// **fixed v1 contract**, not configuration (gate items 3, 4, 5 — see
/// `README.md`):
///
/// - **CID version: `V1`** (`Cid::new_v1`). There is no v0 / agility path.
/// - **Multicodec: `dag-cbor` (`0x71`)** — see [`DAG_CBOR_CODEC`].
/// - **Multihash: `BLAKE3` (`0x1e`)** — see [`BLAKE3_HASH_CODE`] — over a
///   **fixed 32-byte digest** (`BLAKE3_DIGEST_LEN`, 256 bits).
///
/// These values are **not selectable**. [`from_canonical_bytes`](Self::from_canonical_bytes)
/// is the single mint site and always produces this shape. Changing the
/// version, codec, hash function, or digest length is a **new representation =
/// major version bump**, never a patch — every previously emitted address would
/// become unreachable. CIDs are self-describing, so a future hash/codec can
/// ship as a *new* representation under a *new* major without retrofitting
/// agility now. A raw [`Cid`] is admitted only if it matches this profile —
/// [`TryFrom<Cid>`](#impl-TryFrom%3CCid%3E-for-ContentId) validates it — and the
/// inner CID is readable via [`as_cid`](Self::as_cid).
///
/// # Serde representation (FROZEN at 0.1.0)
///
/// `ContentId` has two serde forms, chosen by the serializer via
/// `is_human_readable`, and **both are a stability contract across the entire
/// `0.1.x` line** — changing either is a **major version bump**. The committed
/// golden vectors (`tests/vectors.json`) plus the in-crate golden test pin
/// these exact bytes so a dependency bump cannot silently perturb them:
///
/// - **Binary / IPLD form (e.g. dag-cbor): a tag-42 link.** Through
///   [`serde_ipld_dagcbor`] a `ContentId` is encoded as a CBOR **tag-42 link**
///   (major-type-6 head `0xd8 0x2a`, then a byte string carrying the `0x00`
///   multibase-identity prefix followed by the CID binary form). This is the
///   natural IPLD encoding and makes `ContentId` fields inside other
///   content-addressed structures behave as real links — the crate's
///   IPLD-native thesis. See [`canonical`](crate::canonical).
/// - **Human-readable form (e.g. `serde_json`, TOML): the multibase base32
///   string.** In a human-readable serializer a `ContentId` is the
///   base32-lower `b…` CID string — the same text
///   [`Display`](fmt::Display)/[`FromStr`] produce — so JSON and config files
///   carry a readable, portable CID rather than a raw byte array. The crate
///   adds a thin `is_human_readable` branch over the inner `Cid` to guarantee
///   this; the IPLD/binary path is unchanged.
///
/// # Presentation contract (FROZEN at 0.1.0)
///
/// Whatever string and byte forms this type emits become a **byte/wire
/// contract** at `0.1.0`: changing any of them afterward is a **major version
/// bump**, never a patch (issue #6, gate item 2). The full presentation
/// surface is therefore named and frozen explicitly, with conformance vectors
/// (`tests/vectors.json` + the in-crate goldens) pinning the exact bytes. There
/// are **four** distinct presentation forms, each load-bearing for a different
/// reason — do not confuse them:
///
/// | Form | Method | What it is |
/// |------|--------|------------|
/// | **Canonical text** | [`Display`](fmt::Display) / [`to_string`](ToString::to_string) | multibase **base32-lower** (`b…`), the IPLD-canonical CID string |
/// | **Binary envelope** | [`to_bytes`](Self::to_bytes) / [`from_bytes`](Self::from_bytes) | the full **CID binary** form (version + codec + multihash + digest) |
/// | **Bare digest** | [`digest_bytes`](Self::digest_bytes) | the raw **32-byte BLAKE3** hash (no envelope) |
/// | **Bare-digest-hex** | [`digest_hex`](Self::digest_hex) | lowercase **hex of the 32-byte digest** (64 chars, no prefix) |
///
/// **Three incompatible "hex" conventions** exist in the wild for a CID; an
/// adopter must pick one, so the crate names them to end the ambiguity:
///
/// 1. **bare-digest-hex** — hex of the raw 32-byte BLAKE3 digest. *This is what
///    [`digest_hex`](Self::digest_hex) returns* (the "swarm" / kyln `to_hex()`
///    convention: shortest, hash-only).
/// 2. **full-CID-bytes-hex** — hex of [`to_bytes`](Self::to_bytes) (the whole
///    CID envelope as base16). *Deliberately **not** a method.* Convention #2 is
///    just `hex::encode(id.to_bytes())`; blessing it as `cid_hex()` would add a
///    third "hex" accessor that invites exactly the confusion this contract
///    exists to end. A caller who genuinely needs CID-bytes-as-hex hex-encodes
///    [`to_bytes`](Self::to_bytes) explicitly and owns that choice. (This
///    non-decision is recorded so it is not re-litigated; it can be added later
///    additively without breaking the frozen surface.)
/// 3. **multibase base32-lower** — the [`Display`](fmt::Display) string. Use
///    this, not a hex form, as the canonical text representation.
///
/// `Display` emits base32-lower and is the **inverse of [`FromStr`] for that
/// form**: the `Display`→`FromStr` round-trip is frozen and tested. `FromStr`
/// *also* tolerates other multibases (base58, base16, …) as a **convenience,
/// not a contract** — that tolerance may be tightened later without breaking
/// the frozen base32-lower round-trip.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct ContentId(Cid);

impl ContentId {
    /// Compute the content id of bytes that **are already canonical dag-cbor**.
    ///
    /// This is the crate's fast, unchecked minting primitive — the hot path that
    /// [`ContentAddressable::content_id`](crate::ContentAddressable::content_id)
    /// leans on. The pipeline is, end to end:
    ///
    /// 1. `digest = BLAKE3(bytes)` — a 32-byte hash.
    /// 2. `mh = Multihash::wrap(0x1e, digest)` — tag the digest with the
    ///    BLAKE3 multihash code.
    /// 3. `cid = Cid::new_v1(0x71, mh)` — a CIDv1 declaring the bytes are
    ///    dag-cbor.
    ///
    /// # Precondition — CALLER MUST PASS CANONICAL DAG-CBOR
    ///
    /// **The bytes you pass MUST already be canonical dag-cbor** — typically the
    /// output of
    /// [`to_canonical_dagcbor`](crate::canonical::to_canonical_dagcbor) or, for a
    /// whole value, [`ContentAddressable::canonical_form`](crate::ContentAddressable::canonical_form).
    /// This function **does not re-canonicalize and does not validate**; it only
    /// hashes whatever it is handed and stamps the result with the `0x71`
    /// (dag-cbor) codec.
    ///
    /// Passing **non-canonical** CBOR (wrong map-key order, indefinite-length
    /// items, non-smallest integers), hand-rolled CBOR, or arbitrary non-dag-cbor
    /// bytes is a **logic error, not handled at runtime**: you get back a
    /// perfectly valid-looking [`ContentId`] whose `0x71` codec is a **lie** —
    /// the bytes it names are not canonical dag-cbor. A second party who
    /// canonicalizes "the same value" will compute a **different** id, and
    /// nothing here will have told you. This is silent and by design (validation
    /// is not free, so it is opt-in — see below), so honor the precondition.
    ///
    /// # Which door to use
    ///
    /// - **Have a value?** Use
    ///   [`ContentAddressable::content_id`](crate::ContentAddressable::content_id)
    ///   (the documented default). It canonicalizes *then* hashes, so it can
    ///   never hand this function non-canonical bytes — the precondition holds by
    ///   construction and there is no per-call validation tax on the safe path.
    /// - **Have bytes you canonicalized yourself** (via
    ///   [`to_canonical_dagcbor`](crate::canonical::to_canonical_dagcbor))?
    ///   This primitive is correct and fastest.
    /// - **Have foreign / untrusted bytes you did *not* canonicalize?** Use the
    ///   checked sibling
    ///   [`from_canonical_bytes_checked`](Self::from_canonical_bytes_checked),
    ///   which re-encodes and rejects non-canonical input with a typed error.
    ///
    /// # Naming (FROZEN at 0.1.0)
    ///
    /// This door keeps the name [`from_canonical_bytes`](Self::from_canonical_bytes) (the fast primitive) and
    /// is **not** renamed to `_unchecked`; the checked variant is the explicitly
    /// suffixed [`from_canonical_bytes_checked`](Self::from_canonical_bytes_checked).
    /// This pairing is a frozen `0.1.0` decision (README gate item #6) — it
    /// cannot change cheaply after `0.1.0`.
    ///
    /// # Panics
    ///
    /// Does not panic. BLAKE3 always produces a 32-byte digest, which always
    /// fits the multihash's 64-byte capacity, so the `wrap` call cannot fail.
    #[must_use]
    pub fn from_canonical_bytes(bytes: &[u8]) -> Self {
        let digest = blake3::hash(bytes);
        Self::wrap_blake3_digest(*digest.as_bytes())
    }

    /// Compute the content id of bytes, **first verifying they are canonical
    /// dag-cbor**.
    ///
    /// This is the opt-in, *checked* sibling of
    /// [`from_canonical_bytes`](Self::from_canonical_bytes). It is for callers
    /// minting an id from bytes they did **not** canonicalize themselves
    /// (foreign, untrusted, hand-rolled, or stored input) and who therefore
    /// cannot rely on the precondition by construction. It closes the silent
    /// integrity hole the fast primitive leaves open, at the cost of a decode +
    /// re-encode + compare on every call — pay it only when byte provenance is
    /// not yours.
    ///
    /// The check is a **round-trip**: decode the bytes as an
    /// [`Ipld`](ipld_core::ipld::Ipld) value, re-encode that value via
    /// [`to_canonical_dagcbor`](crate::canonical::to_canonical_dagcbor), and
    /// require the re-encoding to equal the input byte-for-byte. Because the
    /// dag-cbor codec is canonical by construction (strict key order,
    /// definite-length, smallest-form integers — see
    /// [`canonical`](crate::canonical)), equality proves the input was *already*
    /// the unique canonical encoding of that value. On success the id is computed
    /// over the (canonical) input bytes, so it is **byte-identical** to what
    /// [`from_canonical_bytes`](Self::from_canonical_bytes) would return for the
    /// same bytes — the check changes the fallibility, never the emitted id.
    ///
    /// # Errors
    ///
    /// - [`ContentError::DecodingError`] if `bytes` are not dag-cbor at all.
    /// - [`ContentError::NonCanonical`] if `bytes` decode but are *not* the
    ///   canonical encoding (wrong map-key order, indefinite-length items,
    ///   non-smallest integers, …) — i.e. re-encoding differs from the input.
    /// - [`ContentError::EncodingError`] in the unlikely event the decoded value
    ///   cannot be re-encoded.
    pub fn from_canonical_bytes_checked(bytes: &[u8]) -> Result<Self, ContentError> {
        // Decode to the generic Ipld value, re-encode canonically, require byte
        // equality. That gate lives in ONE place (issue #90) so this door and
        // `canonical::from_canonical_dagcbor_checked` cannot drift apart in what
        // they call canonical; the behavior here is unchanged.
        crate::canonical::ensure_canonical(bytes)?;
        // The bytes are proven canonical: minting over them is identical to the
        // unchecked primitive, so reuse it (no second decode/encode).
        Ok(Self::from_canonical_bytes(bytes))
    }

    /// Wrap an **already-computed** 32-byte BLAKE3 content digest as a
    /// `ContentId` — **without hashing**.
    ///
    /// This is a deliberately narrow, *unchecked* escape hatch for
    /// **BLAKE3-native upstreams** that have already hashed their content and
    /// hold only the 32-byte digest (a signature, an address) — not the
    /// original canonical bytes. For them,
    /// [`from_canonical_bytes`](Self::from_canonical_bytes) is not just
    /// wasteful, it is *impossible*: there are no bytes left to hash. This
    /// constructor takes the digest they already have and wraps it directly:
    /// `Multihash::wrap(0x1e, digest)` → `Cid::new_v1(0x71, mh)`. No BLAKE3
    /// step runs, and the result is byte-identical to what
    /// [`from_canonical_bytes`](Self::from_canonical_bytes) would have produced
    /// *had the caller hashed the real content correctly*.
    ///
    /// The `[u8; 32]` argument makes the 32-byte length a **compile-time**
    /// guarantee: it is impossible to call with a wrong-length digest, so no
    /// runtime length check, no fallible signature, and no new error variant
    /// are needed. Callers holding a `&[u8]` convert with `try_into()` and own
    /// that fallibility.
    ///
    /// # ⚠️ UNCHECKED ESCAPE HATCH — CALLER ASSERTS THE PRECONDITION ⚠️
    ///
    /// This constructor **does not hash and does not canonicalize**. It trusts
    /// the caller completely. By calling it you **assert** that `digest` is
    /// exactly `BLAKE3` over the value's **canonical dag-cbor** bytes — the same
    /// input domain [`from_canonical_bytes`](Self::from_canonical_bytes) would
    /// hash.
    ///
    /// If that assertion is false — the digest was computed over non-canonical
    /// bytes, over a different encoding, with a different hash, or is simply
    /// arbitrary — the result is a `ContentId` that **names content nothing
    /// actually hashed**. Such an id is silently wrong: there is no error, and
    /// verifying it against the real bytes returns `Ok(false)`, never an `Err`.
    ///
    /// **If you have the content bytes, use
    /// [`from_canonical_bytes`](Self::from_canonical_bytes) instead** — it
    /// hashes them for you and cannot be wrong this way. Do *not* reach for
    /// this constructor merely to avoid a hash; reach for it only when you
    /// genuinely hold a precomputed, canonical-dag-cbor BLAKE3 digest and the
    /// content itself is gone.
    ///
    /// # Motivation
    ///
    /// The kyln-lore `address.rs` bridge (`blake3_digest_to_content_id` /
    /// `signature_to_content_id`) does exactly this: a lore revision signature
    /// *is* a raw 32-byte BLAKE3 digest of the content, so it becomes a kyln
    /// `ContentId` by wrapping it — minus the `blake3::hash` step, because lore
    /// already hashed the content. That bridge is what makes a projected git
    /// commit's provenance note [`content_id`](crate::ContentAddressable::content_id) *equal* the originating lore
    /// revision signature. kyln's adoption (kyln #303) and any other
    /// BLAKE3-native source needs this primitive so a system that already
    /// hashed with BLAKE3 gets an *exact* `ContentId` with no re-hash.
    ///
    /// # Panics
    ///
    /// Does not panic. A 32-byte digest always fits the multihash's 64-byte
    /// capacity, so the `wrap` call cannot fail.
    ///
    /// # Deprecated — the codec was a guess
    ///
    /// This door stamps the `dag-cbor` (`0x71`) codec on a digest it **cannot
    /// know** came from canonical dag-cbor. Every real caller (kyln-lore
    /// signatures, bare `blake3::hash` outputs, `blake3:<hex>` digests) held a
    /// digest of *opaque bytes*, for which the honest identity is
    /// [`RawContentId::from_blake3_digest`](crate::RawContentId::from_blake3_digest)
    /// (CIDv1 · `raw` · BLAKE3) — byte-identical to those systems' existing raw
    /// CIDs. If you genuinely hold a digest of canonical dag-cbor and the value
    /// is gone, say so explicitly with
    /// [`from_dag_cbor_digest`](Self::from_dag_cbor_digest). See decision record
    /// `docs/adr/0003` (question 5).
    #[deprecated(
        since = "0.1.1",
        note = "use RawContentId::from_blake3_digest for a digest of opaque bytes (the honest \
                profile), or ContentId::from_dag_cbor_digest when the digest is known to be \
                over canonical dag-cbor"
    )]
    #[must_use]
    pub fn from_blake3_content_digest(digest: [u8; BLAKE3_DIGEST_LEN]) -> Self {
        Self::wrap_blake3_digest(digest)
    }

    /// Wrap a precomputed 32-byte BLAKE3 digest **of canonical dag-cbor bytes**
    /// as a `ContentId`, with no re-hash.
    ///
    /// The name asserts the precondition: the digest MUST be BLAKE3 over the
    /// canonical dag-cbor encoding of a value — the same bytes
    /// [`from_canonical_bytes`](Self::from_canonical_bytes) would have hashed.
    /// This function cannot check that; a digest of anything else mints an id
    /// whose `dag-cbor` codec is a lie (see the precondition discussion on
    /// [`from_canonical_bytes`](Self::from_canonical_bytes)). If your digest is
    /// over opaque bytes, you want
    /// [`RawContentId::from_blake3_digest`](crate::RawContentId::from_blake3_digest).
    ///
    /// Emits exactly the bytes [`from_canonical_bytes`](Self::from_canonical_bytes)
    /// emits for the same digest (one shared construction point), so
    /// `from_dag_cbor_digest(blake3(c)) == from_canonical_bytes(c)`.
    ///
    /// # Panics
    ///
    /// Does not panic: a 32-byte digest always fits the multihash's 64-byte
    /// capacity.
    #[must_use]
    pub fn from_dag_cbor_digest(digest: [u8; BLAKE3_DIGEST_LEN]) -> Self {
        Self::wrap_blake3_digest(digest)
    }

    /// Wrap a 32-byte BLAKE3 digest as a [`ContentId`]: the frozen tail shared
    /// by [`from_canonical_bytes`] (which hashes first) and
    /// [`from_blake3_content_digest`] (which does not). Keeping the
    /// `Multihash::wrap(0x1e, ..)` + `Cid::new_v1(0x71, ..)` construction in
    /// exactly one place guarantees both doors emit **byte-identical** CIDs for
    /// the same digest — the property the no-rehash bridge depends on.
    fn wrap_blake3_digest(digest: [u8; BLAKE3_DIGEST_LEN]) -> Self {
        let mh = Multihash::wrap(BLAKE3_HASH_CODE, &digest)
            .expect("BLAKE3 digest is 32 bytes and always fits a 64-byte multihash");
        ContentId(Cid::new_v1(DAG_CBOR_CODEC, mh))
    }

    /// Borrow the underlying IPLD [`Cid`].
    #[must_use]
    pub fn as_cid(&self) -> &Cid {
        &self.0
    }

    /// The raw 32-byte BLAKE3 content digest, copied out of the multihash.
    ///
    /// This is the **bare hash** — *not* the CID envelope. It is the sovereign
    /// hash an adopter joins on across systems (the same digest a BLAKE3-native
    /// upstream would hand to [`from_blake3_content_digest`](Self::from_blake3_content_digest)).
    ///
    /// The length is a **frozen, enforced invariant**: every `ContentId` carries
    /// exactly `BLAKE3_DIGEST_LEN` (32) digest bytes (see the
    /// [CID-parameters contract](ContentId#cid-parameters-frozen-at-010)), so
    /// this accessor is infallible and returns a fixed-size array. The copy
    /// (`try_into`) can never fail; the `expect` is unreachable for *any*
    /// `ContentId` — every ingress path ([`from_bytes`](Self::from_bytes), `FromStr`, `TryFrom<Cid>`,
    /// binary `Deserialize`) validates the profile, so an off-profile CID can never
    /// become a `ContentId` and reach this method.
    ///
    /// Part of the **frozen presentation contract** — see the
    /// [presentation contract](ContentId#presentation-contract-frozen-at-010).
    #[must_use]
    pub fn digest_bytes(&self) -> [u8; BLAKE3_DIGEST_LEN] {
        self.0
            .hash()
            .digest()
            .try_into()
            .expect("a ContentId multihash always carries a 32-byte BLAKE3 digest")
    }

    /// Lowercase hex of the raw 32-byte BLAKE3 digest: 64 hex chars, **no**
    /// `0x` or multibase prefix.
    ///
    /// This is the **"bare-digest-hex"** convention (the shortest, hash-only
    /// "hex" form an adopter reaches for, e.g. kyln's `to_hex()`). It is hex of
    /// [`digest_bytes`](Self::digest_bytes) — *not* hex of the full CID. The
    /// three "hex" forms in the wild are mutually incompatible; this crate names
    /// each so they cannot be confused:
    ///
    /// - **bare-digest-hex** = this method = `digest_hex()` (64 chars, hash
    ///   only).
    /// - **full-CID-bytes-hex** = `hex::encode(id.to_bytes())` — the whole CID
    ///   envelope (version + codec + multihash header + digest) as base16.
    ///   *Deliberately not provided as a method* (see the
    ///   [presentation contract](ContentId#presentation-contract-frozen-at-010));
    ///   a caller who truly needs it hex-encodes [`to_bytes`](Self::to_bytes)
    ///   and owns that choice.
    /// - **multibase base32-lower** = the [`Display`](fmt::Display) /
    ///   [`to_string`](ToString::to_string) string (`b…`), the IPLD-canonical
    ///   text form.
    ///
    /// Part of the **frozen presentation contract** — see the
    /// [presentation contract](ContentId#presentation-contract-frozen-at-010).
    #[must_use]
    pub fn digest_hex(&self) -> String {
        let digest = self.digest_bytes();
        let mut s = String::with_capacity(digest.len() * 2);
        for b in digest {
            // Hand-rolled 32->64 lowercase hex: avoids taking a `hex` crate
            // dependency for a byte-frozen surface (issue #6: "hand-roll
            // 32->64 hex to avoid the dep — implementer's call").
            s.push(char::from_digit((b >> 4) as u32, 16).expect("nibble < 16 is a hex digit"));
            s.push(char::from_digit((b & 0x0f) as u32, 16).expect("nibble < 16 is a hex digit"));
        }
        s
    }

    /// Encode this id as its canonical CID binary form.
    ///
    /// This is the full **CID envelope**: version + codec (`0x71`) + multihash
    /// header + digest — *not* the bare hash. It round-trips through
    /// [`from_bytes`](Self::from_bytes). For the hex of *this* form ("full-CID-
    /// bytes-hex"), a caller hex-encodes the result explicitly; the crate
    /// deliberately does not bless a `cid_hex()` method (see the
    /// [presentation contract](ContentId#presentation-contract-frozen-at-010)).
    ///
    /// Part of the **frozen presentation contract** — see the
    /// [presentation contract](ContentId#presentation-contract-frozen-at-010).
    #[must_use]
    pub fn to_bytes(&self) -> Vec<u8> {
        self.0.to_bytes()
    }

    /// Parse an id from its canonical CID binary form.
    ///
    /// # Errors
    ///
    /// Returns [`ContentError::InvalidCid`] if the bytes are not a valid CID.
    pub fn from_bytes(bytes: &[u8]) -> Result<Self, ContentError> {
        let cid = Cid::read_bytes(bytes).map_err(|e| ContentError::InvalidCid {
            reason: e.to_string(),
            source: Box::new(e),
        })?;
        validate_profile(&cid)?;
        Ok(ContentId(cid))
    }
}

/// Reject any [`Cid`] that is not this crate's frozen profile: CIDv1 + dag-cbor
/// (`0x71`) + BLAKE3 (`0x1e`) + a 32-byte digest.
///
/// **Every** ingress path — [`from_bytes`](ContentId::from_bytes), the
/// [`FromStr`] impl, [`TryFrom<Cid>`], and the binary/IPLD [`Deserialize`] —
/// routes through this, so a parsed / deserialized / converted `ContentId` carries
/// the same invariant a *minted* one does. That is what makes the presentation
/// accessors ([`digest_bytes`](ContentId::digest_bytes) etc.) total: a foreign CID
/// (a CIDv0, a non-dag-cbor codec, a SHA-256 hash, a non-32-byte digest) can never
/// become a `ContentId`, so it can never reach an accessor and panic.
fn validate_profile(cid: &Cid) -> Result<(), ContentError> {
    if cid.version() != Version::V1 {
        return Err(ContentError::InvalidCidProfile {
            reason: format!("expected CIDv1, got {:?}", cid.version()),
        });
    }
    if cid.codec() != DAG_CBOR_CODEC {
        return Err(ContentError::InvalidCidProfile {
            reason: format!(
                "expected dag-cbor codec 0x{DAG_CBOR_CODEC:x}, got 0x{:x}",
                cid.codec()
            ),
        });
    }
    if cid.hash().code() != BLAKE3_HASH_CODE {
        return Err(ContentError::InvalidCidProfile {
            reason: format!(
                "expected BLAKE3 multihash 0x{BLAKE3_HASH_CODE:x}, got 0x{:x}",
                cid.hash().code()
            ),
        });
    }
    let digest_len = cid.hash().digest().len();
    if digest_len != BLAKE3_DIGEST_LEN {
        return Err(ContentError::InvalidCidProfile {
            reason: format!("expected a {BLAKE3_DIGEST_LEN}-byte digest, got {digest_len} bytes"),
        });
    }
    Ok(())
}

impl TryFrom<Cid> for ContentId {
    type Error = ContentError;

    /// Wrap a [`Cid`] **iff** it is this crate's frozen profile; a foreign CID is
    /// rejected with [`ContentError::InvalidCidProfile`].
    ///
    /// This is `TryFrom`, not `From`, on purpose: an infallible `From<Cid>` would
    /// let an off-profile CID become a `ContentId` and later panic in a
    /// presentation accessor. (The outward [`From<ContentId>`] for [`Cid`] stays
    /// infallible — dropping the newtype is always safe.)
    fn try_from(cid: Cid) -> Result<Self, Self::Error> {
        validate_profile(&cid)?;
        Ok(ContentId(cid))
    }
}

impl From<ContentId> for Cid {
    fn from(id: ContentId) -> Self {
        id.0
    }
}

impl fmt::Display for ContentId {
    /// Render as multibase **base32-lower** (the `b…` CIDv1 string).
    ///
    /// This is the crate's **canonical text form** and is **frozen** for the
    /// `0.1.x` line — see the
    /// [presentation contract](ContentId#presentation-contract-frozen-at-010).
    /// It is the IPLD-canonical CID string and the same text the human-readable
    /// serde form emits. It is the inverse of [`FromStr`] for base32-lower:
    /// `id.to_string().parse() == Ok(id)` is a tested, frozen round-trip. A unit
    /// test and the conformance vectors pin the exact string for fixed inputs,
    /// so any drift in the inner CID's rendering fails loudly. Changing this
    /// form is a major version bump.
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        fmt::Display::fmt(&self.0, f)
    }
}

impl FromStr for ContentId {
    type Err = ContentError;

    /// Parse an id from a multibase CID string.
    ///
    /// The frozen contract is narrow: `FromStr` is the **inverse of
    /// [`Display`](fmt::Display)** for the base32-lower form, and that round-trip
    /// (`id.to_string().parse() == Ok(id)`) is the part pinned by the
    /// [presentation contract](ContentId#presentation-contract-frozen-at-010).
    /// As a **convenience (not a contract)** this also accepts other valid
    /// multibases (base58, base16, …), since the inner [`Cid::from_str`] does;
    /// that tolerance is *not* frozen and may be tightened later without
    /// breaking the frozen base32-lower round-trip — so do not depend on parsing
    /// non-base32 CID strings.
    fn from_str(s: &str) -> Result<Self, Self::Err> {
        let cid = Cid::from_str(s).map_err(|e| ContentError::InvalidCid {
            reason: e.to_string(),
            source: Box::new(e),
        })?;
        validate_profile(&cid)?;
        Ok(ContentId(cid))
    }
}

impl Serialize for ContentId {
    /// Serialize per the frozen serde contract: the multibase base32 string in
    /// human-readable formats (e.g. `serde_json`, TOML), the inner [`Cid`] (a
    /// dag-cbor tag-42 link) in binary/IPLD formats.
    ///
    /// This representation is **frozen** for the `0.1.x` line; see the
    /// [type-level serde contract](ContentId#serde-representation-frozen-at-010).
    /// Changing it is a major version bump.
    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
        if serializer.is_human_readable() {
            serializer.serialize_str(&self.to_string())
        } else {
            self.0.serialize(serializer)
        }
    }
}

impl<'de> Deserialize<'de> for ContentId {
    /// Deserialize per the frozen serde contract: a multibase base32 string from
    /// human-readable formats, the inner [`Cid`] (a dag-cbor tag-42 link) from
    /// binary/IPLD formats.
    ///
    /// The accepted representation is **frozen** for the `0.1.x` line; see the
    /// [type-level serde contract](ContentId#serde-representation-frozen-at-010).
    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
        if deserializer.is_human_readable() {
            let s = String::deserialize(deserializer)?;
            s.parse::<ContentId>()
                .map_err(<D::Error as serde::de::Error>::custom)
        } else {
            // Binary/IPLD: a decoded tag-42 link is an arbitrary CID until proven
            // on-profile — reject a foreign link here, not later in an accessor.
            let cid = Cid::deserialize(deserializer)?;
            validate_profile(&cid).map_err(<D::Error as serde::de::Error>::custom)?;
            Ok(ContentId(cid))
        }
    }
}

#[cfg(test)]
mod cid_param_lock_tests {
    //! Locks the frozen v1 CID parameters (issue #4) so they cannot drift
    //! silently. These assertions live in the [`content_id`](crate::ContentAddressable::content_id) module itself, next
    //! to the definitions, so the freeze is self-evident at the source. They use
    //! **literal** values (`0x71`, `0x1e`, `V1`, `32`), not the crate constants,
    //! so an accidental edit to a `const` is caught by a failing test rather
    //! than silently followed.

    use super::{ContentId, BLAKE3_DIGEST_LEN, BLAKE3_HASH_CODE, DAG_CBOR_CODEC};
    use ipld_core::cid::Version;

    /// A freshly produced id, from the empty-map canonical dag-cbor (`0xa0`).
    /// This is the documented fixed input also used by the embedded-link golden
    /// and the `empty_map` conformance vector, so the three pins agree.
    fn empty_map_id() -> ContentId {
        ContentId::from_canonical_bytes(&[0xa0])
    }

    #[test]
    fn cid_params_are_literally_v1_dagcbor_blake3_32() {
        // Assert against LITERALS, not the constants: editing a `const` must
        // break this test, not be silently followed.
        let id = empty_map_id();
        let cid = id.as_cid();
        assert_eq!(cid.version(), Version::V1, "CID version is frozen at V1");
        assert_eq!(cid.codec(), 0x71, "codec is frozen at dag-cbor (0x71)");
        assert_eq!(
            cid.hash().code(),
            0x1e,
            "multihash is frozen at BLAKE3 (0x1e)"
        );
        assert_eq!(
            cid.hash().digest().len(),
            32,
            "BLAKE3 digest length is frozen at 32 bytes"
        );
    }

    #[test]
    fn frozen_constants_hold_their_documented_values() {
        // The public/private constants must equal the frozen literals, so the
        // doc and the re-exported surface cannot drift from the contract.
        assert_eq!(DAG_CBOR_CODEC, 0x71);
        assert_eq!(BLAKE3_HASH_CODE, 0x1e);
        assert_eq!(BLAKE3_DIGEST_LEN, 32);
    }

    #[test]
    fn cid_binary_prefix_is_frozen() {
        // The leading bytes of the CIDv1 binary form encode the frozen shape:
        //   0x01 = CIDv1
        //   0x71 = multicodec dag-cbor
        //   0x1e = multihash code BLAKE3
        //   0x20 = digest length 32 (varint; 0x20 == 32)
        // Pinning the prefix means the on-wire identifier head cannot drift even
        // if the digest payload changes.
        let bytes = empty_map_id().to_bytes();
        assert_eq!(
            &bytes[..4],
            &[0x01, 0x71, 0x1e, 0x20],
            "CIDv1/dag-cbor/BLAKE3/len-32 binary prefix is frozen"
        );
        assert_eq!(
            bytes.len(),
            4 + BLAKE3_DIGEST_LEN,
            "CID binary form is the 4-byte prefix plus the 32-byte digest"
        );
    }
}

#[cfg(test)]
#[allow(deprecated)] // pins the deprecated door's behavior until it is removed
mod no_rehash_digest_tests {
    //! Tests for [`ContentId::from_blake3_content_digest`] (issue #10) and its
    //! explicit successor [`ContentId::from_dag_cbor_digest`] (#84): the
    //! guarded, no-rehash escape hatch that wraps an already-computed BLAKE3
    //! content digest as a `ContentId` **without hashing it again**. These pin
    //! both the produced CID shape and — critically — the *no-rehash invariant*
    //! that distinguishes this door from [`from_canonical_bytes`](Self::from_canonical_bytes) (which hashes).

    use super::{ContentId, BLAKE3_DIGEST_LEN};

    /// #84: [`from_dag_cbor_digest`](Self::from_dag_cbor_digest) is the explicitly-named successor and emits
    /// exactly the bytes the deprecated door emits (one shared construction
    /// point), so migrating callers changes no ids.
    #[test]
    fn from_dag_cbor_digest_is_byte_identical_to_deprecated_door() {
        let d = *blake3::hash(b"any canonical dag-cbor").as_bytes();
        assert_eq!(
            ContentId::from_dag_cbor_digest(d),
            ContentId::from_blake3_content_digest(d)
        );
        assert_eq!(
            ContentId::from_dag_cbor_digest(d),
            ContentId::from_canonical_bytes(b"any canonical dag-cbor")
        );
    }
    use ipld_core::cid::Version;

    /// A non-trivial, fixed 32-byte digest (`[1, 2, 3, ..., 32]`). Deliberately
    /// not all-zero so that the no-rehash invariant below is a meaningful test.
    fn sample_digest() -> [u8; BLAKE3_DIGEST_LEN] {
        let mut d = [0u8; BLAKE3_DIGEST_LEN];
        for (i, b) in d.iter_mut().enumerate() {
            *b = (i as u8) + 1;
        }
        d
    }

    #[test]
    fn produces_v1_dagcbor_blake3_cid_carrying_the_exact_digest() {
        // Assert against LITERALS, matching the frozen-parameter lock tests.
        let d = sample_digest();
        let id = ContentId::from_blake3_content_digest(d);
        let cid = id.as_cid();
        assert_eq!(cid.version(), Version::V1, "must be a CIDv1");
        assert_eq!(cid.codec(), 0x71, "codec must be dag-cbor (0x71)");
        assert_eq!(cid.hash().code(), 0x1e, "multihash must be BLAKE3 (0x1e)");
        assert_eq!(
            cid.hash().digest().len(),
            BLAKE3_DIGEST_LEN,
            "digest must be 32 bytes"
        );
        // The wrapped digest bytes must be EXACTLY the input — the whole point
        // of a no-rehash door is that the digest passes through untouched.
        assert_eq!(
            cid.hash().digest(),
            &d,
            "the CID must carry the caller's digest verbatim"
        );
    }

    #[test]
    fn does_not_rehash_the_digest() {
        // THE CRITICAL INVARIANT (must-fix gate): for a non-trivial digest `d`,
        // wrapping it must NOT equal hashing it. `from_canonical_bytes(&d)`
        // computes BLAKE3(d) — a second hash — whereas
        // `from_blake3_content_digest(d)` wraps `d` as-is. If these were ever
        // equal, the constructor would secretly be re-hashing.
        let d = sample_digest();
        let wrapped = ContentId::from_blake3_content_digest(d);
        let hashed_again = ContentId::from_canonical_bytes(&d);
        assert_ne!(
            wrapped, hashed_again,
            "from_blake3_content_digest must NOT re-hash: wrapping d must differ \
             from BLAKE3(d)"
        );
        // Tighter: the wrapped id's digest is `d` itself, while the re-hashed
        // id's digest is BLAKE3(d) — explicitly different bytes.
        assert_eq!(wrapped.as_cid().hash().digest(), &d);
        assert_eq!(
            hashed_again.as_cid().hash().digest(),
            blake3::hash(&d).as_bytes(),
        );
    }

    #[test]
    fn digest_survives_a_byte_roundtrip() {
        // Building from a digest, serializing to CID bytes, and reading the
        // digest back must recover the original `d` exactly.
        let d = sample_digest();
        let id = ContentId::from_blake3_content_digest(d);

        // Via the live CID handle.
        assert_eq!(id.as_cid().hash().digest(), &d);

        // Via the full CID binary form: tail 32 bytes are the digest, and a
        // from_bytes parse lands on the same id.
        let bytes = id.to_bytes();
        assert_eq!(
            &bytes[bytes.len() - BLAKE3_DIGEST_LEN..],
            &d,
            "the trailing 32 bytes of the CID binary form are the digest"
        );
        let reparsed = ContentId::from_bytes(&bytes).expect("CID bytes must reparse");
        assert_eq!(reparsed, id);
        assert_eq!(reparsed.as_cid().hash().digest(), &d);
    }

    #[test]
    fn converges_with_from_canonical_bytes_when_caller_hashes_correctly() {
        // The two doors meet in the middle: if a caller hashes the real content
        // with BLAKE3 and feeds the digest here, they get the SAME id as
        // hashing the content via from_canonical_bytes. This is the bridge
        // property the no-rehash primitive exists to provide.
        for content in [
            &b""[..],
            &[0xa0][..], // empty-map canonical dag-cbor
            b"the quick brown fox",
            b"\x00\x01\x02\x03 arbitrary canonical bytes",
        ] {
            let digest: [u8; BLAKE3_DIGEST_LEN] = blake3::hash(content).into();
            let via_digest = ContentId::from_blake3_content_digest(digest);
            let via_hash = ContentId::from_canonical_bytes(content);
            assert_eq!(
                via_digest, via_hash,
                "from_blake3_content_digest(blake3(x)) must equal \
                 from_canonical_bytes(x)"
            );
        }
    }
}

#[cfg(test)]
mod presentation_tests {
    //! Tests for the **frozen presentation contract** (issue #6): the named,
    //! separately-frozen string/byte forms a `ContentId` emits. These pin the
    //! exact values for fixed inputs so any drift in the inner CID's rendering,
    //! the multihash digest, or the hex encoder fails loudly. The cross-language
    //! `tests/vectors.json` gate additionally pins [`digest_hex`](Self::digest_hex) (and the base32
    //! string + CID bytes) so Python parity is enforced too; these in-crate
    //! tests assert the *accessor semantics* that the vectors cannot (the array
    //! return, the no-prefix rule, the Display/FromStr round-trip).

    use super::{ContentId, BLAKE3_DIGEST_LEN};

    /// The documented fixed input: the empty-map canonical dag-cbor (`0xa0`).
    /// This is the same id as the `empty_map` conformance vector and the
    /// embedded-link golden, so all the pins agree.
    fn empty_map_id() -> ContentId {
        ContentId::from_canonical_bytes(&[0xa0])
    }

    /// The frozen `empty_map` digest (the BLAKE3 of `0xa0`), as bare-digest-hex.
    /// Equals the tail of the `empty_map` vector's `content_id_bytes_hex`
    /// (`01711e20` CID prefix, then this 64-char digest).
    const EMPTY_MAP_DIGEST_HEX: &str =
        "1f94cbf313b3ce23257a7251ea0fc95a24556ea611e4f8f475e549971baedb02";

    #[test]
    fn display_is_frozen_base32_lower() {
        // The canonical text form is multibase base32-lower (`b…`), frozen.
        let id = empty_map_id();
        let s = id.to_string();
        assert_eq!(
            s, "bafyr4ia7stf7ge5tzyrsk6tskhva7sk2erkw5jqr4t4pi5pfjglrxlw3ai",
            "Display must be the frozen base32-lower CID string"
        );
        assert!(s.starts_with('b'), "base32-lower multibase prefix is 'b'");
    }

    #[test]
    fn display_fromstr_roundtrip_is_frozen() {
        // The frozen guarantee: Display -> FromStr lands back on the same id.
        let id = empty_map_id();
        let parsed: ContentId = id.to_string().parse().expect("base32 string must reparse");
        assert_eq!(parsed, id, "Display -> FromStr must round-trip");
    }

    #[test]
    fn digest_bytes_is_the_raw_32_byte_blake3_hash() {
        let id = empty_map_id();
        let d = id.digest_bytes();
        // It is a fixed-size 32-byte array...
        assert_eq!(d.len(), BLAKE3_DIGEST_LEN);
        // ...equal to the multihash's digest (the bare hash, no envelope)...
        assert_eq!(&d[..], id.as_cid().hash().digest());
        // ...and equal to an independent BLAKE3 of the input bytes.
        assert_eq!(d, *blake3::hash(&[0xa0]).as_bytes());
    }

    #[test]
    fn digest_hex_is_frozen_64_char_lower_hex_no_prefix() {
        let id = empty_map_id();
        let h = id.digest_hex();
        // Exactly 64 lowercase hex chars, no `0x` prefix. (A hex digest may
        // legitimately begin with the digit `b`; that is NOT the base32
        // multibase prefix, which belongs only to the Display string.)
        assert_eq!(h.len(), 64, "digest_hex is 64 chars (32 bytes)");
        assert!(!h.starts_with("0x"), "digest_hex carries no 0x prefix");
        assert!(
            h.chars()
                .all(|c| c.is_ascii_digit() || ('a'..='f').contains(&c)),
            "digest_hex is lowercase hex only"
        );
        // The exact frozen value for the empty map.
        assert_eq!(
            h, EMPTY_MAP_DIGEST_HEX,
            "frozen empty-map digest_hex drifted"
        );
    }

    #[test]
    fn digest_hex_is_hex_of_digest_bytes_not_of_the_cid() {
        // digest_hex names convention #1 (bare-digest-hex): it is hex of the
        // bare digest, and is STRICTLY SHORTER than full-CID-bytes-hex
        // (`hex::encode(to_bytes())`), which carries the 4-byte CID prefix too.
        let id = empty_map_id();
        let digest = id.digest_bytes();
        let mut expected = String::new();
        for b in digest {
            expected.push_str(&format!("{b:02x}"));
        }
        assert_eq!(id.digest_hex(), expected);

        // The full-CID-bytes-hex (the deliberately-unmethodd convention #2) is
        // longer and ENDS WITH the digest hex, after the CID envelope prefix.
        let cid_bytes_hex: String = id.to_bytes().iter().map(|b| format!("{b:02x}")).collect();
        assert!(
            cid_bytes_hex.ends_with(&id.digest_hex()),
            "the full-CID-bytes-hex ends with the bare-digest-hex"
        );
        assert!(
            cid_bytes_hex.len() > id.digest_hex().len(),
            "full-CID-bytes-hex is longer than bare-digest-hex (it has the envelope)"
        );
        // Concretely: the 4-byte CIDv1/dag-cbor/BLAKE3/len-32 prefix, then digest.
        assert_eq!(cid_bytes_hex, format!("01711e20{}", id.digest_hex()));
    }
}

#[cfg(test)]
mod checked_input_tests {
    //! Tests for [`ContentId::from_canonical_bytes_checked`] (issue #5): the
    //! opt-in, round-trip-validating sibling of the fast [`from_canonical_bytes`](Self::from_canonical_bytes)
    //! primitive. They pin the four behaviors the issue's AC names: (a) canonical
    //! bytes accepted and yielding the *same* id as the unchecked door, (b)
    //! non-canonical-but-valid CBOR rejected, (c) non-dag-cbor garbage rejected,
    //! and (d) a regression test documenting that the *unchecked* primitive still
    //! happily mints an id for non-canonical bytes (the precondition is real and
    //! unenforced by design).

    use super::ContentId;
    use crate::canonical::to_canonical_dagcbor;
    use crate::error::ContentError;

    #[test]
    fn checked_accepts_canonical_bytes_and_matches_the_unchecked_door() {
        // The always-canonical path: canonicalize a value, then both doors must
        // agree on the id (the check changes fallibility, never the bytes).
        for value in [
            &serde_json::json!({}),
            &serde_json::json!({"alpha": 1, "zeta": 26}),
            &serde_json::json!({"name": "hello", "n": [1, 2, 3], "nested": {"a": 1}}),
            &serde_json::json!([1, "two", [3, 4], null]),
        ] {
            // Drive canonical bytes through Ipld so the encoding is the codec's
            // canonical form, then assert the two doors converge.
            let ipld: ipld_core::ipld::Ipld =
                serde_json::from_value((*value).clone()).expect("json -> ipld");
            let canonical = to_canonical_dagcbor(&ipld).expect("encode canonical");

            let via_checked =
                ContentId::from_canonical_bytes_checked(&canonical).expect("canonical accepted");
            let via_unchecked = ContentId::from_canonical_bytes(&canonical);
            assert_eq!(
                via_checked, via_unchecked,
                "checked(canonical) must equal the unchecked id for the same bytes"
            );
        }
    }

    #[test]
    fn always_canonical_path_equals_from_canonical_bytes_of_to_canonical_dagcbor() {
        // The exact identity the issue calls out: the always-canonical path
        // (to_canonical_dagcbor then mint) and the checked door produce the same
        // id, and that id is from_canonical_bytes(to_canonical_dagcbor(x)).
        let ipld: ipld_core::ipld::Ipld = serde_json::from_value(serde_json::json!(
            {"alpha": 1, "zeta": 26, "list": [true, false, null]}
        ))
        .expect("json -> ipld");

        let canonical = to_canonical_dagcbor(&ipld).expect("encode canonical");
        let from_unchecked = ContentId::from_canonical_bytes(&canonical);
        let from_checked =
            ContentId::from_canonical_bytes_checked(&canonical).expect("canonical accepted");

        assert_eq!(
            from_checked, from_unchecked,
            "from_canonical_bytes_checked(to_canonical_dagcbor(x)) == \
             from_canonical_bytes(to_canonical_dagcbor(x))"
        );
    }

    #[test]
    fn checked_rejects_non_canonical_but_valid_cbor() {
        // A map with two string keys encoded in NON-canonical (descending) key
        // order. dag-cbor canonical order is by length-then-bytewise, so {"a",
        // "bb"} canonical is a2 [a] .. [bb]; we hand-build the reverse order.
        // Hand-built CBOR:
        //   a2                      map(2)
        //   62 6262                 text(2) "bb"   <- emitted FIRST (non-canonical)
        //   01                      1
        //   61 61                   text(1) "a"
        //   02                      2
        let non_canonical = [0xa2, 0x62, 0x62, 0x62, 0x01, 0x61, 0x61, 0x02];

        // Sanity: it IS valid CBOR (decodes fine), so this exercises the
        // re-encode-compare path, not the decode path. The UNVERIFIED door is the
        // point of the probe — it is what shows the refusal below belongs to the
        // check and not to the codec — so its deprecation (issue #90) is allowed
        // here rather than routed around.
        #[allow(deprecated)]
        let decoded: ipld_core::ipld::Ipld =
            crate::canonical::from_canonical_dagcbor(&non_canonical)
                .expect("non-canonical bytes are still valid CBOR and decode");
        // And its canonical re-encoding genuinely differs (proving our fixture is
        // really non-canonical, not an accidental canonical form).
        let recanon = to_canonical_dagcbor(&decoded).expect("re-encode");
        assert_ne!(
            &recanon[..],
            &non_canonical[..],
            "fixture must actually be non-canonical"
        );

        let err = ContentId::from_canonical_bytes_checked(&non_canonical)
            .expect_err("non-canonical CBOR must be rejected");
        assert!(
            matches!(err, ContentError::NonCanonical),
            "non-canonical valid CBOR must map to ContentError::NonCanonical, got {err:?}"
        );
    }

    #[test]
    fn checked_rejects_indefinite_length_cbor() {
        // An indefinite-length CBOR array (0x9f .. 0xff) is valid CBOR but
        // forbidden in canonical dag-cbor (definite-length only). dag-cbor's
        // decoder may already refuse it; either way the checked door must error
        // (decode failure OR non-canonical), never silently mint an id.
        //   9f        array(*)  (indefinite)
        //   01 02 03  1, 2, 3
        //   ff        break
        let indefinite = [0x9f, 0x01, 0x02, 0x03, 0xff];
        let err = ContentId::from_canonical_bytes_checked(&indefinite)
            .expect_err("indefinite-length CBOR must be rejected");
        assert!(
            matches!(
                err,
                ContentError::NonCanonical | ContentError::DecodingError { .. }
            ),
            "indefinite-length CBOR must be rejected (NonCanonical or DecodingError), got {err:?}"
        );
    }

    #[test]
    fn checked_rejects_non_dagcbor_garbage() {
        // Arbitrary non-CBOR bytes must fail at the decode step.
        let garbage = [0xff, 0xff, 0xff, 0xff];
        let err = ContentId::from_canonical_bytes_checked(&garbage)
            .expect_err("non-dag-cbor garbage must be rejected");
        assert!(
            matches!(err, ContentError::DecodingError { .. }),
            "non-dag-cbor garbage must map to ContentError::DecodingError, got {err:?}"
        );
    }

    #[test]
    fn unchecked_still_mints_an_id_for_non_canonical_bytes() {
        // REGRESSION / documentation test (issue #5 AC item d): the FAST,
        // unchecked primitive does NOT validate — it mints a (misleading) id even
        // for non-canonical bytes. This pins that the precondition is real and
        // unenforced by design: the unchecked door succeeds where the checked one
        // refuses, and the two ids necessarily differ (the unchecked id hashes
        // the non-canonical bytes; the canonical bytes hash to something else).
        let non_canonical = [0xa2, 0x62, 0x62, 0x62, 0x01, 0x61, 0x61, 0x02];

        // Unchecked: succeeds, no error, no panic — mints over the raw bytes.
        let misleading = ContentId::from_canonical_bytes(&non_canonical);
        assert_eq!(
            misleading.digest_bytes(),
            *blake3::hash(&non_canonical).as_bytes(),
            "the unchecked door hashes exactly the bytes it was given"
        );

        // Checked: refuses.
        assert!(ContentId::from_canonical_bytes_checked(&non_canonical).is_err());

        // The misleading id differs from the id of the *canonical* form of the
        // same value — the silent integrity hole the precondition warns about.
        // Same probe, same reason: the unverified door is the subject here.
        #[allow(deprecated)]
        let decoded: ipld_core::ipld::Ipld =
            crate::canonical::from_canonical_dagcbor(&non_canonical).expect("valid CBOR");
        let canonical = to_canonical_dagcbor(&decoded).expect("re-encode");
        let honest = ContentId::from_canonical_bytes(&canonical);
        assert_ne!(
            misleading, honest,
            "minting over non-canonical bytes names a different id than the \
             canonical form would — exactly the hazard from_canonical_bytes warns of"
        );
    }
}

#[cfg(test)]
mod profile_validation_tests {
    //! Every ingress path ([`from_bytes`](Self::from_bytes), `FromStr`, `TryFrom<Cid>`, binary
    //! `Deserialize`) admits ONLY the frozen profile — CIDv1 + dag-cbor (`0x71`) +
    //! BLAKE3 (`0x1e`) + 32-byte digest. A foreign CID is rejected with
    //! [`ContentError::InvalidCidProfile`] rather than smuggled in to panic later in
    //! a presentation accessor. (Closes the ContentId profile-validation hole.)
    use super::*;
    use ipld_core::cid::multihash::Multihash;
    use ipld_core::cid::Cid;

    const SHA2_256: u64 = 0x12;
    const RAW_CODEC: u64 = 0x55;

    fn blake3_mh(digest: &[u8]) -> Multihash<64> {
        Multihash::wrap(BLAKE3_HASH_CODE, digest).expect("wrap blake3")
    }

    // Well-formed CIDs that are NOT this crate's profile, one per parameter.
    fn cidv0_sha256() -> Cid {
        Cid::new_v0(Multihash::wrap(SHA2_256, &[7u8; 32]).expect("wrap sha256")).expect("v0")
    }
    fn v1_non_dagcbor_codec() -> Cid {
        Cid::new_v1(RAW_CODEC, blake3_mh(&[7u8; 32]))
    }
    fn v1_sha256_hash() -> Cid {
        Cid::new_v1(
            DAG_CBOR_CODEC,
            Multihash::wrap(SHA2_256, &[7u8; 32]).expect("wrap sha256"),
        )
    }
    fn v1_short_digest() -> Cid {
        Cid::new_v1(DAG_CBOR_CODEC, blake3_mh(&[7u8; 16]))
    }

    /// A foreign CID is rejected identically by TryFrom, from_bytes, and FromStr.
    fn assert_all_ingress_reject(cid: Cid) {
        assert!(
            matches!(
                ContentId::try_from(cid),
                Err(ContentError::InvalidCidProfile { .. })
            ),
            "TryFrom<Cid> must reject a foreign CID"
        );
        assert!(
            matches!(
                ContentId::from_bytes(&cid.to_bytes()),
                Err(ContentError::InvalidCidProfile { .. })
            ),
            "from_bytes must reject a foreign CID envelope"
        );
        assert!(
            matches!(
                cid.to_string().parse::<ContentId>(),
                Err(ContentError::InvalidCidProfile { .. })
            ),
            "FromStr must reject a foreign CID string"
        );
    }

    #[test]
    fn cidv0_sha256_is_rejected() {
        assert_all_ingress_reject(cidv0_sha256());
    }

    #[test]
    fn v1_non_dagcbor_codec_is_rejected() {
        assert_all_ingress_reject(v1_non_dagcbor_codec());
    }

    #[test]
    fn v1_sha256_hash_is_rejected() {
        assert_all_ingress_reject(v1_sha256_hash());
    }

    #[test]
    fn v1_short_digest_is_rejected() {
        assert_all_ingress_reject(v1_short_digest());
    }

    #[test]
    fn a_foreign_cid_link_in_dagcbor_is_rejected_on_deserialize() {
        // A tag-42 link to a foreign (sha256) CID, encoded as dag-cbor, must NOT
        // deserialize as a ContentId through the binary/IPLD path.
        let foreign = v1_sha256_hash();
        let bytes = serde_ipld_dagcbor::to_vec(&foreign).expect("encode a tag-42 link");
        let decoded: Result<ContentId, _> = serde_ipld_dagcbor::from_slice(&bytes);
        assert!(
            decoded.is_err(),
            "a foreign CID link must not deserialize as a ContentId"
        );
    }

    #[test]
    fn an_on_profile_id_passes_every_ingress_path() {
        // A genuine, minted id round-trips through all validated ingress paths.
        let id = ContentId::from_canonical_bytes(&[0xa0]); // canonical empty map
        assert_eq!(ContentId::try_from(*id.as_cid()).expect("try_from"), id);
        assert_eq!(
            ContentId::from_bytes(&id.to_bytes()).expect("from_bytes"),
            id
        );
        assert_eq!(id.to_string().parse::<ContentId>().expect("parse"), id);
        // And the presentation accessor is now provably total.
        assert_eq!(id.digest_bytes().len(), BLAKE3_DIGEST_LEN);
    }
}