From 2932e041e83baf8677204c84f063a8a17f3b357a Mon Sep 17 00:00:00 2001 From: Sachith Reddy Date: Tue, 25 Aug 2026 00:47:39 +0530 Subject: [PATCH 1/8] Fix __hash__ crashing on unhashable dict attributes Follow-up to #2973, which fixed Role.__hash__ and DelegatedRole.__hash__. The same bug remains in the other implementations: Signed, Root, MetaFile, Snapshot, Delegations, TargetFile, Targets and Metadata all pass a raw dict to hash(), so hash() raises "TypeError: unhashable type: 'dict'". Timestamp.__hash__ is itself correct but inherits the failure from Signed and MetaFile. All of these classes define __eq__, so __hash__ is required for them to be usable in a set or as a dict key. test_metadata_eq_.py covers __eq__ for exactly these classes but never calls hash(), which is why this went unnoticed. Hash a subset of immutable fields, as #2973 did. unrecognized_fields is excluded throughout since it holds arbitrary nested JSON. Snapshot.meta and Targets.targets contribute len() rather than their keys, to keep hashing O(1) for roles with many entries. Signed-off-by: Sachith Reddy --- tests/test_api.py | 50 ++++++++++++++++++++++++++++++++++++++++++++- tuf/api/_payload.py | 24 ++++++++-------------- tuf/api/metadata.py | 2 +- 3 files changed, 59 insertions(+), 17 deletions(-) diff --git a/tests/test_api.py b/tests/test_api.py index e156d6b332..1fa6229852 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -14,7 +14,7 @@ from copy import copy, deepcopy from datetime import datetime, timedelta, timezone from pathlib import Path -from typing import ClassVar +from typing import TYPE_CHECKING, ClassVar from securesystemslib import exceptions as sslib_exceptions from securesystemslib.signer import ( @@ -48,6 +48,9 @@ from tuf.api.serialization import DeserializationError, SerializationError from tuf.api.serialization.json import JSONSerializer +if TYPE_CHECKING: + from collections.abc import Callable + logger = logging.getLogger(__name__) @@ -1111,6 +1114,51 @@ def test_role_and_delegated_role_hash(self) -> None: role_set = {dr, dr2} self.assertEqual(len(role_set), 1) + def test_metadata_hash(self) -> None: + # Each of these __hash__ implementations passed a raw dict + # (unrecognized_fields, keys, roles, meta, targets, hashes, + # signatures) to hash(), which raises TypeError. + expires = datetime(2030, 1, 1, tzinfo=timezone.utc) + key = SSlibKey("kid1", "ed25519", "ed25519", {"public": "aa"}) + delegated_role = DelegatedRole("r", ["kid1"], 1, False, ["*"], None) + + factories: dict[str, Callable[[], object]] = { + "MetaFile": lambda: MetaFile(1, 10, {"sha256": "ab"}), + "TargetFile": lambda: TargetFile(10, {"sha256": "ab"}, "p"), + "Delegations": lambda: Delegations( + {"kid1": key}, {"r": delegated_role} + ), + "Root": lambda: Root(expires=expires), + "Timestamp": lambda: Timestamp(expires=expires), + "Snapshot": lambda: Snapshot(expires=expires), + "Targets": lambda: Targets(expires=expires), + "Metadata": lambda: Metadata(Snapshot(expires=expires)), + } + + for name, factory in factories.items(): + with self.subTest(name): + obj, equal_obj = factory(), factory() + + self.assertIsInstance(hash(obj), int) + + # equal objects must produce equal hashes (Python data model) + self.assertEqual(obj, equal_obj) + self.assertEqual(hash(obj), hash(equal_obj)) + + # the object must work as a set member / dict key + self.assertEqual(len({obj, equal_obj}), 1) + + def test_metadata_hash_ignores_unrecognized_fields(self) -> None: + # unrecognized_fields holds arbitrary (possibly nested) JSON, so it is + # left out of __hash__. Objects differing only in unrecognized_fields + # are unequal but may share a hash, which the data model allows. + expires = datetime(2030, 1, 1, tzinfo=timezone.utc) + plain = Snapshot(expires=expires) + extra = Snapshot(expires=expires, unrecognized_fields={"a": ["b"]}) + + self.assertNotEqual(plain, extra) + self.assertIsInstance(hash(extra), int) + def test_is_delegated_role_in_succinct_roles(self) -> None: succinct_roles = SuccinctRoles([], 1, 5, "bin") false_role_name_examples = [ diff --git a/tuf/api/_payload.py b/tuf/api/_payload.py index 814d4c0fdd..469c09ccb8 100644 --- a/tuf/api/_payload.py +++ b/tuf/api/_payload.py @@ -188,7 +188,6 @@ def __hash__(self) -> int: self.version, self.spec_version, self.expires, - self.unrecognized_fields, ) ) @@ -565,10 +564,9 @@ def __hash__(self) -> int: return hash( ( super().__hash__(), - self.keys, - self.roles, + tuple(sorted(self.keys)), + tuple(sorted(self.roles)), self.consistent_snapshot, - self.unrecognized_fields, ) ) @@ -848,9 +846,7 @@ def __eq__(self, other: object) -> bool: ) def __hash__(self) -> int: - return hash( - (self.version, self.length, self.hashes, self.unrecognized_fields) - ) + return hash((self.version, self.length)) @classmethod def from_dict(cls, meta_dict: dict[str, Any]) -> MetaFile: @@ -1031,7 +1027,7 @@ 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)) + return hash((super().__hash__(), len(self.meta))) @classmethod def from_dict(cls, signed_dict: dict[str, Any]) -> Snapshot: @@ -1463,10 +1459,10 @@ def __eq__(self, other: object) -> bool: def __hash__(self) -> int: return hash( ( - self.keys, - self.roles, + tuple(sorted(self.keys)), + # Order of the delegated roles matters (see __eq__) + tuple(self.roles) if self.roles is not None else None, self.succinct_roles, - self.unrecognized_fields, ) ) @@ -1592,9 +1588,7 @@ def __eq__(self, other: object) -> bool: ) def __hash__(self) -> int: - return hash( - (self.length, self.hashes, self.path, self.unrecognized_fields) - ) + return hash((self.length, self.path)) @classmethod def from_dict(cls, target_dict: dict[str, Any], path: str) -> TargetFile: @@ -1740,7 +1734,7 @@ def __eq__(self, other: object) -> bool: ) def __hash__(self) -> int: - return hash((super().__hash__(), self.targets, self.delegations)) + return hash((super().__hash__(), len(self.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..5dcb231dd3 100644 --- a/tuf/api/metadata.py +++ b/tuf/api/metadata.py @@ -148,7 +148,7 @@ def __eq__(self, other: object) -> bool: ) def __hash__(self) -> int: - return hash((self.signatures, self.signed, self.unrecognized_fields)) + return hash((tuple(self.signatures), self.signed)) @property def signed_bytes(self) -> bytes: From 1fa758965a02a7b450c26849a38c499bd75c011c Mon Sep 17 00:00:00 2001 From: Sachith Reddy Date: Wed, 26 Aug 2026 00:39:26 +0530 Subject: [PATCH 2/8] Include collection contents in __hash__ Signed-off-by: Sachith Reddy --- tests/test_api.py | 36 ++++++++++++++++++++++++++++++++++++ tuf/api/_payload.py | 21 +++++++++++++++++---- 2 files changed, 53 insertions(+), 4 deletions(-) diff --git a/tests/test_api.py b/tests/test_api.py index 1fa6229852..6afc060eca 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -1148,6 +1148,42 @@ def test_metadata_hash(self) -> None: # the object must work as a set member / dict key self.assertEqual(len({obj, equal_obj}), 1) + def test_metadata_hash_covers_content(self) -> None: + # Objects that differ only in their contained collections must not + # collide: the file hashes identify a MetaFile/TargetFile, and meta + # and targets identify a Snapshot/Targets. + expires = datetime(2030, 1, 1, tzinfo=timezone.utc) + + pairs = { + "MetaFile": ( + MetaFile(1, 10, {"sha256": "aa"}), + MetaFile(1, 10, {"sha256": "bb"}), + ), + "TargetFile": ( + TargetFile(10, {"sha256": "aa"}, "f"), + TargetFile(10, {"sha256": "bb"}, "f"), + ), + "Snapshot": ( + Snapshot(expires=expires, meta={"a.json": MetaFile(1)}), + Snapshot(expires=expires, meta={"b.json": MetaFile(2)}), + ), + "Targets": ( + Targets( + expires=expires, + targets={"a": TargetFile(1, {"sha256": "aa"}, "a")}, + ), + Targets( + expires=expires, + targets={"b": TargetFile(2, {"sha256": "bb"}, "b")}, + ), + ), + } + + for name, (first, second) in pairs.items(): + with self.subTest(name): + self.assertNotEqual(first, second) + self.assertNotEqual(hash(first), hash(second)) + def test_metadata_hash_ignores_unrecognized_fields(self) -> None: # unrecognized_fields holds arbitrary (possibly nested) JSON, so it is # left out of __hash__. Objects differing only in unrecognized_fields diff --git a/tuf/api/_payload.py b/tuf/api/_payload.py index 469c09ccb8..127b2b1892 100644 --- a/tuf/api/_payload.py +++ b/tuf/api/_payload.py @@ -846,7 +846,12 @@ def __eq__(self, other: object) -> bool: ) def __hash__(self) -> int: - return hash((self.version, self.length)) + hashes = ( + tuple(sorted(self.hashes.items())) + if self.hashes is not None + else None + ) + return hash((self.version, self.length, hashes)) @classmethod def from_dict(cls, meta_dict: dict[str, Any]) -> MetaFile: @@ -1027,7 +1032,7 @@ def __eq__(self, other: object) -> bool: return super().__eq__(other) and self.meta == other.meta def __hash__(self) -> int: - return hash((super().__hash__(), len(self.meta))) + return hash((super().__hash__(), tuple(sorted(self.meta.items())))) @classmethod def from_dict(cls, signed_dict: dict[str, Any]) -> Snapshot: @@ -1588,7 +1593,9 @@ def __eq__(self, other: object) -> bool: ) def __hash__(self) -> int: - return hash((self.length, self.path)) + return hash( + (self.length, self.path, tuple(sorted(self.hashes.items()))) + ) @classmethod def from_dict(cls, target_dict: dict[str, Any], path: str) -> TargetFile: @@ -1734,7 +1741,13 @@ def __eq__(self, other: object) -> bool: ) def __hash__(self) -> int: - return hash((super().__hash__(), len(self.targets), self.delegations)) + return hash( + ( + super().__hash__(), + tuple(sorted(self.targets.items())), + self.delegations, + ) + ) @classmethod def from_dict(cls, signed_dict: dict[str, Any]) -> Targets: From ad48242647cb12100eb52c257624e3e840479ee3 Mon Sep 17 00:00:00 2001 From: Sanigaram Sachith Reddy Date: Wed, 26 Aug 2026 18:41:13 +0530 Subject: [PATCH 3/8] Update tuf/api/_payload.py Co-authored-by: Jussi Kukkonen Signed-off-by: Sanigaram Sachith Reddy --- tuf/api/_payload.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tuf/api/_payload.py b/tuf/api/_payload.py index 127b2b1892..d7754261e8 100644 --- a/tuf/api/_payload.py +++ b/tuf/api/_payload.py @@ -565,7 +565,7 @@ def __hash__(self) -> int: ( super().__hash__(), tuple(sorted(self.keys)), - tuple(sorted(self.roles)), + tuple(sorted(self.roles.items())), self.consistent_snapshot, ) ) From a620e065064dc9cd1ee1e3353e37873718cc7702 Mon Sep 17 00:00:00 2001 From: Sanigaram Sachith Reddy Date: Wed, 26 Aug 2026 18:41:31 +0530 Subject: [PATCH 4/8] Update tuf/api/_payload.py Co-authored-by: Jussi Kukkonen Signed-off-by: Sanigaram Sachith Reddy --- tuf/api/_payload.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tuf/api/_payload.py b/tuf/api/_payload.py index d7754261e8..945727b45a 100644 --- a/tuf/api/_payload.py +++ b/tuf/api/_payload.py @@ -1466,7 +1466,7 @@ def __hash__(self) -> int: ( tuple(sorted(self.keys)), # Order of the delegated roles matters (see __eq__) - tuple(self.roles) if self.roles is not None else None, + tuple(self.roles.items()) if self.roles is not None else None, self.succinct_roles, ) ) From cbaeced7968222020a90d9f26372e4b59393efff Mon Sep 17 00:00:00 2001 From: Sanigaram Sachith Reddy Date: Wed, 26 Aug 2026 18:41:43 +0530 Subject: [PATCH 5/8] Update tuf/api/_payload.py Co-authored-by: Jussi Kukkonen Signed-off-by: Sanigaram Sachith Reddy --- tuf/api/_payload.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tuf/api/_payload.py b/tuf/api/_payload.py index 945727b45a..f4d500cc77 100644 --- a/tuf/api/_payload.py +++ b/tuf/api/_payload.py @@ -564,7 +564,7 @@ def __hash__(self) -> int: return hash( ( super().__hash__(), - tuple(sorted(self.keys)), + tuple(sorted(self.keys.items())), tuple(sorted(self.roles.items())), self.consistent_snapshot, ) From 9666256e333ae8384ea5eb6a340b2eb5ab47f56b Mon Sep 17 00:00:00 2001 From: Sanigaram Sachith Reddy Date: Wed, 26 Aug 2026 18:41:53 +0530 Subject: [PATCH 6/8] Update tuf/api/_payload.py Co-authored-by: Jussi Kukkonen Signed-off-by: Sanigaram Sachith Reddy --- tuf/api/_payload.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tuf/api/_payload.py b/tuf/api/_payload.py index f4d500cc77..9c0342227e 100644 --- a/tuf/api/_payload.py +++ b/tuf/api/_payload.py @@ -1464,7 +1464,7 @@ def __eq__(self, other: object) -> bool: def __hash__(self) -> int: return hash( ( - tuple(sorted(self.keys)), + tuple(sorted(self.keys.items())), # Order of the delegated roles matters (see __eq__) tuple(self.roles.items()) if self.roles is not None else None, self.succinct_roles, From 246e9be6da6c3b26f606d7e58c745290b11f951b Mon Sep 17 00:00:00 2001 From: Sanigaram Sachith Reddy Date: Wed, 26 Aug 2026 18:42:02 +0530 Subject: [PATCH 7/8] Update tuf/api/metadata.py Co-authored-by: Jussi Kukkonen Signed-off-by: Sanigaram Sachith Reddy --- tuf/api/metadata.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tuf/api/metadata.py b/tuf/api/metadata.py index 5dcb231dd3..fb62706d4e 100644 --- a/tuf/api/metadata.py +++ b/tuf/api/metadata.py @@ -148,7 +148,7 @@ def __eq__(self, other: object) -> bool: ) def __hash__(self) -> int: - return hash((tuple(self.signatures), self.signed)) + return hash((tuple(self.signatures.items()), self.signed)) @property def signed_bytes(self) -> bytes: From e7bf2019213ebad5f469c470f147272b3bb7a590 Mon Sep 17 00:00:00 2001 From: Sachith Reddy Date: Wed, 26 Aug 2026 18:45:14 +0530 Subject: [PATCH 8/8] Skip hash tests that need securesystemslib hashing Signed-off-by: Sachith Reddy --- tests/test_api.py | 35 +++++++++++++++++++++++++++++++++-- 1 file changed, 33 insertions(+), 2 deletions(-) diff --git a/tests/test_api.py b/tests/test_api.py index 6afc060eca..4efbd0a965 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -54,6 +54,22 @@ logger = logging.getLogger(__name__) +def _sslib_hashable() -> bool: + """Return True if securesystemslib Key objects can be hashed. + + Metadata containing Key or Signature objects is only hashable once + securesystemslib makes those hashable. + """ + try: + hash(SSlibKey("kid", "ed25519", "ed25519", {"public": "aa"})) + except TypeError: + return False + return True + + +SSLIB_HASHABLE = _sslib_hashable() + + class TestMetadata(unittest.TestCase): """Tests for public API of all classes in 'tuf/api/metadata.py'.""" @@ -1121,6 +1137,12 @@ def test_metadata_hash(self) -> None: expires = datetime(2030, 1, 1, tzinfo=timezone.utc) key = SSlibKey("kid1", "ed25519", "ed25519", {"public": "aa"}) delegated_role = DelegatedRole("r", ["kid1"], 1, False, ["*"], None) + signature = Signature("kid1", "abcd") + + def root_with_key() -> Root: + root = Root(expires=expires) + root.add_key(key, "targets") + return root factories: dict[str, Callable[[], object]] = { "MetaFile": lambda: MetaFile(1, 10, {"sha256": "ab"}), @@ -1128,15 +1150,24 @@ def test_metadata_hash(self) -> None: "Delegations": lambda: Delegations( {"kid1": key}, {"r": delegated_role} ), - "Root": lambda: Root(expires=expires), + "Root": root_with_key, "Timestamp": lambda: Timestamp(expires=expires), "Snapshot": lambda: Snapshot(expires=expires), "Targets": lambda: Targets(expires=expires), - "Metadata": lambda: Metadata(Snapshot(expires=expires)), + "Metadata": lambda: Metadata( + Snapshot(expires=expires), {"kid1": signature} + ), } + # Metadata holding securesystemslib Key or Signature objects cannot be + # hashed until those are hashable in securesystemslib itself. + needs_sslib_hashing = {"Root", "Delegations", "Metadata"} + for name, factory in factories.items(): with self.subTest(name): + if name in needs_sslib_hashing and not SSLIB_HASHABLE: + self.skipTest("securesystemslib Key/Signature not hashable") + obj, equal_obj = factory(), factory() self.assertIsInstance(hash(obj), int)