Gunnar

nordisk/heimdall

crates/heimdall-cli/tests/wasm_runner.rs

// SPDX-License-Identifier: AGPL-3.0-only
//! heimdall-run's `WasmJobRunner` behind heimdall-core's `JobRunner` trait,
//! with real signed wasm guests. Heimdall is a defensive sandbox; these tests
//! exercise only its own containment, inside temporary dirs.

#[path = "../../heimdall-wasm/tests/common/mod.rs"]
mod common;

use std::path::PathBuf;
use std::sync::Arc;

use common::{component, good_wat, Kit};
use heimdall_core::*;
use heimdall_run::WasmJobRunner;

struct Fix {
    kit: Kit,
    module: PathBuf,
    blake3: String,
    input: PathBuf,
    output: PathBuf,
}

fn fix(name: &str, data: &[u8]) -> Fix {
    let kit = Kit::new(&format!("runner-{name}"));
    let bytes = component(&good_wat());
    let module = kit.place(name, &bytes);
    let blake3 = blake3::hash(&std::fs::read(&module).unwrap())
        .to_hex()
        .to_string();
    let input = kit.dir.join("in");
    let output = kit.dir.join("out");
    std::fs::create_dir_all(&input).unwrap();
    std::fs::create_dir_all(&output).unwrap();
    std::fs::write(input.join("input.bin"), data).unwrap();
    Fix {
        kit,
        module,
        blake3,
        input,
        output,
    }
}

impl Fix {
    fn runner(&self) -> WasmJobRunner {
        WasmJobRunner::new(vec![self.kit.root.clone()], self.kit.dir.join("work"))
    }
    fn job(&self) -> Job {
        Job::new(
            JobKind::Wasm {
                module: GuestRef {
                    path: self.module.clone(),
                    blake3: self.blake3.clone(),
                },
            },
            self.input.clone(),
            self.output.clone(),
            serde_json::Value::Null,
            Policy::default(),
        )
    }
}

#[test]
fn a_wasm_job_through_the_trait_verifies_and_its_outputs_check() {
    let f = fix("ok", b"hello");
    let r = f.runner();
    let job = f.job();
    let id = job.id;
    let s = r.run(job).unwrap();
    let res = verify(&s, &r.verifying_key()).unwrap();
    assert_eq!(res.job_id, id);
    assert!(res.verdict.is_accepted(), "{:?}", res.verdict);
    assert_eq!(res.outputs.len(), 1);
    assert_eq!(res.outputs[0].name, "report.txt");
    assert_eq!(res.outputs[0].sha256, Sha256Digest::of(b"hello"));
    check_outputs(res, &f.output).unwrap();
    assert_eq!(res.runner, r.features());
}

#[test]
fn the_wasm_runner_states_its_features_truthfully() {
    let f = fix("feat", b"x");
    let ft = f.runner().features();
    assert_eq!(ft.runner, "heimdall-wasm");
    assert!(ft.wasm && ft.image_verified_each_start);
    assert!(!ft.container && !ft.landlock && !ft.broker);
    assert_eq!(ft.seccomp, Protection::None);
}

#[test]
fn a_guest_refusal_is_signed_with_the_guest_code() {
    let f = fix("refuse", b"T-bad");
    let r = f.runner();
    let s = r.run(f.job()).unwrap();
    let res = verify(&s, &r.verifying_key()).unwrap();
    assert!(!res.verdict.is_accepted());
    assert!(
        res.verdict.code().unwrap().starts_with("heimdall-wasm-")
            || res.verdict.code() == Some(codes::GUEST_REFUSED),
        "{:?}",
        res.verdict
    );
}

#[test]
fn a_trapping_guest_is_refused_with_a_wasm_code_and_no_outputs() {
    let f = fix("trap", b"M");
    let r = f.runner();
    let s = r.run(f.job()).unwrap();
    let res = verify(&s, &r.verifying_key()).unwrap();
    assert!(
        res.verdict.code().unwrap().starts_with("heimdall-wasm-"),
        "{:?}",
        res.verdict
    );
    assert!(res.outputs.is_empty());
    assert_eq!(std::fs::read_dir(&f.output).unwrap().count(), 0);
}

#[test]
fn a_native_job_is_unsupported() {
    let f = fix("native", b"x");
    let mut job = f.job();
    job.kind = JobKind::Native {
        image: ImageRef {
            name: "busybox".into(),
            digest: format!("sha256:{}", "00".repeat(32)),
        },
        argv: vec!["true".into()],
    };
    assert!(matches!(
        f.runner().run(job),
        Err(RunError::Unsupported { .. })
    ));
}

#[test]
fn a_network_policy_is_unsupported() {
    let f = fix("net", b"x");
    let mut job = f.job();
    job.policy.net = Some(NetAllow {
        host: "example.org".into(),
        port: 443,
    });
    assert!(matches!(
        f.runner().run(job),
        Err(RunError::Unsupported { .. })
    ));
}

#[test]
fn a_wrong_blake3_pin_refuses_to_load_the_guest() {
    let f = fix("pin", b"x");
    let mut job = f.job();
    job.kind = JobKind::Wasm {
        module: GuestRef {
            path: f.module.clone(),
            blake3: "00".repeat(32),
        },
    };
    assert!(matches!(f.runner().run(job), Err(RunError::Image { .. })));
    assert_eq!(std::fs::read_dir(&f.output).unwrap().count(), 0);
}

#[test]
fn a_non_empty_output_dir_is_an_invalid_job() {
    let f = fix("dirty", b"x");
    std::fs::write(f.output.join("old"), b"").unwrap();
    assert!(matches!(
        f.runner().run(f.job()),
        Err(RunError::InvalidJob { .. })
    ));
}

#[test]
fn params_must_be_an_object_of_strings() {
    let f = fix("params", b"x");
    let mut job = f.job();
    job.params = serde_json::json!({"n": 1});
    assert!(matches!(
        f.runner().run(job),
        Err(RunError::InvalidJob { .. })
    ));
    assert_eq!(
        heimdall_run::params_from_json(&serde_json::json!({"a": "b"})).unwrap()["a"],
        "b"
    );
}

#[test]
fn unmetered_fuel_is_an_invalid_job() {
    let f = fix("fuel", b"x");
    let mut job = f.job();
    job.policy.limits.fuel = None;
    assert!(matches!(
        f.runner().run(job),
        Err(RunError::InvalidJob { .. })
    ));
}

#[test]
fn a_result_from_one_runner_does_not_verify_under_another_runners_key() {
    let f = fix("twokeys", b"x");
    let a = f.runner();
    let b = f.runner();
    let s = a.run(f.job()).unwrap();
    assert!(matches!(
        verify(&s, &b.verifying_key()),
        Err(VerifyError::WrongKey { .. })
    ));
}

#[test]
fn an_altered_output_fails_check_outputs() {
    let f = fix("alter", b"hello");
    let r = f.runner();
    let s = r.run(f.job()).unwrap();
    let res = verify(&s, &r.verifying_key()).unwrap();
    std::fs::write(f.output.join("report.txt"), b"hellO").unwrap();
    assert!(matches!(
        check_outputs(res, &f.output),
        Err(VerifyError::Output(OutputError::HashMismatch { .. }))
    ));
}

#[test]
fn the_runner_is_usable_as_a_shared_trait_object() {
    let f = fix("dyn", b"hello");
    let r: Arc<dyn JobRunner> = Arc::new(f.runner());
    let job = f.job();
    let r2 = Arc::clone(&r);
    let s = std::thread::spawn(move || r2.run(job))
        .join()
        .unwrap()
        .unwrap();
    assert!(verify(&s, &r.verifying_key())
        .unwrap()
        .verdict
        .is_accepted());
}