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