Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/heimdall

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}")),
        ),
    }
}