jobs/runner/src/collect.rs
//! **The runner's own look at a job's output**, outside the job: every file it
//! wrote is a regular file, exactly the ones its answer names, each hashed here.
//! Anything else refuses the job by name, and a refused job hands over nothing.
use std::collections::BTreeSet;
use std::path::Path;
use heimdall_core::{Output, Verdict};
use crate::wire::Answer;
/// The verdict and the listed outputs of a job that answered `answer` and wrote
/// into `out`. A refusal (the job's own or this check's) empties `out`.
pub fn collect(answer: &Answer, out: &Path) -> (Verdict, Vec<Output>) {
match check(answer, out) {
Ok(outputs) => (Verdict::Accepted, outputs),
Err(v) => {
empty(out);
(v, Vec::new())
}
}
}
fn check(answer: &Answer, out: &Path) -> Result<Vec<Output>, Verdict> {
use holger_errcode::{
SANDBOX_JOB_ANSWER_INVALID as INVALID, SANDBOX_JOB_UNDECLARED as UNDECLARED,
};
if let Verdict::Refused { .. } = &answer.verdict {
return Err(answer.verdict.clone());
}
let mut declared = BTreeSet::new();
for n in &answer.outputs {
if heimdall_core::validate_output_name(n).is_err()
|| !n
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b"._-+".contains(&b))
{
return Err(refused(
&INVALID,
format!("the answer names {n:?}, which is not one plain file name"),
));
}
if !declared.insert(n.as_str()) {
return Err(refused(&INVALID, format!("the answer names {n:?} twice")));
}
}
let entries = std::fs::read_dir(out).map_err(|e| {
refused(
&INVALID,
format!("the output directory {}: {e}", out.display()),
)
})?;
let mut there = BTreeSet::new();
for e in entries {
let e = e.map_err(|e| refused(&INVALID, format!("the output directory: {e}")))?;
let name = e.file_name().to_string_lossy().into_owned();
let ft = e
.file_type()
.map_err(|e| refused(&INVALID, format!("{name}: {e}")))?;
if !ft.is_file() {
return Err(refused(
&UNDECLARED,
format!("the job wrote {name:?}, which is not a regular file"),
));
}
if !declared.contains(name.as_str()) {
return Err(refused(
&UNDECLARED,
format!("the job wrote {name:?} and did not declare it"),
));
}
there.insert(name);
}
if let Some(n) = declared.iter().find(|n| !there.contains(**n)) {
return Err(refused(
&INVALID,
format!("the answer names {n:?}, which the job did not write"),
));
}
let mut outputs = Vec::new();
for n in declared {
let p = out.join(n);
let bytes = std::fs::symlink_metadata(&p)
.map_err(|e| refused(&INVALID, format!("{n}: {e}")))?
.len();
let sha256 = sha256_file(&p).map_err(|e| refused(&INVALID, format!("{n}: {e}")))?;
outputs.push(Output {
name: n.to_string(),
sha256,
bytes,
});
}
Ok(outputs)
}
/// A refused verdict with a frozen code.
pub fn refused(code: &holger_errcode::ErrCode, reason: impl std::fmt::Display) -> Verdict {
Verdict::Refused {
code: code.code.into(),
reason: code.tag(reason),
}
}
/// Remove every entry of `dir` (best effort): a refused job hands over nothing.
pub fn empty(dir: &Path) {
for e in std::fs::read_dir(dir).into_iter().flatten().flatten() {
let p = e.path();
let _ = match e.file_type() {
Ok(t) if t.is_dir() => std::fs::remove_dir_all(&p),
_ => std::fs::remove_file(&p),
};
}
}
/// The sha256 of the file at `path`, read in pieces, as Heimdall's digest.
pub fn sha256_file(path: &Path) -> std::io::Result<heimdall_core::Sha256Digest> {
use sha2::Digest as _;
use std::io::Read as _;
let mut f = std::fs::File::open(path)?;
let mut h = sha2::Sha256::new();
let mut buf = vec![0u8; 1 << 16];
loop {
let n = f.read(&mut buf)?;
if n == 0 {
break;
}
h.update(&buf[..n]);
}
heimdall_core::Sha256Digest::from_hex(&hex::encode(h.finalize()))
.ok_or_else(|| std::io::Error::other("a sha256 is 32 bytes"))
}