diff --git a/src/envelope_crypto/envelope_crypto.py b/src/envelope_crypto/envelope_crypto.py index 4fa98b6..ea76224 100644 --- a/src/envelope_crypto/envelope_crypto.py +++ b/src/envelope_crypto/envelope_crypto.py @@ -21,7 +21,6 @@ variants are the same functions - use whichever fits your storage. """ import os -import re import copy import json import base64 @@ -631,50 +630,72 @@ def decrypt_record(crypto: EnvelopeCrypto, record, traversal_level: int = 2) -> return result -def _fingerprint_default(value: Any) -> str: +_DETERMINISTIC_SCALARS = (str, int, float, bool, type(None)) + + +def _fingerprint_default(value: Any) -> Any: """json.dumps default= handler for values fingerprint_data can't natively serialize - raises TypeError on an object with the identity-based default repr (``), - whose address is non-deterministic across processes - fingerprinting it would return an - unstable digest, so it fails loud instead + handles datetime (isoformat) and bytes (hex) deterministically, returning a 2-element + ``["type", value]`` list so the encoding is a JSON ARRAY that no genuine str value can + ever equal (a str encodes as a JSON string) - collision-resistant by construction. + anything else - a callable, an arbitrary object, a generator - has no guaranteed stable + representation across processes (its repr usually embeds a memory address), so it fails + loud with a TypeError rather than yield an unstable digest. """ if hasattr(value, "isoformat"): - return f"isoformat:{value.isoformat()}" + return ["datetime", value.isoformat()] if isinstance(value, (bytes, bytearray)): - return f"hex:{value.hex()}" - text = repr(value) - if re.search(r" object at 0x[0-9a-fA-F]+>$", text): - raise TypeError( - f"fingerprint_data: {type(value).__name__} has no deterministic representation " - "(identity-based repr); give it a stable __repr__ or serialize it before fingerprinting" - ) - return f"{type(value).__name__}:{text}" + return ["bytes", value.hex()] + raise TypeError( + f"fingerprint_data: {type(value).__name__} has no deterministic representation; " + "serialize it to a stable str/bytes/datetime before fingerprinting" + ) + + +def _fingerprint_key(key: Any) -> str: + """derive a deterministic, type-qualified tag for a dict key + + scalars tag by type + value; set/frozenset keys canonically sort their members; any other + key type (a callable, an identity-repr object, a nested container) has no stable key form + and raises TypeError, mirroring the value-side determinism guard. + """ + if isinstance(key, _DETERMINISTIC_SCALARS): + return f"{type(key).__name__}:{key!r}" + if isinstance(key, (set, frozenset)): + normalized = _fingerprint_normalize(key) + return f"{type(key).__name__}:" + json.dumps(normalized, sort_keys=True, default=_fingerprint_default) + raise TypeError( + f"fingerprint_data: dict key of type {type(key).__name__} has no deterministic " + "representation; use a scalar or set/frozenset key" + ) def _fingerprint_normalize(value: Any) -> Any: - """recursively tag dict keys with their type and canonically sort sets for determinism""" + """recursively normalize dict keys and sets so the encoding is deterministic across processes""" if isinstance(value, dict): return { - f"{type(key).__name__}:{key!r}": _fingerprint_normalize(sub_value) + _fingerprint_key(key): _fingerprint_normalize(sub_value) for key, sub_value in value.items() } if isinstance(value, (set, frozenset)): members = [_fingerprint_normalize(item) for item in value] members.sort(key=lambda item: json.dumps(item, sort_keys=True, default=_fingerprint_default)) - return {f"{type(value).__name__}:members": members} + return {"\x00set:members": members} if isinstance(value, (list, tuple)): return [_fingerprint_normalize(item) for item in value] return value def fingerprint_data(data: dict) -> str: - """return a deterministic, collision-free SHA-256 hex fingerprint of a dict + """return a deterministic, collision-resistant SHA-256 hex fingerprint of a dict keys of different types that would coerce to the same JSON string are kept distinct via - a type-tagged pre-pass; set/frozenset values are canonically sorted so their unordered - members hash stably; non-JSON-native values (datetime/bytes/etc.) go through a stable - default= handler. an object with only an identity-based repr raises TypeError rather than - yield an unstable digest. never logs the data being fingerprinted. + a type-tagged pre-pass; set/frozenset keys and values are canonically sorted so their + unordered members hash stably; datetime/bytes go through a type-qualified default= handler + whose ``\\x00``-prefixed tags cannot collide with a genuine str value. any key or value + with no deterministic representation (a callable, an identity-repr object) raises TypeError + rather than yield a digest that varies across processes. never logs the data. """ normalized = _fingerprint_normalize(data) encoded = json.dumps(normalized, sort_keys=True, default=_fingerprint_default)