Skip to content

Commit e6787ae

Browse files
author
Jussi Kukkonen
authored
Merge pull request theupdateframework#1587 from jku/tests-repo-sim-targets-support
tests: Add target support to RepositorySimulator
2 parents cc1f95e + 59b0b99 commit e6787ae

File tree

2 files changed

+121
-8
lines changed

2 files changed

+121
-8
lines changed

tests/repository_simulator.py

+80-1
Original file line numberDiff line numberDiff line change
@@ -13,20 +13,51 @@
1313
as a way to "download" new metadata from remote: in practice no downloading,
1414
network connections or even file access happens as RepositorySimulator serves
1515
everything from memory.
16+
17+
Metadata and targets "hosted" by the simulator are made available in URL paths
18+
"/metadata/..." and "/targets/..." respectively.
19+
20+
Example::
21+
22+
# constructor creates repository with top-level metadata
23+
sim = RepositorySimulator()
24+
25+
# metadata can be modified directly: it is immediately available to clients
26+
sim.snapshot.version += 1
27+
28+
# As an exception, new root versions require explicit publishing
29+
sim.root.version += 1
30+
sim.publish_root()
31+
32+
# there are helper functions
33+
sim.add_target("targets", b"content", "targetpath")
34+
sim.targets.version += 1
35+
sim.update_snapshot()
36+
37+
# Use the simulated repository from an Updater:
38+
updater = Updater(
39+
dir,
40+
"https://example.com/metadata/",
41+
"https://example.com/targets/",
42+
sim
43+
)
44+
updater.refresh()
1645
"""
1746

1847
from collections import OrderedDict
48+
from dataclasses import dataclass
1949
from datetime import datetime, timedelta
2050
import logging
2151
import os
2252
import tempfile
53+
from securesystemslib.hash import digest
2354
from securesystemslib.keys import generate_ed25519_key
2455
from securesystemslib.signer import SSlibSigner
2556
from typing import Dict, Iterator, List, Optional, Tuple
2657
from urllib import parse
2758

2859
from tuf.api.serialization.json import JSONSerializer
29-
from tuf.exceptions import FetcherHTTPError
60+
from tuf.exceptions import FetcherHTTPError, RepositoryError
3061
from tuf.api.metadata import (
3162
Key,
3263
Metadata,
@@ -35,6 +66,7 @@
3566
Root,
3667
SPECIFICATION_VERSION,
3768
Snapshot,
69+
TargetFile,
3870
Targets,
3971
Timestamp,
4072
)
@@ -44,6 +76,11 @@
4476

4577
SPEC_VER = ".".join(SPECIFICATION_VERSION)
4678

79+
@dataclass
80+
class RepositoryTarget:
81+
"""Contains actual target data and the related target metadata"""
82+
data: bytes
83+
target_file: TargetFile
4784

4885
class RepositorySimulator(FetcherInterface):
4986
def __init__(self):
@@ -60,6 +97,9 @@ def __init__(self):
6097
# signers are used on-demand at fetch time to sign metadata
6198
self.signers: Dict[str, List[SSlibSigner]] = {}
6299

100+
# target downloads are served from this dict
101+
self.target_files: Dict[str, RepositoryTarget] = {}
102+
63103
self.dump_dir = None
64104
self.dump_version = 0
65105

@@ -126,6 +166,9 @@ def publish_root(self):
126166
logger.debug("Published root v%d", self.root.version)
127167

128168
def fetch(self, url: str) -> Iterator[bytes]:
169+
if not self.root.consistent_snapshot:
170+
raise NotImplementedError("non-consistent snapshot not supported")
171+
129172
spliturl = parse.urlparse(url)
130173
if spliturl.path.startswith("/metadata/"):
131174
parts = spliturl.path[len("/metadata/") :].split(".")
@@ -136,10 +179,36 @@ def fetch(self, url: str) -> Iterator[bytes]:
136179
version = None
137180
role = parts[0]
138181
yield self._fetch_metadata(role, version)
182+
elif spliturl.path.startswith("/targets/"):
183+
# figure out target path and hash prefix
184+
path = spliturl.path[len("/targets/") :]
185+
dir_parts, sep , prefixed_filename = path.rpartition("/")
186+
prefix, _, filename = prefixed_filename.partition(".")
187+
target_path = f"{dir_parts}{sep}{filename}"
188+
189+
yield self._fetch_target(target_path, prefix)
139190
else:
140191
raise FetcherHTTPError(f"Unknown path '{spliturl.path}'", 404)
141192

193+
def _fetch_target(self, target_path: str, hash: Optional[str]) -> bytes:
194+
"""Return data for 'target_path', checking 'hash' if it is given.
195+
196+
If hash is None, then consistent_snapshot is not used
197+
"""
198+
repo_target = self.target_files.get(target_path)
199+
if repo_target is None:
200+
raise FetcherHTTPError(f"No target {target_path}", 404)
201+
if hash and hash not in repo_target.target_file.hashes.values():
202+
raise FetcherHTTPError(f"hash mismatch for {target_path}", 404)
203+
204+
logger.debug("fetched target %s", target_path)
205+
return repo_target.data
206+
142207
def _fetch_metadata(self, role: str, version: Optional[int] = None) -> bytes:
208+
"""Return signed metadata for 'role', using 'version' if it is given.
209+
210+
If version is None, non-versioned metadata is being requested
211+
"""
143212
if role == "root":
144213
# return a version previously serialized in publish_root()
145214
if version is None or version > len(self.signed_roots):
@@ -187,6 +256,16 @@ def update_snapshot(self):
187256
self.snapshot.version += 1
188257
self.update_timestamp()
189258

259+
def add_target(self, role: str, data: bytes, path: str):
260+
if role == "targets":
261+
targets = self.targets
262+
else:
263+
targets = self.md_delegates[role].signed
264+
265+
target = TargetFile.from_data(path, data, ["sha256"])
266+
targets.targets[path] = target
267+
self.target_files[path] = RepositoryTarget(data, target)
268+
190269
def write(self):
191270
"""Dump current repository metadata to self.dump_dir
192271

tests/test_updater_with_simulator.py

+41-7
Original file line numberDiff line numberDiff line change
@@ -24,11 +24,15 @@ class TestUpdater(unittest.TestCase):
2424
dump_dir:Optional[str] = None
2525

2626
def setUp(self):
27-
self.client_dir = tempfile.TemporaryDirectory()
27+
self.temp_dir = tempfile.TemporaryDirectory()
28+
self.metadata_dir = os.path.join(self.temp_dir.name, "metadata")
29+
self.targets_dir = os.path.join(self.temp_dir.name, "targets")
30+
os.mkdir(self.metadata_dir)
31+
os.mkdir(self.targets_dir)
2832

2933
# Setup the repository, bootstrap client root.json
3034
self.sim = RepositorySimulator()
31-
with open(os.path.join(self.client_dir.name, "root.json"), "bw") as f:
35+
with open(os.path.join(self.metadata_dir, "root.json"), "bw") as f:
3236
root = self.sim.download_bytes("https://example.com/metadata/1.root.json", 100000)
3337
f.write(root)
3438

@@ -38,17 +42,21 @@ def setUp(self):
3842
self.sim.dump_dir = os.path.join(self.dump_dir, name)
3943
os.mkdir(self.sim.dump_dir)
4044

41-
def _run_refresh(self):
45+
def tearDown(self):
46+
self.temp_dir.cleanup()
47+
48+
def _run_refresh(self) -> Updater:
4249
if self.sim.dump_dir is not None:
4350
self.sim.write()
4451

4552
updater = Updater(
46-
self.client_dir.name,
53+
self.metadata_dir,
4754
"https://example.com/metadata/",
4855
"https://example.com/targets/",
4956
self.sim
5057
)
5158
updater.refresh()
59+
return updater
5260

5361
def test_refresh(self):
5462
# Update top level metadata
@@ -71,6 +79,35 @@ def test_refresh(self):
7179

7280
self._run_refresh()
7381

82+
def test_targets(self):
83+
# target does not exist yet
84+
updater = self._run_refresh()
85+
self.assertIsNone(updater.get_one_valid_targetinfo("file"))
86+
87+
self.sim.targets.version += 1
88+
self.sim.add_target("targets", b"content", "file")
89+
self.sim.update_snapshot()
90+
91+
# target now exists, is not in cache yet
92+
updater = self._run_refresh()
93+
file_info = updater.get_one_valid_targetinfo("file")
94+
self.assertIsNotNone(file_info)
95+
self.assertEqual(
96+
updater.updated_targets([file_info], self.targets_dir), [file_info]
97+
)
98+
99+
# download target, assert it is in cache and content is correct
100+
updater.download_target(file_info, self.targets_dir)
101+
self.assertEqual(
102+
updater.updated_targets([file_info], self.targets_dir), []
103+
)
104+
with open(os.path.join(self.targets_dir, "file"), "rb") as f:
105+
self.assertEqual(f.read(), b"content")
106+
107+
# TODO: run the same download tests for
108+
# self.sim.add_target("targets", b"more content", "dir/file2")
109+
# This currently fails because issue #1576
110+
74111
def test_keys_and_signatures(self):
75112
"""Example of the two trickiest test areas: keys and root updates"""
76113

@@ -110,9 +147,6 @@ def test_keys_and_signatures(self):
110147

111148
self._run_refresh()
112149

113-
def tearDown(self):
114-
self.client_dir.cleanup()
115-
116150
if __name__ == "__main__":
117151
if "--dump" in sys.argv:
118152
TestUpdater.dump_dir = tempfile.mkdtemp()

0 commit comments

Comments
 (0)