fix: fingerprint_data is deterministic and collision-resistant across the whole input surface

Route dict keys through the same determinism guard as values (a frozenset or
identity-repr key previously used bare repr(), yielding a different digest per
process). Replace the repr-regex identity-repr reject with type-based rejection so
callables, lambdas, bound methods, and generators fail loud instead of fingerprinting
a memory address. Encode bytes/datetime as a tagged JSON array so a genuine str value
of the same shape can never collide (the old 'hex:'/'isoformat:' string tags could).

Signed-off-by: disqualifier <dev@disqualifier.me>
This commit is contained in:
2026-07-06 19:20:18 -04:00
parent 581ff712e0
commit 6d370a45e1
+43 -22
View File
@@ -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 (`<X at 0x...>`),
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)