Gunnar

nordisk/heimdall

crates/heimdall-plain/tests/runner_fake_shim.rs

// SPDX-License-Identifier: AGPL-3.0-only
// Copyright (C) 2026 Ignalina ApS and Vetra Automation AB

//! PlainYoukiRunner end to end with a FAKE shim (a shell script that plays
//! the container and answers a scripted response): runs in plain CI, no
//! namespaces needed. Proves the job checks, image verification each start,
//! output listing, verdict mapping, signing and features. Heimdall is a
//! defensive sandbox; these tests exercise only its own logic.

mod common;

use std::path::{Path, PathBuf};
use std::sync::Arc;

use common::*;
use heimdall_core::*;
use heimdall_plain::*;

struct Fx {
    base: PathBuf,
    layout: Layout,
    image: ImageRef,
}

fn fx(name: &str) -> Fx {
    let base = scratch(name);
    let layout = Layout::new(&base.join("layout"));
    let image = layout.image(
        &[(
            Layout::tar(&[
                Entry::Dir("bin"),
                Entry::File("bin/tool".into(), b"#!/bin/sh\n".to_vec(), 0o755),
            ]),
            true,
        )],
        &["PATH=/bin"],
    );
    Fx {
        base,
        layout,
        image,
    }
}

/// A runner whose shim is `sh <script>`; the script first drains stdin, then
/// runs `body` (which may write into the job's output dir) and prints `resp`.
fn runner(f: &Fx, body: &str, resp: &str) -> PlainYoukiRunner {
    let script = f.base.join(format!("shim-{}.sh", JobId::random()));
    std::fs::write(
        &script,
        format!("cat > /dev/null\n{body}\ncat <<'EOF'\n{resp}\nEOF\n"),
    )
    .unwrap();
    let mut cfg = PlainConfig::new(&f.layout.dir, f.base.join("work"));
    cfg.shim = ShimCommand {
        program: "/bin/sh".into(),
        args: vec![script.display().to_string()],
    };
    PlainYoukiRunner::with_key(cfg, BootKey::from_seed([7; 32], BootId([1; 16])))
}

const OK: &str = r#"{"error":null,"exit_code":0,"signal":null,"wall_killed":false,"oom_kills":0,"pids_max":0,"ran_ms":3}"#;

fn job(f: &Fx, d: &JobDirs) -> Job {
    native_job(&f.image, &["/bin/tool"], d)
}

fn run_ok(f: &Fx, body: &str, resp: &str) -> (PlainYoukiRunner, JobDirs, SignedResult) {
    let d = job_dirs(&f.base);
    let body = body.replace("OUT", &d.output.display().to_string());
    let r = runner(f, &body, resp);
    let s = r.run(job(f, &d)).unwrap();
    (r, d, s)
}

#[test]
fn an_accepted_run_lists_and_hashes_the_outputs_and_verifies() {
    let f = fx("ok");
    let (r, d, s) = run_ok(&f, "printf hello > OUT/a; printf x > OUT/b", OK);
    let res = verify(&s, &r.verifying_key()).unwrap();
    assert!(res.verdict.is_accepted(), "{:?}", res.verdict);
    assert_eq!(
        res.outputs,
        vec![Output::of("a", b"hello"), Output::of("b", b"x")]
    );
    assert_eq!(res.exit, ExitInfo::exited(0));
    check_outputs(res, &d.output).unwrap();
}

#[test]
fn every_result_states_the_plain_features() {
    let f = fx("feat");
    let (r, _d, s) = run_ok(&f, "", OK);
    assert_eq!(s.result.runner, plain_features());
    assert_eq!(r.features(), plain_features());
    let ft = r.features();
    assert_eq!(ft.runner, "youki-plain");
    assert!(ft.container);
    assert_eq!(ft.seccomp, Protection::Default);
    assert!(!ft.landlock && !ft.broker && !ft.wasm);
    assert!(ft.image_verified_each_start);
}

#[test]
fn a_wall_kill_is_refused_with_the_wall_code() {
    let f = fx("wall");
    let (_r, _d, s) = run_ok(
        &f,
        "",
        r#"{"error":null,"exit_code":null,"signal":9,"wall_killed":true,"oom_kills":0,"pids_max":0,"ran_ms":20000}"#,
    );
    assert_eq!(s.result.verdict.code(), Some(codes::LIMIT_WALL));
    assert_eq!(s.result.exit.killed, Some(KillReason::Wall));
    assert_eq!(s.result.exit.signal, Some(9));
}

#[test]
fn an_oom_kill_is_refused_with_the_memory_code() {
    let f = fx("oom");
    let (_r, _d, s) = run_ok(
        &f,
        "",
        r#"{"error":null,"exit_code":null,"signal":9,"wall_killed":false,"oom_kills":1,"pids_max":0,"ran_ms":5}"#,
    );
    assert_eq!(s.result.verdict.code(), Some(codes::LIMIT_MEMORY));
    assert_eq!(s.result.exit.killed, Some(KillReason::Memory));
}

#[test]
fn a_nonzero_exit_is_refused_and_outputs_are_still_listed() {
    let f = fx("exit");
    let (_r, _d, s) = run_ok(
        &f,
        "printf r > OUT/report",
        r#"{"error":null,"exit_code":3,"signal":null,"wall_killed":false,"oom_kills":0,"pids_max":0,"ran_ms":5}"#,
    );
    assert_eq!(s.result.verdict.code(), Some(codes::EXIT_NONZERO));
    assert_eq!(s.result.exit.code, Some(3));
    assert_eq!(s.result.outputs, vec![Output::of("report", b"r")]);
}

#[test]
fn a_pids_limit_hit_is_named_in_the_reason() {
    let f = fx("pids");
    let (_r, _d, s) = run_ok(
        &f,
        "",
        r#"{"error":null,"exit_code":2,"signal":null,"wall_killed":false,"oom_kills":0,"pids_max":4,"ran_ms":5}"#,
    );
    let Verdict::Refused { reason, .. } = &s.result.verdict else {
        panic!()
    };
    assert!(reason.contains("process limit"), "{reason}");
}

#[test]
fn a_signal_death_is_refused_with_the_signal_code() {
    let f = fx("sig");
    let (_r, _d, s) = run_ok(
        &f,
        "",
        r#"{"error":null,"exit_code":null,"signal":11,"wall_killed":false,"oom_kills":0,"pids_max":0,"ran_ms":5}"#,
    );
    assert_eq!(s.result.verdict.code(), Some(codes::EXIT_SIGNAL));
}

#[test]
fn an_extra_directory_in_output_dir_is_refused_out_1() {
    let f = fx("xdir");
    let (_r, _d, s) = run_ok(&f, "mkdir OUT/sub", OK);
    assert_eq!(s.result.verdict.code(), Some(codes::OUT_EXTRA));
    assert_eq!(s.result.verdict.code(), Some("heimdall-out-1"));
    assert!(s.result.outputs.is_empty());
}

#[test]
fn an_extra_symlink_in_output_dir_is_refused_out_1() {
    let f = fx("xlink");
    let (_r, _d, s) = run_ok(&f, "ln -s /etc/passwd OUT/passwd", OK);
    assert_eq!(s.result.verdict.code(), Some("heimdall-out-1"));
}

#[test]
fn an_extra_fifo_in_output_dir_is_refused_out_1() {
    let f = fx("xfifo");
    let (_r, _d, s) = run_ok(&f, "mkfifo OUT/pipe", OK);
    assert_eq!(s.result.verdict.code(), Some("heimdall-out-1"));
}

#[test]
fn a_hidden_or_bad_name_is_not_a_plain_listing_problem_unless_invalid() {
    // Dot files are valid output names in heimdall-core; a control character is not.
    let f = fx("xname");
    let (_r, _d, s) = run_ok(&f, "printf x > \"OUT/$(printf 'a\\tb')\"", OK);
    assert_eq!(s.result.verdict.code(), Some("heimdall-out-1"));
}

#[test]
fn more_files_than_max_output_files_is_refused_out_1() {
    let f = fx("xmany");
    let d = job_dirs(&f.base);
    let body = format!("for i in 1 2 3 4; do : > {}/f$i; done", d.output.display());
    let r = runner(&f, &body, OK);
    let mut j = job(&f, &d);
    j.policy.limits.max_output_files = 3;
    let s = r.run(j).unwrap();
    assert_eq!(s.result.verdict.code(), Some("heimdall-out-1"));
}

#[test]
fn an_output_over_max_output_file_bytes_is_refused_out_2() {
    let f = fx("xbig");
    let d = job_dirs(&f.base);
    let body = format!("printf 0123456789 > {}/big", d.output.display());
    let r = runner(&f, &body, OK);
    let mut j = job(&f, &d);
    j.policy.limits.max_output_file_bytes = 9;
    let s = r.run(j).unwrap();
    assert_eq!(s.result.verdict.code(), Some(codes::OUT_TOO_LARGE));
}

#[test]
fn outputs_over_max_output_total_bytes_are_refused_out_2() {
    let f = fx("xtotal");
    let d = job_dirs(&f.base);
    let body = format!(
        "printf 01234 > {o}/a; printf 56789 > {o}/b",
        o = d.output.display()
    );
    let r = runner(&f, &body, OK);
    let mut j = job(&f, &d);
    j.policy.limits.max_output_total_bytes = 9;
    j.policy.limits.max_output_file_bytes = 9;
    let s = r.run(j).unwrap();
    assert_eq!(s.result.verdict.code(), Some(codes::OUT_TOO_LARGE));
}

#[test]
fn a_file_added_after_the_run_fails_check_outputs() {
    let f = fx("late");
    let (r, d, s) = run_ok(&f, "printf a > OUT/a", OK);
    std::fs::write(d.output.join("late"), b"x").unwrap();
    let res = verify(&s, &r.verifying_key()).unwrap();
    assert_eq!(
        check_outputs(res, &d.output),
        Err(VerifyError::Output(OutputError::Unlisted("late".into())))
    );
}

#[test]
fn a_changed_output_after_the_run_fails_check_outputs() {
    let f = fx("chg");
    let (r, d, s) = run_ok(&f, "printf hello > OUT/a", OK);
    std::fs::write(d.output.join("a"), b"hellO").unwrap();
    assert!(matches!(
        check_outputs(verify(&s, &r.verifying_key()).unwrap(), &d.output),
        Err(VerifyError::Output(OutputError::HashMismatch { .. }))
    ));
}

#[test]
fn a_tampered_signature_and_a_different_key_fail_verify() {
    let f = fx("sig2");
    let (r, _d, s) = run_ok(&f, "", OK);
    let mut t = s.clone();
    t.sig.0[10] ^= 4;
    assert_eq!(
        verify(&t, &r.verifying_key()),
        Err(VerifyError::BadSignature)
    );
    let other = BootKey::from_seed([8; 32], BootId([2; 16]));
    assert!(matches!(
        verify(&s, &other.verifying_key()),
        Err(VerifyError::WrongKey { .. })
    ));
}

#[test]
fn a_shim_error_is_a_setup_error_and_nothing_is_signed() {
    let f = fx("shimerr");
    let d = job_dirs(&f.base);
    let r = runner(
        &f,
        "",
        r#"{"error":"create: no user namespaces","exit_code":null,"signal":null,"wall_killed":false,"oom_kills":0,"pids_max":0,"ran_ms":0}"#,
    );
    assert!(matches!(r.run(job(&f, &d)), Err(RunError::Setup { .. })));
}

#[test]
fn a_missing_shim_is_a_setup_error() {
    let f = fx("noshim");
    let d = job_dirs(&f.base);
    let mut cfg = PlainConfig::new(&f.layout.dir, f.base.join("work"));
    cfg.shim = ShimCommand {
        program: f.base.join("does-not-exist"),
        args: vec![],
    };
    let r = PlainYoukiRunner::new(cfg);
    assert!(matches!(r.run(job(&f, &d)), Err(RunError::Setup { .. })));
}

#[test]
fn a_garbled_shim_response_is_a_setup_error() {
    let f = fx("garble");
    let d = job_dirs(&f.base);
    let r = runner(&f, "", "not json");
    assert!(matches!(r.run(job(&f, &d)), Err(RunError::Setup { .. })));
}

#[test]
fn a_wasm_job_is_unsupported() {
    let f = fx("wasm");
    let d = job_dirs(&f.base);
    let r = runner(&f, "", OK);
    let mut j = job(&f, &d);
    j.kind = JobKind::Wasm {
        module: GuestRef {
            path: "/g.wasm".into(),
            blake3: "00".repeat(32),
        },
    };
    assert!(matches!(r.run(j), Err(RunError::Unsupported { .. })));
}

#[test]
fn a_network_peer_is_unsupported() {
    let f = fx("net");
    let d = job_dirs(&f.base);
    let r = runner(&f, "", OK);
    let mut j = job(&f, &d);
    j.policy.net = Some(NetAllow {
        host: "pypi.org".into(),
        port: 443,
    });
    assert!(matches!(r.run(j), Err(RunError::Unsupported { .. })));
}

type JobMut = Box<dyn Fn(&mut Job)>;

#[test]
fn bad_jobs_are_invalid_before_anything_runs() {
    let f = fx("bad");
    let marker = f.base.join("ran");
    let r = runner(&f, &format!(": > {}", marker.display()), OK);
    let d = job_dirs(&f.base);
    let muts: Vec<(&str, JobMut)> = vec![
        (
            "unpinned digest",
            Box::new(|j| {
                if let JobKind::Native { image, .. } = &mut j.kind {
                    image.digest = "latest".into();
                }
            }),
        ),
        (
            "empty argv",
            Box::new(|j| {
                if let JobKind::Native { argv, .. } = &mut j.kind {
                    argv.clear();
                }
            }),
        ),
        (
            "zero wall",
            Box::new(|j| j.policy.limits.wall = std::time::Duration::ZERO),
        ),
        ("nan cpus", Box::new(|j| j.policy.limits.cpus = f32::NAN)),
        ("negative cpus", Box::new(|j| j.policy.limits.cpus = -1.0)),
        ("zero pids", Box::new(|j| j.policy.limits.pids = 0)),
        (
            "tiny memory",
            Box::new(|j| j.policy.limits.memory_bytes = 1024),
        ),
        (
            "relative read path",
            Box::new(|j| j.policy.read = vec!["rel".into()]),
        ),
        (
            "dotdot write path",
            Box::new(|j| j.policy.write = vec!["/a/../b".into()]),
        ),
        (
            "missing input",
            Box::new(|j| j.input_dir = "/nonexistent-heimdall".into()),
        ),
        (
            "missing read path",
            Box::new(|j| j.policy.read = vec!["/nonexistent-heimdall".into()]),
        ),
    ];
    for (what, m) in muts {
        let mut j = job(&f, &d);
        m(&mut j);
        assert!(
            matches!(r.run(j), Err(RunError::InvalidJob { .. })),
            "{what}"
        );
    }
    assert!(!marker.exists(), "the shim must not have run");
}

#[test]
fn a_non_empty_output_dir_is_invalid() {
    let f = fx("dirty");
    let d = job_dirs(&f.base);
    std::fs::write(d.output.join("stale"), b"").unwrap();
    let r = runner(&f, "", OK);
    assert!(matches!(
        r.run(job(&f, &d)),
        Err(RunError::InvalidJob { .. })
    ));
}

#[test]
fn a_reused_job_id_while_its_bundle_exists_is_invalid() {
    let f = fx("reuse");
    let d = job_dirs(&f.base);
    let r = runner(&f, "", OK);
    let j = job(&f, &d);
    std::fs::create_dir_all(r.config().work_dir.join("jobs").join(j.id.to_hex())).unwrap();
    assert!(matches!(r.run(j), Err(RunError::InvalidJob { .. })));
}

#[test]
fn the_bundle_is_removed_after_every_run() {
    let f = fx("clean");
    let (r, _d, _s) = run_ok(&f, "", OK);
    let jobs = r.config().work_dir.join("jobs");
    assert_eq!(std::fs::read_dir(jobs).unwrap().count(), 0);
}

#[test]
fn the_image_is_verified_again_on_every_start() {
    let f = fx("reverify");
    let r = runner(&f, "", OK);
    let d1 = job_dirs(&f.base);
    assert!(r.run(job(&f, &d1)).is_ok());
    // Corrupt the layer blob between two jobs: the second start must notice.
    let m = f.layout.manifest(&f.image);
    let layer = m["layers"][0]["digest"].as_str().unwrap().to_string();
    let p = f.layout.blob_path(&layer);
    let mut b = std::fs::read(&p).unwrap();
    let n = b.len();
    b[n - 9] ^= 0xff;
    std::fs::write(&p, b).unwrap();
    let d2 = job_dirs(&f.base);
    assert!(matches!(r.run(job(&f, &d2)), Err(RunError::Image { .. })));
}

#[test]
fn an_image_missing_from_the_layout_is_an_image_error() {
    let f = fx("missing");
    let r = runner(&f, "", OK);
    let d = job_dirs(&f.base);
    let mut j = job(&f, &d);
    if let JobKind::Native { image, .. } = &mut j.kind {
        image.digest = format!("sha256:{}", "ab".repeat(32));
    }
    assert!(matches!(r.run(j), Err(RunError::Image { .. })));
}

#[test]
fn the_config_given_to_the_shim_is_the_plain_spec() {
    let f = fx("cfg");
    let d = job_dirs(&f.base);
    let dump = f.base.join("config-copy.json");
    // The shim copies the bundle's config.json so the test can read it.
    let script = f.base.join("dump.sh");
    std::fs::write(
        &script,
        format!(
            "req=$(cat)\nb=$(printf '%s' \"$req\" | sed 's/.*\"bundle\":\"\\([^\"]*\\)\".*/\\1/')\ncp \"$b/config.json\" {}\necho '{}'\n",
            dump.display(),
            OK
        ),
    )
    .unwrap();
    let mut cfg = PlainConfig::new(&f.layout.dir, f.base.join("work"));
    cfg.shim = ShimCommand {
        program: "/bin/sh".into(),
        args: vec![script.display().to_string()],
    };
    let r = PlainYoukiRunner::new(cfg);
    r.run(job(&f, &d)).unwrap();
    let c: serde_json::Value = serde_json::from_slice(&std::fs::read(&dump).unwrap()).unwrap();
    assert_eq!(c["root"]["readonly"], true);
    assert_eq!(c["process"]["args"][0], "/bin/tool");
    assert_eq!(c["process"]["env"][0], "PATH=/bin");
    let mounts = c["mounts"].as_array().unwrap();
    let in_m = mounts.iter().find(|m| m["destination"] == "/in").unwrap();
    assert_eq!(
        Path::new(in_m["source"].as_str().unwrap()),
        d.input.canonicalize().unwrap()
    );
}

#[test]
fn two_jobs_run_in_parallel_through_a_shared_trait_object() {
    let f = fx("par");
    let r: Arc<dyn JobRunner> = Arc::new(runner(&f, "", OK));
    let hs: Vec<_> = (0..4)
        .map(|_| {
            let r = Arc::clone(&r);
            let d = job_dirs(&f.base);
            let j = job(&f, &d);
            std::thread::spawn(move || r.run(j))
        })
        .collect();
    for h in hs {
        let s = h.join().unwrap().unwrap();
        assert!(verify(&s, &r.verifying_key()).is_ok());
    }
}

#[test]
fn the_shim_is_found_through_the_environment_variable() {
    // Only checks the lookup order; no process is started.
    let c = ShimCommand::locate();
    assert!(
        c.program.ends_with("heimdall-plain-shim")
            || std::env::var_os("HEIMDALL_PLAIN_SHIM").is_some()
    );
}