fix: fingerprint_data is deterministic for sets and rejects identity-repr objects

set/frozenset values fell through to repr() (hash-randomized member order), and objects
with the default <X at 0x..> repr embedded a per-process memory address - both made
fingerprint_data return a different digest across restarts despite its 'deterministic'
contract (a fix-wave d446f50 turned a loud TypeError into a silent unstable hash).
_fingerprint_normalize now canonically sorts set/frozenset members, and the default=
handler raises TypeError on an identity-based repr instead of hashing an address.

Signed-off-by: disqualifier <dev@disqualifier.me>
This commit is contained in:
2026-07-06 16:39:34 -04:00
parent 8dffb6bc61
commit 581ff712e0
+24 -14
View File
@@ -21,6 +21,7 @@ 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,21 +632,36 @@ def decrypt_record(crypto: EnvelopeCrypto, record, traversal_level: int = 2) ->
def _fingerprint_default(value: Any) -> str: def _fingerprint_default(value: Any) -> str:
"""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...>`),
whose address is non-deterministic across processes - fingerprinting it would return an
unstable digest, so it fails loud instead
"""
if hasattr(value, "isoformat"): if hasattr(value, "isoformat"):
return f"isoformat:{value.isoformat()}" return f"isoformat:{value.isoformat()}"
if isinstance(value, (bytes, bytearray)): if isinstance(value, (bytes, bytearray)):
return f"hex:{value.hex()}" return f"hex:{value.hex()}"
return f"{type(value).__name__}:{value!r}" 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}"
def _fingerprint_normalize(value: Any) -> Any: def _fingerprint_normalize(value: Any) -> Any:
"""recursively tag dict keys with their type to avoid cross-type key collisions""" """recursively tag dict keys with their type and canonically sort sets for determinism"""
if isinstance(value, dict): if isinstance(value, dict):
return { return {
f"{type(key).__name__}:{key!r}": _fingerprint_normalize(sub_value) f"{type(key).__name__}:{key!r}": _fingerprint_normalize(sub_value)
for key, sub_value in value.items() 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}
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
@@ -654,17 +670,11 @@ def _fingerprint_normalize(value: Any) -> Any:
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-free SHA-256 hex fingerprint of a dict
dict keys of different types that would otherwise coerce to the same JSON keys of different types that would coerce to the same JSON string are kept distinct via
string (e.g. {1: "a"} vs {"1": "a"}) are kept distinct via a type-tagged a type-tagged pre-pass; set/frozenset values are canonically sorted so their unordered
pre-pass. values that aren't JSON-native (datetime/date/time, bytes/bytearray, members hash stably; non-JSON-native values (datetime/bytes/etc.) go through a stable
ObjectId-like objects) are serialized via a stable default= handler instead of default= handler. an object with only an identity-based repr raises TypeError rather than
raising. never logs the data being fingerprinted. yield an unstable digest. never logs the data being fingerprinted.
Args:
data: the dict to fingerprint.
Returns:
a deterministic SHA-256 hex digest.
""" """
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)