13
13
as a way to "download" new metadata from remote: in practice no downloading,
14
14
network connections or even file access happens as RepositorySimulator serves
15
15
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()
16
45
"""
17
46
18
47
from collections import OrderedDict
48
+ from dataclasses import dataclass
19
49
from datetime import datetime , timedelta
20
50
import logging
21
51
import os
22
52
import tempfile
53
+ from securesystemslib .hash import digest
23
54
from securesystemslib .keys import generate_ed25519_key
24
55
from securesystemslib .signer import SSlibSigner
25
56
from typing import Dict , Iterator , List , Optional , Tuple
26
57
from urllib import parse
27
58
28
59
from tuf .api .serialization .json import JSONSerializer
29
- from tuf .exceptions import FetcherHTTPError
60
+ from tuf .exceptions import FetcherHTTPError , RepositoryError
30
61
from tuf .api .metadata import (
31
62
Key ,
32
63
Metadata ,
35
66
Root ,
36
67
SPECIFICATION_VERSION ,
37
68
Snapshot ,
69
+ TargetFile ,
38
70
Targets ,
39
71
Timestamp ,
40
72
)
44
76
45
77
SPEC_VER = "." .join (SPECIFICATION_VERSION )
46
78
79
+ @dataclass
80
+ class RepositoryTarget :
81
+ """Contains actual target data and the related target metadata"""
82
+ data : bytes
83
+ target_file : TargetFile
47
84
48
85
class RepositorySimulator (FetcherInterface ):
49
86
def __init__ (self ):
@@ -60,6 +97,9 @@ def __init__(self):
60
97
# signers are used on-demand at fetch time to sign metadata
61
98
self .signers : Dict [str , List [SSlibSigner ]] = {}
62
99
100
+ # target downloads are served from this dict
101
+ self .target_files : Dict [str , RepositoryTarget ] = {}
102
+
63
103
self .dump_dir = None
64
104
self .dump_version = 0
65
105
@@ -126,6 +166,9 @@ def publish_root(self):
126
166
logger .debug ("Published root v%d" , self .root .version )
127
167
128
168
def fetch (self , url : str ) -> Iterator [bytes ]:
169
+ if not self .root .consistent_snapshot :
170
+ raise NotImplementedError ("non-consistent snapshot not supported" )
171
+
129
172
spliturl = parse .urlparse (url )
130
173
if spliturl .path .startswith ("/metadata/" ):
131
174
parts = spliturl .path [len ("/metadata/" ) :].split ("." )
@@ -136,10 +179,36 @@ def fetch(self, url: str) -> Iterator[bytes]:
136
179
version = None
137
180
role = parts [0 ]
138
181
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 )
139
190
else :
140
191
raise FetcherHTTPError (f"Unknown path '{ spliturl .path } '" , 404 )
141
192
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
+
142
207
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
+ """
143
212
if role == "root" :
144
213
# return a version previously serialized in publish_root()
145
214
if version is None or version > len (self .signed_roots ):
@@ -187,6 +256,16 @@ def update_snapshot(self):
187
256
self .snapshot .version += 1
188
257
self .update_timestamp ()
189
258
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
+
190
269
def write (self ):
191
270
"""Dump current repository metadata to self.dump_dir
192
271
0 commit comments