cadrumo.core.hashing module¶
Canonical SHA-256 utilities for bytes, files, and JSON content ids.
Provides sha256_hex(), sha256_file(), hash_file(),
canonical_json_bytes(), and content_hash_hex() as the single
authoritative SHA-256 implementations. Byte callers hash in memory; file
callers pass a Path and share the chunked-read loop. All
adapters, application services, and domain modules import from here rather than
inlining hashlib.sha256(data).hexdigest() or re-deriving the canonical-JSON
content-hash serialisation.
This module is also the sole owner of the canonical record encoding: the one
byte spelling every digest-keyed or persisted JSON record uses. That encoding is
UTF-8, unescaped, refusing non-finite numbers – see
canonical_json_bytes() for why each of those is the ruled choice. The
strict-decode counterparts belong here for the same reason: a reader that
accepts a duplicate member or a NaN token re-opens on the way in exactly
what the encoder closed on the way out, so reject_duplicate_json_members()
and reject_json_constant() are the hooks every strict json.loads of a
canonical record passes, and validate_prefixed_digest() is the one reader
of the sha256:-prefixed spelling prefixed_digest() writes.
This module owns digest mechanics only. Domain identities such as profile snapshots, calculation revisions, evidence bundles, and filing records own the payload schema, value normalisation, contract-change policy, and pinned digest tests that make a digest semantically stable.
- CONTENT_DIGEST_PREFIX: Final[str]¶
Algorithm tag prefixing a digest wherever the record spells it out.
- HEX_ALPHABET: Final[frozenset[str]]¶
The lowercase hexadecimal alphabet, for validating a digest or id.
One home for a value five modules had each retyped: any character not in this set makes the string it appears in not a lowercase hex digest.
- sha256_hex(data)[source]¶
Return the lowercase hex SHA-256 digest of
data.Use this for in-memory payloads once the caller has already chosen the byte representation (serialised JSON, string keys, ciphertext, etc.). It does not normalise text or domain values. For file-path inputs use
sha256_file()orhash_file().
- blake2b_hex(data)[source]¶
Return the lowercase hex BLAKE2b digest of
dataat discriminator width.A content DISCRIMINATOR, not a proof: these digests answer “did these bytes change since the last look”, so they are deliberately compact rather than collision-proof against an adversary. Use
sha256_hex()wherever a digest is evidence. The width is fixed here because two discriminators are only comparable when both were computed at the same size.
- canonical_json_bytes(payload)[source]¶
Return deterministic canonical-JSON bytes.
Sorted keys, compact separators, UTF-8, so two semantically equal payloads produce the same bytes.
Two uses, both legitimate. Content hashing is the origin: this is the single serialisation the content-hash helpers feed into SHA-256, so equal payloads yield the same content hash / id. Persisting a JSON payload is the other, and it is not a widening of the contract — determinism is a stronger property than a storage payload needs, not a weaker one, so a serialisation fit to key a digest is fit to write to a row. Callers that persist through this helper get byte-stable payloads for free, which is what makes a stored payload comparable across writes at all.
Prefer it over an ad-hoc
json.dumpsfor either use. A hand-rolledindent=/default=call is not equivalent:indentinflates bytes for a reader that does not exist once the payload is encrypted, anddefault=strsilently coerces a value this helper would have refused, turning a type error into a wrong stored value.The payload must already be JSON-compatible — that refusal is the point, not a limitation. Callers normalise
Decimal,Path, datetimes, enums, and domain objects into stable strings or dictionaries before entering this helper (model_dump(mode="json")does this for a pydantic model); any change to that projection is a caller-owned identity change.Two encoder choices are ruled here rather than left to each caller, because a record encoded one way and re-encoded another compares unequal while meaning the same thing.
ensure_ascii=False— emit UTF-8, not\\uXXXXescapes. Escaping is lossless but it inflates accented content roughly threefold, and this is a Spanish tax application where accents in labels, names, addresses and activity descriptions are the ordinary case, not the exception. The cost is not the bytes; it is that a byte ceiling means two different things depending on the encoder, so a label that fits a record’s declared limit under one spelling overflows it under the other.allow_nan=False— refuse the non-finite constants. Python’s default emits the bare tokensNaNandInfinity, which are not JSON and which every strict reader on the other side of the boundary rejects; permitting them here only moves the failure to load time, with the invalid token already persisted.
- bounded_canonical_json_bytes(payload, *, maximum_bytes, subject)[source]¶
Return
canonical_json_bytes()refused abovemaximum_bytes.A record with a fixed on-disk or on-wire slot declares its own ceiling and the
subjectnaming it in the refusal. The bound is measured on the encoded bytes, which is the only measurement that means anything: a scalar count and a byte count diverge for exactly the accented content this encoding exists to carry unescaped.
- prefixed_digest(data)[source]¶
Return the
sha256:-prefixed digest ofdata.The spelling records use when the digest is stored as a self-describing string rather than a bare hex field. Pair with
validate_prefixed_digest()on the reading side.
- canonical_json_digest(payload, *, maximum_bytes, subject)[source]¶
Return the
sha256:-prefixed digest of one bounded canonical record.
- validate_prefixed_digest(value, *, field_name)[source]¶
Return
valuewhen it is a lowercasesha256:-prefixed digest.Uppercase hex is refused rather than folded: two spellings of one digest compare unequal as strings, and these values are compared as strings.
- reject_duplicate_json_members(pairs)[source]¶
Build one JSON object, refusing a repeated member name.
json.loadskeeps the last of a repeated key by default, so a payload carrying a member twice decodes to something its own canonical re-encoding does not reproduce. Pass asobject_pairs_hookwhen decoding a record whose bytes are digest-bound.
- reject_json_constant(value)[source]¶
Refuse
NaN/Infinityon decode, mirroringallow_nan=False.Pass as
parse_constantso a record this encoder would never have written cannot be read back in.
- content_hash_hex(payload)[source]¶
Return the SHA-256 hex digest of the canonical-JSON form of
payload.The canonical content-addressing primitive: equivalent to
sha256_hex(canonical_json_bytes(payload)). Callers that need a truncated id slice the returned digest (content_hash_hex(payload)[:16]).Use this only after the caller’s payload shape is part of that domain’s identity contract. Changing keys, value normalisation, or included fields changes the digest and should be handled as an explicit identity contract change backed by pinned fixtures.
- hash_file(path)[source]¶
Return
(sha256_hex, byte_count)for the file atpath.Reads in 64 KiB chunks so large files (PDFs, export artefacts) hash cleanly without loading the entire file into memory. Hashes file contents exactly; path metadata, permissions, archive member names, and manifest normalisation stay outside this helper. Use this variant when the caller needs both the digest and the content length.
- sha256_file(path)[source]¶
Return the lowercase hex SHA-256 of the bytes at
path.Reads in 64 KiB chunks so large files (PDFs, export artefacts) hash cleanly without loading the entire file into memory. Hashes file contents exactly, not the file’s path or metadata. Use
hash_file()when the byte count is also needed.
- SHA256_HEX_LENGTH: Final¶
Characters in a lowercase hex SHA-256 digest; a property of the algorithm.