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: