// 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)
}