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 ×tamp,
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(×tamp)
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, ×tamp.signed)?;
976 }
977
978 // Now that everything seems okay, write the timestamp file to the datastore.
979 datastore.create("timestamp.json", ×tamp).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}