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