From 3771a77ffefa10f0eae9fed89398ac81e57f2d27 Mon Sep 17 00:00:00 2001 From: Martin Vrachev Date: Tue, 30 Mar 2021 17:39:10 +0300 Subject: [PATCH] New API: Add MetaFile class In the top-level metadata classes, there are complex attributes such as "meta" in Targets and Snapshot, "key" and "roles" in Root etc. We want to represent those complex attributes with a class to allow easier verification and support for metadata with unrecognized fields. For more context read ADR 0004 and ADR 0008 in the docs/adr folder. Additionally, after adding the MetaFile class, when we create an object we are now calling from dict twice - one for the main class (Timestamp, Snapshot) and one for the pacticular complex attribute - MetaFile.from_dict(). Given that the "from_dict" methods have the side effect of destroying the given dictionary, we would need to start using deepcopy() for our tests. Signed-off-by: Martin Vrachev --- tests/test_api.py | 57 +++++++++++--------- tuf/api/metadata.py | 127 +++++++++++++++++++++++++++----------------- 2 files changed, 111 insertions(+), 73 deletions(-) diff --git a/tests/test_api.py b/tests/test_api.py index c0c40ba4..71c4eee6 100755 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -29,6 +29,7 @@ Targets, Key, Role, + MetaFile, Delegations, DelegatedRole, ) @@ -246,28 +247,31 @@ def test_metadata_snapshot(self): self.repo_dir, 'metadata', 'snapshot.json') snapshot = Metadata.from_file(snapshot_path) - # Create a dict representing what we expect the updated data to be - fileinfo = copy.deepcopy(snapshot.signed.meta) + # Create a MetaFile instance representing what we expect + # the updated data to be. hashes = {'sha256': 'c2986576f5fdfd43944e2b19e775453b96748ec4fe2638a6d2f32f1310967095'} - fileinfo['role1.json']['version'] = 2 - fileinfo['role1.json']['hashes'] = hashes - fileinfo['role1.json']['length'] = 123 + fileinfo = MetaFile(2, 123, hashes) - - self.assertNotEqual(snapshot.signed.meta, fileinfo) + self.assertNotEqual( + snapshot.signed.meta['role1.json'].to_dict(), fileinfo.to_dict() + ) snapshot.signed.update('role1', 2, 123, hashes) - self.assertEqual(snapshot.signed.meta, fileinfo) + self.assertEqual( + snapshot.signed.meta['role1.json'].to_dict(), fileinfo.to_dict() + ) # Update only version. Length and hashes are optional. snapshot.signed.update('role2', 3) - fileinfo['role2.json'] = {'version': 3} - self.assertEqual(snapshot.signed.meta, fileinfo) + fileinfo = MetaFile(3) + self.assertEqual( + snapshot.signed.meta['role2.json'].to_dict(), fileinfo.to_dict() + ) # Test from_dict and to_dict without hashes and length. snapshot_dict = snapshot.to_dict() - test_dict = snapshot_dict['signed'].copy() - del test_dict['meta']['role1.json']['length'] - del test_dict['meta']['role1.json']['hashes'] + del snapshot_dict['signed']['meta']['role1.json']['length'] + del snapshot_dict['signed']['meta']['role1.json']['hashes'] + test_dict = copy.deepcopy(snapshot_dict['signed']) snapshot = Snapshot.from_dict(test_dict) self.assertEqual(snapshot_dict['signed'], snapshot.to_dict()) @@ -295,28 +299,33 @@ def test_metadata_timestamp(self): timestamp.signed.bump_expiration(delta) self.assertEqual(timestamp.signed.expires, datetime(2036, 1, 3, 0, 0)) + # Create a MetaFile instance representing what we expect + # the updated data to be. hashes = {'sha256': '0ae9664468150a9aa1e7f11feecb32341658eb84292851367fea2da88e8a58dc'} - fileinfo = copy.deepcopy(timestamp.signed.meta['snapshot.json']) - fileinfo['hashes'] = hashes - fileinfo['version'] = 2 - fileinfo['length'] = 520 + fileinfo = MetaFile(2, 520, hashes) - self.assertNotEqual(timestamp.signed.meta['snapshot.json'], fileinfo) + self.assertNotEqual( + timestamp.signed.meta['snapshot.json'].to_dict(), fileinfo.to_dict() + ) timestamp.signed.update(2, 520, hashes) - self.assertEqual(timestamp.signed.meta['snapshot.json'], fileinfo) + self.assertEqual( + timestamp.signed.meta['snapshot.json'].to_dict(), fileinfo.to_dict() + ) # Test from_dict and to_dict without hashes and length. timestamp_dict = timestamp.to_dict() - test_dict = timestamp_dict['signed'].copy() - del test_dict['meta']['snapshot.json']['length'] - del test_dict['meta']['snapshot.json']['hashes'] + del timestamp_dict['signed']['meta']['snapshot.json']['length'] + del timestamp_dict['signed']['meta']['snapshot.json']['hashes'] + test_dict = copy.deepcopy(timestamp_dict['signed']) timestamp_test = Timestamp.from_dict(test_dict) self.assertEqual(timestamp_dict['signed'], timestamp_test.to_dict()) # Update only version. Length and hashes are optional. timestamp.signed.update(3) - fileinfo = {'version': 3} - self.assertEqual(timestamp.signed.meta['snapshot.json'], fileinfo) + fileinfo = MetaFile(version=3) + self.assertEqual( + timestamp.signed.meta['snapshot.json'].to_dict(), fileinfo.to_dict() + ) def test_key_class(self): keys = { diff --git a/tuf/api/metadata.py b/tuf/api/metadata.py index 47e01ba6..0cd44d76 100644 --- a/tuf/api/metadata.py +++ b/tuf/api/metadata.py @@ -598,6 +598,60 @@ def remove_key(self, role: str, keyid: str) -> None: del self.keys[keyid] +class MetaFile: + """A container with information about a particular metadata file. + + Attributes: + version: An integer indicating the version of the metadata file. + length: An optional integer indicating the length of the metadata file. + hashes: An optional dictionary mapping hash algorithms to the + hashes resulting from applying them over the metadata file + contents.:: + + 'hashes': { + '': '', + '': '', + ... + } + + unrecognized_fields: Dictionary of all unrecognized fields. + + """ + + def __init__( + self, + version: int, + length: Optional[int] = None, + hashes: Optional[Dict[str, str]] = None, + unrecognized_fields: Optional[Mapping[str, Any]] = None, + ) -> None: + self.version = version + self.length = length + self.hashes = hashes + self.unrecognized_fields: Mapping[str, Any] = unrecognized_fields or {} + + @classmethod + def from_dict(cls, meta_dict: Dict[str, Any]) -> "MetaFile": + """Creates MetaFile object from its dict representation.""" + version = meta_dict.pop("version") + length = meta_dict.pop("length", None) + hashes = meta_dict.pop("hashes", None) + # All fields left in the meta_dict are unrecognized. + return cls(version, length, hashes, meta_dict) + + def to_dict(self) -> Dict[str, Any]: + """Returns the dictionary representation of self.""" + res_dict = {"version": self.version, **self.unrecognized_fields} + + if self.length is not None: + res_dict["length"] = self.length + + if self.hashes is not None: + res_dict["hashes"] = self.hashes + + return res_dict + + class Timestamp(Signed): """A container for the signed part of timestamp metadata. @@ -605,15 +659,7 @@ class Timestamp(Signed): meta: A dictionary that contains information about snapshot metadata:: { - 'snapshot.json': { - 'version': , - 'length': , // optional - 'hashes': { - '': '', - '': '', - ... - } // optional - } + 'snapshot.json': } """ @@ -625,26 +671,28 @@ def __init__( version: int, spec_version: str, expires: datetime, - meta: Dict[str, Any], + meta: Dict[str, MetaFile], unrecognized_fields: Optional[Mapping[str, Any]] = None, ) -> None: super().__init__(version, spec_version, expires, unrecognized_fields) - # TODO: Add class for meta self.meta = meta @classmethod def from_dict(cls, timestamp_dict: Dict[str, Any]) -> "Timestamp": """Creates Timestamp object from its dict representation.""" common_args = cls._common_fields_from_dict(timestamp_dict) - meta = timestamp_dict.pop("meta") + meta_dict = timestamp_dict.pop("meta") + meta = {"snapshot.json": MetaFile.from_dict(meta_dict["snapshot.json"])} # All fields left in the timestamp_dict are unrecognized. return cls(*common_args, meta, timestamp_dict) def to_dict(self) -> Dict[str, Any]: """Returns the dict representation of self.""" - timestamp_dict = self._common_fields_to_dict() - timestamp_dict.update({"meta": self.meta}) - return timestamp_dict + res_dict = self._common_fields_to_dict() + res_dict["meta"] = { + "snapshot.json": self.meta["snapshot.json"].to_dict() + } + return res_dict # Modification. def update( @@ -654,13 +702,7 @@ def update( hashes: Optional[Dict[str, Any]] = None, ) -> None: """Assigns passed info about snapshot metadata to meta dict.""" - self.meta["snapshot.json"] = {"version": version} - - if length is not None: - self.meta["snapshot.json"]["length"] = length - - if hashes is not None: - self.meta["snapshot.json"]["hashes"] = hashes + self.meta["snapshot.json"] = MetaFile(version, length, hashes) class Snapshot(Signed): @@ -670,22 +712,9 @@ class Snapshot(Signed): meta: A dictionary that contains information about targets metadata:: { - 'targets.json': { - 'version': , - 'length': , // optional - 'hashes': { - '': '', - '': '', - ... - } // optional - }, - '.json': { - ... - }, - '.json': { - ... - }, - ... + 'targets.json': , + '.json': , + '.json': , } """ @@ -697,25 +726,31 @@ def __init__( version: int, spec_version: str, expires: datetime, - meta: Dict[str, Any], + meta: Dict[str, MetaFile], unrecognized_fields: Optional[Mapping[str, Any]] = None, ) -> None: super().__init__(version, spec_version, expires, unrecognized_fields) - # TODO: Add class for meta self.meta = meta @classmethod def from_dict(cls, snapshot_dict: Dict[str, Any]) -> "Snapshot": """Creates Snapshot object from its dict representation.""" common_args = cls._common_fields_from_dict(snapshot_dict) - meta = snapshot_dict.pop("meta") + meta_dicts = snapshot_dict.pop("meta") + meta = {} + for meta_path, meta_dict in meta_dicts.items(): + meta[meta_path] = MetaFile.from_dict(meta_dict) # All fields left in the snapshot_dict are unrecognized. return cls(*common_args, meta, snapshot_dict) def to_dict(self) -> Dict[str, Any]: """Returns the dict representation of self.""" snapshot_dict = self._common_fields_to_dict() - snapshot_dict.update({"meta": self.meta}) + meta_dict = {} + for meta_path, meta_info in self.meta.items(): + meta_dict[meta_path] = meta_info.to_dict() + + snapshot_dict["meta"] = meta_dict return snapshot_dict # Modification. @@ -728,13 +763,7 @@ def update( ) -> None: """Assigns passed (delegated) targets role info to meta dict.""" metadata_fn = f"{rolename}.json" - - self.meta[metadata_fn] = {"version": version} - if length is not None: - self.meta[metadata_fn]["length"] = length - - if hashes is not None: - self.meta[metadata_fn]["hashes"] = hashes + self.meta[metadata_fn] = MetaFile(version, length, hashes) class DelegatedRole(Role):