tuftool 0.16.0

Utility for creating and signing The Update Framework (TUF) repositories
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: MIT OR Apache-2.0

mod test_utils;

use crate::test_utils::days;
use assert_cmd::assert::Assert;
use assert_cmd::cargo_bin_cmd;
use jiff::Timestamp;
use std::path::Path;
use tempfile::TempDir;
use test_utils::dir_url;
use tough::{RepositoryLoader, TargetName};

fn create_repo<P: AsRef<Path>>(repo_dir: P) {
    let timestamp_expiration = Timestamp::now() + days(1);
    let timestamp_version: u64 = 31;
    let snapshot_expiration = Timestamp::now() + days(2);
    let snapshot_version: u64 = 25;
    let targets_expiration = Timestamp::now() + days(3);
    let targets_version: u64 = 17;
    let targets_input_dir = test_utils::test_data()
        .join("tuf-reference-impl")
        .join("targets");
    let root_json = test_utils::test_data().join("simple-rsa").join("root.json");
    let root_key = test_utils::test_data().join("snakeoil.pem");

    // Create a repo using tuftool and the reference tuf implementation data
    cargo_bin_cmd!("tuftool")
        .args([
            "create",
            "-t",
            targets_input_dir.to_str().unwrap(),
            "-o",
            repo_dir.as_ref().to_str().unwrap(),
            "-k",
            root_key.to_str().unwrap(),
            "--root",
            root_json.to_str().unwrap(),
            "--targets-expires",
            targets_expiration.to_string().as_str(),
            "--targets-version",
            format!("{}", targets_version).as_str(),
            "--snapshot-expires",
            snapshot_expiration.to_string().as_str(),
            "--snapshot-version",
            format!("{}", snapshot_version).as_str(),
            "--timestamp-expires",
            timestamp_expiration.to_string().as_str(),
            "--timestamp-version",
            format!("{}", timestamp_version).as_str(),
        ])
        .assert()
        .success();
}

#[tokio::test]
// Ensure we can read a repo that has had its metadata updated by `tuftool create`
async fn update_command_without_new_targets() {
    let root_json = test_utils::test_data().join("simple-rsa").join("root.json");
    let root_key = test_utils::test_data().join("snakeoil.pem");
    let repo_dir = TempDir::new().unwrap();

    // Create a repo using tuftool and the reference tuf implementation data
    create_repo(repo_dir.path());

    // Set new expiration dates and version numbers for the update command
    let new_timestamp_expiration = Timestamp::now() + days(4);
    let new_timestamp_version: u64 = 310;
    let new_snapshot_expiration = Timestamp::now() + days(5);
    let new_snapshot_version: u64 = 250;
    let new_targets_expiration = Timestamp::now() + days(6);
    let new_targets_version: u64 = 170;
    let metadata_base_url = &dir_url(repo_dir.path().join("metadata"));
    let update_out = TempDir::new().unwrap();

    // Update the repo we just created
    cargo_bin_cmd!("tuftool")
        .args([
            "update",
            "-o",
            update_out.path().to_str().unwrap(),
            "-k",
            root_key.to_str().unwrap(),
            "--root",
            root_json.to_str().unwrap(),
            "--metadata-url",
            metadata_base_url.as_str(),
            "--targets-expires",
            new_targets_expiration.to_string().as_str(),
            "--targets-version",
            format!("{}", new_targets_version).as_str(),
            "--snapshot-expires",
            new_snapshot_expiration.to_string().as_str(),
            "--snapshot-version",
            format!("{}", new_snapshot_version).as_str(),
            "--timestamp-expires",
            new_timestamp_expiration.to_string().as_str(),
            "--timestamp-version",
            format!("{}", new_timestamp_version).as_str(),
        ])
        .assert()
        .success();

    // Load the updated repo
    let repo = RepositoryLoader::new(
        &tokio::fs::read(root_json).await.unwrap(),
        dir_url(update_out.path().join("metadata")),
        dir_url(update_out.path().join("targets")),
    )
    .load()
    .await
    .unwrap();

    // Ensure all the existing targets are accounted for
    assert_eq!(repo.targets().signed.targets.len(), 3);

    // Ensure all the metadata has been updated
    assert_eq!(repo.targets().signed.version.get(), new_targets_version);
    assert_eq!(repo.targets().signed.expires, new_targets_expiration);
    assert_eq!(repo.snapshot().signed.version.get(), new_snapshot_version);
    assert_eq!(repo.snapshot().signed.expires, new_snapshot_expiration);
    assert_eq!(repo.timestamp().signed.version.get(), new_timestamp_version);
    assert_eq!(repo.timestamp().signed.expires, new_timestamp_expiration);
}

#[tokio::test]
// Ensure we can read a repo that has had its metadata and targets updated
// by `tuftool create`
async fn update_command_with_new_targets() {
    let root_json = test_utils::test_data().join("simple-rsa").join("root.json");
    let root_key = test_utils::test_data().join("snakeoil.pem");
    let repo_dir = TempDir::new().unwrap();

    // Create a repo using tuftool and the reference tuf implementation data
    create_repo(repo_dir.path());

    // Set new expiration dates and version numbers for the update command
    let new_timestamp_expiration = Timestamp::now() + days(4);
    let new_timestamp_version: u64 = 310;
    let new_snapshot_expiration = Timestamp::now() + days(5);
    let new_snapshot_version: u64 = 250;
    let new_targets_expiration = Timestamp::now() + days(6);
    let new_targets_version: u64 = 170;
    let new_targets_input_dir = test_utils::test_data().join("targets");
    let metadata_base_url = &dir_url(repo_dir.path().join("metadata"));
    let update_out = TempDir::new().unwrap();

    // Update the repo we just created
    cargo_bin_cmd!("tuftool")
        .args([
            "update",
            "-t",
            new_targets_input_dir.to_str().unwrap(),
            "-o",
            update_out.path().to_str().unwrap(),
            "-k",
            root_key.to_str().unwrap(),
            "--root",
            root_json.to_str().unwrap(),
            "--metadata-url",
            metadata_base_url.as_str(),
            "--targets-expires",
            new_targets_expiration.to_string().as_str(),
            "--targets-version",
            format!("{}", new_targets_version).as_str(),
            "--snapshot-expires",
            new_snapshot_expiration.to_string().as_str(),
            "--snapshot-version",
            format!("{}", new_snapshot_version).as_str(),
            "--timestamp-expires",
            new_timestamp_expiration.to_string().as_str(),
            "--timestamp-version",
            format!("{}", new_timestamp_version).as_str(),
        ])
        .assert()
        .success();

    // Load the updated repo.
    let repo = RepositoryLoader::new(
        &tokio::fs::read(root_json).await.unwrap(),
        dir_url(update_out.path().join("metadata")),
        dir_url(update_out.path().join("targets")),
    )
    .load()
    .await
    .unwrap();

    // Ensure all the targets (new and existing) are accounted for
    assert_eq!(repo.targets().signed.targets.len(), 6);

    // Ensure we can read the newly added targets
    let file4 = TargetName::new("file4.txt").unwrap();
    assert_eq!(
        test_utils::read_to_end(repo.read_target(&file4).await.unwrap().unwrap()).await,
        &b"This is an example target file."[..]
    );
    let file5 = TargetName::new("file5.txt").unwrap();
    assert_eq!(
        test_utils::read_to_end(repo.read_target(&file5).await.unwrap().unwrap()).await,
        &b"This is another example target file."[..]
    );
    let file6 = TargetName::new("file6.txt").unwrap();
    assert_eq!(
        test_utils::read_to_end(repo.read_target(&file6).await.unwrap().unwrap()).await,
        &b"This is yet another example target file."[..]
    );

    // Ensure all the metadata has been updated
    assert_eq!(repo.targets().signed.version.get(), new_targets_version);
    assert_eq!(repo.targets().signed.expires, new_targets_expiration);
    assert_eq!(repo.snapshot().signed.version.get(), new_snapshot_version);
    assert_eq!(repo.snapshot().signed.expires, new_snapshot_expiration);
    assert_eq!(repo.timestamp().signed.version.get(), new_timestamp_version);
    assert_eq!(repo.timestamp().signed.expires, new_timestamp_expiration);
}

#[test]
// Ensure that the update command fails if none of the keys we give it match up with root.json.
fn update_with_incorrect_key() {
    let base = test_utils::test_data().join("tuf-reference-impl");
    let root_json = base.join("metadata").join("1.root.json");
    let bad_key = test_utils::test_data().join("snakeoil.pem");

    cargo_bin_cmd!("tuftool")
        .args([
            "update",
            "--outdir",
            "/outdir/does/not/matter",
            "-k",
            bad_key.to_str().unwrap(),
            "--root",
            root_json.to_str().unwrap(),
            "--metadata-url",
            "https://metadata.url.does.not.matter",
            "--targets-expires",
            "in 7 days",
            "--targets-version",
            "1234",
            "--snapshot-expires",
            "in 7 days",
            "--snapshot-version",
            "1234",
            "--timestamp-expires",
            "in 7 days",
            "--timestamp-version",
            "1234",
        ])
        .assert()
        .failure();
}

#[test]
// Ensure we fail if no key is provided
fn update_with_no_key() {
    cargo_bin_cmd!("tuftool")
        .args([
            "update",
            "--outdir",
            "/outdir/does/not/matter",
            "--root",
            "/root/does/not/matter",
            "--metadata-url",
            "https://metadata.url.does.not.matter",
            "--targets-expires",
            "in 7 days",
            "--targets-version",
            "1234",
            "--snapshot-expires",
            "in 7 days",
            "--snapshot-version",
            "1234",
            "--timestamp-expires",
            "in 7 days",
            "--timestamp-version",
            "1234",
        ])
        .assert()
        .failure();
}

fn updates_expired_repo(
    outdir: &TempDir,
    repo_dir: &TempDir,
    allow_expired_repo: bool,
) -> (Assert, Timestamp, u64, Timestamp, u64, Timestamp, u64) {
    let root_json = test_utils::test_data().join("simple-rsa").join("root.json");
    let root_key = test_utils::test_data().join("snakeoil.pem");
    // Set expiration dates and version numbers for the update command
    let timestamp_expiration = Timestamp::now() + days(4);
    let timestamp_version: u64 = 310;
    let snapshot_expiration = Timestamp::now() + days(5);
    let snapshot_version: u64 = 250;
    let targets_expiration = Timestamp::now() + days(6);
    let targets_version: u64 = 170;
    let metadata_base_url = &test_utils::dir_url(repo_dir.path().join("metadata"));
    let mut cmd = cargo_bin_cmd!("tuftool");
    cmd.args([
        "update",
        "-o",
        outdir.path().to_str().unwrap(),
        "-k",
        root_key.to_str().unwrap(),
        "--root",
        root_json.to_str().unwrap(),
        "--metadata-url",
        metadata_base_url.as_str(),
        "--targets-expires",
        targets_expiration.to_string().as_str(),
        "--targets-version",
        format!("{}", targets_version).as_str(),
        "--snapshot-expires",
        snapshot_expiration.to_string().as_str(),
        "--snapshot-version",
        format!("{}", snapshot_version).as_str(),
        "--timestamp-expires",
        timestamp_expiration.to_string().as_str(),
        "--timestamp-version",
        format!("{}", timestamp_version).as_str(),
    ]);
    let assert = if allow_expired_repo {
        cmd.arg("--allow-expired-repo").assert()
    } else {
        cmd.assert()
    };
    (
        assert,
        timestamp_expiration,
        timestamp_version,
        snapshot_expiration,
        snapshot_version,
        targets_expiration,
        targets_version,
    )
}

#[test]
// Ensure we cannot update a repo that has had its metadata expired
fn update_command_expired_repo_fail() {
    let outdir = TempDir::new().unwrap();
    let repo_dir = TempDir::new().unwrap();
    // Create a expired repo using tuftool and the reference tuf implementation data
    test_utils::create_expired_repo(repo_dir.path());
    let update_expected = updates_expired_repo(&outdir, &repo_dir, false);
    // assert failure for update command
    update_expected.0.failure();
}

#[tokio::test]
// Ensure we can update a repo that has its metadata expired but --allow-expired-repo flag is passed
async fn update_command_expired_repo_allow() {
    let outdir = TempDir::new().unwrap();
    let repo_dir = TempDir::new().unwrap();
    // Create a expired repo using tuftool and the reference tuf implementation data
    test_utils::create_expired_repo(repo_dir.path());
    let update_expected = updates_expired_repo(&outdir, &repo_dir, true);
    // assert success for update command
    update_expected.0.success();
    // Load the updated repo
    let root_json = test_utils::test_data().join("simple-rsa").join("root.json");
    let repo = RepositoryLoader::new(
        &tokio::fs::read(root_json).await.unwrap(),
        dir_url(outdir.path().join("metadata")),
        dir_url(outdir.path().join("targets")),
    )
    .load()
    .await
    .unwrap();
    // Ensure all the existing targets are accounted for
    assert_eq!(repo.targets().signed.targets.len(), 3);
    // Ensure all the metadata has been updated
    assert_eq!(repo.timestamp().signed.expires, update_expected.1);
    assert_eq!(repo.timestamp().signed.version.get(), update_expected.2);
    assert_eq!(repo.snapshot().signed.expires, update_expected.3);
    assert_eq!(repo.snapshot().signed.version.get(), update_expected.4);
    assert_eq!(repo.targets().signed.expires, update_expected.5);
    assert_eq!(repo.targets().signed.version.get(), update_expected.6);
}