// Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: MIT OR Apache-2.0

//! Tough is a client library for [TUF repositories].
//!
//! This client adheres to [TUF version 1.0.0][spec], with the following exceptions:
//!
//! * Delegated roles (and TAP 3) are not yet supported.
//! * TAP 4 (multiple repository consensus) is not yet supported.
//!
//! [TUF repositories]: https://theupdateframework.github.io/
//! [spec]: https://github.com/theupdateframework/specification/blob/9f148556ca15da2ec5c022c8b3e6f99a028e5fe5/tuf-spec.md
//!
//! # Testing
//!
//! Unit tests are run in the usual manner: `cargo test`.
//! Integration tests require `noxious-server` and are disabled by default behind a feature named `integ`.
//! To run all tests, including integration tests: `cargo test --all-features` or
//! `cargo test --features 'http,integ'`.

#![forbid(missing_debug_implementations)]
#![deny(missing_copy_implementations)]
#![deny(rust_2018_idioms)]
// missing_docs is on its own line to make it easy to comment out when making changes.
#![deny(missing_docs)]
#![warn(clippy::pedantic)]
#![allow(
    clippy::module_name_repetitions,
    clippy::must_use_candidate,
    clippy::missing_errors_doc,
    clippy::result_large_err
)]

mod cache;
mod datastore;
pub mod editor;
pub mod error;
mod fetch;
#[cfg(feature = "http")]
pub mod http;
mod io;
pub mod key_source;
pub mod schema;
pub mod sign;
mod target_name;
mod transport;
mod urlpath;

use crate::datastore::Datastore;
use crate::error::Result;
use crate::fetch::{fetch_max_size, fetch_sha256};
/// An HTTP transport that includes retries.
#[cfg(feature = "http")]
pub use crate::http::{HttpTransport, HttpTransportBuilder};
use crate::io::is_dir;
use crate::schema::{
    DelegatedRole, Delegations, Role, RoleType, Root, Signed, Snapshot, Timestamp,
};
pub use crate::target_name::TargetName;
pub use crate::transport::IntoVec;
pub use crate::transport::{
    DefaultTransport, FilesystemTransport, Transport, TransportError, TransportErrorKind,
    TransportStream,
};
pub use crate::urlpath::SafeUrlPath;
use async_recursion::async_recursion;
pub use async_trait::async_trait;
pub use bytes::Bytes;
use error::SnapshotTargetsMetaMissingSnafu;
use futures::StreamExt;
use futures_core::Stream;
use jiff::Timestamp as JiffTimestamp;
use log::warn;
use percent_encoding::{utf8_percent_encode, AsciiSet, NON_ALPHANUMERIC};
use snafu::{ensure, OptionExt, ResultExt};
use std::collections::{BTreeSet, HashMap};
use std::path::{Path, PathBuf};
use tempfile::NamedTempFile;
use tokio::fs::{canonicalize, create_dir_all};
use tokio::io::AsyncWriteExt;
use url::Url;

/// Represents whether a Repository should fail to load when metadata is expired (`Safe`) or whether
/// it should ignore expired metadata (`Unsafe`). Only use `Unsafe` if you are sure you need it.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ExpirationEnforcement {
    /// Expirations will be enforced. You MUST use this option to get TUF security guarantees.
    Safe,

    /// Expirations will not be enforced. This is available for certain offline use cases, does NOT
    /// provide TUF security guarantees, and should only be used if you are sure that you need it.
    Unsafe,
}

/// `ExpirationEnforcement` defaults to `Safe` mode.
impl Default for ExpirationEnforcement {
    fn default() -> Self {
        ExpirationEnforcement::Safe
    }
}

impl From<bool> for ExpirationEnforcement {
    fn from(b: bool) -> Self {
        if b {
            ExpirationEnforcement::Safe
        } else {
            ExpirationEnforcement::Unsafe
        }
    }
}

impl From<ExpirationEnforcement> for bool {
    fn from(ee: ExpirationEnforcement) -> Self {
        ee == ExpirationEnforcement::Safe
    }
}

/// A builder for settings with which to load a [`Repository`]. Required settings are provided in
/// the [`RepositoryLoader::new`] function. Optional parameters can be added after calling new.
/// Finally, call [`RepositoryLoader::load`] to load the [`Repository`].
///
/// # Examples
///
/// ## Basic usage:
///
/// ```rust
/// # use std::path::PathBuf;
/// # use tough::RepositoryLoader;
/// # use url::Url;
/// # let dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests").join("data").join("tuf-reference-impl");
/// # let root = dir.join("metadata").join("1.root.json");
/// # let metadata_base_url = Url::from_file_path(dir.join("metadata")).unwrap();
/// # let targets_base_url = Url::from_file_path(dir.join("targets")).unwrap();
/// # tokio_test::block_on(async {
///
/// let repository = RepositoryLoader::new(
///     &tokio::fs::read(root).await.unwrap(),
///     metadata_base_url,
///     targets_base_url,
/// )
/// .load()
/// .await
/// .unwrap();
///
/// # });
/// ```
///
/// ## With optional settings:
///
/// ```rust
/// # use std::path::PathBuf;
/// # use tough::{RepositoryLoader, FilesystemTransport, ExpirationEnforcement};
/// # use url::Url;
/// # let dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests").join("data").join("tuf-reference-impl");
/// # let root = dir.join("metadata").join("1.root.json");
/// # let metadata_base_url = Url::from_file_path(dir.join("metadata")).unwrap();
/// # let targets_base_url = Url::from_file_path(dir.join("targets")).unwrap();
/// # tokio_test::block_on(async {
///
/// let repository = RepositoryLoader::new(
///     &tokio::fs::read(root).await.unwrap(),
///     metadata_base_url,
///     targets_base_url,
/// )
/// .transport(FilesystemTransport)
/// .expiration_enforcement(ExpirationEnforcement::Unsafe)
/// .load()
/// .await
/// .unwrap();
///
/// # });
/// ```
#[derive(Debug, Clone)]
pub struct RepositoryLoader<'a> {
    root: &'a [u8],
    metadata_base_url: Url,
    targets_base_url: Url,
    transport: Option<Box<dyn Transport + Send + Sync>>,
    limits: Option<Limits>,
    datastore: Option<PathBuf>,
    expiration_enforcement: Option<ExpirationEnforcement>,
}

impl<'a> RepositoryLoader<'a> {
    /// Create a new `RepositoryLoader`.
    ///
    /// `root` is the content of a trusted root metadata file, which you must ship with your
    /// software using an out-of-band process. It should be a copy of the most recent root.json
    /// from your repository. (It's okay if it becomes out of date later; the client establishes
    /// trust up to the most recent root.json file.)
    ///
    /// `metadata_base_url` and `targets_base_url` are the base URLs where the client can find
    /// metadata (such as root.json) and targets (as listed in targets.json).
    pub fn new(root: &'a impl AsRef<[u8]>, metadata_base_url: Url, targets_base_url: Url) -> Self {
        Self {
            root: root.as_ref(),
            metadata_base_url,
            targets_base_url,
            transport: None,
            limits: None,
            datastore: None,
            expiration_enforcement: None,
        }
    }

    /// Load and verify TUF repository metadata.
    pub async fn load(self) -> Result<Repository> {
        Repository::load(self).await
    }

    /// Set the transport. If no transport has been set, [`DefaultTransport`] will be used.
    #[must_use]
    pub fn transport<T: Transport + Send + Sync + 'static>(mut self, transport: T) -> Self {
        self.transport = Some(Box::new(transport));
        self
    }

    /// Set a the repository [`Limits`].
    #[must_use]
    pub fn limits(mut self, limits: Limits) -> Self {
        self.limits = Some(limits);
        self
    }

    /// Set a `datastore` directory path. `datastore` is a directory on a persistent filesystem.
    /// This directory's contents store the most recently fetched timestamp, snapshot, and targets
    /// metadata files to detect version rollback attacks.
    ///
    /// You may chose to provide a [`PathBuf`] to a directory on a persistent filesystem, which must
    /// exist prior to calling [`RepositoryLoader::load`]. If no datastore is provided, a temporary
    /// directory will be created and cleaned up for for you.
    #[must_use]
    pub fn datastore<P: Into<PathBuf>>(mut self, datastore: P) -> Self {
        self.datastore = Some(datastore.into());
        self
    }

    /// Set the [`ExpirationEnforcement`].
    ///
    /// **CAUTION:** TUF metadata expiration dates, particularly `timestamp.json`, are designed to
    /// limit a replay attack window. By setting `expiration_enforcement` to `Unsafe`, you are
    /// disabling this feature of TUF. Use `Safe` unless you have a good reason to use `Unsafe`.
    #[must_use]
    pub fn expiration_enforcement(mut self, exp: ExpirationEnforcement) -> Self {
        self.expiration_enforcement = Some(exp);
        self
    }
}

/// Limits used when fetching repository metadata.
///
/// These limits are implemented to prevent endless data attacks. Clients must ensure these values
/// are set higher than what would reasonably be expected by a repository, but not so high that the
/// amount of data could interfere with the system.
///
/// `max_root_size`, `max_timestamp_size` and `max_snapshot_size` are the maximum size for the `root.json`,
/// `timestamp.json` and `snapshot.json` files, respectively, downloaded from the repository. These must be
/// sufficiently large such that future updates to your repository's key management strategy
/// will still be supported, but sufficiently small such that you are protected against an
/// endless data attack (defined by TUF as an attacker responding to clients with extremely
/// large files that interfere with the client's system).
///
/// The [`Default`] implementation sets the following values:
/// * `max_root_size`: 1 MiB
/// * `max_targets_size`: 10 MiB
/// * `max_timestamp_size`: 1 MiB
/// * `max_snapshot_size`: 1 MiB
/// * `max_root_updates`: 1024
#[derive(Debug, Clone, Copy)]
pub struct Limits {
    /// The maximum allowable size in bytes for downloaded root.json files.
    pub max_root_size: u64,

    /// The maximum allowable size in bytes for downloaded targets.json file **if** the size is not
    /// listed in snapshots.json. This setting is ignored if the size of targets.json is in the
    /// signed snapshots.json file.
    pub max_targets_size: u64,

    /// The maximum allowable size in bytes for the downloaded timestamp.json file.
    pub max_timestamp_size: u64,

    /// The maximum allowable size in bytes for the downloaded snapshot.json file.
    pub max_snapshot_size: u64,

    /// The maximum number of updates to root.json to download.
    pub max_root_updates: u64,
}

impl Default for Limits {
    fn default() -> Self {
        Self {
            max_root_size: 1024 * 1024,         // 1 MiB
            max_targets_size: 1024 * 1024 * 10, // 10 MiB
            max_timestamp_size: 1024 * 1024,    // 1 MiB
            max_snapshot_size: 1024 * 1024,     // 1 MiB
            max_root_updates: 1024,
        }
    }
}

/// Use this enum to specify whether or not we should include a prefix in the target name when
/// saving a target.
#[derive(Debug, Copy, Clone, Eq, PartialEq)]
pub enum Prefix {
    /// Do not prepend the target name when saving the target file, e.g. `my-target.txt`.
    None,
    /// Prepend the sha digest when saving the target file, e.g. `0123456789abcdef.my-target.txt`.
    Digest,
}

/// A TUF repository.
///
/// You can create a `Repository` using a [`RepositoryLoader`].
#[derive(Debug, Clone)]
pub struct Repository {
    transport: Box<dyn Transport + Send + Sync>,
    consistent_snapshot: bool,
    datastore: Datastore,
    earliest_expiration: JiffTimestamp,
    earliest_expiration_role: RoleType,
    root: Signed<Root>,
    snapshot: Signed<Snapshot>,
    timestamp: Signed<Timestamp>,
    targets: Signed<crate::schema::Targets>,
    limits: Limits,
    metadata_base_url: Url,
    targets_base_url: Url,
    expiration_enforcement: ExpirationEnforcement,
    delegated_metadata_bytes: std::collections::HashMap<String, Vec<u8>>,
}

impl Repository {
    /// Load and verify TUF repository metadata using a [`RepositoryLoader`] for the settings.
    async fn load(loader: RepositoryLoader<'_>) -> Result<Self> {
        let datastore = Datastore::new(loader.datastore)?;
        let transport = loader
            .transport
            .unwrap_or_else(|| Box::new(DefaultTransport::new()));
        let limits = loader.limits.unwrap_or_default();
        let expiration_enforcement = loader.expiration_enforcement.unwrap_or_default();
        let metadata_base_url = parse_url(loader.metadata_base_url)?;
        let targets_base_url = parse_url(loader.targets_base_url)?;
        let update_start = datastore.system_time().await?;

        // 0. Load the trusted root metadata file + 1. Update the root metadata file
        let root = load_root(
            transport.as_ref(),
            loader.root,
            &datastore,
            limits.max_root_size,
            limits.max_root_updates,
            &metadata_base_url,
            expiration_enforcement,
            &update_start,
        )
        .await?;

        // 2. Download the timestamp metadata file
        let timestamp = load_timestamp(
            transport.as_ref(),
            &root,
            &datastore,
            limits.max_timestamp_size,
            &metadata_base_url,
            expiration_enforcement,
            &update_start,
        )
        .await?;

        // 3. Download the snapshot metadata file
        let snapshot = load_snapshot(
            transport.as_ref(),
            &root,
            &timestamp,
            limits.max_snapshot_size,
            &datastore,
            &metadata_base_url,
            expiration_enforcement,
            &update_start,
        )
        .await?;

        // 4. Download the targets metadata file
        let (targets, delegated_metadata_bytes) = load_targets(
            transport.as_ref(),
            &root,
            &snapshot,
            &datastore,
            limits.max_targets_size,
            &metadata_base_url,
            expiration_enforcement,
            &update_start,
        )
        .await?;

        let expires_iter = [
            (root.signed.expires, RoleType::Root),
            (timestamp.signed.expires, RoleType::Timestamp),
            (snapshot.signed.expires, RoleType::Snapshot),
            (targets.signed.expires, RoleType::Targets),
        ];
        let (earliest_expiration, earliest_expiration_role) =
            expires_iter.iter().min_by_key(|tup| tup.0).unwrap();

        Ok(Self {
            transport,
            consistent_snapshot: root.signed.consistent_snapshot,
            datastore,
            earliest_expiration: *earliest_expiration,
            earliest_expiration_role: *earliest_expiration_role,
            root,
            snapshot,
            timestamp,
            targets,
            limits,
            metadata_base_url,
            targets_base_url,
            expiration_enforcement,
            delegated_metadata_bytes,
        })
    }

    /// Returns the list of targets present in the repository.
    pub fn targets(&self) -> &Signed<crate::schema::Targets> {
        &self.targets
    }

    /// Returns a reference to the signed root
    pub fn root(&self) -> &Signed<Root> {
        &self.root
    }

    /// Returns a reference to the signed snapshot
    pub fn snapshot(&self) -> &Signed<Snapshot> {
        &self.snapshot
    }

    /// Returns a reference to the signed timestamp
    pub fn timestamp(&self) -> &Signed<Timestamp> {
        &self.timestamp
    }

    ///return a vec of all targets including all target files delegated by targets
    pub fn all_targets(&self) -> impl Iterator<Item = (&TargetName, &schema::Target)> + '_ {
        self.targets.signed.targets_iter()
    }

    /// Fetches a target from the repository.
    ///
    /// If the repository metadata is expired or there is an issue making the request, `Err` is
    /// returned.
    ///
    /// If the requested target is not listed in the repository metadata, `Ok(None)` is returned.
    ///
    /// Otherwise, a stream is returned, which provides access to the target contents before its
    /// checksum is validated. If the maximum size is reached or there is a checksum mismatch, the
    /// stream returns a [`error::Error`]. **Consumers of this library must not use data from the
    /// stream if it returns an error.**
    pub async fn read_target(
        &self,
        name: &TargetName,
    ) -> Result<
        Option<impl Stream<Item = error::Result<Bytes>> + IntoVec<error::Error> + Send + Sync>,
    > {
        // Check for repository metadata expiration.
        if self.expiration_enforcement == ExpirationEnforcement::Safe {
            ensure!(
                self.datastore.system_time().await? < self.earliest_expiration,
                error::ExpiredMetadataSnafu {
                    role: self.earliest_expiration_role
                }
            );
        }

        // 5. Verify the desired target against its targets metadata.
        //
        // 5.1. If there is no targets metadata about this target, abort the update cycle and
        //   report that there is no such target.
        //
        // 5.2. Otherwise, download the target (up to the number of bytes specified in the targets
        //   metadata), and verify that its hashes match the targets metadata. (We download up to
        //   this number of bytes, because in some cases, the exact number is unknown. This may
        //   happen, for example, if an external program is used to compute the root hash of a tree
        //   of targets files, and this program does not provide the total size of all of these
        //   files.) If consistent snapshots are not used (see Section 7), then the filename used
        //   to download the target file is of the fixed form FILENAME.EXT (e.g., foobar.tar.gz).
        //   Otherwise, the filename is of the form HASH.FILENAME.EXT (e.g.,
        //   c14aeb4ac9f4a8fc0d83d12482b9197452f6adf3eb710e3b1e2b79e8d14cb681.foobar.tar.gz), where
        //   HASH is one of the hashes of the targets file listed in the targets metadata file
        //   found earlier in step 4. In either case, the client MUST write the file to
        //   non-volatile storage as FILENAME.EXT.
        Ok(
            if let Ok(target) = self.targets.signed.find_target(name, false) {
                let (sha256, file) = self.target_digest_and_filename(target, name);
                Some(self.fetch_target(target, &sha256, file.as_str()).await?)
            } else {
                None
            },
        )
    }

    /// Fetches a target from the repository and saves it to `outdir`. Attempts to do this as safely
    /// as possible by using `path_clean` to eliminate `../` path traversals from the the target's
    /// name. Ensures that the resulting filepath is in `outdir` or a child of `outdir`.
    ///
    /// # Parameters
    ///
    /// - `name`: the target name.
    /// - `outdir`: the directory to save the target in.
    /// - `prepend`: Whether or not to prepend the sha digest when saving the target file.
    ///
    /// # Preconditions and Behavior
    ///
    /// - `outdir` must exist. For safety we want to canonicalize the path before we join to it.
    /// - intermediate directories will be created in `outdir` with `create_dir_all`
    /// - Will error if the result of path resolution results in a filepath outside of `outdir` or
    ///   outside of a delegated target's correct path of delegation.
    ///
    pub async fn save_target<P>(&self, name: &TargetName, outdir: P, prepend: Prefix) -> Result<()>
    where
        P: AsRef<Path>,
    {
        // Ensure the outdir exists then canonicalize the path.
        let outdir = outdir.as_ref();
        let outdir = canonicalize(outdir)
            .await
            .context(error::SaveTargetOutdirCanonicalizeSnafu { path: outdir })?;
        ensure!(
            is_dir(&outdir).await,
            error::SaveTargetOutdirSnafu { path: outdir }
        );

        if name.resolved() != name.raw() {
            // Since target names with resolvable path segments are unusual and potentially unsafe,
            // we warn the user that we have encountered them.
            warn!(
                "The target named '{}' had path segments that were resolved to produce the \
                following name: {}",
                name.raw(),
                name.resolved()
            );
        }

        let filename = match prepend {
            Prefix::Digest => {
                let target = self
                    .targets
                    .signed
                    .find_target(name, false)
                    .with_context(|_| error::CacheTargetMissingSnafu {
                        target_name: name.clone(),
                    })?;
                let sha256 = target.hashes.sha256.clone().into_vec();
                format!("{}.{}", hex::encode(sha256), name.resolved())
            }
            Prefix::None => name.resolved().to_owned(),
        };

        let resolved_filepath = outdir.join(filename);

        // Find out what directory we will be writing the target file to.
        let filepath_dir =
            resolved_filepath
                .parent()
                .with_context(|| error::SaveTargetNoParentSnafu {
                    path: &resolved_filepath,
                    name: name.clone(),
                })?;

        // Make sure the filepath we are writing to is in or below outdir.
        ensure!(
            filepath_dir.starts_with(&outdir),
            error::SaveTargetUnsafePathSnafu {
                name: name.clone(),
                outdir,
                filepath: &resolved_filepath,
            }
        );

        // Fetch and write the target using NamedTempFile for an atomic file creation.
        let mut stream = self
            .read_target(name)
            .await?
            .with_context(|| error::SaveTargetNotFoundSnafu { name: name.clone() })?;
        create_dir_all(filepath_dir)
            .await
            .context(error::DirCreateSnafu {
                path: &filepath_dir,
            })?;

        let real_filepath_dir = canonicalize(filepath_dir)
            .await
            .context(error::AbsolutePathSnafu { path: filepath_dir })?;
        let real_outdir = canonicalize(&outdir)
            .await
            .context(error::AbsolutePathSnafu { path: &outdir })?;
        ensure!(
            real_filepath_dir.starts_with(&real_outdir),
            error::SaveTargetUnsafePathSnafu {
                name: name.clone(),
                outdir,
                filepath: &resolved_filepath,
            }
        );

        // Create a new temporary file.
        let tmp_path = real_filepath_dir;
        let tmp = tokio::task::spawn_blocking(move || NamedTempFile::new_in(tmp_path))
            .await
            .context(error::JoinSpawnBlockingTaskSnafu)?
            .context(error::NamedTempFileCreateSnafu { path: filepath_dir })?;

        // Convert to `tokio::fs::File`.
        let (f, tmp_path) = tmp.into_parts();
        let mut f = tokio::fs::File::from_std(f);

        // Write input stream to file.
        while let Some(bytes) = stream.next().await {
            f.write_all(bytes?.as_ref())
                .await
                .context(error::FileWriteSnafu { path: &tmp_path })?;
        }

        // Reconstruct `NamedTempFile` in order to persist it at the target location.
        let f = NamedTempFile::from_parts(f.into_std().await, tmp_path);
        f.persist(&resolved_filepath)
            .context(error::NamedTempFilePersistSnafu {
                path: resolved_filepath,
            })?;

        Ok(())
    }

    /// Return the named `DelegatedRole` if found.
    pub fn delegated_role(&self, name: &str) -> Option<&DelegatedRole> {
        self.targets.signed.delegated_role(name).ok()
    }
}

/// The set of characters that will be escaped when converting a delegated role name into a
/// filename. This needs to at least include path traversal characters to prevent tough from writing
/// outside of its datastore.
///
/// In order to match the Python TUF implementation, we mimic the Python function
/// [urllib.parse.quote] (given a 'safe' parameter value of `""`) which follows RFC 3986 and states
///
/// > Replace special characters in string using the %xx escape. Letters, digits, and the characters
/// > `_.-~` are never quoted.
///
/// [urllib.parse.quote]: https://docs.python.org/3/library/urllib.parse.html#url-quoting
const CHARACTERS_TO_ESCAPE: AsciiSet = NON_ALPHANUMERIC
    .remove(b'_')
    .remove(b'.')
    .remove(b'-')
    .remove(b'~');

/// Percent encode a potential filename to ensure it is safe and does not have path traversal
/// characters.
pub(crate) fn encode_filename<S: AsRef<str>>(name: S) -> String {
    utf8_percent_encode(name.as_ref(), &CHARACTERS_TO_ESCAPE).to_string()
}

/// TUF v1.0.16, 5.2.9, 5.3.3, 5.4.5, 5.5.4, The expiration timestamp in the `[metadata]` file MUST
/// be higher than the fixed update start time.
fn check_expired<T: Role>(update_start: &JiffTimestamp, role: &T) -> Result<()> {
    ensure!(
        *update_start <= role.expires(),
        error::ExpiredMetadataSnafu { role: T::TYPE }
    );
    Ok(())
}

/// Checks to see if the `Url` has a trailing slash and adds one if not. Without a trailing slash,
/// the last component of a `Url` is considered to be a file. `metadata_url` and `targets_url`
/// must refer to a base (i.e. directory), so we need them to end with a slash.
fn parse_url(url: Url) -> Result<Url> {
    if url.as_str().ends_with('/') {
        Ok(url)
    } else {
        let mut s = url.to_string();
        s.push('/');
        Url::parse(&s).context(error::ParseUrlSnafu { url: s })
    }
}

/// Steps 0 and 1 of the client application, which load the current root metadata file based on a
/// trusted root metadata file.
#[expect(clippy::too_many_arguments)]
#[allow(clippy::needless_continue)]
async fn load_root<R: AsRef<[u8]>>(
    transport: &dyn Transport,
    root: R,
    datastore: &Datastore,
    max_root_size: u64,
    max_root_updates: u64,
    metadata_base_url: &Url,
    expiration_enforcement: ExpirationEnforcement,
    update_start: &JiffTimestamp,
) -> Result<Signed<Root>> {
    // 5.2. Load the trusted root metadata file. We assume that a good, trusted copy of this file was
    //    shipped with the package manager or software updater using an out-of-band process. Note
    //    that the expiration of the trusted root metadata file does not matter, because we will
    //    attempt to update it in the next step.
    let mut root: Signed<Root> =
        serde_json::from_slice(root.as_ref()).context(error::ParseTrustedMetadataSnafu)?;
    root.signed
        .verify_role(&root)
        .context(error::VerifyTrustedMetadataSnafu)?;

    // Used in step 5.3
    let original_root_version = root.signed.version.get();

    // Used in step 1.9
    let original_timestamp_keys = root
        .signed
        .keys(RoleType::Timestamp)
        .cloned()
        .collect::<Vec<_>>();
    let original_snapshot_keys = root
        .signed
        .keys(RoleType::Snapshot)
        .cloned()
        .collect::<Vec<_>>();

    // 5.3. Update the root metadata file. Since it may now be signed using entirely different keys,
    //    the client must somehow be able to establish a trusted line of continuity to the latest
    //    set of keys. To do so, the client MUST download intermediate root metadata files, until
    //    the latest available one is reached. Therefore, it MUST temporarily turn on consistent
    //    snapshots in order to download versioned root metadata files as described next.
    loop {
        // 5.3.2. Let N denote the version number of the trusted root metadata file.
        //
        // 5.3.3. Try downloading version N+1 of the root metadata file, up to some X number of bytes
        //   (because the size is unknown). The value for X is set by the authors of the
        //   application using TUF. For example, X may be tens of kilobytes. The filename used to
        //   download the root metadata file is of the fixed form VERSION_NUMBER.FILENAME.EXT
        //   (e.g., 42.root.json). If this file is not available, or we have downloaded more than Y
        //   number of root metadata files (because the exact number is as yet unknown), then go to
        //   step 1.8. The value for Y is set by the authors of the application using TUF. For
        //   example, Y may be 2^10.
        ensure!(
            root.signed.version.get() < original_root_version + max_root_updates,
            error::MaxUpdatesExceededSnafu { max_root_updates }
        );
        let path = format!("{}.root.json", root.signed.version.get() + 1);
        let url = metadata_base_url
            .join(&path)
            .with_context(|_| error::JoinUrlSnafu {
                path: path.clone(),
                url: metadata_base_url.clone(),
            })?;
        match fetch_max_size(
            transport,
            url.clone(),
            max_root_size,
            "max_root_size argument",
        )
        .await
        {
            Err(_) => break, // If this file is not available, then go to step 5.3.10.
            Ok(stream) => {
                let data = match stream.into_vec().await {
                    Ok(d) => d,
                    Err(e) if e.kind() == TransportErrorKind::FileNotFound => break,
                    err @ Err(_) => err.context(error::TransportSnafu { url })?,
                };
                let new_root: Signed<Root> =
                    serde_json::from_slice(&data).context(error::ParseMetadataSnafu {
                        role: RoleType::Root,
                    })?;

                // 5.3.4. Check signatures. Version N+1 of the root metadata file MUST have been
                //   signed by: (1) a threshold of keys specified in the trusted root metadata file
                //   (version N), and (2) a threshold of keys specified in the new root metadata
                //   file being validated (version N+1). If version N+1 is not signed as required,
                //   discard it, abort the update cycle, and report the signature failure. On the
                //   next update cycle, begin at step 0 and version N of the root metadata file.
                root.signed
                    .verify_role(&new_root)
                    .context(error::VerifyMetadataSnafu {
                        role: RoleType::Root,
                    })?;
                new_root
                    .signed
                    .verify_role(&new_root)
                    .context(error::VerifyMetadataSnafu {
                        role: RoleType::Root,
                    })?;

                // 5.3.5. Check for a rollback attack. The version number of the new root
                // metadata (version N+1) MUST be exactly the version in the trusted root
                // metadata (version N) incremented by one, that is precisely N+1.
                // off-spec: protect the comparison against u64 overflow (if N < new value,
                // N+1 will not overflow).
                ensure!(
                    root.signed.version < new_root.signed.version
                        && root.signed.version.get() + 1 == new_root.signed.version.get(),
                    error::OlderMetadataSnafu {
                        role: RoleType::Root,
                        current_version: root.signed.version,
                        new_version: new_root.signed.version
                    }
                );

                // 5.3.6. Note that the expiration of the new (intermediate) root metadata file does
                //   not matter yet, because we will check for it in step 1.8.
                //
                // 5.3.7. Set the trusted root metadata file to the new root metadata file.
                //
                // (This is where version N+1 becomes version N.)
                root = new_root;

                // 5.3.8. Persist root metadata. The client MUST write the file to non-volatile storage
                // as FILENAME.EXT (e.g. root.json).
                datastore.remove("root.json").await?;
                datastore.create("root.json", &root).await?;

                // 5.3.9. Repeat 5.3.2 through 5.3.9.
                continue;
            }
        }
    }

    datastore.remove("root.json").await?;
    datastore.create("root.json", &root).await?;

    // TUF v1.0.16, 5.2.9. Check for a freeze attack. The expiration timestamp in the trusted root
    // metadata file MUST be higher than the fixed update start time. If the trusted root metadata
    // file has expired, abort the update cycle, report the potential freeze attack. On the next
    // update cycle, begin at step 5.1 and version N of the root metadata file.
    if expiration_enforcement == ExpirationEnforcement::Safe {
        check_expired(update_start, &root.signed)?;
    }

    // 1.9. If the timestamp and / or snapshot keys have been rotated, then delete the trusted
    //   timestamp and snapshot metadata files. This is done in order to recover from fast-forward
    //   attacks after the repository has been compromised and recovered. A fast-forward attack
    //   happens when attackers arbitrarily increase the version numbers of: (1) the timestamp
    //   metadata, (2) the snapshot metadata, and / or (3) the targets, or a delegated targets,
    //   metadata file in the snapshot metadata.
    if original_timestamp_keys
        .iter()
        .ne(root.signed.keys(RoleType::Timestamp))
        || original_snapshot_keys
            .iter()
            .ne(root.signed.keys(RoleType::Snapshot))
    {
        let r1 = datastore.remove("timestamp.json").await;
        let r2 = datastore.remove("snapshot.json").await;
        r1.and(r2)?;
    }

    // 1.10. Set whether consistent snapshots are used as per the trusted root metadata file (see
    //   Section 4.3).
    //
    // (This is done by checking the value of root.signed.consistent_snapshot throughout this
    // library.)

    Ok(root)
}

/// Step 2 of the client application, which loads the timestamp metadata file.
async fn load_timestamp(
    transport: &dyn Transport,
    root: &Signed<Root>,
    datastore: &Datastore,
    max_timestamp_size: u64,
    metadata_base_url: &Url,
    expiration_enforcement: ExpirationEnforcement,
    update_start: &JiffTimestamp,
) -> Result<Signed<Timestamp>> {
    // 2. Download the timestamp metadata file, up to Y number of bytes (because the size is
    //    unknown.) The value for Y is set by the authors of the application using TUF. For
    //    example, Y may be tens of kilobytes. The filename used to download the timestamp metadata
    //    file is of the fixed form FILENAME.EXT (e.g., timestamp.json).
    let path = "timestamp.json";
    let url = metadata_base_url
        .join(path)
        .with_context(|_| error::JoinUrlSnafu {
            path,
            url: metadata_base_url.clone(),
        })?;
    let stream = fetch_max_size(
        transport,
        url.clone(),
        max_timestamp_size,
        "max_timestamp_size argument",
    )
    .await?;
    let data = stream
        .into_vec()
        .await
        .context(error::TransportSnafu { url })?;
    let timestamp: Signed<Timestamp> =
        serde_json::from_slice(&data).context(error::ParseMetadataSnafu {
            role: RoleType::Timestamp,
        })?;

    // 5.4.2. Check signatures. The new timestamp metadata file must have been signed by a threshold
    //   of keys specified in the trusted root metadata file. If the new timestamp metadata file is
    //   not properly signed, discard it, abort the update cycle, and report the signature failure.
    root.signed
        .verify_role(&timestamp)
        .context(error::VerifyMetadataSnafu {
            role: RoleType::Timestamp,
        })?;

    // 4.6. The meta component must contain exactly one entry, snapshot.json
    ensure!(
        timestamp.signed.meta.len() == 1,
        error::TimestampMetaLengthSnafu {
            version: timestamp.signed.version,
            meta_length: timestamp.signed.meta.len(),
        }
    );
    let snapshot_meta =
        timestamp
            .signed
            .meta
            .get("snapshot.json")
            .context(error::MissingSnapshotMetaSnafu {
                version: timestamp.signed.version,
            })?;

    // 5.4.3.1. Check for a rollback attack. The version number of the trusted timestamp metadata file,
    //   if any, must be less than or equal to the version number of the new timestamp metadata
    //   file. If the new timestamp metadata file is older than the trusted timestamp metadata
    //   file, discard it, abort the update cycle, and report the potential rollback attack.
    if let Some(Ok(old_timestamp)) = datastore
        .bytes("timestamp.json")
        .await?
        .map(|b| serde_json::from_slice::<Signed<Timestamp>>(&b))
    {
        if root.signed.verify_role(&old_timestamp).is_ok() {
            ensure!(
                old_timestamp.signed.version <= timestamp.signed.version,
                error::OlderMetadataSnafu {
                    role: RoleType::Timestamp,
                    current_version: old_timestamp.signed.version,
                    new_version: timestamp.signed.version
                }
            );
            // 4.6 trusted timestamp meta must have one entry, snapshot.json
            ensure!(
                old_timestamp.signed.meta.len() == 1,
                error::TimestampMetaLengthSnafu {
                    version: old_timestamp.signed.version,
                    meta_length: old_timestamp.signed.meta.len(),
                }
            );
            let old_snapshot_meta = old_timestamp.signed.meta.get("snapshot.json").context(
                error::MissingSnapshotMetaSnafu {
                    version: old_timestamp.signed.version,
                },
            )?;
            // 5.4.3.2 trusted snapshot version less than or equal to new snapshot version
            // (rollback attack to fetch older snapshot object)
            ensure!(
                old_snapshot_meta.version <= snapshot_meta.version,
                error::OlderSnapshotInTimestampSnafu {
                    snapshot_new: snapshot_meta.version,
                    timestamp_new: timestamp.signed.version,
                    snapshot_old: old_snapshot_meta.version,
                    timestamp_old: old_timestamp.signed.version,
                }
            );
        }
    }

    // TUF v1.0.16, 5.3.3. Check for a freeze attack. The expiration timestamp in the new timestamp
    // metadata file MUST be higher than the fixed update start time. If so, the new timestamp
    // metadata file becomes the trusted timestamp metadata file. If the new timestamp metadata file
    // has expired, discard it, abort the update cycle, and report the potential freeze attack.
    if expiration_enforcement == ExpirationEnforcement::Safe {
        check_expired(update_start, &timestamp.signed)?;
    }

    // Now that everything seems okay, write the timestamp file to the datastore.
    datastore.create("timestamp.json", &timestamp).await?;

    Ok(timestamp)
}

/// Step 3 of the client application, which loads the snapshot metadata file.
#[expect(clippy::too_many_arguments, clippy::too_many_lines)]
async fn load_snapshot(
    transport: &dyn Transport,
    root: &Signed<Root>,
    timestamp: &Signed<Timestamp>,
    max_snapshot_size: u64,
    datastore: &Datastore,
    metadata_base_url: &Url,
    expiration_enforcement: ExpirationEnforcement,
    update_start: &JiffTimestamp,
) -> Result<Signed<Snapshot>> {
    // 3. Download snapshot metadata file, up to the number of bytes specified in the timestamp
    //    metadata file. If consistent snapshots are not used (see Section 7), then the filename
    //    used to download the snapshot metadata file is of the fixed form FILENAME.EXT (e.g.,
    //    snapshot.json). Otherwise, the filename is of the form VERSION_NUMBER.FILENAME.EXT (e.g.,
    //    42.snapshot.json), where VERSION_NUMBER is the version number of the snapshot metadata
    //    file listed in the timestamp metadata file. In either case, the client MUST write the
    //    file to non-volatile storage as FILENAME.EXT.
    let snapshot_meta =
        timestamp
            .signed
            .meta
            .get("snapshot.json")
            .context(error::MetaMissingSnafu {
                file: "snapshot.json",
                role: RoleType::Timestamp,
            })?;
    let path = if root.signed.consistent_snapshot {
        format!("{}.snapshot.json", snapshot_meta.version)
    } else {
        "snapshot.json".to_owned()
    };
    let url = metadata_base_url
        .join(&path)
        .with_context(|_| error::JoinUrlSnafu {
            path: path.clone(),
            url: metadata_base_url.clone(),
        })?;
    let stream = if let Some(hashes) = &snapshot_meta.hashes {
        fetch_sha256(
            transport,
            url.clone(),
            snapshot_meta.length.unwrap_or(max_snapshot_size),
            "timestamp.json",
            &hashes.sha256,
        )
        .await?
    } else {
        fetch_max_size(
            transport,
            url.clone(),
            snapshot_meta.length.unwrap_or(max_snapshot_size),
            "timestamp.json",
        )
        .await?
    };

    let data = stream
        .into_vec()
        .await
        .context(error::TransportSnafu { url })?;
    let snapshot: Signed<Snapshot> =
        serde_json::from_slice(&data).context(error::ParseMetadataSnafu {
            role: RoleType::Snapshot,
        })?;

    // 3.1. Check against timestamp metadata. The hashes and version number of the new snapshot
    //   metadata file MUST match the hashes and version number listed in timestamp metadata. If
    //   hashes and version do not match, discard the new snapshot metadata, abort the update
    //   cycle, and report the failure.
    //
    // (We already checked the hash in `fetch_sha256` above.)
    ensure!(
        snapshot.signed.version == snapshot_meta.version,
        error::VersionMismatchSnafu {
            role: RoleType::Snapshot,
            fetched: snapshot.signed.version,
            expected: snapshot_meta.version
        }
    );

    // 3.2. Check signatures. The new snapshot metadata file MUST have been signed by a threshold
    //   of keys specified in the trusted root metadata file. If the new snapshot metadata file is
    //   not signed as required, discard it, abort the update cycle, and report the signature
    //   failure.
    root.signed
        .verify_role(&snapshot)
        .context(error::VerifyMetadataSnafu {
            role: RoleType::Snapshot,
        })?;

    // 4.4 Check that snapshot.meta contains at least targets.json
    ensure!(
        snapshot.signed.meta.contains_key("targets.json"),
        SnapshotTargetsMetaMissingSnafu {
            version: snapshot.signed.version,
        }
    );
    // 3.3. Check for a rollback attack.
    //
    // 3.3.1. Note that the trusted snapshot metadata file may be checked for authenticity, but its
    //   expiration does not matter for the following purposes.
    if let Some(Ok(old_snapshot)) = datastore
        .bytes("snapshot.json")
        .await?
        .map(|b| serde_json::from_slice::<Signed<Snapshot>>(&b))
    {
        // 3.3.2. The version number of the trusted snapshot metadata file, if any, MUST be less
        //   than or equal to the version number of the new snapshot metadata file. If the new
        //   snapshot metadata file is older than the trusted metadata file, discard it, abort the
        //   update cycle, and report the potential rollback attack.
        if root.signed.verify_role(&old_snapshot).is_ok() {
            ensure!(
                old_snapshot.signed.version <= snapshot.signed.version,
                error::OlderMetadataSnafu {
                    role: RoleType::Snapshot,
                    current_version: old_snapshot.signed.version,
                    new_version: snapshot.signed.version
                }
            );

            // 3.3.3. The version number of the targets metadata file, and all delegated targets
            //   metadata files (if any), in the trusted snapshot metadata file, if any, MUST be
            //   less than or equal to its version number in the new snapshot metadata file.
            //   Furthermore, any targets metadata filename that was listed in the trusted snapshot
            //   metadata file, if any, MUST continue to be listed in the new snapshot metadata
            //   file. If any of these conditions are not met, discard the new snapshot metadata
            //   file, abort the update cycle, and report the failure.

            // Ensure that the trusted snapshot has at least targets.json
            ensure!(
                old_snapshot.signed.meta.contains_key("targets.json"),
                error::SnapshotTargetsMetaMissingSnafu {
                    version: old_snapshot.signed.version,
                }
            );
            for (name, meta) in &old_snapshot.signed.meta {
                ensure!(
                    snapshot.signed.meta.contains_key(name),
                    error::SnapshotRoleMissingSnafu {
                        role: name,
                        old_version: old_snapshot.signed.version,
                        new_version: snapshot.signed.version,
                    }
                );
                let new_meta = snapshot.signed.meta.get(name).unwrap();
                ensure!(
                    meta.version <= new_meta.version,
                    error::SnapshotRoleRollbackSnafu {
                        role: name,
                        old_role_version: meta.version,
                        old_snapshot_version: old_snapshot.signed.version,
                        new_role_version: new_meta.version,
                        new_snapshot_version: snapshot.signed.version,
                    }
                );
            }
            if let Some(old_targets_meta) = old_snapshot.signed.meta.get("targets.json") {
                let targets_meta =
                    snapshot
                        .signed
                        .meta
                        .get("targets.json")
                        .context(error::MetaMissingSnafu {
                            file: "targets.json",
                            role: RoleType::Snapshot,
                        })?;
                ensure!(
                    old_targets_meta.version <= targets_meta.version,
                    error::OlderMetadataSnafu {
                        role: RoleType::Targets,
                        current_version: old_targets_meta.version,
                        new_version: targets_meta.version,
                    }
                );
            }
        }
    }

    // TUF v1.0.16, 5.4.5. Check for a freeze attack. The expiration timestamp in the new snapshot
    // metadata file MUST be higher than the fixed update start time. If so, the new snapshot
    // metadata file becomes the trusted snapshot metadata file. If the new snapshot metadata file
    // is expired, discard it, abort the update cycle, and report the potential freeze attack.
    if expiration_enforcement == ExpirationEnforcement::Safe {
        check_expired(update_start, &snapshot.signed)?;
    }

    // Now that everything seems okay, write the snapshot file to the datastore.
    datastore.create("snapshot.json", &snapshot).await?;

    Ok(snapshot)
}

/// Step 4 of the client application, which loads the targets metadata file.
#[expect(clippy::too_many_arguments)]
async fn load_targets(
    transport: &dyn Transport,
    root: &Signed<Root>,
    snapshot: &Signed<Snapshot>,
    datastore: &Datastore,
    max_targets_size: u64,
    metadata_base_url: &Url,
    expiration_enforcement: ExpirationEnforcement,
    update_start: &JiffTimestamp,
) -> Result<(
    Signed<crate::schema::Targets>,
    std::collections::HashMap<String, Vec<u8>>,
)> {
    // 4. Download the top-level targets metadata file, up to either the number of bytes specified
    //    in the snapshot metadata file, or some Z number of bytes. The value for Z is set by the
    //    authors of the application using TUF. For example, Z may be tens of kilobytes. If
    //    consistent snapshots are not used (see Section 7), then the filename used to download the
    //    targets metadata file is of the fixed form FILENAME.EXT (e.g., targets.json).  Otherwise,
    //    the filename is of the form VERSION_NUMBER.FILENAME.EXT (e.g., 42.targets.json), where
    //    VERSION_NUMBER is the version number of the targets metadata file listed in the snapshot
    //    metadata file. In either case, the client MUST write the file to non-volatile storage as
    //    FILENAME.EXT.
    let targets_meta =
        snapshot
            .signed
            .meta
            .get("targets.json")
            .context(error::MetaMissingSnafu {
                file: "targets.json",
                role: RoleType::Timestamp,
            })?;
    let path = if root.signed.consistent_snapshot {
        format!("{}.targets.json", targets_meta.version)
    } else {
        "targets.json".to_owned()
    };
    let targets_url = metadata_base_url
        .join(&path)
        .with_context(|_| error::JoinUrlSnafu {
            path,
            url: metadata_base_url.clone(),
        })?;
    let (max_targets_file_size, specifier) = match targets_meta.length {
        Some(length) => (length, "snapshot.json"),
        None => (max_targets_size, "max_targets_size parameter"),
    };
    let stream = if let Some(hashes) = &targets_meta.hashes {
        // 5.6.2. The hashes of the new targets metadata file MUST match the hashes, if any,
        // listed in the trusted snapshot metadata.
        fetch_sha256(
            transport,
            targets_url.clone(),
            max_targets_file_size,
            specifier,
            &hashes.sha256,
        )
        .await?
    } else {
        fetch_max_size(
            transport,
            targets_url.clone(),
            max_targets_file_size,
            specifier,
        )
        .await?
    };
    let data = stream
        .into_vec()
        .await
        .context(error::TransportSnafu { url: targets_url })?;
    let mut targets: Signed<crate::schema::Targets> =
        serde_json::from_slice(&data).context(error::ParseMetadataSnafu {
            role: RoleType::Targets,
        })?;

    // 5.6.3. Check for an arbitrary software attack. The new targets metadata file MUST have been
    //   signed by a threshold of keys specified in the trusted root metadata file. If the new
    //   targets metadata file is not signed as required, discard it, abort the update cycle, and
    //   report the failure.
    root.signed
        .verify_role(&targets)
        .context(error::VerifyMetadataSnafu {
            role: RoleType::Targets,
        })?;

    // 5.6.4. Check against the snapshot role's targets version
    ensure!(
        targets.signed.version == targets_meta.version,
        error::VersionMismatchSnafu {
            role: RoleType::Targets,
            fetched: targets.signed.version,
            expected: targets_meta.version
        }
    );
    // 5.6.5. Check for a freeze attack. The expiration timestamp in the new targets
    // metadata file MUST be higher than the fixed update start time. If so, the new targets
    // metadata file becomes the trusted targets metadata file. If the new targets metadata file is
    // expired, discard it, abort the update cycle, and report the potential freeze attack.
    if expiration_enforcement == ExpirationEnforcement::Safe {
        check_expired(update_start, &targets.signed)?;
    }

    // Now that everything seems okay, write the targets file to the datastore.
    datastore.create("targets.json", &targets).await?;

    // 4.5. Perform a preorder depth-first search for metadata about the desired target, beginning
    //   with the top-level targets role.
    let mut delegated_metadata_bytes: std::collections::HashMap<String, Vec<u8>> =
        std::collections::HashMap::new();
    if let Some(delegations) = &mut targets.signed.delegations {
        let mut loaded_roles: BTreeSet<String> = BTreeSet::new();
        load_delegations(
            transport,
            snapshot,
            root.signed.consistent_snapshot,
            metadata_base_url,
            max_targets_size,
            delegations,
            datastore,
            &mut loaded_roles,
            update_start,
            expiration_enforcement,
            &mut delegated_metadata_bytes,
        )
        .await?;
    }

    // This validation can only be done from the top level targets.json role. This check verifies
    // that each target's delegate hierarchy is a match (i.e. it's delegate ownership is valid).
    targets.signed.validate().context(error::InvalidPathSnafu)?;
    Ok((targets, delegated_metadata_bytes))
}

// Follow the paths of delegations starting with the top level targets.json delegation
#[expect(clippy::too_many_arguments)]
#[async_recursion]
#[allow(clippy::too_many_lines)]
async fn load_delegations(
    transport: &dyn Transport,
    snapshot: &Signed<Snapshot>,
    consistent_snapshot: bool,
    metadata_base_url: &Url,
    max_targets_size: u64,
    delegation: &mut Delegations,
    datastore: &Datastore,
    loaded_roles: &mut BTreeSet<String>,
    update_start: &JiffTimestamp,
    expiration_enforcement: ExpirationEnforcement,
    delegated_metadata_bytes: &mut std::collections::HashMap<String, Vec<u8>>,
) -> Result<()> {
    let mut delegated_roles: HashMap<String, Option<Signed<crate::schema::Targets>>> =
        HashMap::new();
    for delegated_role in &delegation.roles {
        if loaded_roles.contains(&delegated_role.name) {
            // we have already loaded this role, continue
            continue;
        }
        // find the role file metadata
        let role_meta = snapshot
            .signed
            .meta
            .get(&format!("{}.json", &delegated_role.name));

        if role_meta.is_none() {
            // 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.
            loaded_roles.insert(delegated_role.name.clone());
            return Ok(());
        }
        let role_meta = role_meta.unwrap();
        let path = if consistent_snapshot {
            format!(
                "{}.{}.json",
                &role_meta.version,
                encode_filename(&delegated_role.name)
            )
        } else {
            format!("{}.json", encode_filename(&delegated_role.name))
        };
        let role_url = metadata_base_url
            .join(&path)
            .with_context(|_| error::JoinUrlSnafu {
                path: path.clone(),
                url: metadata_base_url.clone(),
            })?;
        // enforce snapshot-pinned length when available, mirroring top-level path
        let (max_delegated_file_size, specifier) = match role_meta.length {
            Some(length) => (length, "snapshot.json"),
            None => (max_targets_size, "max_targets_size parameter"),
        };
        // load the role json file, enforcing snapshot hash when available
        let stream = if let Some(hashes) = &role_meta.hashes {
            fetch_sha256(
                transport,
                role_url.clone(),
                max_delegated_file_size,
                specifier,
                &hashes.sha256,
            )
            .await?
        } else {
            fetch_max_size(
                transport,
                role_url.clone(),
                max_delegated_file_size,
                specifier,
            )
            .await?
        };
        let data = stream
            .into_vec()
            .await
            .context(error::TransportSnafu { url: role_url })?;
        delegated_metadata_bytes.insert(delegated_role.name.clone(), data.clone());
        // since each role is a targets, we load them as such
        let role: Signed<crate::schema::Targets> =
            serde_json::from_slice(&data).context(error::ParseMetadataSnafu {
                role: RoleType::Targets,
            })?;
        // verify each role with the delegation
        delegation
            .verify_role(&role, &delegated_role.name)
            .context(error::VerifyMetadataSnafu {
                role: RoleType::Targets,
            })?;
        ensure!(
            role.signed.version == role_meta.version,
            error::VersionMismatchSnafu {
                role: RoleType::Targets,
                fetched: role.signed.version,
                expected: role_meta.version
            }
        );

        if expiration_enforcement == ExpirationEnforcement::Safe {
            check_expired(update_start, &role.signed)?;
        }

        datastore.create(&path, &role).await?;
        delegated_roles.insert(delegated_role.name.clone(), Some(role));
    }
    // load all roles delegated by this role
    for delegated_role in &mut delegation.roles {
        if loaded_roles.contains(&delegated_role.name) {
            continue;
        }
        loaded_roles.insert(delegated_role.name.clone());
        delegated_role.targets =
            delegated_roles
                .remove(&delegated_role.name)
                .with_context(|| error::DelegatedRolesNotConsistentSnafu {
                    name: delegated_role.name.clone(),
                })?;
        if let Some(targets) = &mut delegated_role.targets {
            if let Some(delegations) = &mut targets.signed.delegations {
                load_delegations(
                    transport,
                    snapshot,
                    consistent_snapshot,
                    metadata_base_url,
                    max_targets_size,
                    delegations,
                    datastore,
                    loaded_roles,
                    update_start,
                    expiration_enforcement,
                    delegated_metadata_bytes,
                )
                .await?;
            }
        }
    }
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;

    // Check if a url with a trailing slash and one without trailing slash can both be parsed
    #[test]
    fn url_missing_trailing_slash() {
        let parsed_url_without_trailing_slash =
            parse_url(Url::parse("https://example.org/a/b/c").unwrap()).unwrap();
        let parsed_url_with_trailing_slash =
            parse_url(Url::parse("https://example.org/a/b/c/").unwrap()).unwrap();
        assert_eq!(
            parsed_url_without_trailing_slash,
            parsed_url_with_trailing_slash
        );
    }

    // Ensure that the `ExpirationEnforcement` traits are not changed by mistake.
    #[test]
    fn expiration_enforcement_traits() {
        let enforce = true;
        let safe: ExpirationEnforcement = enforce.into();
        assert_eq!(safe, ExpirationEnforcement::Safe);
        let not_enforce = false;
        let not_safe: ExpirationEnforcement = not_enforce.into();
        assert_eq!(not_safe, ExpirationEnforcement::Unsafe);
        let enforcing: bool = ExpirationEnforcement::Safe.into();
        assert!(enforcing);
        let non_enforcing: bool = ExpirationEnforcement::Unsafe.into();
        assert!(!non_enforcing);
        let default = ExpirationEnforcement::default();
        assert_eq!(default, ExpirationEnforcement::Safe);
    }

    #[test]
    fn encode_filename_1() {
        let input = "../a";
        let expected = "..%2Fa";
        let actual = encode_filename(input);
        assert_eq!(expected, actual);
    }

    #[test]
    fn encode_filename_2() {
        let input = "";
        let expected = "";
        let actual = encode_filename(input);
        assert_eq!(expected, actual);
    }

    #[test]
    fn encode_filename_3() {
        let input = ".";
        let expected = ".";
        let actual = encode_filename(input);
        assert_eq!(expected, actual);
    }

    #[test]
    fn encode_filename_4() {
        let input = "/";
        let expected = "%2F";
        let actual = encode_filename(input);
        assert_eq!(expected, actual);
    }

    #[test]
    fn encode_filename_5() {
        let input = "ö";
        let expected = "%C3%B6";
        let actual = encode_filename(input);
        assert_eq!(expected, actual);
    }

    #[test]
    fn encode_filename_6() {
        let input = "!@#$%^&*()[]|\\~`'\";:.,><?/-_";
        let expected =
            "%21%40%23%24%25%5E%26%2A%28%29%5B%5D%7C%5C~%60%27%22%3B%3A.%2C%3E%3C%3F%2F-_";
        let actual = encode_filename(input);
        assert_eq!(expected, actual);
    }

    #[test]
    fn encode_filename_7() {
        let input = "../../strange/role/../name";
        let expected = "..%2F..%2Fstrange%2Frole%2F..%2Fname";
        let actual = encode_filename(input);
        assert_eq!(expected, actual);
    }

    #[test]
    fn encode_filename_8() {
        let input = "../🍺/( ͡° ͜ʖ ͡°)";
        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";
        let actual = encode_filename(input);
        assert_eq!(expected, actual);
    }

    #[test]
    fn encode_filename_9() {
        let input = "ᚩ os, ᚱ rad, ᚳ cen, ᚷ gyfu, ᚹ ƿynn, ᚻ hægl, ...";
        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...";
        let actual = encode_filename(input);
        assert_eq!(expected, actual);
    }

    #[test]
    fn encode_filename_10() {
        let input = "../../path/like/dubious";
        let expected = "..%2F..%2Fpath%2Flike%2Fdubious";
        let actual = encode_filename(input);
        assert_eq!(expected, actual);
    }

    #[test]
    fn encode_filename_11() {
        let input = "🍺/30";
        let expected = "%F0%9F%8D%BA%2F30";
        let actual = encode_filename(input);
        assert_eq!(expected, actual);
    }
}
