guests/verify-bundle/tests/cli_split.rs
// SPDX-License-Identifier: AGPL-3.0-only
//! The split guest END TO END through Heimdall's own front doors: the
//! `heimdall` binary (`heimdall run --decoder ...`, then `heimdall
//! verify-result`) and heimdall-run's `WasmJobRunner` (a `JobKind::Wasm`
//! job that names its decoder module). Every bundle runs as the
//! single-instance guest AND as the split guest (verifier + isolated OpenZL
//! decoder instances), and both must give the same exit code, the same
//! signed verdict (code and reason), the same output files, and a result
//! that `verify-result` treats the same. Heimdall is a defensive sandbox;
//! everything here runs inside temporary dirs.
#![cfg(feature = "openzl")]
mod common;
use common::guests::{component, Build, Lab};
use common::zwriter::RawArchive;
use common::*;
use std::path::{Path, PathBuf};
use std::process::{Command, Output};
use std::sync::OnceLock;
use heimdall_core::{JobKind, JobRunner, SignedResult, Verdict};
const OK: i32 = 0;
const HOST: i32 = 3;
const REFUSED: i32 = 4;
// ---------------------------------------------------------------- the kit
/// The `heimdall` binary, built once per test binary from this repo's
/// workspace (or `HEIMDALL_BIN`, when set), in the same profile as the tests.
fn heimdall_bin() -> &'static Path {
static B: OnceLock<PathBuf> = OnceLock::new();
B.get_or_init(|| {
if let Some(p) = std::env::var_os("HEIMDALL_BIN") {
return PathBuf::from(p);
}
let repo = Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
let target = Path::new(env!("CARGO_TARGET_TMPDIR")).join("heimdall-cli");
let release = !cfg!(debug_assertions);
let cargo = std::env::var("CARGO").unwrap_or_else(|_| "cargo".into());
let mut c = Command::new(cargo);
c.args([
"build",
"--locked",
"-p",
"nordisk-heimdall-cli",
"--bin",
"heimdall",
"--manifest-path",
])
.arg(repo.join("Cargo.toml"))
.arg("--target-dir")
.arg(&target)
.env_remove("RUSTFLAGS")
.env_remove("CARGO_ENCODED_RUSTFLAGS");
if release {
c.arg("--release");
}
let st = c.status().expect("cargo could not be started");
assert!(st.success(), "building the heimdall binary failed");
target
.join(if release { "release" } else { "debug" })
.join("heimdall")
})
}
/// The three signed modules, written once: the single-instance guest, the
/// split verifier and its decoder, all under one throwaway CA.
struct Mods {
lab: Lab,
job: PathBuf,
verifier: PathBuf,
decoder: PathBuf,
}
fn mods() -> &'static Mods {
static M: OnceLock<Mods> = OnceLock::new();
M.get_or_init(|| {
let lab = Lab::new();
let job = lab.signed("verify-bundle", component(Build::Job));
let verifier = lab.signed("verifier", component(Build::Verifier));
let decoder = lab.signed("decoder", component(Build::Decoder));
Mods {
lab,
job,
verifier,
decoder,
}
})
}
impl Mods {
fn root(&self) -> PathBuf {
self.lab.dir.path().join("root.der")
}
}
fn blake3_of(p: &Path) -> String {
blake3::hash(&std::fs::read(p).unwrap())
.to_hex()
.to_string()
}
/// How the bundle is verified.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Mode {
Single,
Split { decoders: u32 },
}
/// A policy file roomy enough for the verify-bundle guest (docs/GATLING-WASM.md
/// gives its fuel per byte) with the given decoder count.
fn policy_text(decoders: u32) -> String {
format!(
"version = 1\n[job]\nkind = \"wasm\"\n[limits]\n\
fuel = 20000000000\nwall_time_ms = 120000\nmemory_bytes = 536870912\n\
instances = 16\ntables = 16\nmemories = 4\ndecoders = {decoders}\n"
)
}
fn params(fx: &Fixture) -> Vec<(String, String)> {
vec![
("now".into(), t0().to_rfc3339()),
("policy".into(), fx.policy.to_ron().unwrap()),
]
}
/// One `heimdall run`, then `heimdall verify-result` on what it wrote.
struct CliRun {
dir: tempfile::TempDir,
run: Output,
verify: Option<Output>,
}
fn code(o: &Output) -> i32 {
o.status.code().expect("heimdall was killed by a signal")
}
fn stderr(o: &Output) -> String {
String::from_utf8_lossy(&o.stderr).into_owned()
}
impl CliRun {
fn out(&self) -> PathBuf {
self.dir.path().join("out")
}
fn code(&self) -> i32 {
code(&self.run)
}
fn verify_code(&self) -> i32 {
code(self.verify.as_ref().expect("no result to verify"))
}
fn signed(&self) -> SignedResult {
SignedResult::from_bytes(&std::fs::read(self.out().join("result.signed")).unwrap()).unwrap()
}
fn files(&self) -> Vec<(String, Vec<u8>)> {
let d = self.out().join("files");
let mut v: Vec<(String, Vec<u8>)> = match std::fs::read_dir(&d) {
Err(_) => Vec::new(),
Ok(rd) => rd
.map(|e| {
let e = e.unwrap();
(
e.file_name().to_string_lossy().into_owned(),
std::fs::read(e.path()).unwrap(),
)
})
.collect(),
};
v.sort();
v
}
fn verdict_line(&self) -> String {
String::from_utf8_lossy(&self.run.stdout)
.lines()
.find(|l| l.starts_with("verdict: "))
.unwrap_or_default()
.to_string()
}
}
/// Run `archive` through the CLI in `mode`, with `extra` arguments.
fn cli(archive: &[u8], fx: &Fixture, mode: Mode, extra: &[String]) -> CliRun {
let m = mods();
let dir = tempfile::tempdir().unwrap();
let input = dir.path().join("in");
std::fs::create_dir_all(&input).unwrap();
std::fs::write(input.join("bundle.holger.znippy"), archive).unwrap();
let decoders = match mode {
Mode::Single => 1,
Mode::Split { decoders } => decoders,
};
let policy = dir.path().join("policy.toml");
std::fs::write(&policy, policy_text(decoders)).unwrap();
let pin = dir.path().join("key.pub");
let module = match mode {
Mode::Single => &m.job,
Mode::Split { .. } => &m.verifier,
};
let mut a: Vec<String> = vec![
"run".into(),
"--job".into(),
"wasm".into(),
"--module".into(),
module.display().to_string(),
"--trust-root".into(),
m.root().display().to_string(),
"--in".into(),
input.display().to_string(),
"--out".into(),
dir.path().join("out").display().to_string(),
"--policy".into(),
policy.display().to_string(),
"--pin-out".into(),
pin.display().to_string(),
];
if let Mode::Split { .. } = mode {
a.push("--decoder".into());
a.push(m.decoder.display().to_string());
}
for (k, v) in params(fx) {
a.push("--param".into());
a.push(format!("{k}={v}"));
}
a.extend(extra.iter().cloned());
let run = Command::new(heimdall_bin()).args(&a).output().unwrap();
let result = dir.path().join("out/result.signed");
let verify = result.exists().then(|| {
Command::new(heimdall_bin())
.arg("verify-result")
.arg(&result)
.arg("--key")
.arg(&pin)
.output()
.unwrap()
});
CliRun { dir, run, verify }
}
/// Single-instance and split (N = 1 and N = 8) through the CLI: the same
/// exit code, signed verdict, outputs, files and verify-result answer.
/// Returns the single-instance run.
#[track_caller]
fn same_through_the_cli(archive: &[u8], fx: &Fixture) -> CliRun {
let single = cli(archive, fx, Mode::Single, &[]);
assert!(
single.code() == OK || single.code() == REFUSED,
"single: exit {} {}",
single.code(),
stderr(&single.run)
);
for n in [1, 8] {
let split = cli(archive, fx, Mode::Split { decoders: n }, &[]);
assert_eq!(
split.code(),
single.code(),
"N={n}: exit code; stderr {}",
stderr(&split.run)
);
assert_eq!(split.verdict_line(), single.verdict_line(), "N={n}");
let (s, t) = (single.signed().result, split.signed().result);
assert_eq!(t.verdict, s.verdict, "N={n}: signed verdict");
assert_eq!(t.outputs, s.outputs, "N={n}: signed outputs");
assert_eq!(t.runner, s.runner, "N={n}: runner features");
assert_eq!(split.files(), single.files(), "N={n}: files");
assert_eq!(
split.verify_code(),
single.verify_code(),
"N={n}: verify-result"
);
// verify-result is unchanged: it answers with the run's exit code.
assert_eq!(split.verify_code(), split.code(), "N={n}");
}
single
}
fn refused_code(r: &CliRun) -> String {
match r.signed().result.verdict {
Verdict::Refused { code, .. } => code,
Verdict::Accepted => panic!("expected Refused"),
}
}
/// A bundle with many OpenZL chunks (4 KiB each): several decoder units.
fn many_chunk() -> (Fixture, RawArchive) {
let fx = build(Spec {
payloads: vec![(
"firmware.bin".into(),
(0..1_200_000u32)
.map(|i| (i % 251) as u8 ^ (i >> 9) as u8)
.collect(),
"application/octet-stream".into(),
)],
..Spec::default()
});
let a = RawArchive::from_members_openzl(&fx.members, 4096);
(fx, a)
}
// ---------------------------------------------------------------- verdicts
#[test]
fn a_good_stored_bundle_is_accepted_the_same_by_split_and_single_through_the_cli() {
let fx = good();
let r = same_through_the_cli(&fx.pack(), &fx);
assert_eq!(r.code(), OK, "{}", stderr(&r.run));
assert_eq!(r.verify_code(), OK);
assert!(!r.files().is_empty());
}
#[test]
fn a_good_holger_packed_openzl_bundle_is_accepted_the_same_by_split_and_single() {
let fx = good();
let r = same_through_the_cli(&pack_holger(&fx.members), &fx);
assert_eq!(r.code(), OK, "{}", stderr(&r.run));
}
#[test]
fn a_many_chunk_openzl_bundle_is_accepted_the_same_by_split_and_single() {
let (fx, a) = many_chunk();
let r = same_through_the_cli(&a.encode(), &fx);
assert_eq!(r.code(), OK, "{}", stderr(&r.run));
}
#[test]
fn a_tampered_payload_is_refused_the_same_by_split_and_single() {
let mut fx = good();
fx.members.get_mut("payload/firmware.bin").unwrap()[0] ^= 1;
let r = same_through_the_cli(&pack_holger(&fx.members), &fx);
assert_eq!(r.code(), REFUSED);
assert_eq!(r.verify_code(), REFUSED);
// The guest's own refusal: Holger's code is inside the signed reason.
let v = format!("{:?}", r.signed().result.verdict);
assert!(v.contains("holger-update-"), "{v}");
assert!(!refused_code(&r).is_empty());
}
#[test]
fn a_bit_flip_in_a_late_openzl_frame_is_refused_the_same_by_split_and_single() {
let (fx, mut a) = many_chunk();
let n = a.rows.len();
let row = a.rows[n - 2].clone();
a.blobs[row.blob_offset as usize + 7] ^= 0x40;
let r = same_through_the_cli(&a.encode(), &fx);
assert_eq!(r.code(), REFUSED);
}
#[test]
fn garbage_input_is_refused_the_same_by_split_and_single() {
let fx = good();
let r = same_through_the_cli(b"not a znippy archive at all", &fx);
assert_eq!(r.code(), REFUSED);
}
// ---------------------------------------------------------------- the decoder gate
#[test]
fn the_verifier_without_its_decoder_is_refused_by_the_cli() {
let m = mods();
let fx = good();
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir_all(dir.path().join("in")).unwrap();
std::fs::write(dir.path().join("in/b"), fx.pack()).unwrap();
let o = Command::new(heimdall_bin())
.args(["run", "--job", "wasm", "--module"])
.arg(&m.verifier)
.arg("--trust-root")
.arg(m.root())
.arg("--in")
.arg(dir.path().join("in"))
.arg("--out")
.arg(dir.path().join("out"))
.output()
.unwrap();
assert_eq!(code(&o), HOST, "{}", stderr(&o));
assert!(stderr(&o).contains("decode-chunks"), "{}", stderr(&o));
assert!(!dir.path().join("out").exists());
}
#[test]
fn the_right_decoder_pin_runs_and_a_wrong_one_is_refused_before_anything_runs() {
let m = mods();
let fx = good();
let ok = cli(
&fx.pack(),
&fx,
Mode::Split { decoders: 2 },
&[
"--decoder-pin-blake3".into(),
blake3_of(&m.decoder),
"--pin-blake3".into(),
blake3_of(&m.verifier),
],
);
assert_eq!(ok.code(), OK, "{}", stderr(&ok.run));
let bad = cli(
&fx.pack(),
&fx,
Mode::Split { decoders: 2 },
&["--decoder-pin-blake3".into(), "0".repeat(64)],
);
assert_eq!(bad.code(), HOST, "{}", stderr(&bad.run));
assert!(
stderr(&bad.run).contains("decoder refused"),
"{}",
stderr(&bad.run)
);
assert!(!bad.out().exists());
}
#[test]
fn an_unsigned_decoder_is_refused_by_the_cli() {
let m = mods();
let fx = good();
// A copy of the real decoder with its pin but without its signature.
let dir = tempfile::tempdir().unwrap();
let d = dir.path().join("decoder.wasm");
std::fs::copy(&m.decoder, &d).unwrap();
std::fs::write(
dir.path().join("decoder.wasm.blake3"),
format!("{}\n", blake3_of(&d)),
)
.unwrap();
let mut r = cli(&fx.pack(), &fx, Mode::Split { decoders: 2 }, &[]);
assert_eq!(r.code(), OK, "the signed decoder runs: {}", stderr(&r.run));
r = cli(
&fx.pack(),
&fx,
Mode::Single,
&["--decoder".into(), d.display().to_string()],
);
// Mode::Single + --decoder = the job guest with an unsigned decoder.
assert_eq!(r.code(), HOST, "{}", stderr(&r.run));
assert!(
stderr(&r.run).contains("decoder refused"),
"{}",
stderr(&r.run)
);
assert!(stderr(&r.run).contains("signature"), "{}", stderr(&r.run));
assert!(!r.out().exists());
}
#[test]
fn the_verifier_given_as_the_decoder_is_refused() {
let m = mods();
let fx = good();
let r = cli(
&fx.pack(),
&fx,
Mode::Single,
&["--decoder".into(), m.verifier.display().to_string()],
);
assert_eq!(r.code(), HOST, "{}", stderr(&r.run));
assert!(
stderr(&r.run).contains("decoder refused"),
"{}",
stderr(&r.run)
);
}
// ---------------------------------------------------------------- WasmJobRunner
fn runner_job(fx: &Fixture, archive: &[u8], decoder: bool, root: &Path) -> heimdall_core::Job {
let m = mods();
let input = root.join("in");
let output = root.join("out");
std::fs::create_dir_all(&input).unwrap();
std::fs::create_dir_all(&output).unwrap();
std::fs::write(input.join("bundle"), archive).unwrap();
let guest = |p: &Path| heimdall_core::GuestRef {
path: p.to_path_buf(),
blake3: blake3_of(p),
};
let kind = if decoder {
JobKind::wasm_split(guest(&m.verifier), guest(&m.decoder))
} else {
JobKind::wasm(guest(&m.job))
};
let params: serde_json::Map<String, serde_json::Value> = params(fx)
.into_iter()
.map(|(k, v)| (k, serde_json::Value::String(v)))
.collect();
let mut policy = heimdall_core::Policy::default();
policy.limits.fuel = Some(20_000_000_000);
policy.limits.wall = std::time::Duration::from_secs(120);
policy.limits.memory_bytes = 512 << 20;
policy.limits.instances = 16;
policy.limits.tables = 16;
policy.limits.memories = 4;
policy.limits.decoders = 4;
heimdall_core::Job::new(
kind,
input,
output,
serde_json::Value::Object(params),
policy,
)
}
#[test]
fn a_split_job_through_wasm_job_runner_gives_the_single_instance_verdict_and_outputs() {
let m = mods();
let runner = heimdall_run::WasmJobRunner::new(vec![m.root()], m.lab.dir.path().join("work"));
let (fx, a) = many_chunk();
let a = a.encode();
let mut bad = fx.members.clone();
bad.get_mut("payload/firmware.bin").unwrap()[3] ^= 1;
for archive in [a, pack_holger(&bad)] {
let (d1, d2) = (tempfile::tempdir().unwrap(), tempfile::tempdir().unwrap());
let js = runner_job(&fx, &archive, false, d1.path());
let jt = runner_job(&fx, &archive, true, d2.path());
let (os, ot) = (js.output_dir.clone(), jt.output_dir.clone());
let s = runner.run(js).unwrap();
let t = runner.run(jt).unwrap();
let k = runner.verifying_key();
let (s, t) = (
heimdall_core::verify(&s, &k).unwrap().clone(),
heimdall_core::verify(&t, &k).unwrap().clone(),
);
assert_eq!(t.verdict, s.verdict);
assert_eq!(t.outputs, s.outputs);
heimdall_core::check_outputs(&t, &ot).unwrap();
heimdall_core::check_outputs(&s, &os).unwrap();
}
}
#[test]
fn a_split_job_with_a_wrong_decoder_pin_is_refused_by_wasm_job_runner() {
let m = mods();
let runner = heimdall_run::WasmJobRunner::new(vec![m.root()], m.lab.dir.path().join("work"));
let fx = good();
let d = tempfile::tempdir().unwrap();
let mut j = runner_job(&fx, &fx.pack(), true, d.path());
if let JobKind::Wasm {
decoder: Some(dec), ..
} = &mut j.kind
{
dec.blake3 = "00".repeat(32);
}
let out = j.output_dir.clone();
match runner.run(j) {
Err(heimdall_core::RunError::Image { reason }) => {
assert!(reason.contains("decoder refused"), "{reason}")
}
other => panic!("expected RunError::Image, got {other:?}"),
}
assert_eq!(std::fs::read_dir(out).unwrap().count(), 0);
}