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
"""Tests for the `content_addressable` Python extension.

These tests exercise the PyO3 face of the `content-addressable` crate. The
contract they pin down:

- Content addressing is **deterministic**: the same value always names itself
  the same way, and different values name themselves differently.
- A `ContentId` round-trips through both its byte form and its string form.
- Canonicalization is a property of the *codec*, not caller field order: two
  dicts built in different insertion orders encode to identical bytes.
- Python and the Rust core share one canonicalizer + one hasher, so the
  `ContentId` of a value equals the id of its own canonical bytes.
- dag-cbor encode/decode is a true round-trip for representable values.
"""

import hashlib
import json
import pathlib

import pytest

import content_addressable as ca
from content_addressable import (
    ContentId,
    RawContentId,
    content_id,
    from_canonical_dagcbor,
    from_canonical_dagcbor_checked,
    to_canonical_dagcbor,
)


def _blake3_digest(data: bytes):
    """Return a 32-byte BLAKE3 digest using whatever is available, or None.

    Prefers stdlib `hashlib.blake3` (CPython 3.13+); falls back to the `blake3`
    PyPI package; returns None if neither is present (then the cross-language
    wire-shape test is skipped rather than forcing a test-only dependency).
    """
    try:
        return hashlib.blake3(data).digest()  # type: ignore[attr-defined]
    except (AttributeError, ValueError):
        pass
    try:
        import blake3 as _blake3

        return _blake3.blake3(data).digest()
    except ImportError:
        return None


# --------------------------------------------------------------------------- #
# Determinism of content addressing
# --------------------------------------------------------------------------- #


def test_content_id_is_deterministic():
    """Same value -> same ContentId string, every time."""
    value = {"name": "alpha", "attrs": {"zeta": 26, "alpha": 1}}
    id_a = content_id(value)
    id_b = content_id(value)
    assert str(id_a) == str(id_b)
    assert id_a == id_b
    assert hash(id_a) == hash(id_b)


def test_content_id_differs_for_different_values():
    """Distinct values -> distinct ContentIds."""
    a = content_id({"name": "hello"})
    b = content_id({"name": "world"})
    assert a != b
    assert str(a) != str(b)


def test_content_id_usable_as_dict_key():
    """__hash__/__eq__ agree, so a ContentId works as a set/dict key."""
    a1 = content_id({"k": 1})
    a2 = content_id({"k": 1})
    b = content_id({"k": 2})
    s = {a1, a2, b}
    assert len(s) == 2  # a1 and a2 collapse to one entry


# --------------------------------------------------------------------------- #
# ContentId round-trips
# --------------------------------------------------------------------------- #


def test_content_id_bytes_roundtrip():
    """to_bytes / from_bytes is a lossless round-trip."""
    original = content_id({"x": [1, 2, 3]})
    raw = original.to_bytes()
    assert isinstance(raw, bytes)
    restored = ContentId.from_bytes(raw)
    assert restored == original
    assert str(restored) == str(original)


def test_content_id_string_roundtrip():
    """str / parse is a lossless round-trip."""
    original = content_id({"x": [1, 2, 3]})
    text = str(original)
    restored = ContentId.parse(text)
    assert restored == original
    assert restored.to_bytes() == original.to_bytes()


def test_content_id_repr():
    cid = content_id({"a": 1})
    r = repr(cid)
    assert r.startswith("ContentId('")
    assert str(cid) in r


def test_content_id_v1_base32_string_shape():
    """CIDv1 default multibase string is base32-lower, prefixed 'b'."""
    cid = content_id({"a": 1})
    s = str(cid)
    assert s.startswith("b")  # base32-lower multibase prefix


# --------------------------------------------------------------------------- #
# Frozen presentation accessors (issue #6): digest_bytes / digest_hex
# --------------------------------------------------------------------------- #


def test_digest_bytes_is_raw_32_byte_blake3_hash():
    """digest_bytes() is the bare 32-byte BLAKE3 digest — no CID envelope."""
    cid = content_id({"a": 1})
    d = cid.digest_bytes()
    assert isinstance(d, bytes)
    assert len(d) == 32
    # It is the tail of to_bytes() after the 4-byte CID envelope prefix
    # (0x01 v1, 0x71 dag-cbor, 0x1e BLAKE3, 0x20 len-32).
    assert cid.to_bytes() == bytes([0x01, 0x71, 0x1E, 0x20]) + d
    assert cid.to_bytes()[-32:] == d


def test_digest_hex_is_64_char_lower_hex_no_prefix():
    """digest_hex() is the 'bare-digest-hex' form: 64 lower-hex chars, no prefix."""
    cid = content_id({"a": 1})
    h = cid.digest_hex()
    assert isinstance(h, str)
    assert len(h) == 64
    assert not h.startswith("0x")
    assert h == h.lower()
    assert all(c in "0123456789abcdef" for c in h)
    # It is exactly hex of digest_bytes() — NOT hex of the full CID.
    assert h == cid.digest_bytes().hex()
    # And it is strictly shorter than full-CID-bytes-hex, which it is the tail of.
    cid_hex = cid.to_bytes().hex()
    assert cid_hex.endswith(h)
    assert len(cid_hex) > len(h)
    assert cid_hex == "01711e20" + h


def test_digest_hex_empty_map_is_frozen_value():
    """The empty-map digest_hex is the documented frozen value (issue #6)."""
    cid = content_id({})
    assert (
        cid.digest_hex()
        == "1f94cbf313b3ce23257a7251ea0fc95a24556ea611e4f8f475e549971baedb02"
    )


def test_digest_forms_agree_with_no_rehash_bridge():
    """digest_bytes() round-trips through the no-rehash constructor.

    Wrapping a ContentId's own digest_bytes() via from_blake3_content_digest
    reproduces the same id — proving digest_bytes() is exactly the digest the
    CID carries.
    """
    cid = content_id({"x": [1, 2, 3]})
    rebuilt = ContentId.from_blake3_content_digest(cid.digest_bytes())
    assert rebuilt == cid
    assert rebuilt.digest_hex() == cid.digest_hex()


# --------------------------------------------------------------------------- #
# Canonical dag-cbor: order independence (canonical sorting reaches Python)
# --------------------------------------------------------------------------- #


def test_canonical_dagcbor_is_insertion_order_independent():
    """Same dict built two ways -> identical canonical bytes."""
    one = {}
    one["alpha"] = 1
    one["zeta"] = 26

    two = {}
    two["zeta"] = 26
    two["alpha"] = 1

    assert to_canonical_dagcbor(one) == to_canonical_dagcbor(two)
    # ...and therefore the same ContentId.
    assert content_id(one) == content_id(two)


def test_canonical_dagcbor_nested_order_independent():
    a = {"outer": {"b": 2, "a": 1}, "k": [1, 2, 3]}
    b = {"k": [1, 2, 3], "outer": {"a": 1, "b": 2}}
    assert to_canonical_dagcbor(a) == to_canonical_dagcbor(b)


# --------------------------------------------------------------------------- #
# Cross-language consistency: one Rust core under both paths
# --------------------------------------------------------------------------- #


def test_content_id_equals_hash_of_its_own_canonical_bytes():
    """content_id(x) == ContentId.from_canonical_bytes(to_canonical_dagcbor(x)).

    This proves the helper, the codec, and the hasher all share one Rust core:
    canonicalizing then hashing by hand reproduces the helper's id exactly.
    """
    value = {"name": "alpha", "attrs": {"zeta": 26, "alpha": 1}, "n": 7}
    canon = to_canonical_dagcbor(value)
    by_hand = ContentId.from_canonical_bytes(canon)
    via_helper = content_id(value)
    assert by_hand == via_helper
    assert str(by_hand) == str(via_helper)


def test_content_id_matches_independent_blake3_cidv1():
    """The CID is BLAKE3(canonical bytes) wrapped as a CIDv1 dag-cbor link.

    Reconstruct the multihash + CIDv1 binary form from a stdlib BLAKE3 digest
    and assert it equals ContentId.to_bytes(). This pins the exact wire shape:
    codec 0x71 (dag-cbor), multihash code 0x1e (BLAKE3), 32-byte digest.
    """
    value = {"hello": "world", "n": [1, 2, 3]}
    canon = to_canonical_dagcbor(value)

    digest = _blake3_digest(canon)
    if digest is None:
        pytest.skip("no BLAKE3 implementation available (need py3.13+ or `blake3`)")
    assert len(digest) == 32

    # multihash = varint(code=0x1e) || varint(len=32) || digest
    multihash = bytes([0x1E, 0x20]) + digest
    # CIDv1 = varint(version=1) || varint(codec=0x71) || multihash
    cid_bytes = bytes([0x01, 0x71]) + multihash

    cid = content_id(value)
    assert cid.to_bytes() == cid_bytes
    # And the round-trip from those bytes lands on the same id.
    assert ContentId.from_bytes(cid_bytes) == cid


# --------------------------------------------------------------------------- #
# No-rehash digest bridge (issue #10)
# --------------------------------------------------------------------------- #


def test_from_blake3_content_digest_wraps_without_rehashing():
    """ContentId.from_blake3_content_digest(d) wraps d as-is — no second hash.

    For a non-trivial 32-byte digest, wrapping it must differ from
    from_canonical_bytes(d) (which hashes d again). Proves the no-rehash door
    does NOT re-hash. Also checks it matches content_id(...) of the same bytes,
    which canonicalizes-then-hashes — a third, different path.
    """
    d = bytes(range(1, 33))  # [1, 2, ..., 32], a non-trivial digest
    assert len(d) == 32

    wrapped = ContentId.from_blake3_content_digest(d)

    # The wrapped CID carries the digest verbatim: the trailing 32 bytes of the
    # CIDv1 binary form are exactly d.
    assert wrapped.to_bytes()[-32:] == d
    # CID shape: 0x01 (v1) 0x71 (dag-cbor) 0x1e (BLAKE3) 0x20 (len 32) || d
    assert wrapped.to_bytes() == bytes([0x01, 0x71, 0x1E, 0x20]) + d

    # The no-rehash invariant: hashing d again (from_canonical_bytes) MUST
    # produce a different id.
    rehashed = ContentId.from_canonical_bytes(d)
    assert wrapped != rehashed

    # And it differs from content_id(d), which canonicalizes d to dag-cbor then
    # hashes — yet another distinct path that must not collide with the wrap.
    assert wrapped != content_id(d)

    # Round-trips through its own byte form.
    assert ContentId.from_bytes(wrapped.to_bytes()) == wrapped


def test_from_blake3_content_digest_converges_with_from_canonical_bytes():
    """from_blake3_content_digest(blake3(x)) == from_canonical_bytes(x).

    When the caller hashes the real content correctly, the no-rehash door lands
    on exactly the id the hashing door produces. Skipped if no host BLAKE3.
    """
    content = b"\x00\x01\x02 some canonical dag-cbor bytes"
    digest = _blake3_digest(content)
    if digest is None:
        pytest.skip("no BLAKE3 implementation available (need py3.13+ or `blake3`)")
    assert len(digest) == 32

    via_digest = ContentId.from_blake3_content_digest(digest)
    via_hash = ContentId.from_canonical_bytes(content)
    assert via_digest == via_hash
    assert str(via_digest) == str(via_hash)


def test_from_blake3_content_digest_rejects_wrong_length():
    """A Python bytes of the wrong length raises ValueError (no [u8;32] guard)."""
    for bad in (b"", b"too short", bytes(31), bytes(33), bytes(64)):
        with pytest.raises(ValueError):
            ContentId.from_blake3_content_digest(bad)


# --------------------------------------------------------------------------- #
# dag-cbor encode/decode round-trip
# --------------------------------------------------------------------------- #


def test_dagcbor_roundtrip_values():
    for value in [
        None,
        True,
        False,
        0,
        -1,
        42,
        "hello",
        b"\x00\x01\x02bytes",
        [1, "two", [3, 4], None],
        {"a": 1, "b": [True, False], "c": {"nested": "map"}},
        {},
        [],
    ]:
        encoded = to_canonical_dagcbor(value)
        assert isinstance(encoded, bytes)
        decoded = from_canonical_dagcbor(encoded)
        assert decoded == value, f"round-trip mismatch for {value!r}"


def test_dagcbor_float_roundtrip():
    value = {"pi": 3.14159, "e": 2.71828}
    decoded = from_canonical_dagcbor(to_canonical_dagcbor(value))
    assert decoded == value


def test_bytes_decode_as_python_bytes():
    """A bytes value decodes back as `bytes`, not a list of ints."""
    decoded = from_canonical_dagcbor(to_canonical_dagcbor(b"raw"))
    assert decoded == b"raw"
    assert isinstance(decoded, bytes)


def test_tuple_encodes_like_list():
    """Tuples are dag-cbor lists; they decode back as lists."""
    assert to_canonical_dagcbor((1, 2, 3)) == to_canonical_dagcbor([1, 2, 3])
    assert from_canonical_dagcbor(to_canonical_dagcbor((1, 2, 3))) == [1, 2, 3]


# --------------------------------------------------------------------------- #
# Error handling
# --------------------------------------------------------------------------- #


def test_parse_invalid_cid_string_raises_value_error():
    with pytest.raises(ValueError):
        ContentId.parse("not-a-cid")


def test_from_bytes_invalid_raises_value_error():
    with pytest.raises(ValueError):
        ContentId.from_bytes(b"\xff\xff\xff not a cid")


def test_unsupported_type_raises_type_error():
    class Custom:
        pass

    with pytest.raises(TypeError):
        to_canonical_dagcbor(Custom())  # a custom object is not representable


# --------------------------------------------------------------------------- #
# Checked decode: the two negative vectors from issue #90
# --------------------------------------------------------------------------- #
#
# The Rust core's `from_canonical_dagcbor` never compared the bytes it was handed
# against the canonical encoding of the value it returned, so a decoded value
# could carry a DIFFERENT ContentId than its own source. The Python face mirrors
# both doors: the plain one is unverified (and is the anti-vacuous control arm
# below), `from_canonical_dagcbor_checked` refuses.
#
# Only ONE of the two Rust hazards can reach Python, and the asymmetry is
# structural, not an omission:
#
#   * NON-CANONICAL BYTES — reachable. Pinned by the first two tests.
#   * A LOSSY TYPED DECODE — NOT reachable. Rust drops unknown fields because it
#     decodes into a *typed* T that does not name them. Python decodes into the
#     generic data model (dict/list/int/...), which keeps every key, so there is
#     no field for it to drop. The third test pins that as a property rather than
#     leaving it an untested assumption: the very bytes that make Rust's checked
#     decoder raise LossyDecode round-trip exactly here.

# `{"bb": 1, "a": 2}` with the keys emitted in the WRONG order — valid CBOR, not
# canonical dag-cbor (canonical is length-first, then bytewise).
REORDERED_KEYS = bytes([0xA2, 0x62, 0x62, 0x62, 0x01, 0x61, 0x61, 0x02])
# `{"a": 1}` with `1` in the two-byte uint8 form (0x18 0x01) instead of the
# smallest form (0x01) — valid CBOR, not canonical dag-cbor.
NON_MINIMAL_INT = bytes([0xA1, 0x61, 0x61, 0x18, 0x01])


@pytest.mark.parametrize(
    "name,raw",
    [("reordered_keys", REORDERED_KEYS), ("non_minimal_int", NON_MINIMAL_INT)],
)
def test_checked_decode_refuses_non_canonical_bytes(name, raw):
    with pytest.raises(ValueError):
        from_canonical_dagcbor_checked(raw)


@pytest.mark.parametrize(
    "name,raw",
    [("reordered_keys", REORDERED_KEYS), ("non_minimal_int", NON_MINIMAL_INT)],
)
def test_unchecked_decode_accepts_them_and_the_identity_shifts(name, raw):
    """The anti-vacuous twin, and the hazard itself, in one place.

    These bytes decode perfectly well — so the refusal above is the CHECK
    talking, not the codec's strictness. And the value that comes back
    re-encodes to DIFFERENT bytes, so its content id is not the id of the bytes
    it was decoded from. Nothing said so; that is the whole defect.
    """
    value = from_canonical_dagcbor(raw)
    assert to_canonical_dagcbor(value) != raw
    assert content_id(value) != ContentId.from_canonical_bytes(raw)


def test_a_python_decode_cannot_drop_a_field():
    """Rust's LossyDecode hazard has no Python analogue — pinned, not assumed.

    `{"alpha": 1, "zeta": 26}` is exactly the record that makes Rust's checked
    decoder raise LossyDecode when the target type names only `alpha`. Python
    decodes into a dict, which names everything, so the round trip is exact and
    the checked door accepts it.
    """
    record = {"alpha": 1, "zeta": 26}
    raw = to_canonical_dagcbor(record)
    assert from_canonical_dagcbor_checked(raw) == record
    assert to_canonical_dagcbor(from_canonical_dagcbor_checked(raw)) == raw
    assert content_id(record) == ContentId.from_canonical_bytes(raw)


def test_checked_decode_accepts_canonical_bytes_and_preserves_identity():
    for value in [None, True, 0, -1, 42, "hello", b"bytes", [1, [2]], {}, {"a": 1}]:
        raw = to_canonical_dagcbor(value)
        assert from_canonical_dagcbor_checked(raw) == value
        assert (
            content_id(from_canonical_dagcbor_checked(raw))
            == ContentId.from_canonical_bytes(raw)
        ), f"identity must survive the checked round trip for {value!r}"


def test_checked_decode_rejects_non_cbor_garbage():
    with pytest.raises(ValueError):
        from_canonical_dagcbor_checked(b"\xff\xff\xff\xff")


# --------------------------------------------------------------------------- #
# tag-42 links: what the checked door does and does NOT promise
# --------------------------------------------------------------------------- #
#
# The Python codec is asymmetric about links and has been since it shipped:
# `ipld_to_py` maps `Ipld::Link` to a `ContentId` object, while the encode side
# (`pythonize`) has no case for a `ContentId` and raises TypeError. That predates
# this door; what it means for the door is pinned here rather than left to be
# discovered, because links are dag-cbor's headline feature and every Merkle node
# carries one.


def _link_bytes(cid):
    """Canonical dag-cbor for a bare tag-42 link to `cid` (0x00 multibase prefix).

    `d8 2a` (tag 42) + `58 <len>` (byte string, one-byte length — the payload is
    37 bytes, above the 23-byte inline limit and well below 256).
    """
    payload = b"\x00" + cid.to_bytes()
    return bytes([0xD8, 0x2A, 0x58, len(payload)]) + payload


def test_the_checked_door_accepts_a_canonical_link():
    """Canonicality is sound for links; only the *re-encode* is unavailable.

    Acceptance by the checked door IS the canonicality proof — it refuses
    anything whose IPLD re-encoding differs from the input.
    """
    cid = content_id({"a": 1})
    raw = _link_bytes(cid)
    assert from_canonical_dagcbor_checked(raw) == cid
    assert isinstance(from_canonical_dagcbor_checked(raw), ContentId)


def test_a_decoded_link_cannot_be_re_encoded_yet():
    """The known limitation, stated as a test so it cannot surprise anyone.

    A link decodes to a `ContentId` object, and `to_canonical_dagcbor` cannot
    represent one — so `content_id(from_canonical_dagcbor_checked(b))` raises
    instead of returning `ContentId.from_canonical_bytes(b)`. Validate the bytes,
    then mint from the *bytes*, never from the decoded value.
    """
    cid = content_id({"a": 1})
    raw = _link_bytes(cid)
    decoded = from_canonical_dagcbor_checked(raw)
    with pytest.raises(TypeError):
        to_canonical_dagcbor(decoded)
    # The same limitation nested inside an ordinary record.
    nested = bytes([0xA1, 0x64]) + b"link" + raw
    value = from_canonical_dagcbor_checked(nested)
    assert value == {"link": cid}
    with pytest.raises(TypeError):
        to_canonical_dagcbor(value)
    # The supported recipe still works: validate the bytes with the checked door,
    # then mint from those same bytes rather than from the decoded value.
    validated = from_canonical_dagcbor_checked(nested)  # raises if non-canonical
    assert validated == {"link": cid}
    minted = ContentId.from_canonical_bytes(nested)
    assert str(minted).startswith("b") and len(minted.digest_hex()) == 64


def test_checked_decode_rejects_a_link_to_a_foreign_cid():
    """A canonical link whose CID is off-profile is a ValueError, not a pass.

    The bytes ARE canonical dag-cbor, so the canonicality stage says nothing; the
    refusal comes from the `ContentId` conversion, the same profile law the plain
    decoder enforces (see tests/test_profile_validation.py).
    """
    foreign_link = bytes.fromhex("d82a58250001711220" + "07" * 32)
    with pytest.raises(ValueError):
        from_canonical_dagcbor_checked(foreign_link)
    with pytest.raises(ValueError):
        from_canonical_dagcbor(foreign_link)


# --------------------------------------------------------------------------- #
# Module surface
# --------------------------------------------------------------------------- #


def test_module_all_and_doc():
    assert set(ca.__all__) == {
        "ContentId",
        "RawContentId",
        "to_canonical_dagcbor",
        "from_canonical_dagcbor",
        "from_canonical_dagcbor_checked",
        "content_id",
    }
    assert ca.__doc__


# --------------------------------------------------------------------------- #
# Shared golden-vector byte-parity gate (Rust <-> Python)
# --------------------------------------------------------------------------- #
#
# tests/vectors.json is the ONE source of truth, generated by the Rust core
# (the authority that the Python face delegates to) and consumed verbatim here.
# The Rust integration test (tests/conformance.rs) asserts the same three fields
# against the same file, so any future byte drift fails LOUDLY in BOTH languages.
#
# These tests must NOT skip: the expected bytes come from the file, so no host
# BLAKE3 dependency is needed to assert agreement (unlike
# test_content_id_matches_independent_blake3_cidv1, which skips when BLAKE3 is
# absent). The vectors pin only the currently-stable, JSON-expressible dag-cbor
# subset (null/bool/int/str/list/map + bytes via the `{"$bytes": "<hex>"}`
# escape). Floats and the not-yet-frozen ContentId serde repr are deliberately
# excluded; they join the vectors once those must-fix-gate items freeze.

_VECTORS_PATH = pathlib.Path(__file__).parent / "vectors.json"

#: The reserved key that escapes a CBOR byte string inside JSON. Must match
#: ``BYTES_TAG`` in tests/common/mod.rs on the Rust side.
_BYTES_TAG = "$bytes"


def _expand_vector_value(value):
    """Expand a vector's JSON ``value`` into the native Python value it denotes.

    Mirrors ``json_value_to_ipld`` in tests/common/mod.rs: a single-key object
    ``{"$bytes": "<hex>"}`` becomes ``bytes``; everything else maps structurally.
    ``json.load`` already yields dict/list/int/str/bool/None for the rest of the
    subset, so only the bytes escape needs handling.
    """
    if isinstance(value, dict):
        if len(value) == 1 and _BYTES_TAG in value:
            return bytes.fromhex(value[_BYTES_TAG])
        return {k: _expand_vector_value(v) for k, v in value.items()}
    if isinstance(value, list):
        return [_expand_vector_value(v) for v in value]
    return value


def _load_vectors():
    with _VECTORS_PATH.open(encoding="utf-8") as fh:
        return json.load(fh)


_VECTORS = _load_vectors()


def test_vectors_file_present_and_covers_required_cases():
    """The shared file exists and carries the issue's required coverage."""
    assert len(_VECTORS) >= 8, f"need >= 8 vectors, found {len(_VECTORS)}"
    names = {v["name"] for v in _VECTORS}
    for required in (
        "empty_map",
        "empty_list",
        "nested_map",
        "map_order_independent",
        "ints_strings_bytes_mix",
        "sample_a",
    ):
        assert required in names, f"vectors.json must contain {required!r}"


@pytest.mark.parametrize("vector", _VECTORS, ids=[v["name"] for v in _VECTORS])
def test_golden_vector_matches_python_face(vector):
    """Every golden vector reproduces byte-for-byte through the Python face.

    Asserts the three pinned fields — canonical dag-cbor hex, ContentId string,
    and ContentId bytes hex — against the same vectors.json the Rust gate uses.
    Does NOT skip: agreement is asserted from the committed file, not a host hash.
    """
    value = _expand_vector_value(vector["value"])

    canon = to_canonical_dagcbor(value)
    assert canon.hex() == vector["canonical_dagcbor_hex"], (
        f"[{vector['name']}] canonical dag-cbor hex drifted"
    )

    cid = content_id(value)
    assert str(cid) == vector["content_id_str"], (
        f"[{vector['name']}] ContentId string drifted"
    )
    assert cid.to_bytes().hex() == vector["content_id_bytes_hex"], (
        f"[{vector['name']}] ContentId bytes drifted"
    )

    # The named presentation accessor digest_hex (issue #6) — the
    # bare-digest-hex form — must match the pinned value byte-for-byte, just as
    # the Rust gate asserts. This is what enforces cross-language parity for the
    # new accessor.
    assert cid.digest_hex() == vector["digest_hex"], (
        f"[{vector['name']}] digest_hex drifted"
    )
    # And digest_bytes() is the raw form of the same digest.
    assert cid.digest_bytes().hex() == vector["digest_hex"], (
        f"[{vector['name']}] digest_bytes() disagrees with pinned digest_hex"
    )
    # Structural cross-check: digest_hex is the CID-bytes tail after the prefix.
    assert vector["content_id_bytes_hex"] == "01711e20" + vector["digest_hex"], (
        f"[{vector['name']}] digest_hex must be the CID-bytes tail"
    )

    # The pinned bytes must also parse back to the same id (cross-check).
    assert ContentId.from_bytes(bytes.fromhex(vector["content_id_bytes_hex"])) == cid


# --------------------------------------------------------------------------- #
# RawContentId — the raw profile (issue #84)
# --------------------------------------------------------------------------- #
#
# tests/raw_vectors.json is generated by the Rust core (tests/gen_raw_vectors.rs)
# and consumed verbatim here and by tests/raw_conformance.rs, so the raw profile
# is byte-identical across both faces. It also pins law 6: the dag-cbor
# ContentId over the *same digest* is a different identity.

_RAW_VECTORS_PATH = pathlib.Path(__file__).parent / "raw_vectors.json"


def _load_raw_vectors():
    with _RAW_VECTORS_PATH.open(encoding="utf-8") as fh:
        return json.load(fh)


_RAW_VECTORS = _load_raw_vectors()


def test_raw_vectors_present():
    assert len(_RAW_VECTORS) >= 6
    names = {v["name"] for v in _RAW_VECTORS}
    for required in ("empty", "hello", "all_byte_values", "dagcbor_empty_map_as_raw_bytes"):
        assert required in names, f"raw_vectors.json must contain {required!r}"


@pytest.mark.parametrize("vector", _RAW_VECTORS, ids=[v["name"] for v in _RAW_VECTORS])
def test_raw_golden_vector_matches_python_face(vector):
    content = bytes.fromhex(vector["content_hex"])
    rid = RawContentId.from_content(content)
    assert str(rid) == vector["raw_content_id_str"]
    assert rid.to_bytes() == bytes.fromhex(vector["raw_content_id_bytes_hex"])
    assert rid.digest_hex() == vector["digest_hex"]
    assert rid.digest_bytes() == bytes.fromhex(vector["digest_hex"])
    assert rid.verify(content)
    assert not rid.verify(content + b"x")
    # Envelope prefix: CIDv1 (01) raw (55) blake3 (1e) 32-byte digest (20).
    assert vector["raw_content_id_bytes_hex"].startswith("01551e20")
    # Every ingress path agrees.
    assert RawContentId.parse(vector["raw_content_id_str"]) == rid
    assert RawContentId.from_bytes(rid.to_bytes()) == rid
    assert RawContentId.from_blake3_digest(rid.digest_bytes()) == rid


@pytest.mark.parametrize("vector", _RAW_VECTORS, ids=[v["name"] for v in _RAW_VECTORS])
def test_raw_and_dagcbor_profiles_never_compare_equal(vector):
    """Law 6: same digest, different profile => different identity."""
    rid = RawContentId.parse(vector["raw_content_id_str"])
    cid = ContentId.parse(vector["content_id_of_same_digest_str"])
    assert rid.digest_hex() == cid.digest_hex()
    assert str(rid) != str(cid)
    assert rid.to_bytes() != cid.to_bytes()
    # Cross-type comparison is False (not an error) and hashes need not collide.
    assert (rid == cid) is False
    assert (cid == rid) is False
    # Each parser refuses the other profile.
    with pytest.raises(ValueError):
        RawContentId.parse(vector["content_id_of_same_digest_str"])
    with pytest.raises(ValueError):
        ContentId.parse(vector["raw_content_id_str"])
    with pytest.raises(ValueError):
        RawContentId.from_bytes(cid.to_bytes())
    with pytest.raises(ValueError):
        ContentId.from_bytes(rid.to_bytes())


def test_raw_content_id_matches_independent_blake3_cidv1():
    """Independent construction: 0x01 0x55 0x1e 0x20 || blake3(content)."""
    blake3 = pytest.importorskip("blake3")
    content = b"independent raw check"
    digest = blake3.blake3(content).digest()
    assert RawContentId.from_content(content).to_bytes() == bytes([0x01, 0x55, 0x1E, 0x20]) + digest


def test_raw_content_id_rejects_wrong_digest_length_and_garbage():
    with pytest.raises(ValueError):
        RawContentId.from_blake3_digest(b"\x00" * 31)
    with pytest.raises(ValueError):
        RawContentId.parse("not a cid")
    with pytest.raises(ValueError):
        RawContentId.from_bytes(b"nope")


def test_raw_content_id_repr_hash_and_dict_key():
    a = RawContentId.from_content(b"k")
    b = RawContentId.from_content(b"k")
    assert repr(a).startswith("RawContentId('b")
    assert a == b and hash(a) == hash(b)
    assert {a: 1}[b] == 1


def test_from_dag_cbor_digest_is_the_explicit_successor():
    """ContentId.from_dag_cbor_digest == the (deprecated) from_blake3_content_digest."""
    cid = content_id({"a": 1})
    assert ContentId.from_dag_cbor_digest(cid.digest_bytes()) == cid
    assert ContentId.from_dag_cbor_digest(cid.digest_bytes()) == ContentId.from_blake3_content_digest(
        cid.digest_bytes()
    )
    with pytest.raises(ValueError):
        ContentId.from_dag_cbor_digest(b"\x00" * 8)


# --------------------------------------------------------------------------- #
# Portable legacy-dialect vectors (issue #84)
# --------------------------------------------------------------------------- #
#
# tests/legacy_vectors.json is generated by the Rust core
# (tests/gen_legacy_vectors.rs) and consumed by BOTH gates. The Rust gate
# (tests/legacy_conformance.rs) additionally drives the `unstable-legacy`
# adapters; Python checks the same identities with the API it has, which is what
# makes these conversions *portable* conformance data rather than Rust-only unit
# tests. Python also re-derives the SHA-256 digests with hashlib, so the
# `sha2-256:` rows are genuine nessie interop values, not synthetic shapes.

_LEGACY_VECTORS_PATH = pathlib.Path(__file__).parent / "legacy_vectors.json"


def _load_legacy_vectors():
    with _LEGACY_VECTORS_PATH.open(encoding="utf-8") as fh:
        return json.load(fh)


_LEGACY_VECTORS = _load_legacy_vectors()


def test_legacy_vectors_present():
    assert len(_LEGACY_VECTORS) >= 4
    names = {v["name"] for v in _LEGACY_VECTORS}
    assert {"empty", "hello", "quick_brown_fox"} <= names


@pytest.mark.parametrize(
    "vector", _LEGACY_VECTORS, ids=[v["name"] for v in _LEGACY_VECTORS]
)
def test_legacy_blake3_dialects_name_the_same_identity(vector):
    """kyln envelope-hex, nessie blake3:<hex>, and bare hex are one identity."""
    content = bytes.fromhex(vector["content_hex"])
    rid = RawContentId.from_content(content)
    assert str(rid) == vector["expect_raw_content_id_str"]
    # kyln-core's ContentId.to_hex() IS the CID envelope, hex-encoded.
    assert vector["kyln_envelope_hex"] == rid.to_bytes().hex()
    assert RawContentId.from_bytes(bytes.fromhex(vector["kyln_envelope_hex"])) == rid
    # nessie blake3 text and the bare hex carry the bare digest.
    assert vector["nessie_blake3_text"] == f"blake3:{rid.digest_hex()}"
    assert vector["bare_blake3_hex"] == rid.digest_hex()
    assert RawContentId.from_blake3_digest(bytes.fromhex(vector["bare_blake3_hex"])) == rid


@pytest.mark.parametrize(
    "vector", _LEGACY_VECTORS, ids=[v["name"] for v in _LEGACY_VECTORS]
)
def test_nessie_sha256_is_genuine_and_stays_foreign(vector):
    """`sha2-256:<hex>` is a real SHA-256 and converts to a non-mintable id."""
    content = bytes.fromhex(vector["content_hex"])
    algo, sha_hex = vector["nessie_sha256_text"].split(":")
    assert algo == "sha2-256"
    # The pinned digest really is SHA-256 of the content — a wrong literal in
    # the Rust generator fails HERE, so the vector cannot drift into fiction.
    assert sha_hex == hashlib.sha256(content).hexdigest()
    # The foreign CID is CIDv1(raw 0x55, sha2-256 0x12, 32 bytes) over it.
    assert vector["expect_foreign_cid_bytes_hex"] == "01551220" + sha_hex
    # Fail-closed: neither mintable profile will accept it, in either spelling.
    with pytest.raises(ValueError):
        RawContentId.parse(vector["expect_foreign_cid_str"])
    with pytest.raises(ValueError):
        ContentId.parse(vector["expect_foreign_cid_str"])
    with pytest.raises(ValueError):
        RawContentId.from_bytes(bytes.fromhex(vector["expect_foreign_cid_bytes_hex"]))
    with pytest.raises(ValueError):
        ContentId.from_bytes(bytes.fromhex(vector["expect_foreign_cid_bytes_hex"]))
    # And it is a different identity from the BLAKE3 one for the same content.
    assert vector["expect_foreign_cid_str"] != vector["expect_raw_content_id_str"]


def test_legacy_dialects_are_not_accepted_by_the_canonical_parsers():
    """ADVERSARIAL: the canonical parsers never learned the legacy dialects.

    Legacy spellings are read only through the explicit, default-off Rust
    adapters at a system's edge; teaching `parse` these forms would make a
    migration convenience into a permanent wire format (issue #84, D3).
    """
    v = _LEGACY_VECTORS[1]  # "hello"
    for dialect in (
        v["kyln_envelope_hex"],  # full-CID-bytes hex, no multibase prefix
        v["nessie_blake3_text"],  # "blake3:<hex>"
        v["nessie_sha256_text"],  # "sha2-256:<hex>"
        v["bare_blake3_hex"],  # bare 64-char digest hex
    ):
        with pytest.raises(ValueError):
            RawContentId.parse(dialect)
        with pytest.raises(ValueError):
            ContentId.parse(dialect)