// SPDX-License-Identifier: AGPL-3.0-only
// Copyright (C) 2026 Ignalina ApS and Vetra Automation AB
//! The caller accepts only the outputs a verified result lists, with matching
//! SHA-256 and length (spec §7). Anything else in out/ is refused: a missing
//! file, a changed file, a symlink or directory, or a file the result does not list.
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::types::*;
/// Why an output is refused.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, thiserror::Error)]
pub enum OutputError {
#[error("output `{name}`: {err}")]
BadName { name: String, err: OutputNameError },
#[error("listed output `{0}` is missing")]
Missing(String),
#[error("output `{0}` is not a regular file")]
NotRegularFile(String),
#[error("output `{name}` is {got} bytes, the result says {expected}")]
LengthMismatch {
name: String,
expected: u64,
got: u64,
},
#[error("output `{name}` has sha256 {got}, the result says {expected}")]
HashMismatch {
name: String,
expected: Sha256Digest,
got: Sha256Digest,
},
#[error("`{0}` is in the output directory but not in the result")]
Unlisted(String),
#[error("output `{name}`: {err}")]
Io { name: String, err: String },
}
fn check_name(entry: &Output) -> Result<(), OutputError> {
validate_output_name(&entry.name).map_err(|err| OutputError::BadName {
name: entry.name.clone(),
err,
})
}
/// Check one output's bytes against its entry.
pub fn check_output_bytes(entry: &Output, bytes: &[u8]) -> Result<(), OutputError> {
check_name(entry)?;
let got = bytes.len() as u64;
if got != entry.bytes {
return Err(OutputError::LengthMismatch {
name: entry.name.clone(),
expected: entry.bytes,
got,
});
}
let h = Sha256Digest::of(bytes);
if h != entry.sha256 {
return Err(OutputError::HashMismatch {
name: entry.name.clone(),
expected: entry.sha256,
got: h,
});
}
Ok(())
}
/// Hash one regular file without following a symlink at its name, reading at
/// most `expected + 1` bytes so a huge file cannot exhaust memory or time.
fn check_file(entry: &Output, path: &Path) -> Result<(), OutputError> {
use sha2::{Digest, Sha256};
use std::io::Read;
let io = |err: std::io::Error| OutputError::Io {
name: entry.name.clone(),
err: err.to_string(),
};
let meta = match std::fs::symlink_metadata(path) {
Ok(m) => m,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
return Err(OutputError::Missing(entry.name.clone()))
}
Err(e) => return Err(io(e)),
};
if !meta.file_type().is_file() {
return Err(OutputError::NotRegularFile(entry.name.clone()));
}
let mut opts = std::fs::OpenOptions::new();
opts.read(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
// Refuse a symlink swapped in after the metadata check.
opts.custom_flags(libc::O_NOFOLLOW);
}
let f = opts.open(path).map_err(io)?;
if !f.metadata().map_err(io)?.file_type().is_file() {
return Err(OutputError::NotRegularFile(entry.name.clone()));
}
let mut h = Sha256::new();
let mut got: u64 = 0;
let mut buf = vec![0u8; 64 * 1024];
let mut limited = f.take(entry.bytes.saturating_add(1));
loop {
let n = limited.read(&mut buf).map_err(io)?;
if n == 0 {
break;
}
got += n as u64;
h.update(&buf[..n]);
}
if got != entry.bytes {
return Err(OutputError::LengthMismatch {
name: entry.name.clone(),
expected: entry.bytes,
got,
});
}
let digest = Sha256Digest(h.finalize().into());
if digest != entry.sha256 {
return Err(OutputError::HashMismatch {
name: entry.name.clone(),
expected: entry.sha256,
got: digest,
});
}
Ok(())
}
/// Check `dir` against `result.outputs`: every listed file present, regular,
/// with matching size and SHA-256, and nothing unlisted. Run it on a result
/// that [`crate::verify`] accepted.
pub fn check_outputs(result: &JobResult, dir: &Path) -> Result<(), crate::VerifyError> {
check_output_files(result, dir)?;
Ok(())
}
/// [`check_outputs`], returning the checked paths in list order.
pub fn check_output_files(result: &JobResult, out_dir: &Path) -> Result<Vec<PathBuf>, OutputError> {
let mut listed = std::collections::HashSet::with_capacity(result.outputs.len());
for e in &result.outputs {
check_name(e)?;
listed.insert(e.name.as_str());
}
let dir_io = |err: std::io::Error| OutputError::Io {
name: out_dir.display().to_string(),
err: err.to_string(),
};
for entry in std::fs::read_dir(out_dir).map_err(dir_io)? {
let entry = entry.map_err(dir_io)?;
let name = entry.file_name();
match name.to_str() {
Some(n) if listed.contains(n) => {}
_ => return Err(OutputError::Unlisted(name.to_string_lossy().into_owned())),
}
}
let mut paths = Vec::with_capacity(result.outputs.len());
for e in &result.outputs {
let p = out_dir.join(&e.name);
check_file(e, &p)?;
paths.push(p);
}
Ok(paths)
}