Gunnar

nordisk/heimdall

crates/heimdall-wasm/tests/good_guest.rs

// SPDX-License-Identifier: AGPL-3.0-only
//! Spec §11 Wasm: a good input is accepted, a tampered input is refused with
//! the guest's reason, a malformed input traps and the host only refuses.
//! Heimdall is a defensive sandbox; these guests exercise its own host.

mod common;
use common::*;
use heimdall_wasm::{codes, RunLimits, Verdict};
use sha2::{Digest, Sha256};

#[test]
fn a_good_input_is_accepted_with_the_guest_note_and_its_file() {
    let kit = Kit::new("good");
    let g = kit.guest("good", &good_wat());
    let o = g.run(b"hello bundle", &none(), &RunLimits::default());
    assert_eq!(
        o.verdict,
        Verdict::Accepted {
            note: "accepted".into()
        }
    );
    assert_eq!(o.files.len(), 1);
    assert_eq!(o.files[0].name, "report.txt");
    assert_eq!(o.files[0].bytes, b"hello bundle");
    assert!(o.fuel_used > 0, "fuel was not metered");
}

#[test]
fn every_emitted_file_carries_the_sha256_of_its_bytes() {
    let kit = Kit::new("sha");
    let g = kit.guest("good", &good_wat());
    let o = g.run(b"abc", &none(), &RunLimits::default());
    let want: [u8; 32] = Sha256::digest(b"abc").into();
    assert_eq!(o.files[0].sha256.0, want);
    assert_eq!(
        o.files[0].entry(),
        heimdall_core::Output {
            name: "report.txt".into(),
            sha256: o.files[0].sha256,
            bytes: 3
        }
    );
}

#[test]
fn a_tampered_input_is_refused_with_the_guests_own_reason() {
    let kit = Kit::new("tampered");
    let g = kit.guest("good", &good_wat());
    let o = g.run(b"Tampered", &none(), &RunLimits::default());
    assert_eq!(
        o.verdict,
        Verdict::Refused {
            code: codes::GUEST_REFUSED,
            reason: "input signature mismatch".into()
        }
    );
    assert_eq!(
        o.verdict.to_string(),
        "refused: input signature mismatch [heimdall-wasm-1]"
    );
}

#[test]
fn a_malformed_input_traps_the_guest_and_the_host_only_refuses() {
    let kit = Kit::new("malformed");
    let g = kit.guest("good", &good_wat());
    let o = g.run(b"Malformed", &none(), &RunLimits::default());
    assert_trapped(&o, "unreachable");
    assert!(
        o.verdict
            .to_string()
            .starts_with("refused: guest trapped ("),
        "{}",
        o.verdict
    );
}

#[test]
fn the_same_guest_runs_again_cleanly_after_a_trap() {
    let kit = Kit::new("again");
    let g = kit.guest("good", &good_wat());
    assert!(!g
        .run(b"M", &none(), &RunLimits::default())
        .verdict
        .is_accepted());
    assert!(g
        .run(b"fine", &none(), &RunLimits::default())
        .verdict
        .is_accepted());
}

#[test]
fn an_empty_input_is_a_valid_input() {
    let kit = Kit::new("empty");
    let g = kit.guest("good", &good_wat());
    let o = g.run(b"", &none(), &RunLimits::default());
    assert!(o.verdict.is_accepted(), "{}", o.verdict);
    assert_eq!(o.files[0].bytes, b"");
}

#[test]
fn the_same_input_gives_the_same_outcome_and_the_same_fuel() {
    let kit = Kit::new("determinism");
    let g = kit.guest("good", &good_wat());
    let a = g.run(b"deterministic", &none(), &RunLimits::default());
    let b = g.run(b"deterministic", &none(), &RunLimits::default());
    assert_eq!(a, b);
}

#[test]
fn params_reach_the_guest_in_order() {
    let kit = Kit::new("params");
    // Note = the first param's value, or "none".
    let g = kit.guest(
        "params",
        &Wat::new(
            r#"(if (i32.eqz (local.get $params_len)) (then (return (call $ok (i32.const 16) (i32.const 4)))))
    (call $ok (i32.load offset=8 (local.get $params)) (i32.load offset=12 (local.get $params)))"#,
        )
        .extra(r#"(data (i32.const 16) "none")"#)
        .text(),
    );
    // Params are sorted by key (heimdall-core's BTreeMap): "mode" comes first.
    let p = params(&[("x", "y"), ("mode", "strict")]);
    assert_eq!(
        g.run(b"", &p, &RunLimits::default()).verdict,
        Verdict::Accepted {
            note: "strict".into()
        }
    );
    assert_eq!(
        g.run(b"", &none(), &RunLimits::default()).verdict,
        Verdict::Accepted {
            note: "none".into()
        }
    );
}

#[test]
fn the_input_reaches_the_guest_byte_for_byte() {
    let kit = Kit::new("echo");
    let g = kit.guest(
        "echo",
        &Wat::new("(call $ok (local.get $in) (local.get $in_len))").text(),
    );
    let o = g.run("åäö ✓".as_bytes(), &none(), &RunLimits::default());
    assert_eq!(
        o.verdict,
        Verdict::Accepted {
            note: "åäö ✓".into()
        }
    );
}

#[test]
fn a_guest_refusal_keeps_the_files_it_emitted_as_its_report() {
    let kit = Kit::new("refusal-report");
    let g = kit.guest(
        "report",
        &Wat::new(
            r#"(call $emit (i32.const 16) (i32.const 10) (i32.const 32) (i32.const 3))
    (call $refuse (i32.const 32) (i32.const 3))"#,
        )
        .extra(r#"(data (i32.const 16) "why.report") (data (i32.const 32) "bad")"#)
        .text(),
    );
    let o = g.run(b"", &none(), &RunLimits::default());
    assert_eq!(
        o.verdict,
        Verdict::Refused {
            code: codes::GUEST_REFUSED,
            reason: "bad".into()
        }
    );
    assert_eq!(o.files.len(), 1);
    assert_eq!(o.files[0].name, "why.report");
}

#[test]
fn one_guest_runs_on_many_threads_at_once() {
    let kit = Kit::new("threads");
    let g = std::sync::Arc::new(kit.guest("good", &good_wat()));
    let hs: Vec<_> = (0..4)
        .map(|i| {
            let g = g.clone();
            std::thread::spawn(move || {
                g.run(format!("in{i}").as_bytes(), &none(), &RunLimits::default())
            })
        })
        .collect();
    for (i, h) in hs.into_iter().enumerate() {
        let o = h.join().unwrap();
        assert!(o.verdict.is_accepted(), "{}", o.verdict);
        assert_eq!(o.files[0].bytes, format!("in{i}").as_bytes());
    }
}

#[test]
fn a_loaded_guest_reports_its_digest_and_signer() {
    let kit = Kit::new("census");
    let bytes = component(&good_wat());
    let path = kit.place("good", &bytes);
    let g = kit.host().load(&path).unwrap();
    assert_eq!(g.digest_hex(), blake3::hash(&bytes).to_hex().as_str());
    assert_eq!(g.signer(), "heimdall-test-release");
}

#[test]
fn a_guest_runs_under_heimdall_core_default_limits() {
    // The limits a job policy carries by default must admit an ordinary
    // component guest (three core instances: module, shim, fixup).
    let kit = Kit::new("core-defaults");
    let g = kit.guest("good", &good_wat());
    let limits = RunLimits::from(&heimdall_core::Limits::default());
    let o = g.run(b"x", &none(), &limits);
    assert!(o.verdict.is_accepted(), "{}", o.verdict);
}

#[test]
fn the_core_limits_map_field_by_field() {
    let core = heimdall_core::Limits {
        fuel: Some(7),
        wall: std::time::Duration::from_millis(1500),
        memory_bytes: 1 << 20,
        cpus: 1.0,
        pids: 8,
        table_elements: 11,
        instances: 5,
        tables: 2,
        memories: 1,
        stack_bytes: 1 << 16,
        max_output_files: 3,
        max_output_file_bytes: 100,
        max_output_total_bytes: 200,
    };
    let r = RunLimits::from(&core);
    assert_eq!(r.fuel, 7);
    assert_eq!(r.timeout, std::time::Duration::from_millis(1500));
    assert_eq!(
        (
            r.memory_bytes,
            r.table_elements,
            r.instances,
            r.tables,
            r.memories
        ),
        (1 << 20, 11, 5, 2, 1)
    );
    assert_eq!(
        (r.max_files, r.max_file_bytes, r.max_emit_bytes),
        (3, 100, 200)
    );
    let unmetered = RunLimits::from(&heimdall_core::Limits { fuel: None, ..core });
    assert_eq!(
        unmetered.fuel,
        u64::MAX,
        "no fuel metering means only the clock stops it"
    );
    let cfg = heimdall_wasm::HostConfig::for_policy([std::path::PathBuf::from("r.der")], &core);
    assert_eq!(cfg.stack_bytes, 1 << 16);
}