4949
5050from tuf .api import exceptions
5151from tuf .api .metadata import Root , Snapshot , TargetFile , Targets , Timestamp
52- from tuf .ngclient import urllib3_fetcher
53- from tuf .ngclient ._internal import trusted_metadata_set
52+ from tuf .ngclient ._internal .trusted_metadata_set import TrustedMetadataSet
5453from tuf .ngclient .config import EnvelopeType , UpdaterConfig
54+ from tuf .ngclient .urllib3_fetcher import Urllib3Fetcher
5555
5656if TYPE_CHECKING :
5757 from tuf .ngclient .fetcher import FetcherInterface
@@ -75,6 +75,9 @@ class Updater:
7575 download both metadata and targets. Default is ``Urllib3Fetcher``
7676 config: ``Optional``; ``UpdaterConfig`` could be used to setup common
7777 configuration options.
78+ bootstrap: ``Optional``; initial root metadata. If a boostrap root is
79+ not provided then the root.json in the metadata cache is used as the
80+ initial root.
7881
7982 Raises:
8083 OSError: Local root.json cannot be read
@@ -89,6 +92,7 @@ def __init__(
8992 target_base_url : str | None = None ,
9093 fetcher : FetcherInterface | None = None ,
9194 config : UpdaterConfig | None = None ,
95+ bootstrap : bytes | None = None ,
9296 ):
9397 self ._dir = metadata_dir
9498 self ._metadata_base_url = _ensure_trailing_slash (metadata_base_url )
@@ -99,27 +103,28 @@ def __init__(
99103 self ._target_base_url = _ensure_trailing_slash (target_base_url )
100104
101105 self .config = config or UpdaterConfig ()
102-
103106 if fetcher is not None :
104107 self ._fetcher = fetcher
105108 else :
106- self ._fetcher = urllib3_fetcher . Urllib3Fetcher (
109+ self ._fetcher = Urllib3Fetcher (
107110 app_user_agent = self .config .app_user_agent
108111 )
109-
110112 supported_envelopes = [EnvelopeType .METADATA , EnvelopeType .SIMPLE ]
111113 if self .config .envelope_type not in supported_envelopes :
112114 raise ValueError (
113115 f"config: envelope_type must be one of { supported_envelopes } , "
114116 f"got '{ self .config .envelope_type } '"
115117 )
116118
117- # Read trusted local root metadata
118- data = self ._load_local_metadata (Root .type )
119+ if not bootstrap :
120+ # if no root was provided, use the cached non-versioned root.json
121+ bootstrap = self ._load_local_metadata (Root .type )
119122
120- self ._trusted_set = trusted_metadata_set .TrustedMetadataSet (
121- data , self .config .envelope_type
123+ # Load the initial root, make sure it's cached in root_history/
124+ self ._trusted_set = TrustedMetadataSet (
125+ bootstrap , self .config .envelope_type
122126 )
127+ self ._persist_root (self ._trusted_set .root .version , bootstrap )
123128
124129 def refresh (self ) -> None :
125130 """Refresh top-level metadata.
@@ -296,12 +301,31 @@ def _load_local_metadata(self, rolename: str) -> bytes:
296301 return f .read ()
297302
298303 def _persist_metadata (self , rolename : str , data : bytes ) -> None :
299- """Write metadata to disk atomically to avoid data loss."""
300- temp_file_name : str | None = None
304+ """Write metadata to disk atomically to avoid data loss.
305+
306+ Use a filename _not_ prefixed with version (e.g. "timestamp.json")
307+ . Encode the rolename to avoid issues with e.g. path separators
308+ """
309+
310+ encoded_name = parse .quote (rolename , "" )
311+ filename = os .path .join (self ._dir , f"{ encoded_name } .json" )
312+ self ._persist_file (filename , data )
313+
314+ def _persist_root (self , version : int , data : bytes ) -> None :
315+ """Write root metadata to disk atomically to avoid data loss.
316+
317+ Use a filename prefixed with version (e.g. "1.root.json").
318+ """
319+ rootdir = os .path .join (self ._dir , "root_history" )
320+ with contextlib .suppress (FileExistsError ):
321+ os .mkdir (rootdir )
322+ self ._persist_file (os .path .join (rootdir , f"{ version } .root.json" ), data )
323+
324+ def _persist_file (self , filename : str , data : bytes ) -> None :
325+ """Write a file to disk atomically to avoid data loss."""
326+ temp_file_name = None
327+
301328 try :
302- # encode the rolename to avoid issues with e.g. path separators
303- encoded_name = parse .quote (rolename , "" )
304- filename = os .path .join (self ._dir , f"{ encoded_name } .json" )
305329 with tempfile .NamedTemporaryFile (
306330 dir = self ._dir , delete = False
307331 ) as temp_file :
@@ -317,32 +341,53 @@ def _persist_metadata(self, rolename: str, data: bytes) -> None:
317341 raise e
318342
319343 def _load_root (self ) -> None :
320- """Load remote root metadata.
344+ """Load root metadata.
321345
322- Sequentially load and persist on local disk every newer root metadata
323- version available on the remote.
346+ Sequentially load and persist every newer root metadata
347+ version available, either locally or on the remote.
324348 """
325349
326350 # Update the root role
327351 lower_bound = self ._trusted_set .root .version + 1
328352 upper_bound = lower_bound + self .config .max_root_rotations
329353
330354 for next_version in range (lower_bound , upper_bound ):
355+ # look for next_version in local cache
356+ try :
357+ root_path = os .path .join (
358+ self ._dir , "root_history" , f"{ next_version } .root.json"
359+ )
360+ with open (root_path , "rb" ) as f :
361+ self ._trusted_set .update_root (f .read ())
362+ continue
363+ except (OSError , exceptions .RepositoryError ) as e :
364+ # this root did not exist locally or is invalid
365+ logger .debug ("Local root is not valid: %s" , e )
366+
367+ # next_version was not found locally, try remote
331368 try :
332369 data = self ._download_metadata (
333370 Root .type ,
334371 self .config .root_max_length ,
335372 next_version ,
336373 )
337374 self ._trusted_set .update_root (data )
338- self ._persist_metadata ( Root . type , data )
375+ self ._persist_root ( next_version , data )
339376
340377 except exceptions .DownloadHTTPError as exception :
341378 if exception .status_code not in {403 , 404 }:
342379 raise
343380 # 404/403 means current root is newest available
344381 break
345382
383+ # Make sure there's a non-versioned root.json
384+ linkname = os .path .join (self ._dir , "root.json" )
385+ version = self ._trusted_set .root .version
386+ current = os .path .join ("root_history" , f"{ version } .root.json" )
387+ with contextlib .suppress (FileNotFoundError ):
388+ os .remove (linkname )
389+ os .symlink (current , linkname )
390+
346391 def _load_timestamp (self ) -> None :
347392 """Load local and remote timestamp metadata."""
348393 try :
0 commit comments