jobs/runner/src/wire.rs
//! **The worker's wire**: what a job hands `holger-scan-worker` and what it answers.
//!
//! The job document is `/in/job.json` (schema [`JOB_SCHEMA`]); a scan's own
//! document (`holger.scan-worker-job/v1`) still reads as before, so the worker tells
//! the two apart by the schema. The answer is ONE JSON document on stdout, capped,
//! and the runner believes only its verdict and the names it lists: the files are
//! listed and hashed by the runner itself.
use std::path::{Path, PathBuf};
use heimdall_core::Verdict;
use serde::{Deserialize, Serialize};
/// The job document's schema.
pub const JOB_SCHEMA: &str = "holger.job/v1";
/// The answer's schema.
pub const ANSWER_SCHEMA: &str = "holger.job-answer/v1";
/// The job's input directory's mount name: `/in/i` in a container.
pub const INPUT_MOUNT: &str = "i";
/// The longest answer the runner reads.
pub const ANSWER_MAX: usize = 64 << 10;
/// The mount name of the policy's `n`th read path: `/in/r<n>`.
pub fn read_mount(n: usize) -> String {
format!("r{n}")
}
/// What the worker reads at `/in/job.json`.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct WireJob {
pub schema: String,
pub job_id: String,
/// The program and its arguments: `["verify-bundle"]`, `["scan"]`.
pub argv: Vec<String>,
pub params: serde_json::Value,
/// How many read paths the policy gave (`/in/r0` …).
#[serde(default)]
pub reads: usize,
/// Where the answer goes (lane HOL-HEIMDALL-02): `None`, ONE document on stdout (the
/// interim runner reads it); `Some(name)`, the file `<out>/<name>`. Heimdall's runner
/// gives a job's stdout to `/dev/null` (docs/API.md, "Scan-worker job contract"), so its
/// answer is a file in `/out` the runner lists, hashes and signs with the rest.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub answer: Option<String>,
}
impl WireJob {
/// The program's name.
pub fn program(&self) -> &str {
self.argv.first().map(String::as_str).unwrap_or("")
}
}
/// What the worker answers on stdout.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Answer {
pub schema: String,
pub verdict: Verdict,
/// The files it wrote into its output directory, by name.
#[serde(default)]
pub outputs: Vec<String>,
}
impl Answer {
pub fn accepted(outputs: Vec<String>) -> Self {
Self {
schema: ANSWER_SCHEMA.into(),
verdict: Verdict::Accepted,
outputs,
}
}
pub fn refused(code: impl Into<String>, reason: impl Into<String>) -> Self {
Self {
schema: ANSWER_SCHEMA.into(),
verdict: Verdict::Refused {
code: code.into(),
reason: reason.into(),
},
outputs: Vec::new(),
}
}
/// The worker's answer, read under its cap and schema.
pub fn parse(bytes: &[u8]) -> Result<Self, String> {
if bytes.len() > ANSWER_MAX {
return Err(format!(
"the answer is {} bytes, more than {ANSWER_MAX}",
bytes.len()
));
}
let a: Answer =
serde_json::from_slice(bytes).map_err(|e| format!("the answer does not read: {e}"))?;
if a.schema != ANSWER_SCHEMA {
return Err(format!(
"the answer's schema is {:?}, not {ANSWER_SCHEMA}",
a.schema
));
}
Ok(a)
}
}
/// The answer file's name under Heimdall's runner: one plain file name beside the
/// program's own outputs in `/out`, never one of them.
pub const ANSWER_FILE: &str = "holger-job-answer.json";
/// **Deliver `answer` the way `job` asks**: into `<out>/<name>` when the job names an
/// answer file (written in one pass: under Heimdall `/out` is write-only), else the bytes
/// the caller prints on stdout (`Ok(Some(bytes))`).
pub fn deliver(job: &WireJob, out: &Path, answer: &Answer) -> std::io::Result<Option<Vec<u8>>> {
let bytes = serde_json::to_vec(answer).map_err(std::io::Error::other)?;
match &job.answer {
None => Ok(Some(bytes)),
Some(name) => {
heimdall_core::validate_output_name(name)
.map_err(|e| std::io::Error::other(format!("the answer file {name:?}: {e}")))?;
std::fs::write(out.join(name), &bytes)?;
Ok(None)
}
}
}
/// Where a program finds its input and writes its output.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Dirs {
/// The job's input directory (read-only).
pub input: PathBuf,
/// The policy's read paths, in order.
pub read: Vec<PathBuf>,
/// The one writable directory.
pub out: PathBuf,
}
impl Dirs {
/// The directories of a worker started over `base`: `/in` in a container (the
/// output at `/out`), a job directory under the sidecar (its output beside the
/// input, `<base>/out`).
pub fn under(base: &Path, reads: usize) -> Self {
let out = if base == Path::new("/in") {
PathBuf::from(holger_sandbox::OUT_PATH)
} else {
base.join("out")
};
Self {
input: base.join(INPUT_MOUNT),
read: (0..reads).map(|n| base.join(read_mount(n))).collect(),
out,
}
}
}
/// A job's program: what the worker runs for one [`WireJob`] (server-lib's
/// `sandbox::programs::run`). The in-process test runner calls it directly.
pub type Program = dyn Fn(&WireJob, &Dirs) -> Answer + Send + Sync;