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

nordisk/holger

jobs/runner/src/tests.rs

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

use heimdall_core::{
    verify, ExitInfo, Features, Job, JobId, JobResult, JobRunner, JobTicket, KillReason, NetAllow,
    Nonce, Output, Policy, Protection, RunError, Sha256Digest, Verdict,
};
use holger_sandbox::{Engine, EngineKind, Refusal, Sandbox, SandboxConfig};

use crate::wire::{Answer, Dirs, WireJob, JOB_SCHEMA};
use crate::*;

// ── helpers ─────────────────────────────────────────────────────────────────

/// The host path a `run` argument list mounts at `container` (read-only or not).
fn host_of(args: &[String], container: &str) -> Option<PathBuf> {
    args.iter()
        .filter_map(|a| a.strip_prefix("--volume="))
        .find_map(|v| {
            let v = v.strip_suffix(":ro").or_else(|| v.strip_suffix(":rw"))?;
            let (h, c) = v.rsplit_once(':')?;
            (c == container).then(|| PathBuf::from(h))
        })
}

/// An engine that plays the worker: the program `prog` over the mounted
/// directories, its answer on stdout. Records the limits and job it saw.
struct Plays {
    prog: Arc<Program>,
    seen: Arc<Mutex<Vec<(holger_sandbox::Limits, WireJob)>>>,
}

use crate::wire::Program;

impl Engine for Plays {
    fn ready(&self, _: &SandboxConfig) -> Result<(), Refusal> {
        Ok(())
    }
    fn run(
        &self,
        cfg: &SandboxConfig,
        _: &str,
        args: &[String],
    ) -> Result<holger_sandbox::Output, Refusal> {
        let job: WireJob =
            serde_json::from_slice(&std::fs::read(host_of(args, "/in/job.json").unwrap()).unwrap())
                .unwrap();
        self.seen
            .lock()
            .unwrap()
            .push((cfg.limits.clone(), job.clone()));
        let dirs = Dirs {
            input: host_of(args, "/in/i").expect("the input at /in/i"),
            read: (0..job.reads)
                .map(|n| host_of(args, &format!("/in/r{n}")).unwrap())
                .collect(),
            out: PathBuf::from(holger_sandbox::out_of(args).expect("the output at /out")),
        };
        let a = (self.prog)(&job, &dirs);
        Ok(holger_sandbox::Output {
            report: serde_json::to_vec(&a).unwrap(),
            elapsed: std::time::Duration::ZERO,
        })
    }
}

/// An engine that refuses every run with `r`.
struct Fails(Refusal);
impl Engine for Fails {
    fn ready(&self, _: &SandboxConfig) -> Result<(), Refusal> {
        Ok(())
    }
    fn run(
        &self,
        _: &SandboxConfig,
        _: &str,
        _: &[String],
    ) -> Result<holger_sandbox::Output, Refusal> {
        Err(self.0.clone())
    }
}

type Seen = Arc<Mutex<Vec<(holger_sandbox::Limits, WireJob)>>>;

fn runner(prog: Arc<Program>) -> (ContainerRunner, Seen) {
    let seen = Seen::default();
    let sb = Sandbox::with_engine(
        SandboxConfig::default(),
        Box::new(Plays {
            prog,
            seen: seen.clone(),
        }),
    )
    .unwrap();
    (
        ContainerRunner::with_key(sb, ResultKey::from_seed([9; 32])),
        seen,
    )
}

/// The copy program: `in/a` → `out/a`.
fn copy_a() -> Arc<Program> {
    Arc::new(|_job: &WireJob, d: &Dirs| {
        std::fs::copy(d.input.join("a"), d.out.join("a")).unwrap();
        Answer::accepted(vec!["a".into()])
    })
}

struct Dirs2 {
    _root: tempfile::TempDir,
    input: PathBuf,
    out: PathBuf,
}

fn dirs(a: &[u8]) -> Dirs2 {
    let root = tempfile::tempdir().unwrap();
    let input = root.path().join("in");
    let out = root.path().join("out");
    std::fs::create_dir(&input).unwrap();
    std::fs::create_dir(&out).unwrap();
    std::fs::write(input.join("a"), a).unwrap();
    Dirs2 {
        _root: root,
        input,
        out,
    }
}

fn job(d: &Dirs2) -> Job {
    Job::new(
        worker(&["copy"]),
        d.input.clone(),
        d.out.clone(),
        serde_json::json!({"x": 1}),
        Policy::default(),
    )
}

// ── keys and ids ────────────────────────────────────────────────────────────

#[test]
fn every_boot_and_every_job_gets_its_own() {
    let (a, b) = (ResultKey::per_boot(), ResultKey::per_boot());
    assert_ne!(a.verifying_key(), b.verifying_key());
    assert_ne!(JobId::random(), JobId::random());
    assert_ne!(Nonce::random(), Nonce::random());
    let w = key_words(&a.verifying_key());
    assert!(
        w.starts_with("ed25519:") && w.len() == "ed25519:".len() + 32,
        "{w}"
    );
}

// ── the output check ────────────────────────────────────────────────────────

#[test]
fn the_declared_files_are_hashed_and_listed() {
    let d = tempfile::tempdir().unwrap();
    std::fs::write(d.path().join("a"), b"alpha").unwrap();
    std::fs::write(d.path().join("b"), b"").unwrap();
    let (v, outs) = collect::collect(&Answer::accepted(vec!["b".into(), "a".into()]), d.path());
    assert_eq!(v, Verdict::Accepted);
    assert_eq!(outs, vec![Output::of("a", b"alpha"), Output::of("b", b"")]);
}

fn refused_code(v: &Verdict) -> &str {
    match v {
        Verdict::Refused { code, .. } => code,
        Verdict::Accepted => "accepted",
    }
}

#[test]
fn an_undeclared_a_linked_or_a_missing_output_refuses_and_hands_over_nothing() {
    let d = tempfile::tempdir().unwrap();
    std::fs::write(d.path().join("a"), b"alpha").unwrap();
    std::fs::write(d.path().join("planted"), b"x").unwrap();
    let (v, outs) = collect::collect(&Answer::accepted(vec!["a".into()]), d.path());
    assert_eq!(refused_code(&v), "holger-sandbox-16", "{v:?}");
    assert!(outs.is_empty());
    assert_eq!(
        std::fs::read_dir(d.path()).unwrap().count(),
        0,
        "a refused job hands over nothing"
    );

    std::os::unix::fs::symlink("/etc/passwd", d.path().join("a")).unwrap();
    let (v, _) = collect::collect(&Answer::accepted(vec!["a".into()]), d.path());
    assert_eq!(refused_code(&v), "holger-sandbox-16", "{v:?}");

    let (v, _) = collect::collect(&Answer::accepted(vec!["gone".into()]), d.path());
    assert_eq!(refused_code(&v), "holger-sandbox-17", "{v:?}");
    let (v, _) = collect::collect(&Answer::accepted(vec!["../x".into()]), d.path());
    assert_eq!(refused_code(&v), "holger-sandbox-17", "{v:?}");
}

#[test]
fn a_jobs_own_refusal_stands_and_hands_over_nothing() {
    let d = tempfile::tempdir().unwrap();
    std::fs::write(d.path().join("half"), b"x").unwrap();
    let (v, outs) = collect::collect(&Answer::refused("holger-import-5", "tampered"), d.path());
    assert_eq!(
        v,
        Verdict::Refused {
            code: "holger-import-5".into(),
            reason: "tampered".into()
        }
    );
    assert!(outs.is_empty());
    assert_eq!(std::fs::read_dir(d.path()).unwrap().count(), 0);
}

// ── the caller's check ──────────────────────────────────────────────────────

fn signed_copy() -> (
    ContainerRunner,
    Dirs2,
    heimdall_core::SignedResult,
    JobTicket,
) {
    let (r, _) = runner(copy_a());
    let d = dirs(b"alpha");
    let j = job(&d);
    let t = j.ticket();
    let s = r.run(j).unwrap();
    (r, d, s, t)
}

#[test]
fn a_good_run_is_accepted_with_its_outputs() {
    let (r, d, s, t) = signed_copy();
    let res = accept(&s, &r.verifying_key(), &t, &d.out).unwrap();
    assert_eq!(res.verdict, Verdict::Accepted);
    assert_eq!(res.outputs, vec![Output::of("a", b"alpha")]);
    assert_eq!(res.runner, r.features());
    assert!(res.finished_unix >= res.started_unix && res.started_unix > 1_700_000_000);
    assert_eq!(std::fs::read(d.out.join("a")).unwrap(), b"alpha");
}

#[test]
fn a_forged_a_foreign_or_a_replayed_result_is_refused() {
    let (r, d, s, t) = signed_copy();
    let pinned = r.verifying_key();
    let mut forged = s.clone();
    forged.result.outputs[0].sha256 = Sha256Digest::of(b"evil");
    let e = accept(&forged, &pinned, &t, &d.out).unwrap_err();
    assert_eq!(e.code.code, "holger-sandbox-18", "{e}");

    let theirs = ResultKey::from_seed([1; 32])
        .sign(s.result.clone())
        .unwrap();
    assert_eq!(
        accept(&theirs, &pinned, &t, &d.out).unwrap_err().code.code,
        "holger-sandbox-18"
    );

    // The genuine result of one job, offered as the answer to another.
    let d2 = dirs(b"alpha");
    let other = job(&d2).ticket();
    let e = accept(&s, &pinned, &other, &d.out).unwrap_err();
    assert_eq!(e.code.code, "holger-sandbox-18");
    assert!(
        e.why.contains(&t.job_id.to_string()) && e.why.contains("replayed"),
        "{e}"
    );
    // The same id with another nonce is another run too.
    let e = accept(
        &s,
        &pinned,
        &JobTicket {
            job_id: t.job_id,
            nonce: Nonce::random(),
        },
        &d.out,
    )
    .unwrap_err();
    assert_eq!(e.code.code, "holger-sandbox-18");
}

#[test]
fn an_output_changed_after_the_run_is_refused() {
    let (r, d, s, t) = signed_copy();
    // The copy kept the input's read-only mode; the attacker here may write anyway.
    std::fs::remove_file(d.out.join("a")).unwrap();
    std::fs::write(d.out.join("a"), b"alphA").unwrap();
    let e = accept(&s, &r.verifying_key(), &t, &d.out).unwrap_err();
    assert_eq!(e.code.code, "holger-sandbox-19", "{e}");
    std::fs::remove_file(d.out.join("a")).unwrap();
    std::fs::write(d.out.join("a"), b"alpha").unwrap();
    std::fs::write(d.out.join("b"), b"planted").unwrap();
    assert_eq!(
        accept(&s, &r.verifying_key(), &t, &d.out)
            .unwrap_err()
            .code
            .code,
        "holger-sandbox-19"
    );
}

// ── the container runner ────────────────────────────────────────────────────

#[test]
fn the_job_reaches_the_worker_as_its_wire_document_under_its_own_limits() {
    let (r, seen) = runner(copy_a());
    let d = dirs(b"alpha");
    let mut j = job(&d);
    let id = j.id;
    j.policy.limits = heimdall_core::Limits {
        wall: std::time::Duration::from_secs(9),
        memory_bytes: 512 << 20,
        cpus: 0.5,
        pids: 32,
        ..Default::default()
    };
    let rules = tempfile::tempdir().unwrap();
    j.policy.read = vec![rules.path().to_path_buf()];
    r.run(j).unwrap();
    let (l, w) = seen.lock().unwrap()[0].clone();
    assert_eq!(
        (l.timeout_secs, l.memory_mib, l.cpu_millis, l.pids),
        (9, 512, 500, 32)
    );
    assert!(l.scratch_mib < l.memory_mib);
    assert_eq!(w.schema, JOB_SCHEMA);
    assert_eq!(w.job_id, id.to_hex());
    assert_eq!(w.argv, ["copy"]);
    assert_eq!(w.params, serde_json::json!({"x": 1}));
    assert_eq!(w.reads, 1);
}

#[test]
fn a_job_killed_by_a_limit_is_a_signed_refusal_and_no_engine_is_an_error() {
    let d = dirs(b"alpha");
    let sb = Sandbox::with_engine(
        SandboxConfig::default(),
        Box::new(Fails(Refusal::Timeout { secs: 3 })),
    )
    .unwrap();
    let r = ContainerRunner::with_key(sb, ResultKey::from_seed([2; 32]));
    let s = r.run(job(&d)).unwrap();
    let res = verify(&s, &r.verifying_key()).unwrap();
    assert_eq!(refused_code(&res.verdict), "holger-sandbox-2");
    assert!(res.exit.killed == Some(KillReason::Wall) && res.outputs.is_empty());

    let sb = Sandbox::with_engine(
        SandboxConfig::default(),
        Box::new(Fails(Refusal::Killed {
            exit: Some(137),
            oom: true,
            stderr: String::new(),
        })),
    )
    .unwrap();
    let r = ContainerRunner::with_key(sb, ResultKey::from_seed([2; 32]));
    let res = r.run(job(&dirs(b"a"))).unwrap().result;
    assert_eq!(refused_code(&res.verdict), "holger-sandbox-3");
    assert_eq!(
        res.exit,
        ExitInfo {
            code: Some(137),
            signal: None,
            killed: Some(KillReason::Memory)
        }
    );

    let sb = Sandbox::with_engine(
        SandboxConfig::default(),
        Box::new(Fails(Refusal::WorkerFailed {
            exit: Some(101),
            stderr: "panicked at the index".into(),
        })),
    )
    .unwrap();
    let r = ContainerRunner::with_key(sb, ResultKey::from_seed([2; 32]));
    let res = r.run(job(&dirs(b"a"))).unwrap().result;
    assert_eq!(
        refused_code(&res.verdict),
        "holger-sandbox-9",
        "a crashed worker fails only its job: {res:?}"
    );

    let sb = Sandbox::with_engine(
        SandboxConfig::default(),
        Box::new(Fails(Refusal::NoEngine("no podman".into()))),
    )
    .unwrap();
    let r = ContainerRunner::with_key(sb, ResultKey::from_seed([2; 32]));
    assert!(matches!(
        r.run(job(&dirs(b"a"))),
        Err(RunError::Setup { .. })
    ));
}

#[test]
fn a_wasm_job_a_network_a_foreign_image_or_a_used_output_is_refused_before_it_runs() {
    let (r, seen) = runner(copy_a());
    let d = dirs(b"a");
    let mut w = job(&d);
    w.kind = heimdall_core::JobKind::wasm(heimdall_core::GuestRef {
        path: "/g.wasm".into(),
        blake3: "00".into(),
    });
    assert!(matches!(r.run(w), Err(RunError::Unsupported { .. })));
    let mut n = job(&d);
    n.policy.net = Some(NetAllow {
        peers: vec![heimdall_core::NetPeer::new(
            "upstream.test",
            "10.0.0.1",
            443,
        )],
    });
    assert!(matches!(r.run(n), Err(RunError::Unsupported { .. })));
    let mut i = job(&d);
    i.kind = heimdall_core::JobKind::Native {
        image: heimdall_core::ImageRef {
            name: "docker.io/evil".into(),
            digest: String::new(),
        },
        argv: vec!["copy".into()],
    };
    assert!(matches!(r.run(i), Err(RunError::Unsupported { .. })));
    let mut wr = job(&d);
    wr.policy.write = vec!["/etc".into()];
    assert!(matches!(r.run(wr), Err(RunError::Unsupported { .. })));
    std::fs::write(d.out.join("old"), b"x").unwrap();
    assert!(matches!(r.run(job(&d)), Err(RunError::InvalidJob { .. })));
    assert!(seen.lock().unwrap().is_empty(), "nothing ran");
}

#[test]
fn the_interim_runner_says_what_it_does_not_have() {
    for (engine, runner, container, seccomp) in [
        (
            EngineKind::Podman,
            "holger-podman",
            true,
            Protection::Default,
        ),
        (EngineKind::Crun, "holger-crun", true, Protection::Default),
        (
            EngineKind::Remote,
            "holger-sidecar",
            false,
            Protection::None,
        ),
    ] {
        let f = ContainerRunner::features_of(engine);
        assert_eq!(
            f,
            Features {
                runner: runner.into(),
                container,
                seccomp,
                landlock: false,
                broker: false,
                wasm: false,
                image_verified_each_start: false
            },
            "{engine:?}"
        );
        let rows = feature_rows(&f);
        assert!(rows[0].2.contains("not Heimdall"), "{rows:?}");
        assert!(rows
            .iter()
            .any(|(k, has, w)| *k == "broker" && !has && w.contains("no syscall broker")));
        assert!(rows.iter().any(|(k, has, w)| *k == "seccomp"
            && !has
            && (w.contains("not one generated") || w.contains("no seccomp"))));
    }
}

/// Lane HOL-HEIMDALL-02: the producer (this runner's signing) and the verifier (`accept`)
/// are Heimdall 0.2 in ONE build: every result is the canonical wire v2
/// (`heimdall.signed-result.v2`), and a v1 result (Heimdall 0.1's) is refused.
#[test]
fn the_signed_result_is_heimdalls_wire_v2_and_a_v1_result_is_refused() {
    let (r, d, s, t) = signed_copy();
    assert_eq!(s.version, 2, "the producer signs Heimdall 0.2's wire v2");
    let bytes = s.to_bytes();
    assert_eq!(&bytes[..8], b"HMDL-SR\0");
    assert_eq!(
        &bytes[8..10],
        &2u16.to_be_bytes(),
        "the wire's version field"
    );
    assert!(accept(&s, &r.verifying_key(), &t, &d.out).is_ok());
    let mut v1 = s.clone();
    v1.version = 1;
    let refused = accept(&v1, &r.verifying_key(), &t, &d.out).unwrap_err();
    assert!(refused.why.contains('1'), "{refused}");
}

#[test]
fn a_result_names_the_job_and_the_features_that_protected_it() {
    let (r, d, s, t) = signed_copy();
    let JobResult {
        job_id,
        nonce,
        runner,
        ..
    } = verify(&s, &r.verifying_key()).unwrap().clone();
    assert_eq!((job_id, nonce), (t.job_id, t.nonce));
    assert_eq!(runner, ContainerRunner::features_of(EngineKind::Podman));
    let _ = d;
}

// ── the test runner ─────────────────────────────────────────────────────────

#[cfg(feature = "test-runner")]
#[test]
fn the_test_runner_runs_the_program_in_process_and_survives_its_panic() {
    let r = InProcessRunner::new(copy_a());
    let d = dirs(b"alpha");
    let j = job(&d);
    let t = j.ticket();
    let s = r.run(j).unwrap();
    let res = accept(&s, &r.verifying_key(), &t, &d.out).unwrap();
    assert_eq!(res.verdict, Verdict::Accepted);
    assert_eq!(res.runner.runner, "holger-in-process-test");
    assert!(!res.runner.container && res.runner.seccomp == Protection::None);

    let boom = InProcessRunner::new(Arc::new(|_: &WireJob, _: &Dirs| -> Answer {
        panic!("a malformed index")
    }));
    let d = dirs(b"alpha");
    let res = boom.run(job(&d)).unwrap().result;
    assert_eq!(refused_code(&res.verdict), "holger-sandbox-9", "{res:?}");
    assert!(
        matches!(&res.verdict, Verdict::Refused { reason, .. } if reason.contains("a malformed index"))
    );
}

#[test]
fn a_dirs_paths_follow_the_engine() {
    let c = Dirs::under(Path::new("/in"), 2);
    assert_eq!(c.input, Path::new("/in/i"));
    assert_eq!(c.read, [PathBuf::from("/in/r0"), PathBuf::from("/in/r1")]);
    assert_eq!(c.out, Path::new("/out"));
    let s = Dirs::under(Path::new("/run/holger-scan/holger-scan-x"), 0);
    assert_eq!(s.out, Path::new("/run/holger-scan/holger-scan-x/out"));
}