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