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
}