Gunnar

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