nordisk/heimdall
crates/heimdall-run/tests/outdir.rs
// SPDX-License-Identifier: AGPL-3.0-only
//! The out-dir rules: fresh, no overwrite, no symlinks, no path traversal.
//! Heimdall is a defensive sandbox; every test works inside its own temporary
//! directory and checks that nothing escapes it.
use std::os::unix::fs::PermissionsExt;
use std::path::Path;
use heimdall_run::outdir::{validate_output_name_bytes, FILES_DIR, MAX_NAME_BYTES, RESULT_FILE};
use heimdall_run::{validate_output_name, OutDir, OutDirError, OutputCaps};
use proptest::prelude::*;
use sha2::{Digest, Sha256};
const CAPS: OutputCaps = OutputCaps {
max_files: 4,
max_file_bytes: 100,
max_total_bytes: 150,
};
fn fresh() -> (tempfile::TempDir, OutDir) {
let d = tempfile::tempdir().unwrap();
let o = OutDir::create(&d.path().join("out"), CAPS).unwrap();
(d, o)
}
fn entries(p: &Path) -> Vec<String> {
let mut v: Vec<String> = std::fs::read_dir(p)
.unwrap()
.map(|e| e.unwrap().file_name().to_string_lossy().into_owned())
.collect();
v.sort();
v
}
#[test]
fn create_makes_the_out_dir_and_its_files_subdir_mode_0700() {
let (_d, o) = fresh();
assert!(o.root().is_dir());
assert!(o.root().join(FILES_DIR).is_dir());
for p in [o.root().to_path_buf(), o.root().join(FILES_DIR)] {
assert_eq!(
std::fs::metadata(&p).unwrap().permissions().mode() & 0o777,
0o700
);
}
}
#[test]
fn existing_empty_dir_is_refused_as_not_fresh() {
let d = tempfile::tempdir().unwrap();
let p = d.path().join("out");
std::fs::create_dir(&p).unwrap();
assert_eq!(
OutDir::create(&p, CAPS).unwrap_err(),
OutDirError::AlreadyExists(p)
);
}
#[test]
fn existing_file_at_out_path_is_refused_and_left_untouched() {
let d = tempfile::tempdir().unwrap();
let p = d.path().join("out");
std::fs::write(&p, b"keep").unwrap();
assert!(matches!(
OutDir::create(&p, CAPS),
Err(OutDirError::AlreadyExists(_))
));
assert_eq!(std::fs::read(&p).unwrap(), b"keep");
}
#[test]
fn symlink_at_out_path_is_refused_even_when_it_points_to_a_dir() {
let d = tempfile::tempdir().unwrap();
let target = d.path().join("elsewhere");
std::fs::create_dir(&target).unwrap();
let p = d.path().join("out");
std::os::unix::fs::symlink(&target, &p).unwrap();
assert!(matches!(
OutDir::create(&p, CAPS),
Err(OutDirError::AlreadyExists(_))
));
assert!(entries(&target).is_empty());
}
#[test]
fn dangling_symlink_at_out_path_is_refused_and_its_target_never_created() {
let d = tempfile::tempdir().unwrap();
let target = d.path().join("would-be-created");
let p = d.path().join("out");
std::os::unix::fs::symlink(&target, &p).unwrap();
assert!(matches!(
OutDir::create(&p, CAPS),
Err(OutDirError::AlreadyExists(_))
));
assert!(!target.exists());
}
#[test]
fn missing_parent_is_refused_and_not_created() {
let d = tempfile::tempdir().unwrap();
let p = d.path().join("no/such/parent/out");
assert!(matches!(
OutDir::create(&p, CAPS),
Err(OutDirError::ParentMissing(_))
));
assert!(!d.path().join("no").exists());
}
#[test]
fn out_path_ending_in_dot_dot_is_refused() {
let d = tempfile::tempdir().unwrap();
assert!(matches!(
OutDir::create(&d.path().join(".."), CAPS),
Err(OutDirError::BadPath(_))
));
}
#[test]
fn root_path_is_refused() {
assert!(OutDir::create(Path::new("/"), CAPS).is_err());
}
#[test]
fn written_file_lands_in_files_subdir_with_mode_0600_and_right_bytes() {
let (_d, mut o) = fresh();
o.write_output("report.json", b"{}").unwrap();
let p = o.root().join(FILES_DIR).join("report.json");
assert_eq!(std::fs::read(&p).unwrap(), b"{}");
assert_eq!(
std::fs::metadata(&p).unwrap().permissions().mode() & 0o777,
0o600
);
}
#[test]
fn written_output_records_the_sha256_of_its_bytes() {
let (_d, mut o) = fresh();
let w = o.write_output("a", b"hello").unwrap().clone();
let want: [u8; 32] = Sha256::digest(b"hello").into();
assert_eq!(w.sha256, want);
assert_eq!(w.len, 5);
}
#[test]
fn empty_output_file_is_allowed_and_hashed() {
let (_d, mut o) = fresh();
let w = o.write_output("empty", b"").unwrap().clone();
let want: [u8; 32] = Sha256::digest(b"").into();
assert_eq!(w.sha256, want);
}
#[test]
fn outputs_are_listed_in_emit_order() {
let (_d, mut o) = fresh();
o.write_output("b", b"2").unwrap();
o.write_output("a", b"1").unwrap();
o.write_output("c", b"3").unwrap();
let names: Vec<_> = o.outputs().into_iter().map(|w| w.name).collect();
assert_eq!(names, ["b", "a", "c"]);
}
#[test]
fn second_write_of_same_name_is_refused_and_first_bytes_kept() {
let (_d, mut o) = fresh();
o.write_output("x", b"first").unwrap();
assert_eq!(
o.write_output("x", b"second").unwrap_err(),
OutDirError::Duplicate("x".into())
);
assert_eq!(
std::fs::read(o.root().join(FILES_DIR).join("x")).unwrap(),
b"first"
);
}
#[test]
fn symlink_planted_in_files_dir_is_never_followed() {
let (d, mut o) = fresh();
let victim = d.path().join("victim");
std::fs::write(&victim, b"untouched").unwrap();
std::os::unix::fs::symlink(&victim, o.root().join(FILES_DIR).join("evil")).unwrap();
assert!(o.write_output("evil", b"pwned").is_err());
assert_eq!(std::fs::read(&victim).unwrap(), b"untouched");
}
#[test]
fn dangling_symlink_planted_in_files_dir_does_not_create_its_target() {
let (d, mut o) = fresh();
let target = d.path().join("created-through-link");
std::os::unix::fs::symlink(&target, o.root().join(FILES_DIR).join("evil")).unwrap();
assert!(o.write_output("evil", b"x").is_err());
assert!(!target.exists());
}
#[test]
fn name_with_slash_is_refused() {
assert!(matches!(
validate_output_name("a/b"),
Err(OutDirError::NameBadChar(_))
));
}
#[test]
fn name_dot_dot_is_refused() {
assert!(matches!(
validate_output_name(".."),
Err(OutDirError::NameLeadingDot(_))
));
}
#[test]
fn name_dot_is_refused() {
assert!(matches!(
validate_output_name("."),
Err(OutDirError::NameLeadingDot(_))
));
}
#[test]
fn traversal_name_is_refused_and_nothing_written_outside() {
let (d, mut o) = fresh();
assert!(o.write_output("../../escaped", b"x").is_err());
assert!(!d.path().join("escaped").exists());
assert!(entries(&o.root().join(FILES_DIR)).is_empty());
}
#[test]
fn absolute_name_is_refused() {
assert!(validate_output_name("/etc/passwd").is_err());
}
#[test]
fn hidden_name_is_refused() {
assert!(matches!(
validate_output_name(".bashrc"),
Err(OutDirError::NameLeadingDot(_))
));
}
#[test]
fn backslash_name_is_refused() {
assert!(validate_output_name("..\\x").is_err());
}
#[test]
fn nul_byte_in_name_is_refused() {
assert!(validate_output_name("a\0b").is_err());
}
#[test]
fn newline_and_control_chars_in_name_are_refused() {
assert!(validate_output_name("a\nb").is_err());
assert!(validate_output_name("a\x1bb").is_err());
}
#[test]
fn space_in_name_is_refused() {
assert!(validate_output_name("a b").is_err());
}
#[test]
fn unicode_lookalike_slash_is_refused() {
assert!(validate_output_name("a\u{2215}b").is_err());
}
#[test]
fn empty_name_is_refused() {
assert_eq!(validate_output_name(""), Err(OutDirError::NameEmpty));
}
#[test]
fn name_of_max_length_is_accepted_and_one_more_is_refused() {
assert!(validate_output_name(&"a".repeat(MAX_NAME_BYTES)).is_ok());
assert_eq!(
validate_output_name(&"a".repeat(MAX_NAME_BYTES + 1)),
Err(OutDirError::NameTooLong)
);
}
#[test]
fn ordinary_names_are_accepted() {
for n in [
"report.json",
"SBOM_v1-2.cdx",
"a",
"0",
"x..y",
"trailing.",
"c++.txt",
"a+b",
] {
assert!(validate_output_name(n).is_ok(), "{n}");
}
}
#[test]
fn invalid_utf8_name_bytes_are_refused() {
assert_eq!(
validate_output_name_bytes(b"a\xffb"),
Err(OutDirError::NameNotUtf8)
);
}
#[test]
fn valid_name_bytes_are_returned_as_str() {
assert_eq!(validate_output_name_bytes(b"ok.txt").unwrap(), "ok.txt");
}
#[test]
fn file_count_cap_holds() {
let (_d, mut o) = fresh();
for i in 0..CAPS.max_files {
o.write_output(&format!("f{i}"), b"").unwrap();
}
assert_eq!(
o.write_output("one-more", b"").unwrap_err(),
OutDirError::TooManyFiles(CAPS.max_files)
);
assert!(!o.root().join(FILES_DIR).join("one-more").exists());
}
#[test]
fn single_file_size_cap_holds_and_nothing_is_written() {
let (_d, mut o) = fresh();
let big = vec![0u8; CAPS.max_file_bytes as usize + 1];
assert!(matches!(
o.write_output("big", &big),
Err(OutDirError::FileTooLarge { .. })
));
assert!(!o.root().join(FILES_DIR).join("big").exists());
}
#[test]
fn file_exactly_at_size_cap_is_accepted() {
let (_d, mut o) = fresh();
o.write_output("exact", &vec![1u8; CAPS.max_file_bytes as usize])
.unwrap();
}
#[test]
fn total_size_cap_holds_across_files() {
let (_d, mut o) = fresh();
o.write_output("a", &[0u8; 100]).unwrap();
assert_eq!(
o.write_output("b", &[0u8; 51]).unwrap_err(),
OutDirError::TotalTooLarge(150)
);
o.write_output("c", &[0u8; 50]).unwrap();
}
#[test]
fn audit_passes_for_untouched_outputs() {
let (_d, mut o) = fresh();
o.write_output("a", b"1").unwrap();
o.audit().unwrap();
}
#[test]
fn audit_catches_a_foreign_file_in_files_dir() {
let (_d, mut o) = fresh();
o.write_output("a", b"1").unwrap();
std::fs::write(o.root().join(FILES_DIR).join("sneaky"), b"x").unwrap();
assert_eq!(o.audit(), Err(OutDirError::Foreign("sneaky".into())));
}
#[test]
fn audit_catches_a_changed_output() {
let (_d, mut o) = fresh();
o.write_output("a", b"1").unwrap();
let p = o.root().join(FILES_DIR).join("a");
std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o600)).unwrap();
std::fs::write(&p, b"2").unwrap();
assert_eq!(o.audit(), Err(OutDirError::Foreign("a".into())));
}
#[test]
fn audit_catches_a_removed_output() {
let (_d, mut o) = fresh();
o.write_output("a", b"1").unwrap();
std::fs::remove_file(o.root().join(FILES_DIR).join("a")).unwrap();
assert!(o.audit().is_err());
}
#[test]
fn discard_removes_every_output_and_resets_counts() {
let (_d, mut o) = fresh();
o.write_output("a", &[0u8; 100]).unwrap();
o.write_output("b", b"2").unwrap();
o.discard_outputs().unwrap();
assert!(entries(&o.root().join(FILES_DIR)).is_empty());
assert!(o.outputs().is_empty());
o.write_output("c", &[0u8; 100]).unwrap();
}
#[test]
fn result_file_is_written_once_and_never_overwritten() {
let (_d, o) = fresh();
let p = o.write_result(b"signed").unwrap();
assert_eq!(p, o.root().join(RESULT_FILE));
assert!(o.write_result(b"again").is_err());
assert_eq!(std::fs::read(&p).unwrap(), b"signed");
}
#[test]
fn result_file_symlink_planted_beforehand_is_not_followed() {
let (d, o) = fresh();
let victim = d.path().join("victim");
std::fs::write(&victim, b"untouched").unwrap();
std::os::unix::fs::symlink(&victim, o.root().join(RESULT_FILE)).unwrap();
assert!(o.write_result(b"x").is_err());
assert_eq!(std::fs::read(&victim).unwrap(), b"untouched");
}
#[test]
fn guest_output_named_like_the_result_file_cannot_collide_with_it() {
let (_d, mut o) = fresh();
o.write_output(RESULT_FILE, b"guest").unwrap();
o.write_result(b"real").unwrap();
assert_eq!(std::fs::read(o.root().join(RESULT_FILE)).unwrap(), b"real");
}
proptest! {
#[test]
fn prop_any_accepted_name_is_one_plain_component(name in "\\PC{0,140}") {
if validate_output_name(&name).is_ok() {
let p = Path::new(&name);
let comps: Vec<_> = p.components().collect();
prop_assert_eq!(comps.len(), 1);
prop_assert!(matches!(comps[0], std::path::Component::Normal(_)));
prop_assert!(!name.starts_with('.'));
}
}
#[test]
fn prop_arbitrary_name_bytes_never_panic(bytes in proptest::collection::vec(any::<u8>(), 0..200)) {
let _ = validate_output_name_bytes(&bytes);
}
#[test]
fn prop_writes_never_leave_the_files_dir(name in "[./a-z\\\\]{1,12}") {
let d = tempfile::tempdir().unwrap();
let mut o = OutDir::create(&d.path().join("out"), CAPS).unwrap();
let _ = o.write_output(&name, b"x");
prop_assert_eq!(entries(d.path()), vec!["out".to_string()]);
let mut top = entries(o.root());
top.retain(|e| e != FILES_DIR);
prop_assert!(top.is_empty());
}
}