Skip to main content

tough/
lib.rs

1// Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved.
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Tough is a client library for [TUF repositories].
5//!
6//! This client adheres to [TUF version 1.0.0][spec], with the following exceptions:
7//!
8//! * Delegated roles (and TAP 3) are not yet supported.
9//! * TAP 4 (multiple repository consensus) is not yet supported.
10//!
11//! [TUF repositories]: https://theupdateframework.github.io/
12//! [spec]: https://github.com/theupdateframework/specification/blob/9f148556ca15da2ec5c022c8b3e6f99a028e5fe5/tuf-spec.md
13//!
14//! # Testing
15//!
16//! Unit tests are run in the usual manner: `cargo test`.
17//! Integration tests require `noxious-server` and are disabled by default behind a feature named `integ`.
18//! To run all tests, including integration tests: `cargo test --all-features` or
19//! `cargo test --features 'http,integ'`.
20
21#![forbid(missing_debug_implementations)]
22#![deny(missing_copy_implementations)]
23#![deny(rust_2018_idioms)]
24// missing_docs is on its own line to make it easy to comment out when making changes.
25#![deny(missing_docs)]
26#![warn(clippy::pedantic)]
27#![allow(
28    clippy::module_name_repetitions,
29    clippy::must_use_candidate,
30    clippy::missing_errors_doc,
31    clippy::result_large_err
32)]
33
34mod cache;
35mod datastore;
36pub mod editor;
37pub mod error;
38mod fetch;
39#[cfg(feature = "http")]
40pub mod http;
41mod io;
42pub mod key_source;
43pub mod schema;
44pub mod sign;
45mod target_name;
46mod transport;
47mod urlpath;
48
49use crate::datastore::Datastore;
50use crate::error::Result;
51use crate::fetch::{fetch_max_size, fetch_sha256};
52/// An HTTP transport that includes retries.
53#[cfg(feature = "http")]
54pub use crate::http::{HttpTransport, HttpTransportBuilder};
55use crate::io::is_dir;
56use crate::schema::{
57    DelegatedRole, Delegations, Role, RoleType, Root, Signed, Snapshot, Timestamp,
58};
59pub use crate::target_name::TargetName;
60pub use crate::transport::IntoVec;
61pub use crate::transport::{
62    DefaultTransport, FilesystemTransport, Transport, TransportError, TransportErrorKind,
63    TransportStream,
64};
65pub use crate::urlpath::SafeUrlPath;
66use async_recursion::async_recursion;
67pub use async_trait::async_trait;
68pub use bytes::Bytes;
69use error::SnapshotTargetsMetaMissingSnafu;
70use futures::StreamExt;
71use futures_core::Stream;
72use jiff::Timestamp as JiffTimestamp;
73use log::warn;
74use percent_encoding::{utf8_percent_encode, AsciiSet, NON_ALPHANUMERIC};
75use snafu::{ensure, OptionExt, ResultExt};
76use std::collections::{BTreeSet, HashMap};
77use std::path::{Path, PathBuf};
78use tempfile::NamedTempFile;
79use tokio::fs::{canonicalize, create_dir_all};
80use tokio::io::AsyncWriteExt;
81use url::Url;
82
83/// Represents whether a Repository should fail to load when metadata is expired (`Safe`) or whether
84/// it should ignore expired metadata (`Unsafe`). Only use `Unsafe` if you are sure you need it.
85#[derive(Debug, Clone, Copy, PartialEq, Eq)]
86pub enum ExpirationEnforcement {
87    /// Expirations will be enforced. You MUST use this option to get TUF security guarantees.
88    Safe,
89
90    /// Expirations will not be enforced. This is available for certain offline use cases, does NOT
91    /// provide TUF security guarantees, and should only be used if you are sure that you need it.
92    Unsafe,
93}
94
95/// `ExpirationEnforcement` defaults to `Safe` mode.
96impl Default for ExpirationEnforcement {
97    fn default() -> Self {
98        ExpirationEnforcement::Safe
99    }
100}
101
102impl From<bool> for ExpirationEnforcement {
103    fn from(b: bool) -> Self {
104        if b {
105            ExpirationEnforcement::Safe
106        } else {
107            ExpirationEnforcement::Unsafe
108        }
109    }
110}
111
112impl From<ExpirationEnforcement> for bool {
113    fn from(ee: ExpirationEnforcement) -> Self {
114        ee == ExpirationEnforcement::Safe
115    }
116}
117
118/// A builder for settings with which to load a [`Repository`]. Required settings are provided in
119/// the [`RepositoryLoader::new`] function. Optional parameters can be added after calling new.
120/// Finally, call [`RepositoryLoader::load`] to load the [`Repository`].
121///
122/// # Examples
123///
124/// ## Basic usage:
125///
126/// ```rust
127/// # use std::path::PathBuf;
128/// # use tough::RepositoryLoader;
129/// # use url::Url;
130/// # let dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests").join("data").join("tuf-reference-impl");
131/// # let root = dir.join("metadata").join("1.root.json");
132/// # let metadata_base_url = Url::from_file_path(dir.join("metadata")).unwrap();
133/// # let targets_base_url = Url::from_file_path(dir.join("targets")).unwrap();
134/// # tokio_test::block_on(async {
135///
136/// let repository = RepositoryLoader::new(
137///     &tokio::fs::read(root).await.unwrap(),
138///     metadata_base_url,
139///     targets_base_url,
140/// )
141/// .load()
142/// .await
143/// .unwrap();
144///
145/// # });
146/// ```
147///
148/// ## With optional settings:
149///
150/// ```rust
151/// # use std::path::PathBuf;
152/// # use tough::{RepositoryLoader, FilesystemTransport, ExpirationEnforcement};
153/// # use url::Url;
154/// # let dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests").join("data").join("tuf-reference-impl");
155/// # let root = dir.join("metadata").join("1.root.json");
156/// # let metadata_base_url = Url::from_file_path(dir.join("metadata")).unwrap();
157/// # let targets_base_url = Url::from_file_path(dir.join("targets")).unwrap();
158/// # tokio_test::block_on(async {
159///
160/// let repository = RepositoryLoader::new(
161///     &tokio::fs::read(root).await.unwrap(),
162///     metadata_base_url,
163///     targets_base_url,
164/// )
165/// .transport(FilesystemTransport)
166/// .expiration_enforcement(ExpirationEnforcement::Unsafe)
167/// .load()
168/// .await
169/// .unwrap();
170///
171/// # });
172/// ```
173#[derive(Debug, Clone)]
174pub struct RepositoryLoader<'a> {
175    root: &'a [u8],
176    metadata_base_url: Url,
177    targets_base_url: Url,
178    transport: Option<Box<dyn Transport + Send + Sync>>,
179    limits: Option<Limits>,
180    datastore: Option<PathBuf>,
181    expiration_enforcement: Option<ExpirationEnforcement>,
182}
183
184impl<'a> RepositoryLoader<'a> {
185    /// Create a new `RepositoryLoader`.
186    ///
187    /// `root` is the content of a trusted root metadata file, which you must ship with your
188    /// software using an out-of-band process. It should be a copy of the most recent root.json
189    /// from your repository. (It's okay if it becomes out of date later; the client establishes
190    /// trust up to the most recent root.json file.)
191    ///
192    /// `metadata_base_url` and `targets_base_url` are the base URLs where the client can find
193    /// metadata (such as root.json) and targets (as listed in targets.json).
194    pub fn new(root: &'a impl AsRef<[u8]>, metadata_base_url: Url, targets_base_url: Url) -> Self {
195        Self {
196            root: root.as_ref(),
197            metadata_base_url,
198            targets_base_url,
199            transport: None,
200            limits: None,
201            datastore: None,
202            expiration_enforcement: None,
203        }
204    }
205
206    /// Load and verify TUF repository metadata.
207    pub async fn load(self) -> Result<Repository> {
208        Repository::load(self).await
209    }
210
211    /// Set the transport. If no transport has been set, [`DefaultTransport`] will be used.
212    #[must_use]
213    pub fn transport<T: Transport + Send + Sync + 'static>(mut self, transport: T) -> Self {
214        self.transport = Some(Box::new(transport));
215        self
216    }
217
218    /// Set a the repository [`Limits`].
219    #[must_use]
220    pub fn limits(mut self, limits: Limits) -> Self {
221        self.limits = Some(limits);
222        self
223    }
224
225    /// Set a `datastore` directory path. `datastore` is a directory on a persistent filesystem.
226    /// This directory's contents store the most recently fetched timestamp, snapshot, and targets
227    /// metadata files to detect version rollback attacks.
228    ///
229    /// You may chose to provide a [`PathBuf`] to a directory on a persistent filesystem, which must
230    /// exist prior to calling [`RepositoryLoader::load`]. If no datastore is provided, a temporary
231    /// directory will be created and cleaned up for for you.
232    #[must_use]
233    pub fn datastore<P: Into<PathBuf>>(mut self, datastore: P) -> Self {
234        self.datastore = Some(datastore.into());
235        self
236    }
237
238    /// Set the [`ExpirationEnforcement`].
239    ///
240    /// **CAUTION:** TUF metadata expiration dates, particularly `timestamp.json`, are designed to
241    /// limit a replay attack window. By setting `expiration_enforcement` to `Unsafe`, you are
242    /// disabling this feature of TUF. Use `Safe` unless you have a good reason to use `Unsafe`.
243    #[must_use]
244    pub fn expiration_enforcement(mut self, exp: ExpirationEnforcement) -> Self {
245        self.expiration_enforcement = Some(exp);
246        self
247    }
248}
249
250/// Limits used when fetching repository metadata.
251///
252/// These limits are implemented to prevent endless data attacks. Clients must ensure these values
253/// are set higher than what would reasonably be expected by a repository, but not so high that the
254/// amount of data could interfere with the system.
255///
256/// `max_root_size`, `max_timestamp_size` and `max_snapshot_size` are the maximum size for the `root.json`,
257/// `timestamp.json` and `snapshot.json` files, respectively, downloaded from the repository. These must be
258/// sufficiently large such that future updates to your repository's key management strategy
259/// will still be supported, but sufficiently small such that you are protected against an
260/// endless data attack (defined by TUF as an attacker responding to clients with extremely
261/// large files that interfere with the client's system).
262///
263/// The [`Default`] implementation sets the following values:
264/// * `max_root_size`: 1 MiB
265/// * `max_targets_size`: 10 MiB
266/// * `max_timestamp_size`: 1 MiB
267/// * `max_snapshot_size`: 1 MiB
268/// * `max_root_updates`: 1024
269#[derive(Debug, Clone, Copy)]
270pub struct Limits {
271    /// The maximum allowable size in bytes for downloaded root.json files.
272    pub max_root_size: u64,
273
274    /// The maximum allowable size in bytes for downloaded targets.json file **if** the size is not
275    /// listed in snapshots.json. This setting is ignored if the size of targets.json is in the
276    /// signed snapshots.json file.
277    pub max_targets_size: u64,
278
279    /// The maximum allowable size in bytes for the downloaded timestamp.json file.
280    pub max_timestamp_size: u64,
281
282    /// The maximum allowable size in bytes for the downloaded snapshot.json file.
283    pub max_snapshot_size: u64,
284
285    /// The maximum number of updates to root.json to download.
286    pub max_root_updates: u64,
287}
288
289impl Default for Limits {
290    fn default() -> Self {
291        Self {
292            max_root_size: 1024 * 1024,         // 1 MiB
293            max_targets_size: 1024 * 1024 * 10, // 10 MiB
294            max_timestamp_size: 1024 * 1024,    // 1 MiB
295            max_snapshot_size: 1024 * 1024,     // 1 MiB
296            max_root_updates: 1024,
297        }
298    }
299}
300
301/// Use this enum to specify whether or not we should include a prefix in the target name when
302/// saving a target.
303#[derive(Debug, Copy, Clone, Eq, PartialEq)]
304pub enum Prefix {
305    /// Do not prepend the target name when saving the target file, e.g. `my-target.txt`.
306    None,
307    /// Prepend the sha digest when saving the target file, e.g. `0123456789abcdef.my-target.txt`.
308    Digest,
309}
310
311/// A TUF repository.
312///
313/// You can create a `Repository` using a [`RepositoryLoader`].
314#[derive(Debug, Clone)]
315pub struct Repository {
316    transport: Box<dyn Transport + Send + Sync>,
317    consistent_snapshot: bool,
318    datastore: Datastore,
319    earliest_expiration: JiffTimestamp,
320    earliest_expiration_role: RoleType,
321    root: Signed<Root>,
322    snapshot: Signed<Snapshot>,
323    timestamp: Signed<Timestamp>,
324    targets: Signed<crate::schema::Targets>,
325    limits: Limits,
326    metadata_base_url: Url,
327    targets_base_url: Url,
328    expiration_enforcement: ExpirationEnforcement,
329    delegated_metadata_bytes: std::collections::HashMap<String, Vec<u8>>,
330}
331
332impl Repository {
333    /// Load and verify TUF repository metadata using a [`RepositoryLoader`] for the settings.
334    async fn load(loader: RepositoryLoader<'_>) -> Result<Self> {
335        let datastore = Datastore::new(loader.datastore)?;
336        let transport = loader
337            .transport
338            .unwrap_or_else(|| Box::new(DefaultTransport::new()));
339        let limits = loader.limits.unwrap_or_default();
340        let expiration_enforcement = loader.expiration_enforcement.unwrap_or_default();
341        let metadata_base_url = parse_url(loader.metadata_base_url)?;
342        let targets_base_url = parse_url(loader.targets_base_url)?;
343        let update_start = datastore.system_time().await?;
344
345        // 0. Load the trusted root metadata file + 1. Update the root metadata file
346        let root = load_root(
347            transport.as_ref(),
348            loader.root,
349            &datastore,
350            limits.max_root_size,
351            limits.max_root_updates,
352            &metadata_base_url,
353            expiration_enforcement,
354            &update_start,
355        )
356        .await?;
357
358        // 2. Download the timestamp metadata file
359        let timestamp = load_timestamp(
360            transport.as_ref(),
361            &root,
362            &datastore,
363            limits.max_timestamp_size,
364            &metadata_base_url,
365            expiration_enforcement,
366            &update_start,
367        )
368        .await?;
369
370        // 3. Download the snapshot metadata file
371        let snapshot = load_snapshot(
372            transport.as_ref(),
373            &root,
374            &timestamp,
375            limits.max_snapshot_size,
376            &datastore,
377            &metadata_base_url,
378            expiration_enforcement,
379            &update_start,
380        )
381        .await?;
382
383        // 4. Download the targets metadata file
384        let (targets, delegated_metadata_bytes) = load_targets(
385            transport.as_ref(),
386            &root,
387            &snapshot,
388            &datastore,
389            limits.max_targets_size,
390            &metadata_base_url,
391            expiration_enforcement,
392            &update_start,
393        )
394        .await?;
395
396        let expires_iter = [
397            (root.signed.expires, RoleType::Root),
398            (timestamp.signed.expires, RoleType::Timestamp),
399            (snapshot.signed.expires, RoleType::Snapshot),
400            (targets.signed.expires, RoleType::Targets),
401        ];
402        let (earliest_expiration, earliest_expiration_role) =
403            expires_iter.iter().min_by_key(|tup| tup.0).unwrap();
404
405        Ok(Self {
406            transport,
407            consistent_snapshot: root.signed.consistent_snapshot,
408            datastore,
409            earliest_expiration: *earliest_expiration,
410            earliest_expiration_role: *earliest_expiration_role,
411            root,
412            snapshot,
413            timestamp,
414            targets,
415            limits,
416            metadata_base_url,
417            targets_base_url,
418            expiration_enforcement,
419            delegated_metadata_bytes,
420        })
421    }
422
423    /// Returns the list of targets present in the repository.
424    pub fn targets(&self) -> &Signed<crate::schema::Targets> {
425        &self.targets
426    }
427
428    /// Returns a reference to the signed root
429    pub fn root(&self) -> &Signed<Root> {
430        &self.root
431    }
432
433    /// Returns a reference to the signed snapshot
434    pub fn snapshot(&self) -> &Signed<Snapshot> {
435        &self.snapshot
436    }
437
438    /// Returns a reference to the signed timestamp
439    pub fn timestamp(&self) -> &Signed<Timestamp> {
440        &self.timestamp
441    }
442
443    ///return a vec of all targets including all target files delegated by targets
444    pub fn all_targets(&self) -> impl Iterator<Item = (&TargetName, &schema::Target)> + '_ {
445        self.targets.signed.targets_iter()
446    }
447
448    /// Fetches a target from the repository.
449    ///
450    /// If the repository metadata is expired or there is an issue making the request, `Err` is
451    /// returned.
452    ///
453    /// If the requested target is not listed in the repository metadata, `Ok(None)` is returned.
454    ///
455    /// Otherwise, a stream is returned, which provides access to the target contents before its
456    /// checksum is validated. If the maximum size is reached or there is a checksum mismatch, the
457    /// stream returns a [`error::Error`]. **Consumers of this library must not use data from the
458    /// stream if it returns an error.**
459    pub async fn read_target(
460        &self,
461        name: &TargetName,
462    ) -> Result<
463        Option<impl Stream<Item = error::Result<Bytes>> + IntoVec<error::Error> + Send + Sync>,
464    > {
465        // Check for repository metadata expiration.
466        if self.expiration_enforcement == ExpirationEnforcement::Safe {
467            ensure!(
468                self.datastore.system_time().await? < self.earliest_expiration,
469                error::ExpiredMetadataSnafu {
470                    role: self.earliest_expiration_role
471                }
472            );
473        }
474
475        // 5. Verify the desired target against its targets metadata.
476        //
477        // 5.1. If there is no targets metadata about this target, abort the update cycle and
478        //   report that there is no such target.
479        //
480        // 5.2. Otherwise, download the target (up to the number of bytes specified in the targets
481        //   metadata), and verify that its hashes match the targets metadata. (We download up to
482        //   this number of bytes, because in some cases, the exact number is unknown. This may
483        //   happen, for example, if an external program is used to compute the root hash of a tree
484        //   of targets files, and this program does not provide the total size of all of these
485        //   files.) If consistent snapshots are not used (see Section 7), then the filename used
486        //   to download the target file is of the fixed form FILENAME.EXT (e.g., foobar.tar.gz).
487        //   Otherwise, the filename is of the form HASH.FILENAME.EXT (e.g.,
488        //   c14aeb4ac9f4a8fc0d83d12482b9197452f6adf3eb710e3b1e2b79e8d14cb681.foobar.tar.gz), where
489        //   HASH is one of the hashes of the targets file listed in the targets metadata file
490        //   found earlier in step 4. In either case, the client MUST write the file to
491        //   non-volatile storage as FILENAME.EXT.
492        Ok(
493            if let Ok(target) = self.targets.signed.find_target(name, false) {
494                let (sha256, file) = self.target_digest_and_filename(target, name);
495                Some(self.fetch_target(target, &sha256, file.as_str()).await?)
496            } else {
497                None
498            },
499        )
500    }
501
502    /// Fetches a target from the repository and saves it to `outdir`. Attempts to do this as safely
503    /// as possible by using `path_clean` to eliminate `../` path traversals from the the target's
504    /// name. Ensures that the resulting filepath is in `outdir` or a child of `outdir`.
505    ///
506    /// # Parameters
507    ///
508    /// - `name`: the target name.
509    /// - `outdir`: the directory to save the target in.
510    /// - `prepend`: Whether or not to prepend the sha digest when saving the target file.
511    ///
512    /// # Preconditions and Behavior
513    ///
514    /// - `outdir` must exist. For safety we want to canonicalize the path before we join to it.
515    /// - intermediate directories will be created in `outdir` with `create_dir_all`
516    /// - Will error if the result of path resolution results in a filepath outside of `outdir` or
517    ///   outside of a delegated target's correct path of delegation.
518    ///
519    pub async fn save_target<P>(&self, name: &TargetName, outdir: P, prepend: Prefix) -> Result<()>
520    where
521        P: AsRef<Path>,
522    {
523        // Ensure the outdir exists then canonicalize the path.
524        let outdir = outdir.as_ref();
525        let outdir = canonicalize(outdir)
526            .await
527            .context(error::SaveTargetOutdirCanonicalizeSnafu { path: outdir })?;
528        ensure!(
529            is_dir(&outdir).await,
530            error::SaveTargetOutdirSnafu { path: outdir }
531        );
532
533        if name.resolved() != name.raw() {
534            // Since target names with resolvable path segments are unusual and potentially unsafe,
535            // we warn the user that we have encountered them.
536            warn!(
537                "The target named '{}' had path segments that were resolved to produce the \
538                following name: {}",
539                name.raw(),
540                name.resolved()
541            );
542        }
543
544        let filename = match prepend {
545            Prefix::Digest => {
546                let target = self
547                    .targets
548                    .signed
549                    .find_target(name, false)
550                    .with_context(|_| error::CacheTargetMissingSnafu {
551                        target_name: name.clone(),
552                    })?;
553                let sha256 = target.hashes.sha256.clone().into_vec();
554                format!("{}.{}", hex::encode(sha256), name.resolved())
555            }
556            Prefix::None => name.resolved().to_owned(),
557        };
558
559        let resolved_filepath = outdir.join(filename);
560
561        // Find out what directory we will be writing the target file to.
562        let filepath_dir =
563            resolved_filepath
564                .parent()
565                .with_context(|| error::SaveTargetNoParentSnafu {
566                    path: &resolved_filepath,
567                    name: name.clone(),
568                })?;
569
570        // Make sure the filepath we are writing to is in or below outdir.
571        ensure!(
572            filepath_dir.starts_with(&outdir),
573            error::SaveTargetUnsafePathSnafu {
574                name: name.clone(),
575                outdir,
576                filepath: &resolved_filepath,
577            }
578        );
579
580        // Fetch and write the target using NamedTempFile for an atomic file creation.
581        let mut stream = self
582            .read_target(name)
583            .await?
584            .with_context(|| error::SaveTargetNotFoundSnafu { name: name.clone() })?;
585        create_dir_all(filepath_dir)
586            .await
587            .context(error::DirCreateSnafu {
588                path: &filepath_dir,
589            })?;
590
591        let real_filepath_dir = canonicalize(filepath_dir)
592            .await
593            .context(error::AbsolutePathSnafu { path: filepath_dir })?;
594        let real_outdir = canonicalize(&outdir)
595            .await
596            .context(error::AbsolutePathSnafu { path: &outdir })?;
597        ensure!(
598            real_filepath_dir.starts_with(&real_outdir),
599            error::SaveTargetUnsafePathSnafu {
600                name: name.clone(),
601                outdir,
602                filepath: &resolved_filepath,
603            }
604        );
605
606        // Create a new temporary file.
607        let tmp_path = real_filepath_dir;
608        let tmp = tokio::task::spawn_blocking(move || NamedTempFile::new_in(tmp_path))
609            .await
610            .context(error::JoinSpawnBlockingTaskSnafu)?
611            .context(error::NamedTempFileCreateSnafu { path: filepath_dir })?;
612
613        // Convert to `tokio::fs::File`.
614        let (f, tmp_path) = tmp.into_parts();
615        let mut f = tokio::fs::File::from_std(f);
616
617        // Write input stream to file.
618        while let Some(bytes) = stream.next().await {
619            f.write_all(bytes?.as_ref())
620                .await
621                .context(error::FileWriteSnafu { path: &tmp_path })?;
622        }
623
624        // Reconstruct `NamedTempFile` in order to persist it at the target location.
625        let f = NamedTempFile::from_parts(f.into_std().await, tmp_path);
626        f.persist(&resolved_filepath)
627            .context(error::NamedTempFilePersistSnafu {
628                path: resolved_filepath,
629            })?;
630
631        Ok(())
632    }
633
634    /// Return the named `DelegatedRole` if found.
635    pub fn delegated_role(&self, name: &str) -> Option<&DelegatedRole> {
636        self.targets.signed.delegated_role(name).ok()
637    }
638}
639
640/// The set of characters that will be escaped when converting a delegated role name into a
641/// filename. This needs to at least include path traversal characters to prevent tough from writing
642/// outside of its datastore.
643///
644/// In order to match the Python TUF implementation, we mimic the Python function
645/// [urllib.parse.quote] (given a 'safe' parameter value of `""`) which follows RFC 3986 and states
646///
647/// > Replace special characters in string using the %xx escape. Letters, digits, and the characters
648/// > `_.-~` are never quoted.
649///
650/// [urllib.parse.quote]: https://docs.python.org/3/library/urllib.parse.html#url-quoting
651const CHARACTERS_TO_ESCAPE: AsciiSet = NON_ALPHANUMERIC
652    .remove(b'_')
653    .remove(b'.')
654    .remove(b'-')
655    .remove(b'~');
656
657/// Percent encode a potential filename to ensure it is safe and does not have path traversal
658/// characters.
659pub(crate) fn encode_filename<S: AsRef<str>>(name: S) -> String {
660    utf8_percent_encode(name.as_ref(), &CHARACTERS_TO_ESCAPE).to_string()
661}
662
663/// TUF v1.0.16, 5.2.9, 5.3.3, 5.4.5, 5.5.4, The expiration timestamp in the `[metadata]` file MUST
664/// be higher than the fixed update start time.
665fn check_expired<T: Role>(update_start: &JiffTimestamp, role: &T) -> Result<()> {
666    ensure!(
667        *update_start <= role.expires(),
668        error::ExpiredMetadataSnafu { role: T::TYPE }
669    );
670    Ok(())
671}
672
673/// Checks to see if the `Url` has a trailing slash and adds one if not. Without a trailing slash,
674/// the last component of a `Url` is considered to be a file. `metadata_url` and `targets_url`
675/// must refer to a base (i.e. directory), so we need them to end with a slash.
676fn parse_url(url: Url) -> Result<Url> {
677    if url.as_str().ends_with('/') {
678        Ok(url)
679    } else {
680        let mut s = url.to_string();
681        s.push('/');
682        Url::parse(&s).context(error::ParseUrlSnafu { url: s })
683    }
684}
685
686/// Steps 0 and 1 of the client application, which load the current root metadata file based on a
687/// trusted root metadata file.
688#[expect(clippy::too_many_arguments)]
689#[allow(clippy::needless_continue)]
690async fn load_root<R: AsRef<[u8]>>(
691    transport: &dyn Transport,
692    root: R,
693    datastore: &Datastore,
694    max_root_size: u64,
695    max_root_updates: u64,
696    metadata_base_url: &Url,
697    expiration_enforcement: ExpirationEnforcement,
698    update_start: &JiffTimestamp,
699) -> Result<Signed<Root>> {
700    // 5.2. Load the trusted root metadata file. We assume that a good, trusted copy of this file was
701    //    shipped with the package manager or software updater using an out-of-band process. Note
702    //    that the expiration of the trusted root metadata file does not matter, because we will
703    //    attempt to update it in the next step.
704    let mut root: Signed<Root> =
705        serde_json::from_slice(root.as_ref()).context(error::ParseTrustedMetadataSnafu)?;
706    root.signed
707        .verify_role(&root)
708        .context(error::VerifyTrustedMetadataSnafu)?;
709
710    // Used in step 5.3
711    let original_root_version = root.signed.version.get();
712
713    // Used in step 1.9
714    let original_timestamp_keys = root
715        .signed
716        .keys(RoleType::Timestamp)
717        .cloned()
718        .collect::<Vec<_>>();
719    let original_snapshot_keys = root
720        .signed
721        .keys(RoleType::Snapshot)
722        .cloned()
723        .collect::<Vec<_>>();
724
725    // 5.3. Update the root metadata file. Since it may now be signed using entirely different keys,
726    //    the client must somehow be able to establish a trusted line of continuity to the latest
727    //    set of keys. To do so, the client MUST download intermediate root metadata files, until
728    //    the latest available one is reached. Therefore, it MUST temporarily turn on consistent
729    //    snapshots in order to download versioned root metadata files as described next.
730    loop {
731        // 5.3.2. Let N denote the version number of the trusted root metadata file.
732        //
733        // 5.3.3. Try downloading version N+1 of the root metadata file, up to some X number of bytes
734        //   (because the size is unknown). The value for X is set by the authors of the
735        //   application using TUF. For example, X may be tens of kilobytes. The filename used to
736        //   download the root metadata file is of the fixed form VERSION_NUMBER.FILENAME.EXT
737        //   (e.g., 42.root.json). If this file is not available, or we have downloaded more than Y
738        //   number of root metadata files (because the exact number is as yet unknown), then go to
739        //   step 1.8. The value for Y is set by the authors of the application using TUF. For
740        //   example, Y may be 2^10.
741        ensure!(
742            root.signed.version.get() < original_root_version + max_root_updates,
743            error::MaxUpdatesExceededSnafu { max_root_updates }
744        );
745        let path = format!("{}.root.json", root.signed.version.get() + 1);
746        let url = metadata_base_url
747            .join(&path)
748            .with_context(|_| error::JoinUrlSnafu {
749                path: path.clone(),
750                url: metadata_base_url.clone(),
751            })?;
752        match fetch_max_size(
753            transport,
754            url.clone(),
755            max_root_size,
756            "max_root_size argument",
757        )
758        .await
759        {
760            Err(_) => break, // If this file is not available, then go to step 5.3.10.
761            Ok(stream) => {
762                let data = match stream.into_vec().await {
763                    Ok(d) => d,
764                    Err(e) if e.kind() == TransportErrorKind::FileNotFound => break,
765                    err @ Err(_) => err.context(error::TransportSnafu { url })?,
766                };
767                let new_root: Signed<Root> =
768                    serde_json::from_slice(&data).context(error::ParseMetadataSnafu {
769                        role: RoleType::Root,
770                    })?;
771
772                // 5.3.4. Check signatures. Version N+1 of the root metadata file MUST have been
773                //   signed by: (1) a threshold of keys specified in the trusted root metadata file
774                //   (version N), and (2) a threshold of keys specified in the new root metadata
775                //   file being validated (version N+1). If version N+1 is not signed as required,
776                //   discard it, abort the update cycle, and report the signature failure. On the
777                //   next update cycle, begin at step 0 and version N of the root metadata file.
778                root.signed
779                    .verify_role(&new_root)
780                    .context(error::VerifyMetadataSnafu {
781                        role: RoleType::Root,
782                    })?;
783                new_root
784                    .signed
785                    .verify_role(&new_root)
786                    .context(error::VerifyMetadataSnafu {
787                        role: RoleType::Root,
788                    })?;
789
790                // 5.3.5. Check for a rollback attack. The version number of the new root
791                // metadata (version N+1) MUST be exactly the version in the trusted root
792                // metadata (version N) incremented by one, that is precisely N+1.
793                // off-spec: protect the comparison against u64 overflow (if N < new value,
794                // N+1 will not overflow).
795                ensure!(
796                    root.signed.version < new_root.signed.version
797                        && root.signed.version.get() + 1 == new_root.signed.version.get(),
798                    error::OlderMetadataSnafu {
799                        role: RoleType::Root,
800                        current_version: root.signed.version,
801                        new_version: new_root.signed.version
802                    }
803                );
804
805                // 5.3.6. Note that the expiration of the new (intermediate) root metadata file does
806                //   not matter yet, because we will check for it in step 1.8.
807                //
808                // 5.3.7. Set the trusted root metadata file to the new root metadata file.
809                //
810                // (This is where version N+1 becomes version N.)
811                root = new_root;
812
813                // 5.3.8. Persist root metadata. The client MUST write the file to non-volatile storage
814                // as FILENAME.EXT (e.g. root.json).
815                datastore.remove("root.json").await?;
816                datastore.create("root.json", &root).await?;
817
818                // 5.3.9. Repeat 5.3.2 through 5.3.9.
819                continue;
820            }
821        }
822    }
823
824    datastore.remove("root.json").await?;
825    datastore.create("root.json", &root).await?;
826
827    // TUF v1.0.16, 5.2.9. Check for a freeze attack. The expiration timestamp in the trusted root
828    // metadata file MUST be higher than the fixed update start time. If the trusted root metadata
829    // file has expired, abort the update cycle, report the potential freeze attack. On the next
830    // update cycle, begin at step 5.1 and version N of the root metadata file.
831    if expiration_enforcement == ExpirationEnforcement::Safe {
832        check_expired(update_start, &root.signed)?;
833    }
834
835    // 1.9. If the timestamp and / or snapshot keys have been rotated, then delete the trusted
836    //   timestamp and snapshot metadata files. This is done in order to recover from fast-forward
837    //   attacks after the repository has been compromised and recovered. A fast-forward attack
838    //   happens when attackers arbitrarily increase the version numbers of: (1) the timestamp
839    //   metadata, (2) the snapshot metadata, and / or (3) the targets, or a delegated targets,
840    //   metadata file in the snapshot metadata.
841    if original_timestamp_keys
842        .iter()
843        .ne(root.signed.keys(RoleType::Timestamp))
844        || original_snapshot_keys
845            .iter()
846            .ne(root.signed.keys(RoleType::Snapshot))
847    {
848        let r1 = datastore.remove("timestamp.json").await;
849        let r2 = datastore.remove("snapshot.json").await;
850        r1.and(r2)?;
851    }
852
853    // 1.10. Set whether consistent snapshots are used as per the trusted root metadata file (see
854    //   Section 4.3).
855    //
856    // (This is done by checking the value of root.signed.consistent_snapshot throughout this
857    // library.)
858
859    Ok(root)
860}
861
862/// Step 2 of the client application, which loads the timestamp metadata file.
863async fn load_timestamp(
864    transport: &dyn Transport,
865    root: &Signed<Root>,
866    datastore: &Datastore,
867    max_timestamp_size: u64,
868    metadata_base_url: &Url,
869    expiration_enforcement: ExpirationEnforcement,
870    update_start: &JiffTimestamp,
871) -> Result<Signed<Timestamp>> {
872    // 2. Download the timestamp metadata file, up to Y number of bytes (because the size is
873    //    unknown.) The value for Y is set by the authors of the application using TUF. For
874    //    example, Y may be tens of kilobytes. The filename used to download the timestamp metadata
875    //    file is of the fixed form FILENAME.EXT (e.g., timestamp.json).
876    let path = "timestamp.json";
877    let url = metadata_base_url
878        .join(path)
879        .with_context(|_| error::JoinUrlSnafu {
880            path,
881            url: metadata_base_url.clone(),
882        })?;
883    let stream = fetch_max_size(
884        transport,
885        url.clone(),
886        max_timestamp_size,
887        "max_timestamp_size argument",
888    )
889    .await?;
890    let data = stream
891        .into_vec()
892        .await
893        .context(error::TransportSnafu { url })?;
894    let timestamp: Signed<Timestamp> =
895        serde_json::from_slice(&data).context(error::ParseMetadataSnafu {
896            role: RoleType::Timestamp,
897        })?;
898
899    // 5.4.2. Check signatures. The new timestamp metadata file must have been signed by a threshold
900    //   of keys specified in the trusted root metadata file. If the new timestamp metadata file is
901    //   not properly signed, discard it, abort the update cycle, and report the signature failure.
902    root.signed
903        .verify_role(&timestamp)
904        .context(error::VerifyMetadataSnafu {
905            role: RoleType::Timestamp,
906        })?;
907
908    // 4.6. The meta component must contain exactly one entry, snapshot.json
909    ensure!(
910        timestamp.signed.meta.len() == 1,
911        error::TimestampMetaLengthSnafu {
912            version: timestamp.signed.version,
913            meta_length: timestamp.signed.meta.len(),
914        }
915    );
916    let snapshot_meta =
917        timestamp
918            .signed
919            .meta
920            .get("snapshot.json")
921            .context(error::MissingSnapshotMetaSnafu {
922                version: timestamp.signed.version,
923            })?;
924
925    // 5.4.3.1. Check for a rollback attack. The version number of the trusted timestamp metadata file,
926    //   if any, must be less than or equal to the version number of the new timestamp metadata
927    //   file. If the new timestamp metadata file is older than the trusted timestamp metadata
928    //   file, discard it, abort the update cycle, and report the potential rollback attack.
929    if let Some(Ok(old_timestamp)) = datastore
930        .bytes("timestamp.json")
931        .await?
932        .map(|b| serde_json::from_slice::<Signed<Timestamp>>(&b))
933    {
934        if root.signed.verify_role(&old_timestamp).is_ok() {
935            ensure!(
936                old_timestamp.signed.version <= timestamp.signed.version,
937                error::OlderMetadataSnafu {
938                    role: RoleType::Timestamp,
939                    current_version: old_timestamp.signed.version,
940                    new_version: timestamp.signed.version
941                }
942            );
943            // 4.6 trusted timestamp meta must have one entry, snapshot.json
944            ensure!(
945                old_timestamp.signed.meta.len() == 1,
946                error::TimestampMetaLengthSnafu {
947                    version: old_timestamp.signed.version,
948                    meta_length: old_timestamp.signed.meta.len(),
949                }
950            );
951            let old_snapshot_meta = old_timestamp.signed.meta.get("snapshot.json").context(
952                error::MissingSnapshotMetaSnafu {
953                    version: old_timestamp.signed.version,
954                },
955            )?;
956            // 5.4.3.2 trusted snapshot version less than or equal to new snapshot version
957            // (rollback attack to fetch older snapshot object)
958            ensure!(
959                old_snapshot_meta.version <= snapshot_meta.version,
960                error::OlderSnapshotInTimestampSnafu {
961                    snapshot_new: snapshot_meta.version,
962                    timestamp_new: timestamp.signed.version,
963                    snapshot_old: old_snapshot_meta.version,
964                    timestamp_old: old_timestamp.signed.version,
965                }
966            );
967        }
968    }
969
970    // TUF v1.0.16, 5.3.3. Check for a freeze attack. The expiration timestamp in the new timestamp
971    // metadata file MUST be higher than the fixed update start time. If so, the new timestamp
972    // metadata file becomes the trusted timestamp metadata file. If the new timestamp metadata file
973    // has expired, discard it, abort the update cycle, and report the potential freeze attack.
974    if expiration_enforcement == ExpirationEnforcement::Safe {
975        check_expired(update_start, &timestamp.signed)?;
976    }
977
978    // Now that everything seems okay, write the timestamp file to the datastore.
979    datastore.create("timestamp.json", &timestamp).await?;
980
981    Ok(timestamp)
982}
983
984/// Step 3 of the client application, which loads the snapshot metadata file.
985#[expect(clippy::too_many_arguments, clippy::too_many_lines)]
986async fn load_snapshot(
987    transport: &dyn Transport,
988    root: &Signed<Root>,
989    timestamp: &Signed<Timestamp>,
990    max_snapshot_size: u64,
991    datastore: &Datastore,
992    metadata_base_url: &Url,
993    expiration_enforcement: ExpirationEnforcement,
994    update_start: &JiffTimestamp,
995) -> Result<Signed<Snapshot>> {
996    // 3. Download snapshot metadata file, up to the number of bytes specified in the timestamp
997    //    metadata file. If consistent snapshots are not used (see Section 7), then the filename
998    //    used to download the snapshot metadata file is of the fixed form FILENAME.EXT (e.g.,
999    //    snapshot.json). Otherwise, the filename is of the form VERSION_NUMBER.FILENAME.EXT (e.g.,
1000    //    42.snapshot.json), where VERSION_NUMBER is the version number of the snapshot metadata
1001    //    file listed in the timestamp metadata file. In either case, the client MUST write the
1002    //    file to non-volatile storage as FILENAME.EXT.
1003    let snapshot_meta =
1004        timestamp
1005            .signed
1006            .meta
1007            .get("snapshot.json")
1008            .context(error::MetaMissingSnafu {
1009                file: "snapshot.json",
1010                role: RoleType::Timestamp,
1011            })?;
1012    let path = if root.signed.consistent_snapshot {
1013        format!("{}.snapshot.json", snapshot_meta.version)
1014    } else {
1015        "snapshot.json".to_owned()
1016    };
1017    let url = metadata_base_url
1018        .join(&path)
1019        .with_context(|_| error::JoinUrlSnafu {
1020            path: path.clone(),
1021            url: metadata_base_url.clone(),
1022        })?;
1023    let stream = if let Some(hashes) = &snapshot_meta.hashes {
1024        fetch_sha256(
1025            transport,
1026            url.clone(),
1027            snapshot_meta.length.unwrap_or(max_snapshot_size),
1028            "timestamp.json",
1029            &hashes.sha256,
1030        )
1031        .await?
1032    } else {
1033        fetch_max_size(
1034            transport,
1035            url.clone(),
1036            snapshot_meta.length.unwrap_or(max_snapshot_size),
1037            "timestamp.json",
1038        )
1039        .await?
1040    };
1041
1042    let data = stream
1043        .into_vec()
1044        .await
1045        .context(error::TransportSnafu { url })?;
1046    let snapshot: Signed<Snapshot> =
1047        serde_json::from_slice(&data).context(error::ParseMetadataSnafu {
1048            role: RoleType::Snapshot,
1049        })?;
1050
1051    // 3.1. Check against timestamp metadata. The hashes and version number of the new snapshot
1052    //   metadata file MUST match the hashes and version number listed in timestamp metadata. If
1053    //   hashes and version do not match, discard the new snapshot metadata, abort the update
1054    //   cycle, and report the failure.
1055    //
1056    // (We already checked the hash in `fetch_sha256` above.)
1057    ensure!(
1058        snapshot.signed.version == snapshot_meta.version,
1059        error::VersionMismatchSnafu {
1060            role: RoleType::Snapshot,
1061            fetched: snapshot.signed.version,
1062            expected: snapshot_meta.version
1063        }
1064    );
1065
1066    // 3.2. Check signatures. The new snapshot metadata file MUST have been signed by a threshold
1067    //   of keys specified in the trusted root metadata file. If the new snapshot metadata file is
1068    //   not signed as required, discard it, abort the update cycle, and report the signature
1069    //   failure.
1070    root.signed
1071        .verify_role(&snapshot)
1072        .context(error::VerifyMetadataSnafu {
1073            role: RoleType::Snapshot,
1074        })?;
1075
1076    // 4.4 Check that snapshot.meta contains at least targets.json
1077    ensure!(
1078        snapshot.signed.meta.contains_key("targets.json"),
1079        SnapshotTargetsMetaMissingSnafu {
1080            version: snapshot.signed.version,
1081        }
1082    );
1083    // 3.3. Check for a rollback attack.
1084    //
1085    // 3.3.1. Note that the trusted snapshot metadata file may be checked for authenticity, but its
1086    //   expiration does not matter for the following purposes.
1087    if let Some(Ok(old_snapshot)) = datastore
1088        .bytes("snapshot.json")
1089        .await?
1090        .map(|b| serde_json::from_slice::<Signed<Snapshot>>(&b))
1091    {
1092        // 3.3.2. The version number of the trusted snapshot metadata file, if any, MUST be less
1093        //   than or equal to the version number of the new snapshot metadata file. If the new
1094        //   snapshot metadata file is older than the trusted metadata file, discard it, abort the
1095        //   update cycle, and report the potential rollback attack.
1096        if root.signed.verify_role(&old_snapshot).is_ok() {
1097            ensure!(
1098                old_snapshot.signed.version <= snapshot.signed.version,
1099                error::OlderMetadataSnafu {
1100                    role: RoleType::Snapshot,
1101                    current_version: old_snapshot.signed.version,
1102                    new_version: snapshot.signed.version
1103                }
1104            );
1105
1106            // 3.3.3. The version number of the targets metadata file, and all delegated targets
1107            //   metadata files (if any), in the trusted snapshot metadata file, if any, MUST be
1108            //   less than or equal to its version number in the new snapshot metadata file.
1109            //   Furthermore, any targets metadata filename that was listed in the trusted snapshot
1110            //   metadata file, if any, MUST continue to be listed in the new snapshot metadata
1111            //   file. If any of these conditions are not met, discard the new snapshot metadata
1112            //   file, abort the update cycle, and report the failure.
1113
1114            // Ensure that the trusted snapshot has at least targets.json
1115            ensure!(
1116                old_snapshot.signed.meta.contains_key("targets.json"),
1117                error::SnapshotTargetsMetaMissingSnafu {
1118                    version: old_snapshot.signed.version,
1119                }
1120            );
1121            for (name, meta) in &old_snapshot.signed.meta {
1122                ensure!(
1123                    snapshot.signed.meta.contains_key(name),
1124                    error::SnapshotRoleMissingSnafu {
1125                        role: name,
1126                        old_version: old_snapshot.signed.version,
1127                        new_version: snapshot.signed.version,
1128                    }
1129                );
1130                let new_meta = snapshot.signed.meta.get(name).unwrap();
1131                ensure!(
1132                    meta.version <= new_meta.version,
1133                    error::SnapshotRoleRollbackSnafu {
1134                        role: name,
1135                        old_role_version: meta.version,
1136                        old_snapshot_version: old_snapshot.signed.version,
1137                        new_role_version: new_meta.version,
1138                        new_snapshot_version: snapshot.signed.version,
1139                    }
1140                );
1141            }
1142            if let Some(old_targets_meta) = old_snapshot.signed.meta.get("targets.json") {
1143                let targets_meta =
1144                    snapshot
1145                        .signed
1146                        .meta
1147                        .get("targets.json")
1148                        .context(error::MetaMissingSnafu {
1149                            file: "targets.json",
1150                            role: RoleType::Snapshot,
1151                        })?;
1152                ensure!(
1153                    old_targets_meta.version <= targets_meta.version,
1154                    error::OlderMetadataSnafu {
1155                        role: RoleType::Targets,
1156                        current_version: old_targets_meta.version,
1157                        new_version: targets_meta.version,
1158                    }
1159                );
1160            }
1161        }
1162    }
1163
1164    // TUF v1.0.16, 5.4.5. Check for a freeze attack. The expiration timestamp in the new snapshot
1165    // metadata file MUST be higher than the fixed update start time. If so, the new snapshot
1166    // metadata file becomes the trusted snapshot metadata file. If the new snapshot metadata file
1167    // is expired, discard it, abort the update cycle, and report the potential freeze attack.
1168    if expiration_enforcement == ExpirationEnforcement::Safe {
1169        check_expired(update_start, &snapshot.signed)?;
1170    }
1171
1172    // Now that everything seems okay, write the snapshot file to the datastore.
1173    datastore.create("snapshot.json", &snapshot).await?;
1174
1175    Ok(snapshot)
1176}
1177
1178/// Step 4 of the client application, which loads the targets metadata file.
1179#[expect(clippy::too_many_arguments)]
1180async fn load_targets(
1181    transport: &dyn Transport,
1182    root: &Signed<Root>,
1183    snapshot: &Signed<Snapshot>,
1184    datastore: &Datastore,
1185    max_targets_size: u64,
1186    metadata_base_url: &Url,
1187    expiration_enforcement: ExpirationEnforcement,
1188    update_start: &JiffTimestamp,
1189) -> Result<(
1190    Signed<crate::schema::Targets>,
1191    std::collections::HashMap<String, Vec<u8>>,
1192)> {
1193    // 4. Download the top-level targets metadata file, up to either the number of bytes specified
1194    //    in the snapshot metadata file, or some Z number of bytes. The value for Z is set by the
1195    //    authors of the application using TUF. For example, Z may be tens of kilobytes. If
1196    //    consistent snapshots are not used (see Section 7), then the filename used to download the
1197    //    targets metadata file is of the fixed form FILENAME.EXT (e.g., targets.json).  Otherwise,
1198    //    the filename is of the form VERSION_NUMBER.FILENAME.EXT (e.g., 42.targets.json), where
1199    //    VERSION_NUMBER is the version number of the targets metadata file listed in the snapshot
1200    //    metadata file. In either case, the client MUST write the file to non-volatile storage as
1201    //    FILENAME.EXT.
1202    let targets_meta =
1203        snapshot
1204            .signed
1205            .meta
1206            .get("targets.json")
1207            .context(error::MetaMissingSnafu {
1208                file: "targets.json",
1209                role: RoleType::Timestamp,
1210            })?;
1211    let path = if root.signed.consistent_snapshot {
1212        format!("{}.targets.json", targets_meta.version)
1213    } else {
1214        "targets.json".to_owned()
1215    };
1216    let targets_url = metadata_base_url
1217        .join(&path)
1218        .with_context(|_| error::JoinUrlSnafu {
1219            path,
1220            url: metadata_base_url.clone(),
1221        })?;
1222    let (max_targets_file_size, specifier) = match targets_meta.length {
1223        Some(length) => (length, "snapshot.json"),
1224        None => (max_targets_size, "max_targets_size parameter"),
1225    };
1226    let stream = if let Some(hashes) = &targets_meta.hashes {
1227        // 5.6.2. The hashes of the new targets metadata file MUST match the hashes, if any,
1228        // listed in the trusted snapshot metadata.
1229        fetch_sha256(
1230            transport,
1231            targets_url.clone(),
1232            max_targets_file_size,
1233            specifier,
1234            &hashes.sha256,
1235        )
1236        .await?
1237    } else {
1238        fetch_max_size(
1239            transport,
1240            targets_url.clone(),
1241            max_targets_file_size,
1242            specifier,
1243        )
1244        .await?
1245    };
1246    let data = stream
1247        .into_vec()
1248        .await
1249        .context(error::TransportSnafu { url: targets_url })?;
1250    let mut targets: Signed<crate::schema::Targets> =
1251        serde_json::from_slice(&data).context(error::ParseMetadataSnafu {
1252            role: RoleType::Targets,
1253        })?;
1254
1255    // 5.6.3. Check for an arbitrary software attack. The new targets metadata file MUST have been
1256    //   signed by a threshold of keys specified in the trusted root metadata file. If the new
1257    //   targets metadata file is not signed as required, discard it, abort the update cycle, and
1258    //   report the failure.
1259    root.signed
1260        .verify_role(&targets)
1261        .context(error::VerifyMetadataSnafu {
1262            role: RoleType::Targets,
1263        })?;
1264
1265    // 5.6.4. Check against the snapshot role's targets version
1266    ensure!(
1267        targets.signed.version == targets_meta.version,
1268        error::VersionMismatchSnafu {
1269            role: RoleType::Targets,
1270            fetched: targets.signed.version,
1271            expected: targets_meta.version
1272        }
1273    );
1274    // 5.6.5. Check for a freeze attack. The expiration timestamp in the new targets
1275    // metadata file MUST be higher than the fixed update start time. If so, the new targets
1276    // metadata file becomes the trusted targets metadata file. If the new targets metadata file is
1277    // expired, discard it, abort the update cycle, and report the potential freeze attack.
1278    if expiration_enforcement == ExpirationEnforcement::Safe {
1279        check_expired(update_start, &targets.signed)?;
1280    }
1281
1282    // Now that everything seems okay, write the targets file to the datastore.
1283    datastore.create("targets.json", &targets).await?;
1284
1285    // 4.5. Perform a preorder depth-first search for metadata about the desired target, beginning
1286    //   with the top-level targets role.
1287    let mut delegated_metadata_bytes: std::collections::HashMap<String, Vec<u8>> =
1288        std::collections::HashMap::new();
1289    if let Some(delegations) = &mut targets.signed.delegations {
1290        let mut loaded_roles: BTreeSet<String> = BTreeSet::new();
1291        load_delegations(
1292            transport,
1293            snapshot,
1294            root.signed.consistent_snapshot,
1295            metadata_base_url,
1296            max_targets_size,
1297            delegations,
1298            datastore,
1299            &mut loaded_roles,
1300            update_start,
1301            expiration_enforcement,
1302            &mut delegated_metadata_bytes,
1303        )
1304        .await?;
1305    }
1306
1307    // This validation can only be done from the top level targets.json role. This check verifies
1308    // that each target's delegate hierarchy is a match (i.e. it's delegate ownership is valid).
1309    targets.signed.validate().context(error::InvalidPathSnafu)?;
1310    Ok((targets, delegated_metadata_bytes))
1311}
1312
1313// Follow the paths of delegations starting with the top level targets.json delegation
1314#[expect(clippy::too_many_arguments)]
1315#[async_recursion]
1316#[allow(clippy::too_many_lines)]
1317async fn load_delegations(
1318    transport: &dyn Transport,
1319    snapshot: &Signed<Snapshot>,
1320    consistent_snapshot: bool,
1321    metadata_base_url: &Url,
1322    max_targets_size: u64,
1323    delegation: &mut Delegations,
1324    datastore: &Datastore,
1325    loaded_roles: &mut BTreeSet<String>,
1326    update_start: &JiffTimestamp,
1327    expiration_enforcement: ExpirationEnforcement,
1328    delegated_metadata_bytes: &mut std::collections::HashMap<String, Vec<u8>>,
1329) -> Result<()> {
1330    let mut delegated_roles: HashMap<String, Option<Signed<crate::schema::Targets>>> =
1331        HashMap::new();
1332    for delegated_role in &delegation.roles {
1333        if loaded_roles.contains(&delegated_role.name) {
1334            // we have already loaded this role, continue
1335            continue;
1336        }
1337        // find the role file metadata
1338        let role_meta = snapshot
1339            .signed
1340            .meta
1341            .get(&format!("{}.json", &delegated_role.name));
1342
1343        if role_meta.is_none() {
1344            // 5.6.7: If any metadata requested in steps 5.6.7.1 - 5.6.7.2 cannot be downloaded nor validated, end the search and report that the target cannot be found.
1345            loaded_roles.insert(delegated_role.name.clone());
1346            return Ok(());
1347        }
1348        let role_meta = role_meta.unwrap();
1349        let path = if consistent_snapshot {
1350            format!(
1351                "{}.{}.json",
1352                &role_meta.version,
1353                encode_filename(&delegated_role.name)
1354            )
1355        } else {
1356            format!("{}.json", encode_filename(&delegated_role.name))
1357        };
1358        let role_url = metadata_base_url
1359            .join(&path)
1360            .with_context(|_| error::JoinUrlSnafu {
1361                path: path.clone(),
1362                url: metadata_base_url.clone(),
1363            })?;
1364        // enforce snapshot-pinned length when available, mirroring top-level path
1365        let (max_delegated_file_size, specifier) = match role_meta.length {
1366            Some(length) => (length, "snapshot.json"),
1367            None => (max_targets_size, "max_targets_size parameter"),
1368        };
1369        // load the role json file, enforcing snapshot hash when available
1370        let stream = if let Some(hashes) = &role_meta.hashes {
1371            fetch_sha256(
1372                transport,
1373                role_url.clone(),
1374                max_delegated_file_size,
1375                specifier,
1376                &hashes.sha256,
1377            )
1378            .await?
1379        } else {
1380            fetch_max_size(
1381                transport,
1382                role_url.clone(),
1383                max_delegated_file_size,
1384                specifier,
1385            )
1386            .await?
1387        };
1388        let data = stream
1389            .into_vec()
1390            .await
1391            .context(error::TransportSnafu { url: role_url })?;
1392        delegated_metadata_bytes.insert(delegated_role.name.clone(), data.clone());
1393        // since each role is a targets, we load them as such
1394        let role: Signed<crate::schema::Targets> =
1395            serde_json::from_slice(&data).context(error::ParseMetadataSnafu {
1396                role: RoleType::Targets,
1397            })?;
1398        // verify each role with the delegation
1399        delegation
1400            .verify_role(&role, &delegated_role.name)
1401            .context(error::VerifyMetadataSnafu {
1402                role: RoleType::Targets,
1403            })?;
1404        ensure!(
1405            role.signed.version == role_meta.version,
1406            error::VersionMismatchSnafu {
1407                role: RoleType::Targets,
1408                fetched: role.signed.version,
1409                expected: role_meta.version
1410            }
1411        );
1412
1413        if expiration_enforcement == ExpirationEnforcement::Safe {
1414            check_expired(update_start, &role.signed)?;
1415        }
1416
1417        datastore.create(&path, &role).await?;
1418        delegated_roles.insert(delegated_role.name.clone(), Some(role));
1419    }
1420    // load all roles delegated by this role
1421    for delegated_role in &mut delegation.roles {
1422        if loaded_roles.contains(&delegated_role.name) {
1423            continue;
1424        }
1425        loaded_roles.insert(delegated_role.name.clone());
1426        delegated_role.targets =
1427            delegated_roles
1428                .remove(&delegated_role.name)
1429                .with_context(|| error::DelegatedRolesNotConsistentSnafu {
1430                    name: delegated_role.name.clone(),
1431                })?;
1432        if let Some(targets) = &mut delegated_role.targets {
1433            if let Some(delegations) = &mut targets.signed.delegations {
1434                load_delegations(
1435                    transport,
1436                    snapshot,
1437                    consistent_snapshot,
1438                    metadata_base_url,
1439                    max_targets_size,
1440                    delegations,
1441                    datastore,
1442                    loaded_roles,
1443                    update_start,
1444                    expiration_enforcement,
1445                    delegated_metadata_bytes,
1446                )
1447                .await?;
1448            }
1449        }
1450    }
1451    Ok(())
1452}
1453
1454#[cfg(test)]
1455mod tests {
1456    use super::*;
1457
1458    // Check if a url with a trailing slash and one without trailing slash can both be parsed
1459    #[test]
1460    fn url_missing_trailing_slash() {
1461        let parsed_url_without_trailing_slash =
1462            parse_url(Url::parse("https://example.org/a/b/c").unwrap()).unwrap();
1463        let parsed_url_with_trailing_slash =
1464            parse_url(Url::parse("https://example.org/a/b/c/").unwrap()).unwrap();
1465        assert_eq!(
1466            parsed_url_without_trailing_slash,
1467            parsed_url_with_trailing_slash
1468        );
1469    }
1470
1471    // Ensure that the `ExpirationEnforcement` traits are not changed by mistake.
1472    #[test]
1473    fn expiration_enforcement_traits() {
1474        let enforce = true;
1475        let safe: ExpirationEnforcement = enforce.into();
1476        assert_eq!(safe, ExpirationEnforcement::Safe);
1477        let not_enforce = false;
1478        let not_safe: ExpirationEnforcement = not_enforce.into();
1479        assert_eq!(not_safe, ExpirationEnforcement::Unsafe);
1480        let enforcing: bool = ExpirationEnforcement::Safe.into();
1481        assert!(enforcing);
1482        let non_enforcing: bool = ExpirationEnforcement::Unsafe.into();
1483        assert!(!non_enforcing);
1484        let default = ExpirationEnforcement::default();
1485        assert_eq!(default, ExpirationEnforcement::Safe);
1486    }
1487
1488    #[test]
1489    fn encode_filename_1() {
1490        let input = "../a";
1491        let expected = "..%2Fa";
1492        let actual = encode_filename(input);
1493        assert_eq!(expected, actual);
1494    }
1495
1496    #[test]
1497    fn encode_filename_2() {
1498        let input = "";
1499        let expected = "";
1500        let actual = encode_filename(input);
1501        assert_eq!(expected, actual);
1502    }
1503
1504    #[test]
1505    fn encode_filename_3() {
1506        let input = ".";
1507        let expected = ".";
1508        let actual = encode_filename(input);
1509        assert_eq!(expected, actual);
1510    }
1511
1512    #[test]
1513    fn encode_filename_4() {
1514        let input = "/";
1515        let expected = "%2F";
1516        let actual = encode_filename(input);
1517        assert_eq!(expected, actual);
1518    }
1519
1520    #[test]
1521    fn encode_filename_5() {
1522        let input = "ö";
1523        let expected = "%C3%B6";
1524        let actual = encode_filename(input);
1525        assert_eq!(expected, actual);
1526    }
1527
1528    #[test]
1529    fn encode_filename_6() {
1530        let input = "!@#$%^&*()[]|\\~`'\";:.,><?/-_";
1531        let expected =
1532            "%21%40%23%24%25%5E%26%2A%28%29%5B%5D%7C%5C~%60%27%22%3B%3A.%2C%3E%3C%3F%2F-_";
1533        let actual = encode_filename(input);
1534        assert_eq!(expected, actual);
1535    }
1536
1537    #[test]
1538    fn encode_filename_7() {
1539        let input = "../../strange/role/../name";
1540        let expected = "..%2F..%2Fstrange%2Frole%2F..%2Fname";
1541        let actual = encode_filename(input);
1542        assert_eq!(expected, actual);
1543    }
1544
1545    #[test]
1546    fn encode_filename_8() {
1547        let input = "../🍺/( ͡° ͜ʖ ͡°)";
1548        let expected = "..%2F%F0%9F%8D%BA%2F%28%20%CD%A1%C2%B0%20%CD%9C%CA%96%20%CD%A1%C2%B0%29";
1549        let actual = encode_filename(input);
1550        assert_eq!(expected, actual);
1551    }
1552
1553    #[test]
1554    fn encode_filename_9() {
1555        let input = "ᚩ os, ᚱ rad, ᚳ cen, ᚷ gyfu, ᚹ ƿynn, ᚻ hægl, ...";
1556        let expected = "%E1%9A%A9%20os%2C%20%E1%9A%B1%20rad%2C%20%E1%9A%B3%20cen%2C%20%E1%9A%B7%20gyfu%2C%20%E1%9A%B9%20%C6%BFynn%2C%20%E1%9A%BB%20h%C3%A6gl%2C%20...";
1557        let actual = encode_filename(input);
1558        assert_eq!(expected, actual);
1559    }
1560
1561    #[test]
1562    fn encode_filename_10() {
1563        let input = "../../path/like/dubious";
1564        let expected = "..%2F..%2Fpath%2Flike%2Fdubious";
1565        let actual = encode_filename(input);
1566        assert_eq!(expected, actual);
1567    }
1568
1569    #[test]
1570    fn encode_filename_11() {
1571        let input = "🍺/30";
1572        let expected = "%F0%9F%8D%BA%2F30";
1573        let actual = encode_filename(input);
1574        assert_eq!(expected, actual);
1575    }
1576}