nordisk/heimdall
crates/heimdall-core/tests/outputs.rs
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright (C) 2026 Ignalina ApS and Vetra Automation AB
//! The caller accepts only the listed outputs with matching hashes (spec §7).
//! Heimdall is a defensive sandbox; these tests use private temp dirs only.
mod common;
use common::*;
use heimdall_core::*;
use std::fs;
use std::path::PathBuf;
struct TempDir(PathBuf);
impl TempDir {
fn new(tag: &str) -> Self {
let p = std::env::temp_dir().join(format!(
"heimdall-core-test-{}-{}-{}",
tag,
std::process::id(),
JobId::random().to_hex()
));
fs::create_dir_all(&p).unwrap();
Self(p)
}
}
impl Drop for TempDir {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.0);
}
}
fn populated(tag: &str) -> TempDir {
let d = TempDir::new(tag);
fs::write(d.0.join("report.json"), b"{\"ok\":true}").unwrap();
fs::write(d.0.join("index.bin"), [0u8, 1, 2, 3, 255]).unwrap();
d
}
#[test]
fn listed_outputs_with_matching_hashes_are_accepted_in_list_order() {
let d = populated("ok");
let got = check_output_files(&sample_result(), &d.0).unwrap();
assert_eq!(got, vec![d.0.join("report.json"), d.0.join("index.bin")]);
}
#[test]
fn empty_out_dir_with_no_listed_outputs_is_accepted() {
let d = TempDir::new("empty");
assert!(check_output_files(&refused_result(), &d.0)
.unwrap()
.is_empty());
}
#[test]
fn changed_output_content_is_refused_as_hash_mismatch() {
let d = populated("hash");
fs::write(d.0.join("report.json"), b"{\"ok\":fals}").unwrap(); // same length
assert!(matches!(
check_output_files(&sample_result(), &d.0).unwrap_err(),
OutputError::HashMismatch { name, .. } if name == "report.json"
));
}
#[test]
fn appended_output_is_refused_as_length_mismatch() {
let d = populated("len");
fs::write(d.0.join("index.bin"), [0u8, 1, 2, 3, 255, 0]).unwrap();
assert!(matches!(
check_output_files(&sample_result(), &d.0).unwrap_err(),
OutputError::LengthMismatch {
expected: 5,
got: 6,
..
}
));
}
#[test]
fn missing_listed_output_is_refused() {
let d = populated("missing");
fs::remove_file(d.0.join("index.bin")).unwrap();
assert!(matches!(
check_output_files(&sample_result(), &d.0).unwrap_err(),
OutputError::Missing(n) if n == "index.bin"
));
}
#[test]
fn unlisted_extra_file_is_refused() {
let d = populated("extra");
fs::write(d.0.join("smuggled.sh"), b"#!/bin/sh").unwrap();
assert!(matches!(
check_output_files(&sample_result(), &d.0).unwrap_err(),
OutputError::Unlisted(n) if n == "smuggled.sh"
));
}
#[test]
fn unlisted_extra_directory_is_refused() {
let d = populated("extradir");
fs::create_dir(d.0.join("sub")).unwrap();
assert!(matches!(
check_output_files(&sample_result(), &d.0).unwrap_err(),
OutputError::Unlisted(n) if n == "sub"
));
}
#[cfg(unix)]
#[test]
fn listed_output_that_is_a_symlink_is_refused_even_if_target_matches() {
let d = populated("symlink");
let target = TempDir::new("symlink-target");
fs::write(target.0.join("r"), b"{\"ok\":true}").unwrap();
fs::remove_file(d.0.join("report.json")).unwrap();
std::os::unix::fs::symlink(target.0.join("r"), d.0.join("report.json")).unwrap();
assert!(matches!(
check_output_files(&sample_result(), &d.0).unwrap_err(),
OutputError::NotRegularFile(n) if n == "report.json"
));
}
#[test]
fn listed_output_that_is_a_directory_is_refused() {
let d = populated("isdir");
fs::remove_file(d.0.join("index.bin")).unwrap();
fs::create_dir(d.0.join("index.bin")).unwrap();
assert!(matches!(
check_output_files(&sample_result(), &d.0).unwrap_err(),
OutputError::NotRegularFile(n) if n == "index.bin"
));
}
#[test]
fn traversal_name_in_an_unverified_result_is_refused_before_any_file_access() {
let d = populated("trav");
let mut r = sample_result();
r.outputs.push(Output::of("../report.json", b"x"));
assert!(matches!(
check_output_files(&r, &d.0).unwrap_err(),
OutputError::BadName { .. }
));
}
#[test]
fn absolute_name_is_refused() {
let d = populated("abs");
let mut r = sample_result();
r.outputs = vec![Output::of("/etc/passwd", b"x")];
assert!(matches!(
check_output_files(&r, &d.0).unwrap_err(),
OutputError::BadName { .. }
));
}
#[test]
fn missing_out_dir_is_refused() {
let d = TempDir::new("gone");
let p = d.0.join("nope");
assert!(check_output_files(&sample_result(), &p).is_err());
}
#[test]
fn check_output_bytes_accepts_matching_bytes() {
check_output_bytes(&Output::of("a", b"hello"), b"hello").unwrap();
}
#[test]
fn check_output_bytes_refuses_one_flipped_bit() {
assert!(matches!(
check_output_bytes(&Output::of("a", b"hello"), b"hellp").unwrap_err(),
OutputError::HashMismatch { .. }
));
}
#[test]
fn check_output_bytes_refuses_truncated_bytes() {
assert!(matches!(
check_output_bytes(&Output::of("a", b"hello"), b"hell").unwrap_err(),
OutputError::LengthMismatch { .. }
));
}
#[test]
fn check_output_bytes_refuses_a_bad_name() {
assert!(matches!(
check_output_bytes(&Output::of("..", b""), b"").unwrap_err(),
OutputError::BadName { .. }
));
}
#[test]
fn end_to_end_verified_result_then_outputs_checked() {
let d = populated("e2e");
let mut v = verifier_a();
let r = v.accept(&signed_wire(), &ticket()).unwrap();
assert_eq!(check_output_files(&r, &d.0).unwrap().len(), 2);
}
#[test]
fn check_outputs_wraps_the_output_error_in_verify_error() {
let d = populated("wrap");
fs::write(d.0.join("smuggled.sh"), b"#!/bin/sh").unwrap();
assert_eq!(
check_outputs(&sample_result(), &d.0),
Err(VerifyError::Output(OutputError::Unlisted(
"smuggled.sh".into()
)))
);
}
#[test]
fn check_outputs_is_ok_for_a_matching_dir() {
let d = populated("okwrap");
assert_eq!(check_outputs(&sample_result(), &d.0), Ok(()));
}