Skip to content

Commit 1758fbb

Browse files
author
Jussi Kukkonen
committed
Metadata API: Move "typed constructors" to Signed
Change the way Metadata[Root] and other typed Metadata are constructed. Before: root_md = Metadata._from_file(filename, signed_type=Root) after: root_md = Root.metadata_from_file(filename) This nicely removes the requirement for an extra argument. This unfortunately adds a lot of lines to metadata.py but actual LOC count does not really change -- it's just 2 additional abstract methods and 4 one-liner implementations for each of those. The only things these implementations do are: * annotates returns type as e.g. Metadata[Root] * raises if the signed type in input is incorrect Signed-off-by: Jussi Kukkonen <jkukkonen@vmware.com>
1 parent c79c7c6 commit 1758fbb

File tree

2 files changed

+171
-32
lines changed

2 files changed

+171
-32
lines changed

tests/test_api.py

Lines changed: 12 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -140,15 +140,15 @@ def test_typed_read(self):
140140
data = f.read()
141141

142142
# Loading a root file as "Metadata[Root]" succeeds
143-
md = Metadata.from_bytes(data, signed_type=Root)
144-
md2 = Metadata.from_file(path, signed_type=Root)
143+
md = Root.metadata_from_bytes(data)
144+
md2 = Root.metadata_from_file(path)
145145

146146
# Loading the file fails with non-"Root" type constraints
147147
for expected_type in [Timestamp, Snapshot, Targets]:
148148
with self.assertRaises(DeserializationError):
149-
Metadata.from_bytes(data, signed_type=expected_type)
149+
expected_type.metadata_from_bytes(data)
150150
with self.assertRaises(DeserializationError):
151-
Metadata.from_file(path, signed_type=expected_type)
151+
expected_type.metadata_from_file(path)
152152

153153

154154
def test_compact_json(self):
@@ -177,7 +177,7 @@ def test_read_write_read_compare(self):
177177

178178
def test_sign_verify(self):
179179
root_path = os.path.join(self.repo_dir, 'metadata', 'root.json')
180-
root = Metadata.from_file(root_path, signed_type=Root).signed
180+
root = Root.metadata_from_file(root_path).signed
181181

182182
# Locate the public keys we need from root
183183
targets_keyid = next(iter(root.roles["targets"].keyids))
@@ -189,7 +189,7 @@ def test_sign_verify(self):
189189

190190
# Load sample metadata (targets) and assert ...
191191
path = os.path.join(self.repo_dir, 'metadata', 'targets.json')
192-
metadata_obj = Metadata.from_file(path, signed_type=Targets)
192+
metadata_obj = Targets.metadata_from_file(path)
193193

194194
# ... it has a single existing signature,
195195
self.assertEqual(len(metadata_obj.signatures), 1)
@@ -306,7 +306,7 @@ def test_targetfile_class(self):
306306
def test_metadata_snapshot(self):
307307
snapshot_path = os.path.join(
308308
self.repo_dir, 'metadata', 'snapshot.json')
309-
snapshot = Metadata.from_file(snapshot_path, signed_type=Snapshot)
309+
snapshot = Snapshot.metadata_from_file(snapshot_path)
310310

311311
# Create a MetaFile instance representing what we expect
312312
# the updated data to be.
@@ -332,7 +332,7 @@ def test_metadata_snapshot(self):
332332
def test_metadata_timestamp(self):
333333
timestamp_path = os.path.join(
334334
self.repo_dir, 'metadata', 'timestamp.json')
335-
timestamp = Metadata.from_file(timestamp_path, signed_type=Timestamp)
335+
timestamp = Timestamp.metadata_from_file(timestamp_path)
336336

337337
self.assertEqual(timestamp.signed.version, 1)
338338
timestamp.signed.bump_version()
@@ -441,8 +441,7 @@ def test_role_class(self):
441441
def test_metadata_root(self):
442442
root_path = os.path.join(
443443
self.repo_dir, 'metadata', 'root.json')
444-
root = Metadata.from_file(root_path, signed_type=Root)
445-
444+
root = Root.metadata_from_file(root_path)
446445
# Add a second key to root role
447446
root_key2 = import_ed25519_publickey_from_file(
448447
os.path.join(self.keystore_dir, 'root_key2.pub'))
@@ -579,7 +578,7 @@ def test_delegation_class(self):
579578
def test_metadata_targets(self):
580579
targets_path = os.path.join(
581580
self.repo_dir, 'metadata', 'targets.json')
582-
targets = Metadata.from_file(targets_path, signed_type=Targets)
581+
targets = Targets.metadata_from_file(targets_path)
583582

584583
# Create a fileinfo dict representing what we expect the updated data to be
585584
filename = 'file2.txt'
@@ -668,7 +667,7 @@ def test_length_and_hash_validation(self):
668667
# for untrusted metadata file to verify.
669668
timestamp_path = os.path.join(
670669
self.repo_dir, 'metadata', 'timestamp.json')
671-
timestamp = Metadata.from_file(timestamp_path, signed_type=Timestamp)
670+
timestamp = Timestamp.metadata_from_file(timestamp_path)
672671
snapshot_metafile = timestamp.signed.meta["snapshot.json"]
673672

674673
snapshot_path = os.path.join(
@@ -702,7 +701,7 @@ def test_length_and_hash_validation(self):
702701
# Test target files' hash and length verification
703702
targets_path = os.path.join(
704703
self.repo_dir, 'metadata', 'targets.json')
705-
targets = Metadata.from_file(targets_path, signed_type=Targets)
704+
targets = Targets.metadata_from_file(targets_path)
706705
file1_targetfile = targets.signed.targets['file1.txt']
707706
filepath = os.path.join(
708707
self.repo_dir, 'targets', 'file1.txt')

tuf/api/metadata.py

Lines changed: 159 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -73,14 +73,11 @@ class Metadata(Generic[T]):
7373
[Root, Timestamp, Snapshot, Targets]. The purpose of this is to allow
7474
type checking of the signed attribute in code using Metadata::
7575
76-
root_md = Metadata.from_file("root.json", signed_type=Root)
77-
# root_md type is now Metadata[Root]. This means signed and its
76+
root_md = Root.metadata_from_file("root.json")
77+
print(root_md.signed.consistent_snapshot)
78+
# root_md type is now Metadata[Root]. This means root_md.signed and its
7879
# attributes like consistent_snapshot are now statically typed and the
7980
# types can be verified by static type checkers and shown by IDEs
80-
print(root_md.signed.consistent_snapshot)
81-
82-
Using the signed_type argument in factory constructors is not required but
83-
not doing so means T is not a specific type so static typing cannot happen.
8481
8582
Attributes:
8683
signed: A subclass of Signed, which has the actual metadata payload,
@@ -147,7 +144,6 @@ def from_file(
147144
filename: str,
148145
deserializer: Optional[MetadataDeserializer] = None,
149146
storage_backend: Optional[StorageBackendInterface] = None,
150-
signed_type: Optional[Type[T]] = None,
151147
) -> "Metadata[T]":
152148
"""Loads TUF metadata from file storage.
153149
@@ -159,7 +155,6 @@ def from_file(
159155
storage_backend: An object that implements
160156
securesystemslib.storage.StorageBackendInterface. Per default
161157
a (local) FilesystemBackend is used.
162-
signed_type: Optional; Expected type of deserialized signed object.
163158
164159
Raises:
165160
securesystemslib.exceptions.StorageError: The file cannot be read.
@@ -174,21 +169,19 @@ def from_file(
174169
storage_backend = FilesystemBackend()
175170

176171
with storage_backend.get(filename) as f:
177-
return Metadata.from_bytes(f.read(), deserializer, signed_type)
172+
return Metadata.from_bytes(f.read(), deserializer)
178173

179174
@staticmethod
180175
def from_bytes(
181176
data: bytes,
182177
deserializer: Optional[MetadataDeserializer] = None,
183-
signed_type: Optional[Type[T]] = None,
184178
) -> "Metadata[T]":
185179
"""Loads TUF metadata from raw data.
186180
187181
Arguments:
188182
data: metadata content as bytes.
189183
deserializer: Optional; A MetadataDeserializer instance that
190184
implements deserialization. Default is JSONDeserializer.
191-
signed_type: Optional; Expected type of deserialized signed object.
192185
193186
Raises:
194187
tuf.api.serialization.DeserializationError:
@@ -205,14 +198,7 @@ def from_bytes(
205198

206199
deserializer = JSONDeserializer()
207200

208-
md = deserializer.deserialize(data)
209-
210-
# Ensure deserialized signed type matches the requested type
211-
if signed_type is not None and signed_type != type(md.signed):
212-
raise DeserializationError(
213-
f"Expected {signed_type}, got {type(md.signed)}"
214-
)
215-
return md
201+
return deserializer.deserialize(data)
216202

217203
def to_dict(self) -> Dict[str, Any]:
218204
"""Returns the dict representation of self."""
@@ -364,6 +350,60 @@ def to_dict(self) -> Dict[str, Any]:
364350
"""Serialization helper that returns dict representation of self"""
365351
raise NotImplementedError
366352

353+
@classmethod
354+
@abc.abstractmethod
355+
def metadata_from_bytes(
356+
cls,
357+
data: bytes,
358+
deserializer: Optional[MetadataDeserializer] = None,
359+
) -> Metadata:
360+
"""Loads a Metadata object from bytes.
361+
362+
Like Metadata.from_bytes() but also raises DeserializationError if
363+
bytes does not contain the correct metadata type."""
364+
raise NotImplementedError
365+
366+
@classmethod
367+
@abc.abstractmethod
368+
def metadata_from_file(
369+
cls,
370+
filename: str,
371+
deserializer: Optional[MetadataDeserializer] = None,
372+
storage_backend: Optional[StorageBackendInterface] = None,
373+
) -> Metadata:
374+
"""Loads a Metadata object from file.
375+
376+
Like Metadata.from_file() but also raises DeserializationError if
377+
file does not contain the correct metadata type."""
378+
raise NotImplementedError
379+
380+
@classmethod
381+
def _metadata_from_bytes(
382+
cls, data: bytes, deserializer: Optional[MetadataDeserializer]
383+
) -> Metadata:
384+
"""Like Metadata.from_bytes() but raises on wrong type"""
385+
metadata = Metadata.from_bytes(data, deserializer)
386+
if not isinstance(metadata.signed, cls):
387+
raise DeserializationError(
388+
f"Expected {cls}, got {type(metadata.signed)}"
389+
)
390+
return metadata
391+
392+
@classmethod
393+
def _metadata_from_file(
394+
cls,
395+
filename: str,
396+
deserializer: Optional[MetadataDeserializer],
397+
storage_backend: Optional[StorageBackendInterface],
398+
) -> Metadata:
399+
"""Like Metadata.from_file() but raises on wrong type"""
400+
metadata = Metadata.from_file(filename, deserializer, storage_backend)
401+
if not isinstance(metadata.signed, cls):
402+
raise DeserializationError(
403+
f"Expected {cls}, got {type(metadata.signed)}"
404+
)
405+
return metadata
406+
367407
@classmethod
368408
@abc.abstractmethod
369409
def from_dict(cls, signed_dict: Dict[str, Any]) -> "Signed":
@@ -632,6 +672,31 @@ def __init__(
632672
self.keys = keys
633673
self.roles = roles
634674

675+
@classmethod
676+
def metadata_from_bytes(
677+
cls,
678+
data: bytes,
679+
deserializer: Optional[MetadataDeserializer] = None,
680+
) -> Metadata["Root"]:
681+
"""Loads a Metadata[Root] from raw data.
682+
683+
Like Metadata.from_bytes() but also raises DeserializationError if
684+
bytes does not contain root metadata."""
685+
return cls._metadata_from_bytes(data, deserializer)
686+
687+
@classmethod
688+
def metadata_from_file(
689+
cls,
690+
filename: str,
691+
deserializer: Optional[MetadataDeserializer] = None,
692+
storage_backend: Optional[StorageBackendInterface] = None,
693+
) -> Metadata["Root"]:
694+
"""Loads a Metadata[Root] from file.
695+
696+
Like Metadata.from_file() but also raises DeserializationError if file
697+
does not contain root metadata."""
698+
return cls._metadata_from_file(filename, deserializer, storage_backend)
699+
635700
@classmethod
636701
def from_dict(cls, signed_dict: Dict[str, Any]) -> "Root":
637702
"""Creates Root object from its dict representation."""
@@ -846,6 +911,31 @@ def from_dict(cls, signed_dict: Dict[str, Any]) -> "Timestamp":
846911
# All fields left in the timestamp_dict are unrecognized.
847912
return cls(*common_args, meta, signed_dict)
848913

914+
@classmethod
915+
def metadata_from_bytes(
916+
cls,
917+
data: bytes,
918+
deserializer: Optional[MetadataDeserializer] = None,
919+
) -> Metadata["Timestamp"]:
920+
"""Loads a Metadata[Timestamp] from raw data.
921+
922+
Like Metadata.from_bytes() but also raises DeserializationError if
923+
bytes does not contain timestamp metadata."""
924+
return cls._metadata_from_bytes(data, deserializer)
925+
926+
@classmethod
927+
def metadata_from_file(
928+
cls,
929+
filename: str,
930+
deserializer: Optional[MetadataDeserializer] = None,
931+
storage_backend: Optional[StorageBackendInterface] = None,
932+
) -> Metadata["Timestamp"]:
933+
"""Loads a Metadata[Timestamp] from file.
934+
935+
Like Metadata.from_file() but also raises DeserializationError if file
936+
does not contain timestamp metadata."""
937+
return cls._metadata_from_file(filename, deserializer, storage_backend)
938+
849939
def to_dict(self) -> Dict[str, Any]:
850940
"""Returns the dict representation of self."""
851941
res_dict = self._common_fields_to_dict()
@@ -898,6 +988,31 @@ def from_dict(cls, signed_dict: Dict[str, Any]) -> "Snapshot":
898988
# All fields left in the snapshot_dict are unrecognized.
899989
return cls(*common_args, meta, signed_dict)
900990

991+
@classmethod
992+
def metadata_from_bytes(
993+
cls,
994+
data: bytes,
995+
deserializer: Optional[MetadataDeserializer] = None,
996+
) -> Metadata["Snapshot"]:
997+
"""Loads a Metadata[Snapshot] from raw data.
998+
999+
Like Metadata.from_bytes() but also raises DeserializationError if
1000+
bytes does not contain snapshot metadata."""
1001+
return cls._metadata_from_bytes(data, deserializer)
1002+
1003+
@classmethod
1004+
def metadata_from_file(
1005+
cls,
1006+
filename: str,
1007+
deserializer: Optional[MetadataDeserializer] = None,
1008+
storage_backend: Optional[StorageBackendInterface] = None,
1009+
) -> Metadata["Snapshot"]:
1010+
"""Loads a Metadata[Snapshot] from file.
1011+
1012+
Like Metadata.from_file() but also raises DeserializationError if file
1013+
does not contain snapshot metadata."""
1014+
return cls._metadata_from_file(filename, deserializer, storage_backend)
1015+
9011016
def to_dict(self) -> Dict[str, Any]:
9021017
"""Returns the dict representation of self."""
9031018
snapshot_dict = self._common_fields_to_dict()
@@ -1161,6 +1276,31 @@ def from_dict(cls, signed_dict: Dict[str, Any]) -> "Targets":
11611276
# All fields left in the targets_dict are unrecognized.
11621277
return cls(*common_args, res_targets, delegations, signed_dict)
11631278

1279+
@classmethod
1280+
def metadata_from_bytes(
1281+
cls,
1282+
data: bytes,
1283+
deserializer: Optional[MetadataDeserializer] = None,
1284+
) -> Metadata["Targets"]:
1285+
"""Loads a Metadata[Targets] from raw data.
1286+
1287+
Like Metadata.from_bytes() but also raises DeserializationError if
1288+
bytes does not contain targets metadata."""
1289+
return cls._metadata_from_bytes(data, deserializer)
1290+
1291+
@classmethod
1292+
def metadata_from_file(
1293+
cls,
1294+
filename: str,
1295+
deserializer: Optional[MetadataDeserializer] = None,
1296+
storage_backend: Optional[StorageBackendInterface] = None,
1297+
) -> Metadata["Targets"]:
1298+
"""Loads a Metadata[Targets] from file.
1299+
1300+
Like Metadata.from_file() but also raises DeserializationError if file
1301+
does not contain targets metadata."""
1302+
return cls._metadata_from_file(filename, deserializer, storage_backend)
1303+
11641304
def to_dict(self) -> Dict[str, Any]:
11651305
"""Returns the dict representation of self."""
11661306
targets_dict = self._common_fields_to_dict()

0 commit comments

Comments
 (0)