Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/holger

update-bundle/src/tests/cli.rs

//! The bundle tool's commands (`holger-cli bundle …`, `holger-cli verify`) driven the way
//! the CLI drives them: parsed by clap from argv, run against files on disk. The life of
//! one bundle (prepare, inspect, approve twice, finalize, verify, extract) and the
//! refusals on the way: a finalize short of approvals, a verdict that is not `pass`, a
//! key that is not the certificate's, a wrong target, a second extract into a used
//! directory, and a PEM that is not base64.

use std::path::{Path, PathBuf};

use clap::Parser;

use super::testkit::{pinned_policy, self_signed, TestSigner};
use crate::cli::{bundle, verify, BundleTool, VerifyArgs};

#[derive(Parser)]
struct BundleArgv {
    #[command(subcommand)]
    cmd: BundleTool,
}

#[derive(Parser)]
struct VerifyArgv {
    #[command(flatten)]
    a: VerifyArgs,
}

fn bundle_cmd(args: &[&str]) -> anyhow::Result<()> {
    let argv = std::iter::once("bundle").chain(args.iter().copied());
    bundle::run(BundleArgv::try_parse_from(argv).expect("argv parses").cmd)
}

fn verify_cmd(args: &[&str]) -> anyhow::Result<()> {
    let argv = std::iter::once("verify").chain(args.iter().copied());
    verify::verify(VerifyArgv::try_parse_from(argv).expect("argv parses").a)
}

fn s(p: &Path) -> &str {
    p.to_str().unwrap()
}

/// Standard base64 with padding, wrapped at 64 columns, as a PEM block.
fn pem(label: &str, der: &[u8]) -> String {
    const A: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
    let mut b64 = String::new();
    for c in der.chunks(3) {
        let n = (c[0] as u32) << 16
            | (*c.get(1).unwrap_or(&0) as u32) << 8
            | *c.get(2).unwrap_or(&0) as u32;
        for i in 0..4 {
            if i <= c.len() {
                b64.push(A[(n >> (18 - 6 * i) & 63) as usize] as char);
            } else {
                b64.push('=');
            }
        }
    }
    let mut out = format!("-----BEGIN {label}-----\n");
    for line in b64.as_bytes().chunks(64) {
        out.push_str(std::str::from_utf8(line).unwrap());
        out.push('\n');
    }
    out.push_str(&format!("-----END {label}-----\n"));
    out
}

struct Desk {
    dir: tempfile::TempDir,
    reviewer: TestSigner,
    owner: TestSigner,
    release: TestSigner,
}

impl Desk {
    fn new() -> Self {
        let d = Desk {
            dir: tempfile::tempdir().unwrap(),
            reviewer: self_signed("reviewer"),
            owner: self_signed("owner"),
            release: self_signed("release"),
        };
        // The reviewer's pair as DER, the owner's and the release signer's as PEM:
        // the tool reads both.
        d.write("reviewer.der", &d.reviewer.cert_der);
        d.write("reviewer.key", &d.reviewer.key_pkcs8);
        d.write(
            "owner.pem",
            pem("CERTIFICATE", &d.owner.cert_der).as_bytes(),
        );
        d.write(
            "owner.key.pem",
            pem("PRIVATE KEY", &d.owner.key_pkcs8).as_bytes(),
        );
        d.write(
            "release.pem",
            pem("CERTIFICATE", &d.release.cert_der).as_bytes(),
        );
        d.write(
            "release.key.pem",
            pem("PRIVATE KEY", &d.release.key_pkcs8).as_bytes(),
        );
        let policy = pinned_policy(&d.reviewer, &d.owner, &d.release)
            .to_ron()
            .unwrap();
        d.write("policy.ron", policy.as_bytes());
        d.write("app.bin", b"the payload bytes");
        d.write(
            "sbom.json",
            br#"{"bomFormat":"CycloneDX","specVersion":"1.6"}"#,
        );
        d.write("vex.json", br#"{"statements":[]}"#);
        d.write("scan.xml", b"<scan/>");
        d.write("advisory.json", br#"{"snapshot":"2026-10-31"}"#);
        d.write("exception.json", br#"{"id":"EX-1"}"#);
        d
    }

    fn p(&self, name: &str) -> PathBuf {
        self.dir.path().join(name)
    }

    fn write(&self, name: &str, bytes: &[u8]) {
        std::fs::write(self.p(name), bytes).unwrap();
    }

    fn prepare(&self, candidate: &str, verdict: &str) -> anyhow::Result<()> {
        let c = self.p(candidate);
        let (app, sbom, vex, scan, adv, ex) = (
            self.p("app.bin"),
            self.p("sbom.json"),
            self.p("vex.json"),
            self.p("scan.xml"),
            self.p("advisory.json"),
            self.p("exception.json"),
        );
        bundle_cmd(&[
            "prepare",
            "--candidate",
            s(&c),
            "--bundle-id",
            "b-1",
            "--sequence",
            "7",
            "--supplier",
            "acme",
            "--product",
            "widget",
            "--release-version",
            "1.2.3",
            "--model",
            "m1",
            "--revision",
            "r1",
            "--payload",
            s(&app),
            "--sbom",
            s(&sbom),
            "--vex",
            s(&vex),
            "--scan",
            s(&scan),
            "--advisory-snapshot",
            s(&adv),
            "--exception",
            s(&ex),
            "--verdict",
            verdict,
            "--source-kind",
            "vendor",
            "--source-uri",
            "https://vendor.invalid/widget",
        ])
    }

    fn approve(&self, candidate: &str, role: &str, cert: &str, key: &str) -> anyhow::Result<()> {
        let (c, cert, key) = (self.p(candidate), self.p(cert), self.p(key));
        bundle_cmd(&[
            "approve",
            "--candidate",
            s(&c),
            "--role",
            role,
            "--cert",
            s(&cert),
            "--key",
            s(&key),
            "--comment",
            "reviewed",
        ])
    }

    fn finalize(&self, candidate: &str, out: &str) -> anyhow::Result<()> {
        let (c, pol, rc, rk, o, r) = (
            self.p(candidate),
            self.p("policy.ron"),
            self.p("release.pem"),
            self.p("release.key.pem"),
            self.p(out),
            self.p("receipt.json"),
        );
        bundle_cmd(&[
            "finalize",
            "--candidate",
            s(&c),
            "--policy",
            s(&pol),
            "--release-cert",
            s(&rc),
            "--release-key",
            s(&rk),
            "--output",
            s(&o),
            "--receipt",
            s(&r),
        ])
    }
}

#[test]
fn a_bundle_lives_its_whole_life_through_the_cli() {
    let d = Desk::new();
    d.prepare("cand", "pass").expect("prepare");
    bundle_cmd(&["inspect", "--candidate", s(&d.p("cand"))]).expect("inspect");

    // One approval is short of the policy's two roles: finalize refuses and writes nothing.
    d.approve("cand", "security-reviewer", "reviewer.der", "reviewer.key")
        .expect("reviewer approves (DER)");
    assert!(
        d.finalize("cand", "early.holger.znippy").is_err(),
        "one approval must not finalize"
    );
    assert!(
        !d.p("early.holger.znippy").exists(),
        "a refused finalize writes no bundle"
    );

    d.approve("cand", "system-owner", "owner.pem", "owner.key.pem")
        .expect("owner approves (PEM)");
    d.finalize("cand", "out.holger.znippy").expect("finalize");

    // The receipt names the bytes on disk.
    let bundle_bytes = std::fs::read(d.p("out.holger.znippy")).unwrap();
    let receipt: serde_json::Value =
        serde_json::from_slice(&std::fs::read(d.p("receipt.json")).unwrap()).unwrap();
    assert_eq!(
        receipt["bundle_sha256"],
        crate::digest::sha256_hex(&bundle_bytes)
    );
    assert_eq!(receipt["bundle_size_bytes"], bundle_bytes.len() as u64);
    assert_eq!(receipt["sequence"], 7);
    assert_eq!(receipt["approvals"].as_array().unwrap().len(), 2);

    let (b, pol, state) = (d.p("out.holger.znippy"), d.p("policy.ron"), d.p("state"));
    verify_cmd(&[s(&b), "--policy", s(&pol)]).expect("verify");
    verify_cmd(&[
        s(&b),
        "--policy",
        s(&pol),
        "--json",
        "--target-product",
        "widget",
        "--target-model",
        "m1",
        "--target-revision",
        "r1",
        "--state-dir",
        s(&state),
    ])
    .expect("verify for its own target, with rollback state");
    assert!(
        verify_cmd(&[s(&b), "--policy", s(&pol), "--target-product", "gadget"]).is_err(),
        "a bundle for another product must not verify"
    );
    assert!(
        verify_cmd(&[s(&b), "--policy", s(&pol), "--target-model", "m9"]).is_err(),
        "a bundle for another model must not verify"
    );

    let dest = d.p("dest");
    bundle_cmd(&["extract", s(&b), "--policy", s(&pol), "--dest", s(&dest)]).expect("extract");
    let found: Vec<_> = walk(&dest)
        .into_iter()
        .filter(|p| std::fs::read(p).ok().as_deref() == Some(b"the payload bytes".as_slice()))
        .collect();
    assert_eq!(
        found.len(),
        1,
        "the payload is extracted exactly once: {:?}",
        walk(&dest)
    );
    let dest2 = d.p("dest2");
    bundle_cmd(&[
        "extract",
        s(&b),
        "--policy",
        s(&pol),
        "--dest",
        s(&dest2),
        "--json",
    ])
    .expect("extract --json");
    assert!(
        bundle_cmd(&["extract", s(&b), "--policy", s(&pol), "--dest", s(&dest)]).is_err(),
        "extract refuses a directory that is not empty"
    );
}

#[test]
fn a_bundle_signed_by_a_stranger_or_tampered_does_not_verify() {
    let d = Desk::new();
    d.prepare("cand", "pass").unwrap();
    d.approve("cand", "security-reviewer", "reviewer.der", "reviewer.key")
        .unwrap();
    d.approve("cand", "system-owner", "owner.pem", "owner.key.pem")
        .unwrap();
    d.finalize("cand", "out.holger.znippy").unwrap();
    let (b, pol) = (d.p("out.holger.znippy"), d.p("policy.ron"));

    // A policy that trusts other people: the same bytes are refused.
    let strangers = pinned_policy(&self_signed("x"), &self_signed("y"), &self_signed("z"));
    d.write("strangers.ron", strangers.to_ron().unwrap().as_bytes());
    let sp = d.p("strangers.ron");
    assert!(verify_cmd(&[s(&b), "--policy", s(&sp)]).is_err());
    assert!(bundle_cmd(&["extract", s(&b), "--policy", s(&sp), "--dest", s(&d.p("x"))]).is_err());
    assert!(!d.p("x").join("app.bin").exists());

    // One flipped byte in the container.
    let mut bytes = std::fs::read(&b).unwrap();
    let mid = bytes.len() / 2;
    bytes[mid] ^= 0xff;
    d.write("bad.holger.znippy", &bytes);
    assert!(verify_cmd(&[s(&d.p("bad.holger.znippy")), "--policy", s(&pol)]).is_err());

    // A policy file that is not RON, and a bundle that is not there.
    d.write("junk.ron", b"not ( a policy");
    assert!(verify_cmd(&[s(&b), "--policy", s(&d.p("junk.ron"))]).is_err());
    assert!(verify_cmd(&[s(&d.p("absent.holger.znippy")), "--policy", s(&pol)]).is_err());
}

#[test]
fn a_verdict_other_than_pass_is_never_finalized() {
    let d = Desk::new();
    d.prepare("cand", "fail")
        .expect("a failed review can still be recorded");
    d.approve("cand", "security-reviewer", "reviewer.der", "reviewer.key")
        .unwrap();
    d.approve("cand", "system-owner", "owner.pem", "owner.key.pem")
        .unwrap();
    assert!(d.finalize("cand", "out.holger.znippy").is_err());
    assert!(!d.p("out.holger.znippy").exists());
}

#[test]
fn an_approval_with_a_key_that_is_not_the_certificates_is_refused() {
    let d = Desk::new();
    d.prepare("cand", "pass").unwrap();
    // The owner's certificate with the reviewer's key.
    assert!(d
        .approve("cand", "system-owner", "owner.pem", "reviewer.key")
        .is_err());
    // A role the policy does not know is recorded but cannot carry a finalize.
    d.approve("cand", "security-reviewer", "reviewer.der", "reviewer.key")
        .unwrap();
    d.approve("cand", "intern", "owner.pem", "owner.key.pem")
        .unwrap();
    assert!(d.finalize("cand", "out.holger.znippy").is_err());
}

#[test]
fn malformed_inputs_are_refused_before_anything_is_signed() {
    let d = Desk::new();
    // A PEM whose body is not base64.
    d.write(
        "bad.pem",
        b"-----BEGIN CERTIFICATE-----\nnot*base64!\n-----END CERTIFICATE-----\n",
    );
    d.prepare("cand", "pass").unwrap();
    assert!(d
        .approve("cand", "security-reviewer", "bad.pem", "reviewer.key")
        .is_err());
    // A candidate directory that does not exist, for every verb that reads one.
    assert!(bundle_cmd(&["inspect", "--candidate", s(&d.p("nowhere"))]).is_err());
    assert!(d
        .approve(
            "nowhere",
            "security-reviewer",
            "reviewer.der",
            "reviewer.key"
        )
        .is_err());
    assert!(d.finalize("nowhere", "o.holger.znippy").is_err());
    // A payload that is not there.
    let c = d.p("c2");
    let missing = d.p("missing.bin");
    assert!(bundle_cmd(&[
        "prepare",
        "--candidate",
        s(&c),
        "--bundle-id",
        "b",
        "--sequence",
        "1",
        "--supplier",
        "a",
        "--product",
        "p",
        "--release-version",
        "1",
        "--payload",
        s(&missing),
        "--verdict",
        "pass",
    ])
    .is_err());
    // A candidate without a core.json is not inspectable.
    std::fs::create_dir_all(d.p("empty")).unwrap();
    assert!(bundle_cmd(&["inspect", "--candidate", s(&d.p("empty"))]).is_err());
}

fn walk(dir: &Path) -> Vec<PathBuf> {
    let mut out = Vec::new();
    if let Ok(rd) = std::fs::read_dir(dir) {
        for e in rd.flatten() {
            let p = e.path();
            if p.is_dir() {
                out.extend(walk(&p));
            } else {
                out.push(p);
            }
        }
    }
    out
}