Gunnar

nordisk/heimdall

crates/heimdall-cli/tests/cli.rs

// SPDX-License-Identifier: AGPL-3.0-only
//! The `heimdall` binary: verbs, exit codes and `verify-result` against real
//! signed results. Heimdall is a defensive sandbox; every test works inside
//! its own temporary directory.

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

use heimdall_core::{BootKey, Params};
use heimdall_run::outdir::{FILES_DIR, RESULT_FILE};
use heimdall_run::{run_job, GuestRun, GuestRunner, GuestVerdict, JobSpec, PolicyFile, RunLimits};

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

fn heimdall(args: &[&str]) -> Output {
    Command::new(env!("CARGO_BIN_EXE_heimdall"))
        .args(args)
        .output()
        .unwrap()
}

fn code(o: &Output) -> i32 {
    o.status.code().expect("exited by signal")
}

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

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

struct Fixed(GuestVerdict, Vec<(String, Vec<u8>)>);

impl GuestRunner for Fixed {
    fn run_guest(&self, _: &[u8], _: &Params, _: &RunLimits) -> GuestRun {
        GuestRun {
            verdict: self.0.clone(),
            files: self.1.clone(),
            fuel_used: 1,
        }
    }
}

/// A finished job on disk: `out/result.signed`, `out/files/`, `key.pub`.
struct Done {
    _tmp: tempfile::TempDir,
    root: PathBuf,
    spec: JobSpec,
}

impl Done {
    fn result(&self) -> String {
        self.root
            .join("out")
            .join(RESULT_FILE)
            .display()
            .to_string()
    }
    fn key(&self) -> String {
        self.root.join("key.pub").display().to_string()
    }
    fn files(&self) -> PathBuf {
        self.root.join("out").join(FILES_DIR)
    }
}

fn done_with(verdict: GuestVerdict, files: &[(&str, &[u8])]) -> Done {
    let tmp = tempfile::tempdir().unwrap();
    let root = tmp.path().to_path_buf();
    std::fs::create_dir(root.join("in")).unwrap();
    std::fs::write(root.join("in/x"), b"input").unwrap();
    let spec = JobSpec::new(
        &root.join("in"),
        &root.join("out"),
        PolicyFile::default(),
        Params::new(),
    );
    let key = BootKey::generate();
    std::fs::write(root.join("key.pub"), key.pinned().to_text()).unwrap();
    let g = Fixed(
        verdict,
        files
            .iter()
            .map(|(n, b)| (n.to_string(), b.to_vec()))
            .collect(),
    );
    run_job(&spec, &g, &key).unwrap();
    Done {
        _tmp: tmp,
        root,
        spec,
    }
}

fn accepted() -> Done {
    done_with(
        GuestVerdict::Accepted {
            note: "fine".into(),
        },
        &[("report.txt", b"all good")],
    )
}

// ---- verbs that are not in milestone 1 ----

#[test]
fn version_flag_exits_zero() {
    assert_eq!(code(&heimdall(&["--version"])), OK);
}

#[test]
fn help_says_heimdall_is_a_defensive_sandbox() {
    let o = heimdall(&["--help"]);
    assert_eq!(code(&o), OK);
    assert!(stdout(&o).contains("defensive sandbox"));
}

#[test]
fn profile_probe_says_not_in_milestone_1_and_exits_2() {
    let o = heimdall(&["profile", "probe", "clamav"]);
    assert_eq!(code(&o), USAGE);
    assert!(stderr(&o).contains("not in milestone 1"));
}

#[test]
fn profile_gen_and_check_say_not_in_milestone_1() {
    for verb in ["gen", "check"] {
        let o = heimdall(&["profile", verb, "w"]);
        assert_eq!(code(&o), USAGE, "{verb}");
        assert!(stderr(&o).contains("not in milestone 1"), "{verb}");
    }
}

#[test]
fn native_job_says_not_in_milestone_1_and_creates_no_out_dir() {
    let d = tempfile::tempdir().unwrap();
    let out = d.path().join("out");
    let o = heimdall(&[
        "run",
        "--job",
        "native",
        "--in",
        ".",
        "--out",
        out.to_str().unwrap(),
    ]);
    assert_eq!(code(&o), USAGE);
    assert!(stderr(&o).contains("not in milestone 1"));
    assert!(!out.exists());
}

#[test]
fn no_subcommand_is_a_usage_error() {
    assert_eq!(code(&heimdall(&[])), USAGE);
}

// ---- run: refusals before anything runs ----

#[test]
fn run_wasm_without_module_is_a_usage_error() {
    let o = heimdall(&["run", "--job", "wasm", "--in", "i", "--out", "o"]);
    assert_eq!(code(&o), USAGE);
    assert!(stderr(&o).contains("--module"));
}

#[test]
fn run_with_the_same_param_twice_is_a_usage_error() {
    let o = heimdall(&[
        "run", "--job", "wasm", "--module", "g.wasm", "--in", "i", "--out", "o", "--param", "a=1",
        "--param", "a=2",
    ]);
    assert_eq!(code(&o), USAGE);
}

#[test]
fn run_with_a_bad_job_id_is_a_usage_error() {
    let o = heimdall(&[
        "run", "--job", "wasm", "--module", "g", "--in", "i", "--out", "o", "--job-id", "xyz",
    ]);
    assert_eq!(code(&o), USAGE);
}

#[test]
fn run_with_a_widening_policy_exits_3_before_anything_runs() {
    let d = tempfile::tempdir().unwrap();
    let p = d.path().join("p.toml");
    std::fs::write(&p, "version = 1\n[net]\nhost = \"x\"\nport = 1\n").unwrap();
    let out = d.path().join("out");
    let o = heimdall(&[
        "run",
        "--job",
        "wasm",
        "--module",
        "g.wasm",
        "--in",
        ".",
        "--out",
        out.to_str().unwrap(),
        "--policy",
        p.to_str().unwrap(),
    ]);
    assert_eq!(code(&o), HOST);
    assert!(stderr(&o).contains("net"));
    assert!(!out.exists());
}

#[test]
fn run_without_a_trust_root_exits_3_and_creates_no_out_dir() {
    let d = tempfile::tempdir().unwrap();
    let out = d.path().join("out");
    let o = heimdall(&[
        "run",
        "--job",
        "wasm",
        "--module",
        "g.wasm",
        "--in",
        ".",
        "--out",
        out.to_str().unwrap(),
    ]);
    assert_eq!(code(&o), HOST);
    assert!(stderr(&o).contains("trust-root"));
    assert!(!out.exists());
}

// ---- verify-result ----

#[test]
fn verify_good_accepted_result_exits_0_and_checks_the_outputs() {
    let d = accepted();
    let o = heimdall(&["verify-result", &d.result(), "--key", &d.key()]);
    assert_eq!(code(&o), OK, "{}", stderr(&o));
    let s = stdout(&o);
    assert!(s.contains("verdict: accepted"));
    assert!(s.contains("outputs: 1 checked"));
}

#[test]
fn verify_accepts_the_pinned_record_inline() {
    let d = accepted();
    let inline = std::fs::read_to_string(d.key()).unwrap();
    let o = heimdall(&["verify-result", &d.result(), "--key", inline.trim_end()]);
    assert_eq!(code(&o), OK, "{}", stderr(&o));
}

#[test]
fn verify_signed_refused_result_exits_4_with_the_reason() {
    let d = done_with(
        GuestVerdict::Refused {
            code: "test-guest".into(),
            reason: "bad signature".into(),
        },
        &[],
    );
    let o = heimdall(&["verify-result", &d.result(), "--key", &d.key()]);
    assert_eq!(code(&o), REFUSED);
    assert!(stdout(&o).contains("refused: bad signature"));
}

#[test]
fn verify_with_another_key_exits_1() {
    let d = accepted();
    let other = d.root.join("other.pub");
    std::fs::write(&other, BootKey::generate().pinned().to_text()).unwrap();
    let o = heimdall(&[
        "verify-result",
        &d.result(),
        "--key",
        other.to_str().unwrap(),
    ]);
    assert_eq!(code(&o), REJECTED);
    assert!(stderr(&o).contains("pinned key"));
}

#[test]
fn verify_bit_flipped_result_exits_1() {
    let d = accepted();
    let mut w = std::fs::read(d.result()).unwrap();
    let mid = w.len() / 2;
    w[mid] ^= 0x01;
    std::fs::write(d.result(), &w).unwrap();
    assert_eq!(
        code(&heimdall(&[
            "verify-result",
            &d.result(),
            "--key",
            &d.key()
        ])),
        REJECTED
    );
}

#[test]
fn verify_truncated_result_exits_1() {
    let d = accepted();
    let w = std::fs::read(d.result()).unwrap();
    std::fs::write(d.result(), &w[..w.len() - 1]).unwrap();
    assert_eq!(
        code(&heimdall(&[
            "verify-result",
            &d.result(),
            "--key",
            &d.key()
        ])),
        REJECTED
    );
}

#[test]
fn verify_forged_signature_exits_1() {
    let d = accepted();
    let mut w = std::fs::read(d.result()).unwrap();
    let n = w.len();
    w[n - 64..].copy_from_slice(&[0x42; 64]);
    std::fs::write(d.result(), &w).unwrap();
    assert_eq!(
        code(&heimdall(&[
            "verify-result",
            &d.result(),
            "--key",
            &d.key()
        ])),
        REJECTED
    );
}

#[test]
fn verify_result_with_a_changed_output_file_exits_1() {
    let d = accepted();
    std::fs::write(d.files().join("report.txt"), b"all bad").unwrap();
    let o = heimdall(&["verify-result", &d.result(), "--key", &d.key()]);
    assert_eq!(code(&o), REJECTED);
    assert!(stderr(&o).contains("outputs refused"));
}

#[test]
fn verify_result_with_a_planted_extra_file_exits_1() {
    let d = accepted();
    std::fs::write(d.files().join("payload.sh"), b"x").unwrap();
    assert_eq!(
        code(&heimdall(&[
            "verify-result",
            &d.result(),
            "--key",
            &d.key()
        ])),
        REJECTED
    );
}

#[test]
fn verify_with_explicit_files_dir_checks_that_dir() {
    let d = accepted();
    let elsewhere = d.root.join("copy");
    std::fs::create_dir(&elsewhere).unwrap();
    std::fs::copy(d.files().join("report.txt"), elsewhere.join("report.txt")).unwrap();
    let o = heimdall(&[
        "verify-result",
        &d.result(),
        "--key",
        &d.key(),
        "--files",
        elsewhere.to_str().unwrap(),
    ]);
    assert_eq!(code(&o), OK, "{}", stderr(&o));
    std::fs::write(elsewhere.join("report.txt"), b"x").unwrap();
    let o = heimdall(&[
        "verify-result",
        &d.result(),
        "--key",
        &d.key(),
        "--files",
        elsewhere.to_str().unwrap(),
    ]);
    assert_eq!(code(&o), REJECTED);
}

#[test]
fn verify_with_the_right_ticket_exits_0() {
    let d = accepted();
    let (id, nonce) = (d.spec.job_id.to_hex(), d.spec.nonce.to_hex());
    let o = heimdall(&[
        "verify-result",
        &d.result(),
        "--key",
        &d.key(),
        "--job-id",
        &id,
        "--nonce",
        &nonce,
    ]);
    assert_eq!(code(&o), OK, "{}", stderr(&o));
}

#[test]
fn verify_with_another_nonce_exits_1() {
    let d = accepted();
    let id = d.spec.job_id.to_hex();
    let nonce = heimdall_core::Nonce::random().to_hex();
    let o = heimdall(&[
        "verify-result",
        &d.result(),
        "--key",
        &d.key(),
        "--job-id",
        &id,
        "--nonce",
        &nonce,
    ]);
    assert_eq!(code(&o), REJECTED);
}

#[test]
fn verify_with_job_id_but_no_nonce_is_a_usage_error() {
    let d = accepted();
    let id = d.spec.job_id.to_hex();
    assert_eq!(
        code(&heimdall(&[
            "verify-result",
            &d.result(),
            "--key",
            &d.key(),
            "--job-id",
            &id
        ])),
        USAGE
    );
}

#[test]
fn verify_with_a_garbage_key_file_exits_3() {
    let d = accepted();
    let k = d.root.join("bad.pub");
    std::fs::write(&k, b"not a key").unwrap();
    assert_eq!(
        code(&heimdall(&[
            "verify-result",
            &d.result(),
            "--key",
            k.to_str().unwrap()
        ])),
        HOST
    );
}

#[test]
fn verify_with_a_missing_key_file_exits_3() {
    let d = accepted();
    assert_eq!(
        code(&heimdall(&[
            "verify-result",
            &d.result(),
            "--key",
            "/nonexistent/key.pub"
        ])),
        HOST
    );
}

#[test]
fn verify_of_a_missing_result_file_exits_3() {
    let d = accepted();
    assert_eq!(
        code(&heimdall(&[
            "verify-result",
            "/nonexistent/result.signed",
            "--key",
            &d.key()
        ])),
        HOST
    );
}

#[test]
fn verify_of_a_directory_exits_3() {
    let d = accepted();
    assert_eq!(
        code(&heimdall(&[
            "verify-result",
            d.root.to_str().unwrap(),
            "--key",
            &d.key()
        ])),
        HOST
    );
}

#[test]
fn verify_of_an_oversized_result_file_exits_3_without_reading_it_all() {
    let d = accepted();
    let big = d.root.join("big.signed");
    let f = std::fs::File::create(&big).unwrap();
    f.set_len(heimdall_core::MAX_WIRE as u64 + 1).unwrap();
    assert_eq!(
        code(&heimdall(&[
            "verify-result",
            big.to_str().unwrap(),
            "--key",
            &d.key()
        ])),
        HOST
    );
}

#[test]
fn verify_of_an_empty_file_exits_1() {
    let d = accepted();
    let empty = d.root.join("empty.signed");
    std::fs::write(&empty, b"").unwrap();
    assert_eq!(
        code(&heimdall(&[
            "verify-result",
            empty.to_str().unwrap(),
            "--key",
            &d.key()
        ])),
        REJECTED
    );
}