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 os
import re
import copy import copy
import json import json
import base64 import base64
@@ -631,50 +630,72 @@ def decrypt_record(crypto: EnvelopeCrypto, record, traversal_level: int = 2) ->
return result 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 """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...>`), handles datetime (isoformat) and bytes (hex) deterministically, returning a 2-element
whose address is non-deterministic across processes - fingerprinting it would return an ``["type", value]`` list so the encoding is a JSON ARRAY that no genuine str value can
unstable digest, so it fails loud instead 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"): if hasattr(value, "isoformat"):
return f"isoformat:{value.isoformat()}" return ["datetime", value.isoformat()]
if isinstance(value, (bytes, bytearray)): if isinstance(value, (bytes, bytearray)):
return f"hex:{value.hex()}" return ["bytes", value.hex()]
text = repr(value) raise TypeError(
if re.search(r" object at 0x[0-9a-fA-F]+>$", text): f"fingerprint_data: {type(value).__name__} has no deterministic representation; "
raise TypeError( "serialize it to a stable str/bytes/datetime before fingerprinting"
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}" 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: 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): if isinstance(value, dict):
return { 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() for key, sub_value in value.items()
} }
if isinstance(value, (set, frozenset)): if isinstance(value, (set, frozenset)):
members = [_fingerprint_normalize(item) for item in value] members = [_fingerprint_normalize(item) for item in value]
members.sort(key=lambda item: json.dumps(item, sort_keys=True, default=_fingerprint_default)) 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)): if isinstance(value, (list, tuple)):
return [_fingerprint_normalize(item) for item in value] return [_fingerprint_normalize(item) for item in value]
return value return value
def fingerprint_data(data: dict) -> str: 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 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 a type-tagged pre-pass; set/frozenset keys and values are canonically sorted so their
members hash stably; non-JSON-native values (datetime/bytes/etc.) go through a stable unordered members hash stably; datetime/bytes go through a type-qualified default= handler
default= handler. an object with only an identity-based repr raises TypeError rather than whose ``\\x00``-prefixed tags cannot collide with a genuine str value. any key or value
yield an unstable digest. never logs the data being fingerprinted. 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) normalized = _fingerprint_normalize(data)
encoded = json.dumps(normalized, sort_keys=True, default=_fingerprint_default) encoded = json.dumps(normalized, sort_keys=True, default=_fingerprint_default)