From 581ff712e0b135a59dac1392a52743a10e350450 Mon Sep 17 00:00:00 2001 From: disqualifier Date: Mon, 6 Jul 2026 16:39:34 -0400 Subject: [PATCH] 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 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 --- src/envelope_crypto/envelope_crypto.py | 38 ++++++++++++++++---------- 1 file changed, 24 insertions(+), 14 deletions(-) diff --git a/src/envelope_crypto/envelope_crypto.py b/src/envelope_crypto/envelope_crypto.py index 05642c9..4fa98b6 100644 --- a/src/envelope_crypto/envelope_crypto.py +++ b/src/envelope_crypto/envelope_crypto.py @@ -21,6 +21,7 @@ variants are the same functions - use whichever fits your storage. """ import os +import re import copy import json import base64 @@ -631,21 +632,36 @@ def decrypt_record(crypto: EnvelopeCrypto, record, traversal_level: int = 2) -> 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 (``), + whose address is non-deterministic across processes - fingerprinting it would return an + unstable digest, so it fails loud instead + """ if hasattr(value, "isoformat"): return f"isoformat:{value.isoformat()}" if isinstance(value, (bytes, bytearray)): 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: - """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): return { f"{type(key).__name__}:{key!r}": _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} if isinstance(value, (list, tuple)): return [_fingerprint_normalize(item) for item in value] return value @@ -654,17 +670,11 @@ def _fingerprint_normalize(value: Any) -> Any: def fingerprint_data(data: dict) -> str: """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 - string (e.g. {1: "a"} vs {"1": "a"}) are kept distinct via a type-tagged - pre-pass. values that aren't JSON-native (datetime/date/time, bytes/bytearray, - ObjectId-like objects) are serialized via a stable default= handler instead of - raising. never logs the data being fingerprinted. - - Args: - data: the dict to fingerprint. - - Returns: - a deterministic SHA-256 hex digest. + 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. """ normalized = _fingerprint_normalize(data) encoded = json.dumps(normalized, sort_keys=True, default=_fingerprint_default)