From 69c9bd53c381c846a01c92b289eb397ad10366b5 Mon Sep 17 00:00:00 2001 From: Mustafa Senoglu Date: Wed, 26 Aug 2026 13:44:47 +0300 Subject: [PATCH] fix: make metadata classes hashable by fixing __hash__ implementations Fixes TypeError: unhashable type: 'dict' for 8 metadata classes: - Signed, Root, MetaFile, Timestamp, Snapshot - Delegations, TargetFile, Targets, Metadata Following the pattern from #2973 (Role/DelegatedRole): - Convert dicts to tuples of sorted items for hashability - Exclude unrecognized_fields (arbitrary nested JSON) - Preserve Python data model: equal objects produce equal hashes Signed-off-by: Mustafa Senoglu --- tuf/api/_payload.py | 51 ++++++++++++++++++++++++++++----------------- tuf/api/metadata.py | 5 ++++- 2 files changed, 36 insertions(+), 20 deletions(-) diff --git a/tuf/api/_payload.py b/tuf/api/_payload.py index 814d4c0fdd..2da250a85f 100644 --- a/tuf/api/_payload.py +++ b/tuf/api/_payload.py @@ -182,13 +182,13 @@ def __eq__(self, other: object) -> bool: ) def __hash__(self) -> int: + # Exclude unrecognized_fields as it can hold arbitrary nested JSON return hash( ( self.type, self.version, self.spec_version, self.expires, - self.unrecognized_fields, ) ) @@ -562,13 +562,16 @@ def __eq__(self, other: object) -> bool: ) def __hash__(self) -> int: + # Convert dicts to tuples of sorted items for hashability + # Exclude unrecognized_fields as it can hold arbitrary nested JSON + keys = tuple(sorted((k, v) for k, v in self.keys.items())) + roles = tuple(sorted((k, v) for k, v in self.roles.items())) return hash( ( super().__hash__(), - self.keys, - self.roles, + keys, + roles, self.consistent_snapshot, - self.unrecognized_fields, ) ) @@ -848,9 +851,13 @@ def __eq__(self, other: object) -> bool: ) def __hash__(self) -> int: - return hash( - (self.version, self.length, self.hashes, self.unrecognized_fields) - ) + # Convert dict to tuple of sorted items for hashability + # Exclude unrecognized_fields as it can hold arbitrary nested JSON + if self.hashes is not None: + hashes = tuple(sorted(self.hashes.items())) + else: + hashes = None + return hash((self.version, self.length, hashes)) @classmethod def from_dict(cls, meta_dict: dict[str, Any]) -> MetaFile: @@ -1031,7 +1038,9 @@ def __eq__(self, other: object) -> bool: return super().__eq__(other) and self.meta == other.meta def __hash__(self) -> int: - return hash((super().__hash__(), self.meta)) + # Convert dict to tuple of sorted items for hashability + meta = tuple(sorted((k, v) for k, v in self.meta.items())) + return hash((super().__hash__(), meta)) @classmethod def from_dict(cls, signed_dict: dict[str, Any]) -> Snapshot: @@ -1461,14 +1470,15 @@ def __eq__(self, other: object) -> bool: return all_attributes_check def __hash__(self) -> int: - return hash( - ( - self.keys, - self.roles, - self.succinct_roles, - self.unrecognized_fields, - ) + # Convert dicts to tuples of sorted items for hashability + # Exclude unrecognized_fields as it can hold arbitrary nested JSON + keys = tuple(sorted((k, v) for k, v in self.keys.items())) + roles = ( + tuple(sorted((k, v) for k, v in self.roles.items())) + if self.roles is not None + else None ) + return hash((keys, roles, self.succinct_roles)) @classmethod def from_dict(cls, delegations_dict: dict[str, Any]) -> Delegations: @@ -1592,9 +1602,10 @@ def __eq__(self, other: object) -> bool: ) def __hash__(self) -> int: - return hash( - (self.length, self.hashes, self.path, self.unrecognized_fields) - ) + # Convert dict to tuple of sorted items for hashability + # Exclude unrecognized_fields as it can hold arbitrary nested JSON + hashes = tuple(sorted(self.hashes.items())) + return hash((self.length, hashes, self.path)) @classmethod def from_dict(cls, target_dict: dict[str, Any], path: str) -> TargetFile: @@ -1740,7 +1751,9 @@ def __eq__(self, other: object) -> bool: ) def __hash__(self) -> int: - return hash((super().__hash__(), self.targets, self.delegations)) + # Convert dict to tuple of sorted items for hashability + targets = tuple(sorted((k, v) for k, v in self.targets.items())) + return hash((super().__hash__(), targets, self.delegations)) @classmethod def from_dict(cls, signed_dict: dict[str, Any]) -> Targets: diff --git a/tuf/api/metadata.py b/tuf/api/metadata.py index 8bd281131a..ebd02ce523 100644 --- a/tuf/api/metadata.py +++ b/tuf/api/metadata.py @@ -148,7 +148,10 @@ def __eq__(self, other: object) -> bool: ) def __hash__(self) -> int: - return hash((self.signatures, self.signed, self.unrecognized_fields)) + # Convert dict to tuple of sorted items for hashability + # Exclude unrecognized_fields as it can hold arbitrary nested JSON + signatures = tuple(sorted(self.signatures.items())) + return hash((signatures, self.signed)) @property def signed_bytes(self) -> bytes: