Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

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