Gunnar

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