Gunnar

nordisk/heimdall

guests/verify-bundle/tests/wasm_e2e.rs

// SPDX-License-Identifier: AGPL-3.0-only
//! The guest as Heimdall runs it: built for wasm32-unknown-unknown, turned
//! into a component against heimdall-wasm's WIT, signed like a Holger plugin
//! (`.wasm.p7s` + `.wasm.blake3`), loaded by the real host (no WASI) and run
//! under the default limits on real Holger bundles. Heimdall is a defensive
//! sandbox.

mod common;
use common::*;

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

use guest_host::verify::sign::{dev, sign_module};
use heimdall_wasm::{GuestOutcome, Host, HostConfig, RunLimits, Verdict};

/// The guest's core module, built for wasm32 exactly as CI builds it.
fn core_module() -> &'static [u8] {
    static M: OnceLock<Vec<u8>> = OnceLock::new();
    M.get_or_init(|| {
        let manifest = Path::new(env!("CARGO_MANIFEST_DIR")).join("Cargo.toml");
        let target = Path::new(env!("CARGO_TARGET_TMPDIR")).join("guest-wasm");
        let cargo = std::env::var("CARGO").unwrap_or_else(|_| "cargo".into());
        let st = std::process::Command::new(cargo)
            .args([
                "build",
                "--release",
                "--lib",
                "--target",
                "wasm32-unknown-unknown",
                "--manifest-path",
            ])
            .arg(&manifest)
            .arg("--target-dir")
            .arg(&target)
            .env_remove("RUSTFLAGS")
            .env_remove("CARGO_ENCODED_RUSTFLAGS")
            .status()
            .expect("cargo could not be started");
        assert!(st.success(), "building the guest for wasm32 failed");
        std::fs::read(
            target.join("wasm32-unknown-unknown/release/heimdall_guest_verify_bundle.wasm"),
        )
        .unwrap()
    })
}

fn component() -> &'static [u8] {
    static C: OnceLock<Vec<u8>> = OnceLock::new();
    C.get_or_init(|| {
        wit_component::ComponentEncoder::default()
            .module(core_module())
            .unwrap()
            .validate(true)
            .encode()
            .unwrap()
    })
}

struct Loaded {
    _dir: tempfile::TempDir,
    guest: heimdall_wasm::Guest,
}

fn loaded() -> &'static Loaded {
    static L: OnceLock<Loaded> = OnceLock::new();
    L.get_or_init(|| {
        let dir = tempfile::tempdir().unwrap();
        let (ca_key, ca_der) = dev::mint_ca("heimdall-guest-test-ca").unwrap();
        let root = dir.path().join("root.der");
        std::fs::write(&root, &ca_der).unwrap();
        let signer = dev::new_ed25519_signer(&ca_key, &ca_der, "heimdall-guest-release").unwrap();
        let bytes = component();
        let module: PathBuf = dir.path().join("verify-bundle.wasm");
        std::fs::write(&module, bytes).unwrap();
        std::fs::write(
            dir.path().join("verify-bundle.wasm.p7s"),
            sign_module(bytes, &signer).unwrap(),
        )
        .unwrap();
        std::fs::write(
            dir.path().join("verify-bundle.wasm.blake3"),
            format!("{}\n", blake3::hash(bytes).to_hex()),
        )
        .unwrap();
        let host = Host::new(HostConfig::new([root])).unwrap();
        let guest = host
            .load(&module)
            .unwrap_or_else(|e| panic!("the host refused the guest: {e}"));
        Loaded { _dir: dir, guest }
    })
}

fn job_params(fx: &Fixture, extra: &[(&str, &str)]) -> heimdall_core::Params {
    let mut p = heimdall_core::Params::new();
    p.insert("now".into(), t0().to_rfc3339());
    p.insert("policy".into(), fx.policy.to_ron().unwrap());
    for (k, v) in extra {
        p.insert((*k).into(), (*v).into());
    }
    p
}

fn run(archive: &[u8], params: &heimdall_core::Params) -> GuestOutcome {
    loaded().guest.run(archive, params, &RunLimits::default())
}

#[test]
fn the_host_loads_the_signed_guest_component_with_no_wasi() {
    let _ = loaded();
}

#[test]
fn a_real_bundle_is_accepted_inside_the_sandbox_with_the_same_outputs_as_the_library() {
    let fx = good();
    let archive = fx.pack();
    let o = run(&archive, &job_params(&fx, &[]));
    assert!(
        matches!(o.verdict, Verdict::Accepted { .. }),
        "{}",
        o.verdict
    );
    let lib = heimdall_guest_verify_bundle::verify_bundle_pairs(
        &archive,
        job_params(&fx, &[])
            .iter()
            .map(|(k, v)| (k.as_str(), v.as_str())),
    );
    let got: Vec<(&str, &[u8])> = o
        .files
        .iter()
        .map(|f| (f.name.as_str(), f.bytes.as_slice()))
        .collect();
    let want: Vec<(&str, &[u8])> = lib
        .emitted
        .iter()
        .map(|e| (e.name.as_str(), e.bytes.as_slice()))
        .collect();
    assert_eq!(got, want);
    assert_eq!(
        member_for(&fx.members, "payload+2ffirmware.bin"),
        &o.files
            .iter()
            .find(|f| f.name == "payload+2ffirmware.bin")
            .unwrap()
            .bytes
    );
}

#[test]
fn a_tampered_bundle_is_refused_inside_the_sandbox_with_holgers_code_and_no_files() {
    let mut fx = good();
    fx.members.get_mut("payload/firmware.bin").unwrap()[0] ^= 1;
    let o = run(&fx.pack(), &job_params(&fx, &[]));
    let r = o.verdict.reason().expect("refused");
    assert_eq!(o.verdict.code(), Some(heimdall_wasm::codes::GUEST_REFUSED));
    assert!(
        r.starts_with(&format!(
            "[{}]",
            holger_errcode::UPDATE_DIGEST_MISMATCH.code
        )),
        "{r}"
    );
    assert!(o.files.is_empty());
}

#[test]
fn the_guest_module_imports_only_the_wit_emit_file_even_with_openzls_c_linked_in() {
    // OpenZL's C decoder and its libc (malloc/free/memcpy/stdio stubs) are
    // compiled INTO the module: no WASI, no env, nothing but the WIT import.
    let mut imports = Vec::new();
    for payload in wasmparser::Parser::new(0).parse_all(core_module()) {
        if let wasmparser::Payload::ImportSection(r) = payload.unwrap() {
            for imp in r.into_imports() {
                let imp = imp.unwrap();
                imports.push(format!("{}::{}", imp.module, imp.name));
            }
        }
    }
    assert_eq!(imports, vec!["$root::emit-file".to_string()]);
}

#[test]
fn a_holger_packed_openzl_bundle_is_accepted_inside_the_sandbox_like_the_library() {
    let fx = good();
    let archive = pack_holger(&fx.members);
    let o = run(&archive, &job_params(&fx, &[]));
    assert!(
        matches!(o.verdict, Verdict::Accepted { .. }),
        "{}",
        o.verdict
    );
    let lib = heimdall_guest_verify_bundle::verify_bundle_pairs(
        &archive,
        job_params(&fx, &[])
            .iter()
            .map(|(k, v)| (k.as_str(), v.as_str())),
    );
    assert!(lib.is_accepted());
    let got: Vec<(&str, &[u8])> = o
        .files
        .iter()
        .map(|f| (f.name.as_str(), f.bytes.as_slice()))
        .collect();
    let want: Vec<(&str, &[u8])> = lib
        .emitted
        .iter()
        .map(|e| (e.name.as_str(), e.bytes.as_slice()))
        .collect();
    assert_eq!(got, want);
}

#[test]
fn a_multi_megabyte_openzl_bundle_decodes_inside_the_sandbox_to_the_original_bytes() {
    let fx = build(Spec {
        payloads: vec![(
            "firmware.bin".into(),
            (0..4_000_000u32)
                .map(|i| (i % 251) as u8 ^ (i >> 12) as u8)
                .collect(),
            "application/octet-stream".into(),
        )],
        ..Spec::default()
    });
    let o = run(&pack_holger(&fx.members), &job_params(&fx, &[]));
    assert!(
        matches!(o.verdict, Verdict::Accepted { .. }),
        "{}",
        o.verdict
    );
    let got = &o
        .files
        .iter()
        .find(|f| f.name == "payload+2ffirmware.bin")
        .unwrap()
        .bytes;
    assert_eq!(got, member_for(&fx.members, "payload+2ffirmware.bin"));
}

/// Hostile OpenZL frames inside the sandbox: a refusal with Holger's container
/// code, never a trap and never a host fault.
#[test]
fn tampered_truncated_lying_and_bomb_frames_are_refused_inside_the_sandbox_never_trapped() {
    use common::zwriter::{openzl_frame, RawArchive};
    const P: &str = "payload/firmware.bin";
    let fx = good();
    let clean = RawArchive::from_members_openzl(&fx.members, 1 << 20);
    let bomb = openzl_frame(&vec![0u8; 256 << 20]);
    let mut cases: Vec<(&str, RawArchive, &str)> = Vec::new();
    let flen = clean.clone().blob_mut(P).len();
    for i in [0, 3, 7, flen / 2, flen - 1] {
        let mut a = clean.clone();
        a.blob_mut(P)[i] ^= 0x5a;
        cases.push(("flip", a, "[holger-update-"));
    }
    for keep in [0u64, 5, flen as u64 - 1] {
        let mut a = clean.clone();
        a.row_mut(P).blob_size = keep;
        cases.push(("truncated", a, "[holger-update-"));
    }
    let mut a = clean.clone();
    a.row_mut(P).uncompressed_size += 1;
    cases.push(("size lie", a, "[holger-update-7]"));
    let mut a = clean.clone();
    let off = a.blobs.len() as u64;
    a.blobs.extend_from_slice(&bomb);
    let r = a.row_mut(P);
    r.blob_offset = off;
    r.blob_size = bomb.len() as u64;
    cases.push(("hidden bomb", a, "declares 268435456 bytes"));
    // The fuzz find: OpenZL asks malloc for ~2^64 bytes; the guest's malloc
    // returns NULL (no trap), OpenZL reports an allocation error.
    let mut a = RawArchive::from_members(&fx.members, 1 << 20);
    a.append_compressed_tail(
        P,
        include_bytes!("../fuzz/seeds/openzl_frame/regress-constant-stream-alloc"),
    );
    cases.push(("constant-stream alloc", a, "Allocation"));
    for (what, a, needle) in cases {
        let o = run(&a.encode(), &job_params(&fx, &[]));
        assert_eq!(
            o.verdict.code(),
            Some(heimdall_wasm::codes::GUEST_REFUSED),
            "{what}: {}",
            o.verdict
        );
        let reason = o.verdict.reason().unwrap();
        assert!(reason.contains(needle), "{what}: {reason}");
        assert!(o.files.is_empty(), "{what}");
    }
}

#[test]
fn garbage_input_and_bad_parameters_are_refused_never_trapped() {
    let fx = good();
    for input in [&b""[..], b"ZNPYMIDX", &[0xffu8; 4096][..]] {
        let o = run(input, &job_params(&fx, &[]));
        assert_eq!(
            o.verdict.code(),
            Some(heimdall_wasm::codes::GUEST_REFUSED),
            "{}",
            o.verdict
        );
    }
    let o = run(&fx.pack(), &job_params(&fx, &[("surprise", "1")]));
    assert!(o
        .verdict
        .reason()
        .unwrap()
        .starts_with("[heimdall-bundle-1]"));
}

#[test]
fn the_fuzz_found_buffer_overrun_is_a_refusal_inside_the_sandbox_not_a_trap() {
    let fx = good();
    let seed = std::fs::read(
        Path::new(env!("CARGO_MANIFEST_DIR")).join("fuzz/seeds/archive/regress-buffer-overrun"),
    )
    .unwrap();
    let o = run(&seed, &job_params(&fx, &[]));
    assert_eq!(
        o.verdict.code(),
        Some(heimdall_wasm::codes::GUEST_REFUSED),
        "{}",
        o.verdict
    );
}

#[test]
fn a_dsse_attested_real_slsa_bundle_is_accepted_inside_the_sandbox() {
    let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/slsa-dwarf");
    let mut spec = Spec::default();
    spec.payloads.push((
        "crates/holger-errcode-2.0.0.crate".into(),
        std::fs::read(dir.join("holger-errcode-2.0.0.crate")).unwrap(),
        "application/gzip".into(),
    ));
    spec.evidence.push((
        "provenance/holger-2.0.0-rebuild.intoto.jsonl".into(),
        std::fs::read(dir.join("holger-2.0.0-rebuild.intoto.jsonl")).unwrap(),
        "application/jsonl".into(),
        holger_update_bundle::EvidenceKind::Other,
    ));
    let fx = build(spec);
    let pem = std::fs::read_to_string(dir.join("release-test-key.pub")).unwrap();
    let dsse = format!("(keys: [{pem:?}])");
    let o = run(&fx.pack(), &job_params(&fx, &[("dsse", &dsse)]));
    assert!(
        matches!(o.verdict, Verdict::Accepted { .. }),
        "{}",
        o.verdict
    );
}

/// Not a pass/fail test: prints how long a ~8 MB bundle takes, stored versus
/// OpenZL, natively and inside the sandbox (docs/OPENZL-WASM.md).
/// `cargo test --release --test wasm_e2e -- --ignored --nocapture measure`
#[test]
#[ignore]
fn measure_openzl_decode_native_versus_wasm() {
    let fx = build(Spec {
        payloads: vec![(
            "firmware.bin".into(),
            {
                // Word-salad text (compresses about 3:1 — real decoding work).
                const W: [&str; 16] = [
                    "drive ", "motor ", "torque ", "ACS880 ", "R4 ", "fault ", "0x3F ", "speed=",
                    "1450 ", "rpm\n", "param ", "ok ", "warn ", "id=", "42 ", "ramp ",
                ];
                let mut x = 0x2545_f491_4f6c_dd1du64;
                let mut v = Vec::with_capacity(8_000_000);
                while v.len() < 8_000_000 {
                    x ^= x << 13;
                    x ^= x >> 7;
                    x ^= x << 17;
                    v.extend_from_slice(W[(x % 16) as usize].as_bytes());
                    v.push((x >> 40) as u8);
                }
                v.truncate(8_000_000);
                v
            },
            "application/octet-stream".into(),
        )],
        ..Spec::default()
    });
    let p = job_params(&fx, &[]);
    // 8 MB is past what the default 2e9 fuel buys (hashing alone); raise it.
    let limits = RunLimits {
        fuel: 200_000_000_000,
        timeout: std::time::Duration::from_secs(300),
        ..RunLimits::default()
    };
    let run = |a: &[u8], p: &heimdall_core::Params| loaded().guest.run(a, p, &limits);
    let lib_p: Vec<(String, String)> = p.iter().map(|(k, v)| (k.clone(), v.clone())).collect();
    for (kind, archive) in [("stored", fx.pack()), ("openzl", pack_holger(&fx.members))] {
        let _ = run(&archive, &p); // warm
        let t = std::time::Instant::now();
        let n = 5;
        for _ in 0..n {
            let v = run(&archive, &p).verdict;
            assert!(matches!(v, Verdict::Accepted { .. }), "{kind}: {v}");
        }
        let wasm = t.elapsed() / n;
        let t = std::time::Instant::now();
        for _ in 0..n {
            let o = heimdall_guest_verify_bundle::verify_bundle_pairs(
                &archive,
                lib_p.iter().map(|(k, v)| (k.as_str(), v.as_str())),
            );
            assert!(o.is_accepted());
        }
        let native = t.elapsed() / n;
        println!(
            "MEASURE {kind}: bundle {} bytes, native {:?}, wasm {:?}",
            archive.len(),
            native,
            wasm
        );
    }
}