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(),
},
decoder: None,
},
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::one("pypi.org", "151.101.0.223", 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),
},
decoder: None,
};
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());
}
// ---- a split job names its decoder module (JobKind::Wasm { decoder }) ----
//
// Any signed component stands in for a decoder: the pin and the signature
// are checked first. The real split guest runs through WasmJobRunner in
// guests/verify-bundle/tests/cli_split.rs.
impl Fix {
/// A signed component placed as `<name>.wasm`, as a decoder GuestRef.
fn decoder(&self, name: &str) -> GuestRef {
let path = self.kit.place(name, &component(&good_wat()));
let blake3 = blake3::hash(&std::fs::read(&path).unwrap())
.to_hex()
.to_string();
GuestRef { path, blake3 }
}
fn split_job(&self, decoder: GuestRef) -> Job {
let mut j = self.job();
j.kind = JobKind::wasm_split(
GuestRef {
path: self.module.clone(),
blake3: self.blake3.clone(),
},
decoder,
);
j
}
}
fn image_reason(r: Result<SignedResult, RunError>) -> String {
match r {
Err(RunError::Image { reason }) => reason,
other => panic!("expected RunError::Image, got {other:?}"),
}
}
#[test]
fn a_split_job_with_a_wrong_decoder_pin_refuses_to_load_and_writes_nothing() {
let f = fix("decpin", b"x");
let mut d = f.decoder("dec");
d.blake3 = "00".repeat(32);
let reason = image_reason(f.runner().run(f.split_job(d)));
assert!(reason.contains("decoder refused"), "{reason}");
assert!(reason.contains("caller pin"), "{reason}");
assert_eq!(std::fs::read_dir(&f.output).unwrap().count(), 0);
}
#[test]
fn a_split_job_with_an_unsigned_decoder_refuses_to_load() {
let f = fix("decunsigned", b"x");
let d = f.decoder("dec");
std::fs::remove_file(common::sidecar(&d.path, "p7s")).unwrap();
let reason = image_reason(f.runner().run(f.split_job(d)));
assert!(reason.contains("decoder refused"), "{reason}");
assert!(reason.contains("signature"), "{reason}");
assert_eq!(std::fs::read_dir(&f.output).unwrap().count(), 0);
}
#[test]
fn a_split_job_whose_decoder_is_a_job_guest_refuses_to_load() {
let f = fix("decjob", b"x");
let reason = image_reason(f.runner().run(f.split_job(f.decoder("dec"))));
assert!(reason.contains("decoder refused"), "{reason}");
assert!(reason.contains("emit-file"), "{reason}");
}
#[test]
fn zero_decoders_is_an_invalid_job() {
let f = fix("dec0", b"x");
let mut job = f.job();
job.policy.limits.decoders = 0;
match f.runner().run(job) {
Err(RunError::InvalidJob { reason }) => assert!(reason.contains("decoders"), "{reason}"),
other => panic!("{other:?}"),
}
}
#[test]
fn the_jobs_decoder_count_reaches_the_run_limits() {
let l = Limits {
decoders: 5,
..Limits::default()
};
assert_eq!(heimdall_run::run_limits_from_core(&l).unwrap().decoders, 5);
assert_eq!(
heimdall_run::run_limits_from_core(&l)
.unwrap()
.to_core()
.decoders,
5
);
}
#[test]
fn a_wasm_result_names_no_kernel_features() {
let f = fix("nokernel", b"hello");
let r = f.runner();
let s = r.run(f.job()).unwrap();
assert_eq!(verify(&s, &r.verifying_key()).unwrap().kernel, None);
}
#[test]
fn a_seccomp_profile_in_the_policy_is_unsupported_by_the_in_process_runner() {
let f = fix("seccomp", b"x");
let mut job = f.job();
job.policy.seccomp = Some(heimdall_core::SeccompProfileRef {
source: heimdall_core::ProfileSource::Workload("heimdall-wasm-host".into()),
sha256: format!("sha256:{}", "00".repeat(32)),
fallback_to_default: true,
});
assert!(matches!(
f.runner().run(job),
Err(RunError::Unsupported { .. })
));
assert_eq!(std::fs::read_dir(&f.output).unwrap().count(), 0);
}