-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathcanon.py
More file actions
74 lines (58 loc) · 2.66 KB
/
Copy pathcanon.py
File metadata and controls
74 lines (58 loc) · 2.66 KB
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
"""CANON-JSON-v1 + the chain hash: the one serialization every tape hash is computed over.
Rules (constitutional; known-answer vectors pinned in scriptorium.lock, enforced by
tests/test_canon.py):
- UTF-8 bytes, no BOM.
- Every string — keys and values — is NFC-normalized before serialization.
- Object keys sorted by Unicode code point (after NFC normalization).
- Separators "," and ":", no whitespace.
- Floats: CPython shortest-roundtrip repr (json.dumps default). NaN/Inf refused.
- Only JSON types: dict (str keys only), list/tuple, str, int, float, bool, None.
- Two distinct keys that NFC-collide are refused (they would serialize as duplicates).
Chain hash (spec section 3): h_i = blake2b-128( ascii(h_{i-1}) || CANON-JSON(record_i) )
with the genesis prev "0"*32. Hex lowercase, 32 chars.
"""
from __future__ import annotations
import hashlib
import json
import math
import unicodedata
from typing import Any
GENESIS = "0" * 32
_HASH_HEX_LEN = 32 # blake2b digest_size=16 -> 32 hex chars
class CanonError(TypeError):
"""A value cannot be represented in CANON-JSON."""
def _norm(obj: Any) -> Any:
if isinstance(obj, str):
return unicodedata.normalize("NFC", obj)
if isinstance(obj, bool) or obj is None or isinstance(obj, int):
return obj
if isinstance(obj, float):
if not math.isfinite(obj):
raise CanonError(f"non-finite float not representable: {obj!r}")
return obj
if isinstance(obj, dict):
out: dict[str, Any] = {}
for k, v in obj.items():
if not isinstance(k, str):
raise CanonError(f"non-string key not representable: {k!r}")
nk = unicodedata.normalize("NFC", k)
if nk in out:
raise CanonError(f"keys collide after NFC normalization: {k!r}")
out[nk] = _norm(v)
return out
if isinstance(obj, (list, tuple)):
return [_norm(v) for v in obj]
raise CanonError(f"type not representable in CANON-JSON: {type(obj).__name__}")
def canon_bytes(obj: Any) -> bytes:
"""Serialize obj to canonical UTF-8 bytes."""
return json.dumps(
_norm(obj), ensure_ascii=False, sort_keys=True,
separators=(",", ":"), allow_nan=False,
).encode("utf-8")
def blake2b128_hex(data: bytes) -> str:
return hashlib.blake2b(data, digest_size=16).hexdigest()
def chain_hash(prev_h: str, record: dict[str, Any]) -> str:
"""h = blake2b-128( ascii(prev_h) || canon(record) ). record must not contain 'h'."""
if len(prev_h) != _HASH_HEX_LEN:
raise CanonError(f"prev hash must be {_HASH_HEX_LEN} hex chars, got {len(prev_h)}")
return blake2b128_hex(prev_h.encode("ascii") + canon_bytes(record))