crates/heimdall-trace/src/job.rs
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright (C) 2026 Ignalina ApS and Vetra Automation AB
//! The caller's side of a trace job: build the [`Job`] (a native job whose
//! argv starts the tracer), run it on any [`JobRunner`] (HeimdallRunner in
//! production), and turn the signed result into a [`TraceOutcome`].
//! Heimdall is a defensive sandbox and this fails closed: whatever could not
//! be verified, read or traced is [`TraceOutcome::Unknown`], never a clean
//! trace.
use std::path::{Path, PathBuf};
use std::time::Duration;
use heimdall_core::{
check_outputs, codes, verify, ImageRef, Job, JobKind, JobRunner, JobTicket, Limits, Output,
Policy, Sha256Digest, SignedResult, Verdict, VerifyingKey,
};
use crate::doc::{Trace, DEFAULT_MAX_BYTES, TRACE_FILE};
/// Where the tracer sits in a trace image ([`crate::image::with_tracer`]).
pub const TRACER_PATH: &str = "/heimdall/heimdall-trace";
/// How much earlier than the job's wall limit the tracer ends the traced
/// command, so it has time to write the trace before the shim kills the job.
pub const WRITE_MARGIN: Duration = Duration::from_secs(5);
/// One trace job: the command to trace and its limits.
#[derive(Clone, Debug, PartialEq)]
pub struct TraceJob {
/// A trace image: the worker's image plus the tracer at [`TRACER_PATH`].
pub image: ImageRef,
/// The command (argv\[0\] absolute in the image, or found on its PATH).
pub argv: Vec<String>,
/// Added to the image's environment for the command.
pub env: Vec<(String, String)>,
/// The command's working directory (default `/`).
pub cwd: Option<String>,
/// Where the command may write (default `/tmp`; `/out` never).
pub writable: Vec<String>,
/// The trace document's size bound.
pub max_bytes: u64,
/// The job's limits (wall, memory, pids, cpus; the tracer counts too).
pub limits: Limits,
}
impl TraceJob {
/// A trace job with the defaults: `HOME=/tmp/home`, `UV_USE_IO_URING=0`
/// (node's libuv would set up io_uring, which the job's fixed seccomp
/// profile kills), writes only into `/tmp`, a 4 MiB trace, 120 s wall,
/// 1 GiB memory, 256 pids, 2 cpus.
pub fn new(image: ImageRef, argv: Vec<String>) -> TraceJob {
TraceJob {
image,
argv,
env: vec![
("HOME".into(), "/tmp/home".into()),
("UV_USE_IO_URING".into(), "0".into()),
],
cwd: None,
writable: vec!["/tmp".into()],
max_bytes: DEFAULT_MAX_BYTES,
limits: Limits {
wall: Duration::from_secs(120),
memory_bytes: 1 << 30,
pids: 256,
cpus: 2.0,
..Limits::default()
},
}
}
/// The tracer's own deadline: the wall limit less [`WRITE_MARGIN`], and
/// at least half the wall limit.
pub fn tracer_timeout(&self) -> Duration {
let w = self.limits.wall;
w.saturating_sub(WRITE_MARGIN).max(w / 2)
}
/// The argv of the job: the tracer, its options, `--`, the command.
pub fn job_argv(&self) -> Vec<String> {
let mut a = vec![
TRACER_PATH.to_string(),
"--out".into(),
format!("/out/{TRACE_FILE}"),
"--timeout-ms".into(),
self.tracer_timeout().as_millis().to_string(),
"--max-bytes".into(),
self.max_bytes.to_string(),
];
for w in &self.writable {
a.push("--writable".into());
a.push(w.clone());
}
for (k, v) in &self.env {
a.push("--env".into());
a.push(format!("{k}={v}"));
}
if let Some(c) = &self.cwd {
a.push("--cwd".into());
a.push(c.clone());
}
a.push("--".into());
a.extend(self.argv.iter().cloned());
a
}
/// The job (`output_dir` must exist and be empty, as for every job).
pub fn job(&self, input_dir: PathBuf, output_dir: PathBuf) -> Job {
let policy = Policy {
limits: self.limits,
..Policy::default()
};
Job::new(
JobKind::Native {
image: self.image.clone(),
argv: self.job_argv(),
},
input_dir,
output_dir,
serde_json::Value::Null,
policy,
)
}
}
/// What a trace job gave.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum TraceOutcome {
/// A verified trace (complete or truncated). `job_code` is the job's
/// refusal code when the job ended on a limit the trace survives
/// (`heimdall-limit-pids`, `heimdall-limit-memory`), else `None`.
Traced {
trace: Box<Trace>,
job_code: Option<String>,
},
/// No trace to trust: the reason. Never treat this as clean.
Unknown { reason: String },
}
impl TraceOutcome {
fn unknown(r: impl Into<String>) -> TraceOutcome {
TraceOutcome::Unknown { reason: r.into() }
}
/// The trace, when there is one.
pub fn trace(&self) -> Option<&Trace> {
match self {
TraceOutcome::Traced { trace, .. } => Some(trace),
TraceOutcome::Unknown { .. } => None,
}
}
pub fn is_unknown(&self) -> bool {
matches!(self, TraceOutcome::Unknown { .. })
}
}
/// The job codes whose trace is still the tracer's: it ran to the end and
/// wrote the trace, only the traced command hit the limit.
const TRACE_SURVIVES: &[&str] = &[codes::LIMIT_PIDS, codes::LIMIT_MEMORY];
/// Why a trace and a broker do not go together yet: the kernel allows one
/// seccomp USER_NOTIF listener per filter chain, and the tracer must hold it.
/// Refused until the broker has an observer hook (one listener for both).
pub const BROKER_REFUSAL: &str = "a trace job needs the seccomp listener for its tracer, and this runner has a broker listener: a broker and a trace together are refused until the broker has an observer hook";
/// Verify a trace job's signed result and read its trace. Unknown when: the
/// signature, key or ticket do not verify; the outputs in `output_dir` do
/// not match the result; anything but exactly `trace.json` is there; the job
/// was refused for another reason than a limit the trace survives; the trace
/// is over `max_bytes`, not canonical, or incomplete.
pub fn verify_trace(
signed: &SignedResult,
key: &VerifyingKey,
ticket: Option<&JobTicket>,
output_dir: &Path,
max_bytes: u64,
) -> TraceOutcome {
let result = match verify(signed, key) {
Ok(r) => r,
Err(e) => return TraceOutcome::unknown(format!("the signed result does not verify: {e}")),
};
if let Some(t) = ticket {
if result.job_id != t.job_id || result.nonce != t.nonce {
return TraceOutcome::unknown("the signed result is for another job");
}
}
if result.runner.broker {
return TraceOutcome::unknown(BROKER_REFUSAL);
}
if let Err(e) = check_outputs(result, output_dir) {
return TraceOutcome::unknown(format!("the outputs do not match the signed result: {e}"));
}
let job_code = match &result.verdict {
Verdict::Accepted => None,
Verdict::Refused { code, .. } if TRACE_SURVIVES.contains(&code.as_str()) => {
Some(code.clone())
}
Verdict::Refused { code, reason } => {
return TraceOutcome::unknown(format!("the trace job was refused: {code}: {reason}"))
}
};
let names: Vec<&str> = result.outputs.iter().map(|o| o.name.as_str()).collect();
if names != [TRACE_FILE] {
return TraceOutcome::unknown(format!(
"the job's outputs are {names:?}, not exactly [{TRACE_FILE:?}]"
));
}
let out = &result.outputs[0];
if out.bytes > max_bytes {
return TraceOutcome::unknown(format!(
"the trace is {} bytes, over the bound of {max_bytes}",
out.bytes
));
}
let bytes = match read_checked(&output_dir.join(TRACE_FILE), out, max_bytes) {
Ok(b) => b,
Err(e) => return TraceOutcome::unknown(e),
};
match Trace::from_canonical(&bytes, max_bytes) {
Ok(t) if !t.is_usable() => {
TraceOutcome::unknown(format!("the tracer could not trace: {}", t.incomplete))
}
Ok(t) => TraceOutcome::Traced {
trace: Box::new(t),
job_code,
},
Err(e) => TraceOutcome::unknown(e.to_string()),
}
}
/// Read the trace again and check it against the signed output (the file
/// could change between [`check_outputs`] and this read).
fn read_checked(path: &Path, out: &Output, max: u64) -> Result<Vec<u8>, String> {
use std::io::Read;
use std::os::unix::fs::OpenOptionsExt;
let f = std::fs::OpenOptions::new()
.read(true)
.custom_flags(libc::O_NOFOLLOW)
.open(path)
.map_err(|e| format!("the trace cannot be read: {e}"))?;
let mut b = Vec::new();
f.take(max + 1)
.read_to_end(&mut b)
.map_err(|e| format!("the trace cannot be read: {e}"))?;
if Sha256Digest::of(&b) != out.sha256 || b.len() as u64 != out.bytes {
return Err("the trace changed after the output check".into());
}
Ok(b)
}
/// Run `tj` on `runner` and verify the answer with the runner's key and the
/// job's ticket. The signed result comes back beside the outcome (`None`
/// when the runner signed nothing: then the outcome is Unknown).
pub fn run_trace(
runner: &dyn JobRunner,
tj: &TraceJob,
input_dir: PathBuf,
output_dir: PathBuf,
) -> (Option<SignedResult>, TraceOutcome) {
if runner.features().broker {
return (None, TraceOutcome::unknown(BROKER_REFUSAL));
}
let job = tj.job(input_dir, output_dir.clone());
let ticket = job.ticket();
let key = runner.verifying_key();
match runner.run(job) {
Ok(signed) => {
let o = verify_trace(&signed, &key, Some(&ticket), &output_dir, tj.max_bytes);
(Some(signed), o)
}
Err(e) => (
None,
TraceOutcome::unknown(format!("the trace job did not run: {e}")),
),
}
}