Gunnar

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