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:
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user