Gunnar

nordisk/heimdall

crates/heimdall-plain/src/outputs.rs

// SPDX-License-Identifier: AGPL-3.0-only
// Copyright (C) 2026 Ignalina ApS and Vetra Automation AB

//! After the job: hash and list what it left in `output_dir`.
//!
//! Heimdall is a defensive sandbox. An output is a regular file directly in
//! `output_dir` with a valid name (heimdall-core's `validate_output_name`).
//! Anything else there (a directory, a symlink, a fifo, a socket, a device, a
//! bad name, or a file beyond `max_output_files`) is an extra the runner
//! cannot vouch for: the run is refused with `heimdall-out-1`. Files over the
//! size limits refuse it with `heimdall-out-2`. Files are opened with
//! `O_NOFOLLOW` and never followed out of the directory.

use std::fs;
use std::io::Read;
use std::os::unix::fs::OpenOptionsExt;
use std::path::Path;

use heimdall_core::{codes, validate_output_name, Limits, Output, Sha256Digest};
use sha2::{Digest, Sha256};

/// A refusal decided while listing outputs: (code, reason).
pub type Refusal = (&'static str, String);

fn hash_file(path: &Path, max: u64) -> std::io::Result<Option<(Sha256Digest, u64)>> {
    let f = fs::OpenOptions::new()
        .read(true)
        .custom_flags(libc::O_NOFOLLOW)
        .open(path)?;
    if !f.metadata()?.file_type().is_file() {
        return Ok(None);
    }
    let mut h = Sha256::new();
    let mut n: u64 = 0;
    let mut buf = vec![0u8; 64 * 1024];
    let mut r = f.take(max.saturating_add(1));
    loop {
        let k = r.read(&mut buf)?;
        if k == 0 {
            break;
        }
        n += k as u64;
        h.update(&buf[..k]);
    }
    Ok(Some((Sha256Digest(h.finalize().into()), n)))
}

/// List the outputs in `dir`, sorted by name, or say why the run is refused.
pub fn scan(dir: &Path, limits: &Limits) -> Result<Vec<Output>, Refusal> {
    let io = |e: std::io::Error| (codes::OUT_EXTRA, format!("output_dir unreadable: {e}"));
    let mut names = Vec::new();
    for e in fs::read_dir(dir).map_err(io)? {
        let e = e.map_err(io)?;
        let name = e.file_name();
        let Some(name) = name.to_str().map(str::to_owned) else {
            return Err((
                codes::OUT_EXTRA,
                format!(
                    "extra entry with a non-UTF-8 name: {}",
                    name.to_string_lossy()
                ),
            ));
        };
        if let Err(err) = validate_output_name(&name) {
            return Err((
                codes::OUT_EXTRA,
                format!("extra entry `{}`: {err}", name.escape_debug()),
            ));
        }
        let ft = e.file_type().map_err(io)?;
        if !ft.is_file() {
            let kind = if ft.is_dir() {
                "a directory"
            } else if ft.is_symlink() {
                "a symlink"
            } else {
                "not a regular file"
            };
            return Err((
                codes::OUT_EXTRA,
                format!("extra entry `{}` is {kind}", name.escape_debug()),
            ));
        }
        names.push(name);
    }
    names.sort();
    if names.len() as u64 > u64::from(limits.max_output_files) {
        return Err((
            codes::OUT_EXTRA,
            format!(
                "{} files in output_dir, more than max_output_files {}",
                names.len(),
                limits.max_output_files
            ),
        ));
    }
    let mut total: u64 = 0;
    let mut out = Vec::with_capacity(names.len());
    for name in names {
        let p = dir.join(&name);
        let got = hash_file(&p, limits.max_output_file_bytes).map_err(|e| {
            (
                codes::OUT_EXTRA,
                format!("output `{}` unreadable: {e}", name.escape_debug()),
            )
        })?;
        let Some((sha256, bytes)) = got else {
            return Err((
                codes::OUT_EXTRA,
                format!(
                    "extra entry `{}` is not a regular file",
                    name.escape_debug()
                ),
            ));
        };
        if bytes > limits.max_output_file_bytes {
            return Err((
                codes::OUT_TOO_LARGE,
                format!(
                    "output `{}` is larger than max_output_file_bytes {}",
                    name.escape_debug(),
                    limits.max_output_file_bytes
                ),
            ));
        }
        total = total.saturating_add(bytes);
        if total > limits.max_output_total_bytes {
            return Err((
                codes::OUT_TOO_LARGE,
                format!(
                    "outputs exceed max_output_total_bytes {}",
                    limits.max_output_total_bytes
                ),
            ));
        }
        out.push(Output {
            name,
            sha256,
            bytes,
        });
    }
    Ok(out)
}