From fac307e2d1d2ab299f9d7a95997f3358f11b2633 Mon Sep 17 00:00:00 2001 From: Martin Vrachev Date: Tue, 12 Apr 2022 17:34:26 +0300 Subject: [PATCH] Add HashDelegations class Add HashDelegations class containing the information from the succinct_hash_delegation dict described in TAP 15. This allows for easy mypy checks on the types, easy enforcement on TAP 15 restrictions (as for example that "delegation_hash_prefix_len" must be between 1 and 32) and support for unrecognized fields inside succinct_hash_delegation without much of a hassle. Signed-off-by: Martin Vrachev --- tests/test_api.py | 6 +- tests/test_metadata_serialization.py | 14 ++++- tuf/api/metadata.py | 88 +++++++++++++++++++++++----- 3 files changed, 87 insertions(+), 21 deletions(-) diff --git a/tests/test_api.py b/tests/test_api.py index 0638343dbf..db05dedab9 100755 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -698,7 +698,7 @@ class TestData: result: str # For ease of testing we are going to use target files with short names. - get_rolename_dataset: utils.DataSet = { + find_delegation_dataset: utils.DataSet = { "one-hash-prefix & target hash inside zero bin": TestData( # The binary representation of all numbers starts with 0 hash_prefix_len=1, @@ -771,8 +771,8 @@ class TestData: ), } - @utils.run_sub_tests_with_dataset(get_rolename_dataset) - def test_get_rolename_with_succinct_hash_info( + @utils.run_sub_tests_with_dataset(find_delegation_dataset) + def test_find_delegation_with_succinct_hash_info( self, test_data: TestData ) -> None: r = DelegatedRole( diff --git a/tests/test_metadata_serialization.py b/tests/test_metadata_serialization.py index b7e2925851..87a3a0f2ae 100644 --- a/tests/test_metadata_serialization.py +++ b/tests/test_metadata_serialization.py @@ -385,12 +385,14 @@ def test_snapshot_serialization(self, test_case_data: str) -> None: "terminating": false, "threshold": 1}', "ordered keyids": '{"keyids": ["keyid2", "keyid1"], "name": "a", "paths": ["fn1", "fn2"], \ "terminating": false, "threshold": 1}', + "succinct_hash_delegation with unrecognized fieldse": '{"keyids": ["keyid"], "terminating": false, \ + "threshold": 99, "succinct_hash_delegations": {"delegation_hash_prefix_len": 8, "bin_name_prefix": "foo", "foo": "bar"}}', } @utils.run_sub_tests_with_dataset(valid_delegated_roles) def test_delegated_role_serialization(self, test_case_data: str) -> None: case_dict = json.loads(test_case_data) - deserialized_role = DelegatedRole.from_dict(copy.copy(case_dict)) + deserialized_role = DelegatedRole.from_dict(copy.deepcopy(case_dict)) self.assertDictEqual(case_dict, deserialized_role.to_dict()) invalid_delegated_roles: utils.DataSet = { @@ -413,6 +415,14 @@ def test_delegated_role_serialization(self, test_case_data: str) -> None: "succinct_hash_delegations": {"bin_name_prefix" : "foo"}}', "missing bin_name_prefix from succinct_hash_delegations": '{"name": "a", "keyids": ["keyid"], "threshold": 1, "terminating": false, \ "succinct_hash_delegations": {"delegation_hash_prefix_len" : 8}}', + "succinct_hash_delegation with invalid delegation_hash_prefix_len type": '{"keyids": ["keyid"], "terminating": false, \ + "threshold": 99, "succinct_hash_delegations": {"delegation_hash_prefix_len": "a", "bin_name_prefix": "foo"}}', + "succinct_hash_delegation with invalid bin_name_prefix type": '{"keyids": ["keyid"], "terminating": false, \ + "threshold": 99, "succinct_hash_delegations": {"delegation_hash_prefix_len": 8, "bin_name_prefix": 1}}', + "succinct_hash_delegation with high delegation_hash_prefix_len value": '{"keyids": ["keyid"], "terminating": false, \ + "threshold": 99, "succinct_hash_delegations": {"delegation_hash_prefix_len": 50, "bin_name_prefix": "foo"}}', + "succinct_hash_delegation with low delegation_hash_prefix_len value": '{"keyids": ["keyid"], "terminating": false, \ + "threshold": 99, "succinct_hash_delegations": {"delegation_hash_prefix_len": 0, "bin_name_prefix": "foo"}}', } @utils.run_sub_tests_with_dataset(invalid_delegated_roles) @@ -420,7 +430,7 @@ def test_invalid_delegated_role_serialization( self, test_case_data: str ) -> None: case_dict = json.loads(test_case_data) - with self.assertRaises(ValueError): + with self.assertRaises((ValueError, TypeError)): DelegatedRole.from_dict(case_dict) invalid_delegations: utils.DataSet = { diff --git a/tuf/api/metadata.py b/tuf/api/metadata.py index 010ec0c781..b13681c0a5 100644 --- a/tuf/api/metadata.py +++ b/tuf/api/metadata.py @@ -1258,6 +1258,64 @@ def to_dict(self) -> Dict[str, Any]: return snapshot_dict +class HashDelegations: + """A container with information about a succinct hash delegation. + + Args: + hash_prefix_len: Number of bits between 1 and 32 used to separate the + different bins. + bin_name_prefix: Prefix of all bin names. + + Raises: + ValueError, TypeError: Invalid arguments. + """ + + def __init__( + self, + hash_prefix_len: int, + bin_name_prefix: str, + unrecognized_fields: Optional[Mapping[str, Any]] = None, + ) -> None: + if hash_prefix_len <= 0 or hash_prefix_len > 32: + raise ValueError("hash_prefix_len must be between 1 and 32") + if not isinstance(bin_name_prefix, str): + raise ValueError("bin_name_prefix must be a string") + + self.hash_prefix_len = hash_prefix_len + self.bin_name_prefix = bin_name_prefix + self.unrecognized_fields: Mapping[str, Any] = unrecognized_fields or {} + + def __eq__(self, other: Any) -> bool: + if not isinstance(other, HashDelegations): + return False + + return ( + self.hash_prefix_len == other.hash_prefix_len + and self.bin_name_prefix == other.bin_name_prefix + and self.unrecognized_fields == other.unrecognized_fields + ) + + @classmethod + def from_dict(cls, succinct_hash_info: Dict[str, Any]) -> "HashDelegations": + """Creates ``HashDelegations`` object from its json/dict representation. + + Raises: + ValueError, TypeError: Invalid arguments. + """ + hash_prefix_len = succinct_hash_info.pop("delegation_hash_prefix_len") + bin_name_prefix = succinct_hash_info.pop("bin_name_prefix") + # All fields left in the succinct_hash_info are unrecognized. + return cls(hash_prefix_len, bin_name_prefix, succinct_hash_info) + + def to_dict(self) -> Dict[str, Any]: + """Returns the dict representation of self.""" + return { + "delegation_hash_prefix_len": self.hash_prefix_len, + "bin_name_prefix": self.bin_name_prefix, + **self.unrecognized_fields, + } + + class DelegatedRole(Role): """A container with information about a delegated role. @@ -1321,19 +1379,17 @@ def __init__( not isinstance(p, str) for p in path_hash_prefixes ): raise ValueError("Path_hash_prefixes must be strings") + + succinct_info = None if succinct_hash_info is not None: if not isinstance(succinct_hash_info, Dict): raise ValueError("succinct_hash_info must be a dict") - if "delegation_hash_prefix_len" not in succinct_hash_info: - raise ValueError( - "succinct_hash_info requires delegation_hash_prefix_len" - ) - if "bin_name_prefix" not in succinct_hash_info: - raise ValueError("succinct_hash_info requires bin_name_prefix") + + succinct_info = HashDelegations.from_dict(succinct_hash_info) self.paths = paths self.path_hash_prefixes = path_hash_prefixes - self.succinct_hash_info = succinct_hash_info + self.succinct_hash_info = succinct_info def __eq__(self, other: Any) -> bool: if not isinstance(other, DelegatedRole): @@ -1377,19 +1433,19 @@ def from_dict(cls, role_dict: Dict[str, Any]) -> "DelegatedRole": def to_dict(self) -> Dict[str, Any]: """Returns the dict representation of self.""" base_role_dict = super().to_dict() - res_dict: Dict[str, Any] = { + res: Dict[str, Any] = { "terminating": self.terminating, **base_role_dict, } if self.name is not None: - res_dict["name"] = self.name + res["name"] = self.name if self.paths is not None: - res_dict["paths"] = self.paths + res["paths"] = self.paths elif self.path_hash_prefixes is not None: - res_dict["path_hash_prefixes"] = self.path_hash_prefixes + res["path_hash_prefixes"] = self.path_hash_prefixes elif self.succinct_hash_info is not None: - res_dict["succinct_hash_delegations"] = self.succinct_hash_info - return res_dict + res["succinct_hash_delegations"] = self.succinct_hash_info.to_dict() + return res @staticmethod def _is_target_in_pathpattern(targetpath: str, pathpattern: str) -> bool: @@ -1410,7 +1466,7 @@ def _is_target_in_pathpattern(targetpath: str, pathpattern: str) -> bool: return True def _calculate_delegation(self, hash_bits_representation: str) -> str: - """Helper function for get_rolename calculating the actual rolename. + """Helper function for find_delegation calculating the actual rolename. Args: hash_bits_representation: binary bit representation of the target @@ -1421,11 +1477,11 @@ def _calculate_delegation(self, hash_bits_representation: str) -> str: "succinct_hash_info must be set to calculate the delegation" ) - bit_length = self.succinct_hash_info["delegation_hash_prefix_len"] + bit_length = self.succinct_hash_info.hash_prefix_len # Get the first bit_length of bits and then cast them to decimal. bin_number = int(hash_bits_representation[:bit_length], 2) - name_prefix = self.succinct_hash_info["bin_name_prefix"] + name_prefix = self.succinct_hash_info.bin_name_prefix return f"{name_prefix}-{bin_number}" def find_delegation(self, target_filepath: str) -> Optional[str]: