nordisk/heimdall
crates/heimdall-cli/tests/wasm_e2e.rs
// SPDX-License-Identifier: AGPL-3.0-only
//! End to end with real signed wasm guests: `heimdall run --job wasm`, then
//! `heimdall verify-result`. Heimdall is a defensive sandbox; the adversarial
//! guests here attack only Heimdall's own limits, inside temporary dirs.
#[path = "../../heimdall-wasm/tests/common/mod.rs"]
mod common;
use std::path::{Path, PathBuf};
use std::process::{Command, Output};
use std::time::{Duration, Instant};
use common::{component, good_wat, sidecar, Kit, Wat};
use heimdall_core::SignedResult;
use sha2::{Digest, Sha256};
const OK: i32 = 0;
const REJECTED: i32 = 1;
const HOST: i32 = 3;
const REFUSED: i32 = 4;
/// One job directory: `in/` with one input file, a guest, a trust root.
struct Job {
kit: Kit,
root: PathBuf,
module: PathBuf,
}
impl Job {
fn new(name: &str, core_wat: &str, input: &[u8]) -> Job {
let kit = Kit::new(&format!("e2e-{name}"));
let root = kit.dir.join("job");
std::fs::create_dir_all(root.join("in")).unwrap();
std::fs::write(root.join("in/input.bin"), input).unwrap();
let module = kit.place(name, &component(core_wat));
Job { kit, root, module }
}
fn good(name: &str, input: &[u8]) -> Job {
Self::new(name, &good_wat(), input)
}
fn out(&self) -> PathBuf {
self.root.join("out")
}
fn pin(&self) -> PathBuf {
self.root.join("key.pub")
}
fn policy(&self, text: &str) -> String {
let p = self.root.join("policy.toml");
std::fs::write(&p, text).unwrap();
p.display().to_string()
}
fn base_args(&self) -> Vec<String> {
[
"run",
"--job",
"wasm",
"--module",
self.module.to_str().unwrap(),
"--trust-root",
self.kit.root.to_str().unwrap(),
"--in",
self.root.join("in").to_str().unwrap(),
"--out",
self.out().to_str().unwrap(),
"--pin-out",
self.pin().to_str().unwrap(),
]
.iter()
.map(|s| s.to_string())
.collect()
}
fn run(&self, extra: &[&str]) -> Output {
let mut a = self.base_args();
a.extend(extra.iter().map(|s| s.to_string()));
heimdall(&a)
}
fn verify(&self, extra: &[&str]) -> Output {
let mut a = vec![
"verify-result".to_string(),
self.out().join("result.signed").display().to_string(),
"--key".to_string(),
self.pin().display().to_string(),
];
a.extend(extra.iter().map(|s| s.to_string()));
heimdall(&a)
}
fn signed(&self) -> SignedResult {
SignedResult::from_bytes(&std::fs::read(self.out().join("result.signed")).unwrap()).unwrap()
}
fn reason(&self) -> String {
match self.signed().result.verdict {
heimdall_core::Verdict::Refused { reason, .. } => reason,
heimdall_core::Verdict::Accepted => panic!("expected Refused"),
}
}
fn files(&self) -> Vec<String> {
names(&self.out().join("files"))
}
}
fn heimdall<S: AsRef<std::ffi::OsStr>>(args: &[S]) -> Output {
Command::new(env!("CARGO_BIN_EXE_heimdall"))
.args(args)
.output()
.unwrap()
}
fn code(o: &Output) -> i32 {
o.status.code().expect("heimdall was killed by a signal")
}
fn err(o: &Output) -> String {
String::from_utf8_lossy(&o.stderr).into_owned()
}
fn out(o: &Output) -> String {
String::from_utf8_lossy(&o.stdout).into_owned()
}
fn names(p: &Path) -> Vec<String> {
let mut v: Vec<String> = std::fs::read_dir(p)
.unwrap()
.map(|e| e.unwrap().file_name().to_string_lossy().into_owned())
.collect();
v.sort();
v
}
fn stdout_field(o: &Output, key: &str) -> String {
out(o)
.lines()
.find_map(|l| l.strip_prefix(&format!("{key}: ")).map(str::to_string))
.unwrap_or_else(|| panic!("no {key} line in {}", out(o)))
}
// ---- spec §11 Wasm ----
#[test]
fn good_input_gives_signed_accepted_that_verify_result_accepts() {
let j = Job::good("good", b"hello bundle");
let r = j.run(&[]);
assert_eq!(code(&r), OK, "{}", err(&r));
assert!(out(&r).contains("verdict: accepted (1 outputs)"));
let v = j.verify(&[]);
assert_eq!(code(&v), OK, "{}", err(&v));
assert!(out(&v).contains("outputs: 1 checked"));
assert_eq!(
std::fs::read(j.out().join("files/report.txt")).unwrap(),
b"hello bundle"
);
}
#[test]
fn good_input_output_hash_in_the_signed_result_is_the_sha256_of_the_file() {
let j = Job::good("hash", b"abc");
assert_eq!(code(&j.run(&[])), OK);
let e = &j.signed().result.outputs[0];
let want: [u8; 32] = Sha256::digest(b"abc").into();
assert_eq!(e.sha256.0, want);
}
#[test]
fn tampered_input_gives_signed_refused_with_the_guests_reason() {
let j = Job::good("tampered", b"Tampered bundle");
let r = j.run(&[]);
assert_eq!(code(&r), REFUSED, "{}", err(&r));
assert_eq!(j.reason(), "input signature mismatch");
let v = j.verify(&[]);
assert_eq!(code(&v), REFUSED);
assert!(out(&v).contains("refused: input signature mismatch"));
}
#[test]
fn malformed_input_traps_the_guest_and_gives_signed_refused_without_a_host_crash() {
let j = Job::good("malformed", b"Malformed");
let r = j.run(&[]);
assert_eq!(code(&r), REFUSED, "{}", err(&r));
assert!(j.reason().starts_with("guest trapped ("), "{}", j.reason());
assert!(j.files().is_empty());
assert_eq!(code(&j.verify(&[])), REFUSED);
}
#[test]
fn the_ticket_printed_by_run_binds_the_result_in_verify_result() {
let j = Job::good("ticket", b"x");
let r = j.run(&[]);
let (id, nonce) = (stdout_field(&r, "job-id"), stdout_field(&r, "nonce"));
assert_eq!(code(&j.verify(&["--job-id", &id, "--nonce", &nonce])), OK);
let other = heimdall_core::Nonce::random().to_hex();
assert_eq!(
code(&j.verify(&["--job-id", &id, "--nonce", &other])),
REJECTED
);
}
#[test]
fn caller_chosen_job_id_and_nonce_are_signed_into_the_result() {
let j = Job::good("chosen", b"x");
let id = "00112233445566778899aabbccddeeff";
let nonce = "ab".repeat(32);
assert_eq!(code(&j.run(&["--job-id", id, "--nonce", &nonce])), OK);
let r = j.signed().result;
assert_eq!(r.job_id.to_hex(), id);
assert_eq!(r.nonce.to_hex(), nonce);
}
#[test]
fn pin_out_and_the_stdout_pinned_key_are_the_same_record() {
let j = Job::good("pin", b"x");
let r = j.run(&[]);
let file = std::fs::read_to_string(j.pin()).unwrap();
assert_eq!(format!("{}\n", stdout_field(&r, "pinned-key")), file);
}
#[test]
fn every_run_is_its_own_boot_with_a_new_key() {
let j1 = Job::good("boot1", b"x");
let j2 = Job::good("boot2", b"x");
j1.run(&[]);
j2.run(&[]);
assert_ne!(
std::fs::read(j1.pin()).unwrap(),
std::fs::read(j2.pin()).unwrap()
);
let v = heimdall(&[
"verify-result",
j1.out().join("result.signed").to_str().unwrap(),
"--key",
j2.pin().to_str().unwrap(),
]);
assert_eq!(code(&v), REJECTED);
}
#[test]
fn same_guest_and_input_twice_give_the_same_output_hashes() {
let j1 = Job::good("det1", b"deterministic");
let j2 = Job::good("det2", b"deterministic");
j1.run(&[]);
j2.run(&[]);
assert_eq!(j1.signed().result.outputs, j2.signed().result.outputs);
}
// ---- limits ----
const SPIN: &str = "(loop $l (br $l)) (i32.const 0)";
#[test]
fn infinite_loop_is_stopped_by_fuel_and_refused() {
let j = Job::new("spin-fuel", &Wat::new(SPIN).text(), b"x");
let p = j.policy("version = 1\n[limits]\nfuel = 1000000\nwall_time_ms = 60000\n");
let r = j.run(&["--policy", &p]);
assert_eq!(code(&r), REFUSED, "{}", err(&r));
assert!(j.reason().contains("out of fuel"), "{}", j.reason());
}
#[test]
fn infinite_loop_is_stopped_by_the_wall_clock_timeout() {
let j = Job::new("spin-time", &Wat::new(SPIN).text(), b"x");
let p = j.policy("version = 1\n[limits]\nfuel = 1000000000000\nwall_time_ms = 300\n");
let t = Instant::now();
let r = j.run(&["--policy", &p]);
assert_eq!(code(&r), REFUSED, "{}", err(&r));
assert!(j.reason().contains("wall-clock timeout"), "{}", j.reason());
assert!(
t.elapsed() < Duration::from_secs(30),
"took {:?}",
t.elapsed()
);
}
#[test]
fn memory_bomb_is_refused_at_the_memory_limit() {
let body =
"(loop $l (br_if $l (i32.ne (memory.grow (i32.const 16)) (i32.const -1)))) unreachable";
let j = Job::new("membomb", &Wat::new(body).text(), b"x");
let p = j.policy("version = 1\n[limits]\nmemory_bytes = 8388608\n");
let r = j.run(&["--policy", &p]);
assert_eq!(code(&r), REFUSED, "{}", err(&r));
assert!(j.reason().contains("memory limit"), "{}", j.reason());
}
#[test]
fn deep_recursion_is_refused_as_a_trap() {
let w = Wat::new("(drop (call $deep (i32.const 0))) (call $ok (i32.const 16) (i32.const 0))")
.extra(
"(func $deep (param i32) (result i32) (call $deep (i32.add (local.get 0) (i32.const 1))))",
);
let j = Job::new("recurse", &w.text(), b"x");
let r = j.run(&[]);
assert_eq!(code(&r), REFUSED, "{}", err(&r));
assert!(j.reason().contains("stack overflow"), "{}", j.reason());
}
#[test]
fn many_emit_file_calls_are_refused_at_the_file_count_limit() {
let body = r#"(local $i i32)
(loop $l
(call $hexname (local.get $i))
(call $emit (i32.const 64) (i32.const 8) (i32.const 16) (i32.const 1))
(local.set $i (i32.add (local.get $i) (i32.const 1)))
(br_if $l (i32.lt_u (local.get $i) (i32.const 100000))))
(call $ok (i32.const 16) (i32.const 0))"#;
let j = Job::new("manyfiles", &Wat::new(body).text(), b"x");
let p = j.policy("version = 1\n[limits]\nmax_output_files = 8\n");
let r = j.run(&["--policy", &p]);
assert_eq!(code(&r), REFUSED, "{}", err(&r));
assert!(j.reason().contains("too many files"), "{}", j.reason());
assert!(j.files().is_empty());
}
#[test]
fn huge_output_is_refused_at_the_file_size_limit() {
let body = "(call $emit (i32.const 16) (i32.const 3) (i32.const 0) (i32.const 4194304)) (call $ok (i32.const 16) (i32.const 0))";
let w = Wat::new(body)
.pages(80)
.extra(r#"(data (i32.const 16) "big")"#);
let j = Job::new("huge", &w.text(), b"x");
let p = j.policy("version = 1\n[limits]\nmax_output_file_bytes = 1048576\n");
let r = j.run(&["--policy", &p]);
assert_eq!(code(&r), REFUSED, "{}", err(&r));
assert!(j.reason().contains("1048576"), "{}", j.reason());
assert!(j.files().is_empty());
}
// ---- emit-file names ----
/// `name` is WAT string text (escapes allowed); `len` is its byte length.
fn emit_named(name: &str, len: usize) -> String {
Wat::new(&format!(
"(call $emit (i32.const 200) (i32.const {len}) (i32.const 16) (i32.const 2)) (call $ok (i32.const 16) (i32.const 0))"
))
.extra(&format!(r#"(data (i32.const 16) "ok") (data (i32.const 200) "{name}")"#))
.text()
}
fn emit_then_ok(name: &str) -> String {
emit_named(name, name.len())
}
#[test]
fn path_traversal_in_an_emit_file_name_is_refused_and_nothing_escapes() {
let j = Job::new("traverse", &emit_then_ok("../../escaped"), b"x");
let r = j.run(&[]);
assert_eq!(code(&r), REFUSED, "{}", err(&r));
assert!(j.reason().contains("name"), "{}", j.reason());
assert!(!j.root.join("escaped").exists());
assert!(!j.kit.dir.join("escaped").exists());
assert!(j.files().is_empty());
}
#[test]
fn absolute_emit_file_name_is_refused() {
let j = Job::new("absolute", &emit_then_ok("/tmp/heimdall-escape"), b"x");
assert_eq!(code(&j.run(&[])), REFUSED);
assert!(!Path::new("/tmp/heimdall-escape").exists());
}
#[test]
fn default_policy_runs_a_real_component_guest() {
// A component guest needs several core instances; the default limits
// must let an ordinary guest run (regression: instances = 1 refused all).
let j = Job::good("defaults", b"plain");
let r = j.run(&[]);
assert_eq!(code(&r), OK, "{}", err(&r));
}
#[test]
fn documented_example_policy_runs_a_real_guest() {
let j = Job::good("example", b"plain");
let p = j.policy(include_str!("../../../docs/examples/policy.toml"));
let r = j.run(&["--policy", &p]);
assert_eq!(code(&r), OK, "{}", err(&r));
}
#[test]
fn hidden_emit_file_name_is_refused() {
let j = Job::new("hidden", &emit_then_ok(".bashrc"), b"x");
assert_eq!(code(&j.run(&[])), REFUSED);
}
#[test]
fn bad_utf8_in_an_emit_file_name_is_refused_without_a_host_crash() {
let j = Job::new("badutf8", &emit_named("a\\ff\\feb", 4), b"x");
let r = j.run(&[]);
assert_eq!(code(&r), REFUSED, "{}", err(&r));
assert!(j.reason().contains("UTF-8"), "{}", j.reason());
assert!(j.files().is_empty());
}
#[test]
fn a_trap_in_the_middle_of_output_leaves_no_files() {
let body =
"(call $emit (i32.const 200) (i32.const 5) (i32.const 16) (i32.const 2)) unreachable";
let w = Wat::new(body).extra(r#"(data (i32.const 16) "ok") (data (i32.const 200) "first")"#);
let j = Job::new("midtrap", &w.text(), b"x");
assert_eq!(code(&j.run(&[])), REFUSED);
assert!(j.reason().contains("unreachable"), "{}", j.reason());
assert!(j.files().is_empty());
assert!(j.signed().result.outputs.is_empty());
}
// ---- the guest signature gate ----
#[test]
fn unsigned_guest_is_not_loaded_and_no_out_dir_is_made() {
let j = Job::good("unsigned", b"x");
std::fs::remove_file(sidecar(&j.module, "p7s")).unwrap();
let r = j.run(&[]);
assert_eq!(code(&r), HOST, "{}", err(&r));
assert!(err(&r).contains("guest not loaded"), "{}", err(&r));
assert!(!j.out().exists());
}
#[test]
fn guest_changed_after_signing_is_not_loaded() {
let j = Job::good("changed", b"x");
let mut b = std::fs::read(&j.module).unwrap();
let n = b.len();
b[n - 1] ^= 1;
std::fs::write(&j.module, b).unwrap();
assert_eq!(code(&j.run(&[])), HOST);
assert!(!j.out().exists());
}
#[test]
fn guest_signed_under_another_root_is_not_loaded() {
let j = Job::good("otherroot", b"x");
let other = Kit::new("e2e-other-root");
let mut a = j.base_args();
let i = a.iter().position(|s| s == "--trust-root").unwrap();
a[i + 1] = other.root.display().to_string();
assert_eq!(code(&heimdall(&a)), HOST);
}
#[test]
fn matching_blake3_pin_loads_and_a_wrong_one_refuses() {
let j = Job::good("pinok", b"x");
let h = blake3::hash(&std::fs::read(&j.module).unwrap())
.to_hex()
.to_string();
assert_eq!(code(&j.run(&["--pin-blake3", &h])), OK);
let j2 = Job::good("pinbad", b"x");
assert_eq!(code(&j2.run(&["--pin-blake3", &"0".repeat(64)])), HOST);
}
// ---- the out dir ----
#[test]
fn existing_out_dir_is_refused_and_left_untouched() {
let j = Job::good("exists", b"x");
std::fs::create_dir(j.out()).unwrap();
std::fs::write(j.out().join("mine"), b"keep").unwrap();
assert_eq!(code(&j.run(&[])), HOST);
assert_eq!(names(&j.out()), ["mine"]);
}
#[test]
fn existing_pin_out_file_is_never_overwritten() {
let j = Job::good("pinexists", b"x");
std::fs::write(j.pin(), b"old").unwrap();
assert_eq!(code(&j.run(&[])), HOST);
assert_eq!(std::fs::read(j.pin()).unwrap(), b"old");
}