diff --git a/tests/test_api.py b/tests/test_api.py index 5d2dce0269..939434fbbe 100755 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -33,9 +33,12 @@ def setUpModule(): import tuf.exceptions from tuf.api.metadata import ( Metadata, + MetadataInfo, + Root, Snapshot, Timestamp, - Targets + Targets, + TargetInfo ) from securesystemslib.interface import ( @@ -92,6 +95,7 @@ def tearDownClass(cls): def test_generic_read(self): for metadata, inner_metadata_cls in [ + ('root', Root), ('snapshot', Snapshot), ('timestamp', Timestamp), ('targets', Targets)]: @@ -137,7 +141,7 @@ def test_compact_json(self): def test_read_write_read_compare(self): - for metadata in ['snapshot', 'timestamp', 'targets']: + for metadata in ['root', 'snapshot', 'timestamp', 'targets']: path = os.path.join(self.repo_dir, 'metadata', metadata + '.json') metadata_obj = Metadata.from_json_file(path) @@ -222,15 +226,20 @@ def test_metadata_snapshot(self): # Create a dict representing what we expect the updated data to be fileinfo = copy.deepcopy(snapshot.signed.meta) hashes = {'sha256': 'c2986576f5fdfd43944e2b19e775453b96748ec4fe2638a6d2f32f1310967095'} - fileinfo['role1.json']['version'] = 2 - fileinfo['role1.json']['hashes'] = hashes - fileinfo['role1.json']['length'] = 123 + fileinfo['role1.json'].version = 2 + fileinfo['role1.json'].hashes = hashes + fileinfo['role1.json'].length = 123 self.assertNotEqual(snapshot.signed.meta, fileinfo) snapshot.signed.update('role1', 2, 123, hashes) self.assertEqual(snapshot.signed.meta, fileinfo) + # Update only version. Length and hashes are optional. + snapshot.signed.update('role1', 3) + fileinfo['role1.json'] = MetadataInfo(3) + self.assertEqual(snapshot.signed.meta, fileinfo) + def test_metadata_timestamp(self): timestamp_path = os.path.join( @@ -257,14 +266,18 @@ def test_metadata_timestamp(self): self.assertEqual(timestamp.signed.expires, datetime(2036, 1, 3, 0, 0)) hashes = {'sha256': '0ae9664468150a9aa1e7f11feecb32341658eb84292851367fea2da88e8a58dc'} - fileinfo = copy.deepcopy(timestamp.signed.meta['snapshot.json']) - fileinfo['hashes'] = hashes - fileinfo['version'] = 2 - fileinfo['length'] = 520 - - self.assertNotEqual(timestamp.signed.meta['snapshot.json'], fileinfo) + fileinfo = copy.deepcopy(timestamp.signed.meta) + fileinfo['snapshot.json'].hashes = hashes + fileinfo['snapshot.json'].version = 2 + fileinfo['snapshot.json'].length = 520 + self.assertNotEqual(timestamp.signed.meta, fileinfo) timestamp.signed.update(2, 520, hashes) - self.assertEqual(timestamp.signed.meta['snapshot.json'], fileinfo) + self.assertEqual(timestamp.signed.meta, fileinfo) + + # Update only version. Length and hashes are optional. + timestamp.signed.update(3) + fileinfo['snapshot.json'] = MetadataInfo(version=3) + self.assertEqual(timestamp.signed.meta, fileinfo) def test_metadata_root(self): @@ -312,15 +325,12 @@ def test_metadata_targets(self): "sha512": "ef5beafa16041bcdd2937140afebd485296cd54f7348ecd5a4d035c09759608de467a7ac0eb58753d0242df873c305e8bffad2454aa48f44480f15efae1cacd0" }, - fileinfo = { - 'hashes': hashes, - 'length': 28 - } + fileinfo = TargetInfo(length=28, hashes=hashes) # Assert that data is not aleady equal self.assertNotEqual(targets.signed.targets[filename], fileinfo) # Update an already existing fileinfo - targets.signed.update(filename, fileinfo) + targets.signed.update(filename, fileinfo.to_dict()) # Verify that data is updated self.assertEqual(targets.signed.targets[filename], fileinfo) diff --git a/tuf/api/metadata.py b/tuf/api/metadata.py index a747be6d13..7da694cc01 100644 --- a/tuf/api/metadata.py +++ b/tuf/api/metadata.py @@ -1,3 +1,5 @@ +# pylint: disable=E1101 + """TUF role metadata model. This module provides container classes for TUF role metadata, including methods @@ -273,14 +275,14 @@ def __init__( self, _type: str, version: int, spec_version: str, expires: datetime) -> None: + # "_type" is not actually meant to be "protected" in the OOP sense + # but rather is a workaround to not shadow the built-in "type" name. self._type = _type self.version = version self.spec_version = spec_version self.expires = expires # TODO: Should we separate data validation from constructor? - if version < 0: - raise ValueError(f'version must be < 0, got {version}') self.version = version @@ -289,22 +291,27 @@ def __init__( def from_dict(cls, signed_dict: JsonDict) -> 'Signed': """Creates Signed object from its JSON/dict representation. """ + # Create empty object with default or parametrized constructor with + # default arguments. + # Warnings about rules E1120 (no-value-for-parameter) and W0212 + # (protected-access) are not relevant here because cls is most likely + # a descendant of "Signed" and we need to setup the appropriate fields. + obj = cls() # pylint: disable=E1120 + obj._type = signed_dict['_type'] # pylint: disable=W0212 + obj.version = signed_dict['version'] + obj.spec_version = signed_dict['spec_version'] # Convert 'expires' TUF metadata string to a datetime object, which is # what the constructor expects and what we store. The inverse operation # is implemented in 'to_dict'. - signed_dict['expires'] = tuf.formats.expiry_string_to_datetime( + obj.expires = tuf.formats.expiry_string_to_datetime( signed_dict['expires']) - # NOTE: We write the converted 'expires' back into 'signed_dict' above - # so that we can pass it to the constructor as '**signed_dict' below, - # along with other fields that belong to Signed subclasses. - # Any 'from_dict'(-like) conversions of fields that correspond to a - # subclass should be performed in the 'from_dict' method of that - # subclass and also be written back into 'signed_dict' before calling - # super().from_dict. + # NOTE: Any 'from_dict'(-like) conversions of fields that correspond + # to a subclass should be performed in the 'from_dict' method of that + # subclass and assigned to the below returned object. # NOTE: cls might be a subclass of Signed, if 'from_dict' was called on # that subclass (see e.g. Metadata.from_dict). - return cls(**signed_dict) + return obj # Serialization. @@ -342,30 +349,33 @@ class Root(Signed): supports consistent snapshots. keys: A dictionary that contains a public key store used to verify top level roles metadata signatures:: - { - '': { - 'keytype': '', - 'scheme': '', - 'keyid_hash_algorithms': [ - '', - '' - ... - ], - 'keyval': { - 'public': '' - } + + { + '': { + 'keytype': '', + 'scheme': '', + 'keyid_hash_algorithms': [ + '', + '' + ... + ], + 'keyval': { + 'public': '' + } + }, + ... }, - ... - }, + roles: A dictionary that contains a list of signing keyids and a signature threshold for each top level role:: - { - '': { - 'keyids': ['', ...], - 'threshold': , - }, - ... - } + + { + '': { + 'keyids': ['', ...], + 'threshold': , + }, + ... + } """ # TODO: determine an appropriate value for max-args and fix places where @@ -373,9 +383,13 @@ class Root(Signed): # default max-args value for pylint is 5 # pylint: disable=too-many-arguments def __init__( - self, _type: str, version: int, spec_version: str, - expires: datetime, consistent_snapshot: bool, - keys: JsonDict, roles: JsonDict) -> None: + self, _type: str=None, version: int=None, spec_version: str=None, + expires: datetime=None, consistent_snapshot: bool=None, + keys: JsonDict=None, roles: JsonDict=None) -> None: + + # TODO: Add sensible defaults when we have input validation. + # See issue https://github.com/theupdateframework/tuf/issues/1140 + # We need default values to create empty objects in Signed.from_dict() super().__init__(_type, version, spec_version, expires) # TODO: Add classes for keys and roles self.consistent_snapshot = consistent_snapshot @@ -383,6 +397,18 @@ def __init__( self.roles = roles + @classmethod + def from_dict(cls, signed_dict: JsonDict) -> 'Root': + """Creates Root object from its JSON/dict representation. """ + + # Get a parent object with its attributes already assigned. + obj = super().from_dict(signed_dict) + obj.consistent_snapshot = signed_dict['consistent_snapshot'] + obj.keys = signed_dict['keys'] + obj.roles = signed_dict['roles'] + return obj + + # Serialization. def to_dict(self) -> JsonDict: """Returns the JSON-serializable dictionary representation of self. """ @@ -415,6 +441,50 @@ def remove_key(self, role: str, keyid: str) -> None: del self.keys[keyid] +class MetadataInfo: + """A container with information about a particular metadata file. + Instances of MetadataInfo are used as values in a dictionary called + "meta" in Timestamp and Snapshot. + + Attributes: + version: An integer indicating the version of the metadata file. + length: An optional integer indicating the length of the metadata file. + hashes: A optional dictionary containing hash algorithms and the + hashes resulting from applying them over the metadata file.:: + + 'hashes': { + '': '', + '': '', + ... + } + + """ + + def __init__(self, version: int, length: Optional[int] = None, + hashes: Optional[JsonDict] = None) -> None: + self.version = version + self.length = length + self.hashes = hashes + + + def __eq__(self, other: 'MetadataInfo') -> bool: + """Compare objects by their values instead of by their addresses.""" + return (self.version == other.version and + self.length == other.length and + self.hashes == other.hashes) + + + def to_dict(self) -> JsonDict: + """Returns the JSON-serializable dictionary representation of self. """ + json_dict = {'version': self.version} + + if self.length is not None: + json_dict['length'] = self.length + + if self.hashes is not None: + json_dict['hashes'] = self.hashes + + return json_dict class Timestamp(Signed): @@ -424,23 +494,19 @@ class Timestamp(Signed): meta: A dictionary that contains information about snapshot metadata:: { - 'snapshot.json': { - 'version': , - 'length': , // optional - 'hashes': { - '': '', - '': '', - ... - } - } + 'snapshot.json': } """ def __init__( - self, _type: str, version: int, spec_version: str, - expires: datetime, meta: JsonDict) -> None: + self, _type: str=None, version: int=None, spec_version: str=None, + expires: datetime=None, meta: Dict[str, MetadataInfo]=None + ) -> None: + + # TODO: Add sensible defaults when we have input validation. + # See issue https://github.com/theupdateframework/tuf/issues/1140 + # We need default values to create empty objects in Signed.from_dict() super().__init__(_type, version, spec_version, expires) - # TODO: Add class for meta self.meta = meta @@ -449,19 +515,35 @@ def to_dict(self) -> JsonDict: """Returns the JSON-serializable dictionary representation of self. """ json_dict = super().to_dict() json_dict.update({ - 'meta': self.meta + 'meta': { + 'snapshot.json': self.meta['snapshot.json'].to_dict() + } }) return json_dict + @classmethod + def from_dict(cls, signed_dict: JsonDict) -> 'Timestamp': + """Creates Timestamp object from its JSON/dict representation. """ + + # Get a parent object with its attributes already assigned. + obj = super().from_dict(signed_dict) + obj.meta = {} + meta = signed_dict['meta']['snapshot.json'] + obj.meta['snapshot.json'] = MetadataInfo(meta['version'], + meta.get('length'), meta.get('hashes')) + + return obj + + # Modification. - def update(self, version: int, length: int, hashes: JsonDict) -> None: + def update(self, version: int, length: Optional[int] = None, + hashes: Optional[JsonDict] = None) -> None: """Assigns passed info about snapshot metadata to meta dict. """ - self.meta['snapshot.json'] = { - 'version': version, - 'length': length, - 'hashes': hashes - } + + # TODO: Consider renaming this function: + # see: https://github.com/theupdateframework/tuf/issues/1230 + self.meta['snapshot.json'] = MetadataInfo(version, length, hashes) class Snapshot(Signed): @@ -471,38 +553,50 @@ 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': , ... } """ def __init__( - self, _type: str, version: int, spec_version: str, - expires: datetime, meta: JsonDict) -> None: + self, _type: str=None, version: int=None, spec_version: str=None, + expires: datetime=None, meta: Dict[str, MetadataInfo]=None + ) -> None: + + # TODO: Add sensible defaults when we have input validation. + # See issue https://github.com/theupdateframework/tuf/issues/1140 + # We need default values to create empty objects in Signed.from_dict() super().__init__(_type, version, spec_version, expires) - # TODO: Add class for meta self.meta = meta + + @classmethod + def from_dict(cls, signed_dict: JsonDict) -> 'Snapshot': + """Creates Snapshot object from its JSON/dict representation. """ + + # Get a parent object with its attributes already assigned. + obj = super().from_dict(signed_dict) + obj.meta = {} + for meta_path in signed_dict['meta'].keys(): + meta = signed_dict['meta'][meta_path] + obj.meta[meta_path] = MetadataInfo(meta['version'], + meta.get('length'), meta.get('hashes')) + + return obj + + # Serialization. def to_dict(self) -> JsonDict: """Returns the JSON-serializable dictionary representation of self. """ json_dict = super().to_dict() + meta_dict = {} + for meta_path, meta_info in self.meta.items(): + meta_dict[meta_path] = meta_info.to_dict() + json_dict.update({ - 'meta': self.meta + 'meta': meta_dict }) return json_dict @@ -512,14 +606,64 @@ def update( self, rolename: str, version: int, length: Optional[int] = None, hashes: Optional[JsonDict] = None) -> None: """Assigns passed (delegated) targets role info to meta dict. """ + + # TODO: Consider renaming this function: + # see: https://github.com/theupdateframework/tuf/issues/1230 metadata_fn = f'{rolename}.json' - self.meta[metadata_fn] = {'version': version} - if length is not None: - self.meta[metadata_fn]['length'] = length + self.meta[metadata_fn] = MetadataInfo(version, length, hashes) + + +class TargetInfo: + """A container with information about a particular target file. + Instances of TargetInfo are used as values in a dictionary + called "targets" in Targets. + + Attributes: + length: An integer indicating the length of the target file. + hashes: A dictionary containing hash algorithms and the + hashes resulted from applying them over the target file:: + + 'hashes': { + '': '', + '': '', + ... + } + + custom: An optional dictionary which may include version numbers, + dependencies, or any other data that the application wants + to include to describe the target file:: + + 'custom': { + 'type': 'metadata', + 'file_permissions': '0644', + ... + } // optional + + """ - if hashes is not None: - self.meta[metadata_fn]['hashes'] = hashes + def __init__(self, length: int, hashes: JsonDict, + custom: Optional[JsonDict] = None) -> None: + self.length = length + self.hashes = hashes + self.custom = custom + + + def __eq__(self, other: 'TargetInfo') -> bool: + """Compare objects by their values instead of by their addresses.""" + return (self.length == other.length and + self.hashes == other.hashes and + self.custom == other.custom) + + + def to_dict(self) -> JsonDict: + """Returns the JSON-serializable dictionary representation of self. """ + json_dict = {'length': self.length, 'hashes': self.hashes} + + if self.custom is not None: + json_dict['custom'] = self.custom + + return json_dict class Targets(Signed): @@ -529,15 +673,7 @@ class Targets(Signed): targets: A dictionary that contains information about target files:: { - '': { - 'length': , - 'hashes': { - '': '', - '': '', - ... - }, - 'custom': // optional - }, + '': , ... } @@ -545,34 +681,34 @@ class Targets(Signed): roles and public key store used to verify their metadata signatures:: - { - 'keys' : { - '': { - 'keytype': '', - 'scheme': '', - 'keyid_hash_algorithms': [ - '', - '' - ... - ], - 'keyval': { - 'public': '' - } + { + 'keys' : { + '': { + 'keytype': '', + 'scheme': '', + 'keyid_hash_algorithms': [ + '', + '' + ... + ], + 'keyval': { + 'public': '' + } + }, + ... }, + 'roles': [ + { + 'name': '', + 'keyids': ['', ...], + 'threshold': , + 'terminating': , + 'path_hash_prefixes': ['', ... ], // or + 'paths' : ['PATHPATTERN', ... ], + }, ... - }, - 'roles': [ - { - 'name': '', - 'keyids': ['', ...], - 'threshold': , - 'terminating': , - 'path_hash_prefixes': ['', ... ], // or - 'paths' : ['PATHPATTERN', ... ], - }, - ... - ] - } + ] + } """ # TODO: determine an appropriate value for max-args and fix places where @@ -580,21 +716,46 @@ class Targets(Signed): # default max-args value for pylint is 5 # pylint: disable=too-many-arguments def __init__( - self, _type: str, version: int, spec_version: str, - expires: datetime, targets: JsonDict, delegations: JsonDict - ) -> None: + self, _type: str=None, version: int=None, spec_version: str=None, + expires: datetime=None, targets: Dict[str, TargetInfo]=None, + delegations: JsonDict=None) -> None: + + # TODO: Add sensible defaults when we have input validation. + # See issue https://github.com/theupdateframework/tuf/issues/1140 + # We need default values to create empty objects in Signed.from_dict() super().__init__(_type, version, spec_version, expires) - # TODO: Add class for meta self.targets = targets + + # TODO: Add Key and Role classes self.delegations = delegations + @classmethod + def from_dict(cls, signed_dict: JsonDict) -> 'Targets': + """Creates Targets object from its JSON/dict representation. """ + + # Get a parent object with its attributes already assigned. + obj = super().from_dict(signed_dict) + obj.targets = {} + for target_path in signed_dict['targets'].keys(): + info = signed_dict['targets'][target_path] + obj.targets[target_path] = TargetInfo(info['length'], + info['hashes'], info.get('custom')) + + obj.delegations = signed_dict['delegations'] + return obj + + # Serialization. def to_dict(self) -> JsonDict: """Returns the JSON-serializable dictionary representation of self. """ json_dict = super().to_dict() + target_dict = {} + for target_path, target_file_obj in self.targets.items(): + target_dict[target_path] = target_file_obj.to_dict() + json_dict.update({ - 'targets': self.targets, + 'targets': target_dict, 'delegations': self.delegations, }) return json_dict @@ -602,4 +763,8 @@ def to_dict(self) -> JsonDict: # Modification. def update(self, filename: str, fileinfo: JsonDict) -> None: """Assigns passed target file info to meta dict. """ - self.targets[filename] = fileinfo + + # TODO: Consider renaming this function: + # see: https://github.com/theupdateframework/tuf/issues/1230 + self.targets[filename] = TargetInfo(fileinfo['length'], + fileinfo['hashes'], fileinfo.get('custom'))