From 70a9d3ba8780e121542bf1a9286689307f2ff03d Mon Sep 17 00:00:00 2001 From: Sachith Reddy Date: Mon, 3 Aug 2026 21:07:54 +0000 Subject: [PATCH] Fix Role.__hash__ crashing on unhashable list/dict attributes Signed-off-by: Sachith Reddy --- tests/test_api.py | 29 +++++++++++++++++++++++++++++ tuf/api/_payload.py | 12 +++++++++--- 2 files changed, 38 insertions(+), 3 deletions(-) diff --git a/tests/test_api.py b/tests/test_api.py index d5305a9c35..e156d6b332 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -34,6 +34,7 @@ Delegations, Metadata, MetaFile, + Role, Root, RootVerificationResult, Signature, @@ -1082,6 +1083,34 @@ def test_is_delegated_role(self) -> None: self.assertFalse(role.is_delegated_path("a/non-matching path")) self.assertTrue(role.is_delegated_path("a/path")) + def test_role_and_delegated_role_hash(self) -> None: + # Role.__hash__ previously tried to hash self.keyids (a list) and + # self.unrecognized_fields (a dict), both unhashable, so hash() + # crashed with TypeError for every Role subclass. + role = Role(["keyid1", "keyid2"], 1) + self.assertIsInstance(hash(role), int) + + # equal objects must produce equal hashes (Python data model) + role2 = Role(["keyid1", "keyid2"], 1) + self.assertEqual(role, role2) + self.assertEqual(hash(role), hash(role2)) + + # DelegatedRole.__hash__ also referenced a non-existent 'path' + # attribute (the real attribute is 'paths'); verify it's hashable + # with both paths and path_hash_prefixes variants. + dr = DelegatedRole("role1", [], 1, False, ["*"], None) + self.assertIsInstance(hash(dr), int) + dr2 = DelegatedRole("role1", [], 1, False, ["*"], None) + self.assertEqual(dr, dr2) + self.assertEqual(hash(dr), hash(dr2)) + + dr_prefix = DelegatedRole("role2", [], 1, False, None, ["abc"]) + self.assertIsInstance(hash(dr_prefix), int) + + # a DelegatedRole must now actually work as a set member / dict key + role_set = {dr, dr2} + self.assertEqual(len(role_set), 1) + 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 bfcc87a659..bf8d846721 100644 --- a/tuf/api/_payload.py +++ b/tuf/api/_payload.py @@ -311,7 +311,7 @@ def __eq__(self, other: object) -> bool: ) def __hash__(self) -> int: - return hash((self.keyids, self.threshold, self.unrecognized_fields)) + return hash((tuple(self.keyids), self.threshold)) @classmethod def from_dict(cls, role_dict: dict[str, Any]) -> Role: @@ -1131,13 +1131,19 @@ def __eq__(self, other: object) -> bool: ) def __hash__(self) -> int: + paths = tuple(self.paths) if self.paths is not None else None + prefixes = ( + tuple(self.path_hash_prefixes) + if self.path_hash_prefixes is not None + else None + ) return hash( ( super().__hash__(), self.name, self.terminating, - self.path, - self.path_hash_prefixes, + paths, + prefixes, ) )