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/genprof.rs

// SPDX-License-Identifier: AGPL-3.0-only
//! `heimdall run --job wasm` under the GENERATED heimdall-wasm-host seccomp
//! profile (lane genprof): the guest runs in a worker process whose kernel
//! filter is the checked-in profile; the parent signs. Heimdall is a
//! defensive sandbox; the only call these tests make outside the profile is
//! the test hook's own socket (feature `test-hooks`), which the filter must
//! kill.
//!
//! The profiles are generated from the RELEASE build, so these tests build
//! their own release `heimdall` (with `test-hooks`) once, in
//! `CARGO_TARGET_TMPDIR`, or take `HEIMDALL_GENPROF_BIN`.

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

use std::path::{Path, PathBuf};
use std::process::{Command, Output};
use std::sync::OnceLock;

use common::{component, good_wat, Kit, Wat};
use heimdall_core::{codes, Protection, SignedResult, Verdict};
use sha2::{Digest, Sha256};

const OK: i32 = 0;
const USAGE: i32 = 2;
const HOST: i32 = 3;
const REFUSED: i32 = 4;

/// The release `heimdall` with `test-hooks`, built once per test binary.
fn release_bin() -> &'static Path {
    static B: OnceLock<PathBuf> = OnceLock::new();
    B.get_or_init(|| {
        if let Some(p) = std::env::var_os("HEIMDALL_GENPROF_BIN") {
            return PathBuf::from(p);
        }
        let repo = Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
        let target = Path::new(env!("CARGO_TARGET_TMPDIR")).join("heimdall-genprof");
        let cargo = std::env::var("CARGO").unwrap_or_else(|_| "cargo".into());
        let st = Command::new(cargo)
            .args([
                "build",
                "--locked",
                "--release",
                "-p",
                "nordisk-heimdall-cli",
                "--bin",
                "heimdall",
                "--features",
                "test-hooks",
                "--manifest-path",
            ])
            .arg(repo.join("Cargo.toml"))
            .arg("--target-dir")
            .arg(&target)
            .env_remove("RUSTFLAGS")
            .env_remove("CARGO_ENCODED_RUSTFLAGS")
            .status()
            .expect("cargo could not be started");
        assert!(st.success(), "building the release heimdall failed");
        target.join("release/heimdall")
    })
}

struct Job {
    kit: Kit,
    root: PathBuf,
    module: PathBuf,
}

impl Job {
    fn new(name: &str, core_wat: &str, input: &[u8]) -> Job {
        let kit = Kit::new(&format!("genprof-{name}"));
        let root = kit.dir.join("job");
        std::fs::create_dir_all(root.join("in")).unwrap();
        std::fs::write(root.join("in/input.bin"), input).unwrap();
        let module = kit.place(name, &component(core_wat));
        Job { kit, root, module }
    }
    fn good(name: &str, input: &[u8]) -> Job {
        Self::new(name, &good_wat(), input)
    }
    fn out(&self) -> PathBuf {
        self.root.join("out")
    }
    fn cmd(&self, extra: &[&str]) -> Command {
        let mut c = Command::new(release_bin());
        c.args([
            "run",
            "--job",
            "wasm",
            "--module",
            self.module.to_str().unwrap(),
            "--trust-root",
            self.kit.root.to_str().unwrap(),
            "--in",
            self.root.join("in").to_str().unwrap(),
            "--out",
            self.out().to_str().unwrap(),
            "--pin-out",
            self.root.join("key.pub").to_str().unwrap(),
        ])
        .args(extra)
        .env_remove("HEIMDALL_SECCOMP")
        .env_remove("HEIMDALL_TEST_WORKER_CALL");
        c
    }
    fn run(&self, extra: &[&str]) -> Output {
        self.cmd(extra).output().unwrap()
    }
    fn verify(&self) -> Output {
        Command::new(release_bin())
            .args([
                "verify-result",
                self.out().join("result.signed").to_str().unwrap(),
                "--key",
                self.root.join("key.pub").to_str().unwrap(),
            ])
            .output()
            .unwrap()
    }
    fn signed(&self) -> SignedResult {
        SignedResult::from_bytes(&std::fs::read(self.out().join("result.signed")).unwrap()).unwrap()
    }
    fn files(&self) -> Vec<String> {
        let mut v: Vec<String> = std::fs::read_dir(self.out().join("files"))
            .unwrap()
            .map(|e| e.unwrap().file_name().to_string_lossy().into_owned())
            .collect();
        v.sort();
        v
    }
}

fn code(o: &Output) -> i32 {
    o.status
        .code()
        .expect("the heimdall parent is never killed")
}

fn err(o: &Output) -> String {
    String::from_utf8_lossy(&o.stderr).into_owned()
}

fn out(o: &Output) -> String {
    String::from_utf8_lossy(&o.stdout).into_owned()
}

/// `sha256:<hex>` of this build's checked-in wasm-host profile (OCI JSON).
fn builtin_digest() -> String {
    let libc = if cfg!(target_env = "musl") {
        heimdall_seccomp::Libc::Musl
    } else {
        heimdall_seccomp::Libc::Glibc
    };
    let e = heimdall_profiles::ALL
        .iter()
        .find(|e| {
            e.profile.workload == "heimdall-wasm-host"
                && Some(e.profile.arch) == heimdall_seccomp::Arch::native()
                && e.profile.libc == libc
        })
        .expect("a checked-in wasm-host profile for this build");
    let h: [u8; 32] = Sha256::digest(e.oci_json.as_bytes()).into();
    format!("sha256:{}", heimdall_core::hex::encode(&h))
}

#[test]
fn by_default_a_wasm_job_runs_under_the_generated_profile_and_the_signed_result_names_its_digest() {
    let j = Job::good("default", b"hello generated");
    let r = j.run(&[]);
    assert_eq!(code(&r), OK, "{}{}", err(&r), out(&r));
    let d = builtin_digest();
    assert!(
        out(&r).contains(&format!("seccomp: generated {d}")),
        "{}",
        out(&r)
    );
    let s = j.signed();
    assert!(s.result.verdict.is_accepted());
    assert_eq!(
        s.result.runner.seccomp,
        Protection::Generated { profile_digest: d }
    );
    assert!(s.result.runner.wasm && !s.result.runner.container);
    assert_eq!(j.files(), vec!["report.txt".to_string()]);
    assert_eq!(code(&j.verify()), OK, "{}", err(&j.verify()));
}

#[test]
fn a_guest_refusal_and_a_guest_trap_under_the_generated_profile_are_signed_refusals_as_before() {
    let j = Job::good("tampered", b"Tampered bundle");
    let r = j.run(&[]);
    assert_eq!(code(&r), REFUSED, "{}", err(&r));
    match &j.signed().result.verdict {
        Verdict::Refused { reason, .. } => assert_eq!(reason, "input signature mismatch"),
        v => panic!("{v:?}"),
    }
    let j = Job::good("malformed", b"Malformed");
    let r = j.run(&[]);
    assert_eq!(code(&r), REFUSED, "{}", err(&r));
    let s = j.signed();
    assert!(
        s.result
            .verdict
            .code()
            .unwrap()
            .starts_with("heimdall-wasm-"),
        "{:?}",
        s.result.verdict
    );
    assert_eq!(s.result.exit.signal, None);
    assert!(j.files().is_empty());
    assert_eq!(code(&j.verify()), REFUSED);
}

#[test]
fn an_infinite_loop_under_the_generated_profile_is_stopped_by_fuel_not_by_the_filter() {
    let j = Job::new(
        "spin",
        &Wat::new("(loop $l (br $l)) (i32.const 0)").text(),
        b"x",
    );
    let p = j.root.join("policy.toml");
    std::fs::write(
        &p,
        "version = 1\n[limits]\nfuel = 1000000\nwall_time_ms = 60000\n",
    )
    .unwrap();
    let r = j.run(&["--policy", p.to_str().unwrap()]);
    assert_eq!(code(&r), REFUSED, "{}", err(&r));
    let s = j.signed();
    assert!(
        s.result
            .verdict
            .code()
            .unwrap()
            .starts_with("heimdall-wasm-"),
        "{:?}",
        s.result.verdict
    );
    assert_ne!(s.result.verdict.code(), Some(codes::SECCOMP_KILL));
    assert_eq!(s.result.exit.signal, None);
}

#[test]
fn a_call_outside_the_profile_kills_the_worker_with_sigsys_and_the_parent_signs_a_seccomp_refusal()
{
    let j = Job::good("sigsys", b"hello");
    let r = j
        .cmd(&[])
        .env("HEIMDALL_TEST_WORKER_CALL", "socket")
        .output()
        .unwrap();
    assert_eq!(code(&r), REFUSED, "{}", err(&r));
    let s = j.signed();
    assert_eq!(
        s.result.verdict.code(),
        Some(codes::SECCOMP_KILL),
        "{:?}",
        s.result.verdict
    );
    assert_eq!(s.result.exit.signal, Some(libc::SIGSYS));
    assert!(s.result.outputs.is_empty());
    assert!(j.files().is_empty());
    assert_eq!(
        s.result.runner.seccomp,
        Protection::Generated {
            profile_digest: builtin_digest()
        }
    );
    // The refusal verifies under the pinned key like any result.
    assert_eq!(code(&j.verify()), REFUSED);
}

#[test]
fn the_same_hook_without_the_filter_is_not_killed_so_the_filter_did_the_killing() {
    let j = Job::good("hook-none", b"hello");
    let r = j
        .cmd(&["--seccomp", "none"])
        .env("HEIMDALL_TEST_WORKER_CALL", "socket")
        .output()
        .unwrap();
    assert_eq!(code(&r), OK, "{}{}", err(&r), out(&r));
}

#[test]
fn seccomp_none_runs_in_process_and_the_result_says_none() {
    let j = Job::good("none", b"x");
    let r = j.run(&["--seccomp", "none"]);
    assert_eq!(code(&r), OK, "{}{}", err(&r), out(&r));
    assert!(out(&r).contains("seccomp: none"));
    assert_eq!(j.signed().result.runner.seccomp, Protection::None);
    let j = Job::good("none-env", b"x");
    let r = j.cmd(&[]).env("HEIMDALL_SECCOMP", "none").output().unwrap();
    assert_eq!(code(&r), OK, "{}{}", err(&r), out(&r));
    assert_eq!(j.signed().result.runner.seccomp, Protection::None);
    // The flag wins over the environment.
    let j = Job::good("flag-wins", b"x");
    let r = j
        .cmd(&["--seccomp", "generated"])
        .env("HEIMDALL_SECCOMP", "none")
        .output()
        .unwrap();
    assert_eq!(code(&r), OK, "{}{}", err(&r), out(&r));
    assert!(matches!(
        j.signed().result.runner.seccomp,
        Protection::Generated { .. }
    ));
}

#[test]
fn a_bad_heimdall_seccomp_value_is_a_usage_error() {
    let j = Job::good("badenv", b"x");
    let r = j.cmd(&[]).env("HEIMDALL_SECCOMP", "off").output().unwrap();
    assert_eq!(code(&r), USAGE, "{}", err(&r));
    assert!(!j.out().exists());
}

#[test]
fn a_pinned_profile_digest_that_differs_refuses_the_job_and_nothing_runs() {
    let j = Job::good("pin-bad", b"x");
    let r = j.run(&["--seccomp-sha256", &format!("sha256:{}", "00".repeat(32))]);
    assert_eq!(code(&r), HOST, "{}", err(&r));
    assert!(err(&r).contains("digest mismatch"), "{}", err(&r));
    assert!(!j.out().exists());
    let j = Job::good("pin-ok", b"x");
    let r = j.run(&["--seccomp-sha256", &builtin_digest()]);
    assert_eq!(code(&r), OK, "{}{}", err(&r), out(&r));
}

#[test]
fn a_guest_that_fails_to_load_in_the_worker_is_a_host_error_with_nothing_signed() {
    let j = Job::good("badpin", b"x");
    let r = j.run(&["--pin-blake3", &"00".repeat(32)]);
    assert_eq!(code(&r), HOST, "{}", err(&r));
    assert!(!j.out().join("result.signed").exists());
}

#[test]
fn caller_chosen_job_id_and_nonce_are_the_parents_not_the_workers() {
    let j = Job::good("ticket", b"x");
    let id = "00112233445566778899aabbccddeeff";
    let nonce = "cd".repeat(32);
    let r = j.run(&["--job-id", id, "--nonce", &nonce]);
    assert_eq!(code(&r), OK, "{}{}", err(&r), out(&r));
    let s = j.signed();
    assert_eq!(s.result.job_id.to_hex(), id);
    assert_eq!(s.result.nonce.to_hex(), nonce);
}

#[test]
fn a_bare_worker_flag_never_signs_anything() {
    let j = Job::good("bare-worker", b"x");
    let r = j.run(&["--heimdall-worker"]);
    assert_eq!(code(&r), OK, "{}{}", err(&r), out(&r));
    assert!(!j.out().join("result.signed").exists());
    assert!(!j.root.join("key.pub").exists());
}

#[test]
fn a_debug_build_runs_without_a_filter_by_default_and_refuses_an_explicit_generated() {
    if !cfg!(debug_assertions) {
        return; // CARGO_BIN_EXE_heimdall is the release build here
    }
    let j = Job::good("debug-default", b"x");
    // The same arguments, run by the debug binary.
    let args: Vec<_> = j.cmd(&[]).get_args().map(|a| a.to_owned()).collect();
    let r = Command::new(env!("CARGO_BIN_EXE_heimdall"))
        .args(&args)
        .env_remove("HEIMDALL_SECCOMP")
        .output()
        .unwrap();
    assert_eq!(code(&r), OK, "{}{}", err(&r), out(&r));
    assert_eq!(j.signed().result.runner.seccomp, Protection::None);
    let j = Job::good("debug-generated", b"x");
    let args: Vec<_> = j
        .cmd(&["--seccomp", "generated"])
        .get_args()
        .map(|a| a.to_owned())
        .collect();
    let r = Command::new(env!("CARGO_BIN_EXE_heimdall"))
        .args(&args)
        .output()
        .unwrap();
    assert_eq!(code(&r), HOST, "{}", err(&r));
    assert!(err(&r).contains("debug build"), "{}", err(&r));
    assert!(!j.out().exists());
}