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() or hash_file().

Return type:

str

Parameters:

data (bytes)

blake2b_hex(data)[source]

Return the lowercase hex BLAKE2b digest of data at 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.

Return type:

str

Parameters:

data (bytes)

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.dumps for either use. A hand-rolled indent=/default= call is not equivalent: indent inflates bytes for a reader that does not exist once the payload is encrypted, and default=str silently 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 \\uXXXX escapes. 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 tokens NaN and Infinity, 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.

Return type:

bytes

Parameters:

payload (object)

bounded_canonical_json_bytes(payload, *, maximum_bytes, subject)[source]

Return canonical_json_bytes() refused above maximum_bytes.

A record with a fixed on-disk or on-wire slot declares its own ceiling and the subject naming 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.

Return type:

bytes

Parameters:
prefixed_digest(data)[source]

Return the sha256:-prefixed digest of data.

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.

Return type:

str

Parameters:

data (bytes)

canonical_json_digest(payload, *, maximum_bytes, subject)[source]

Return the sha256:-prefixed digest of one bounded canonical record.

Return type:

str

Parameters:
validate_prefixed_digest(value, *, field_name)[source]

Return value when it is a lowercase sha256:-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.

Return type:

str

Parameters:
  • value (str)

  • field_name (str)

reject_duplicate_json_members(pairs)[source]

Build one JSON object, refusing a repeated member name.

json.loads keeps 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 as object_pairs_hook when decoding a record whose bytes are digest-bound.

Return type:

dict[str, object]

Parameters:

pairs (Sequence[tuple[str, object]])

reject_json_constant(value)[source]

Refuse NaN/Infinity on decode, mirroring allow_nan=False.

Pass as parse_constant so a record this encoder would never have written cannot be read back in.

Return type:

NoReturn

Parameters:

value (str)

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.

Return type:

str

Parameters:

payload (object)

hash_file(path)[source]

Return (sha256_hex, byte_count) for the file 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; 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.

Return type:

tuple[str, int]

Parameters:

path (Path)

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.

Return type:

str

Parameters:

path (Path)

SHA256_HEX_LENGTH: Final

Characters in a lowercase hex SHA-256 digest; a property of the algorithm.