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

nordisk/heimdall

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);
}