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

nordisk/heimdall

crates/heimdall-probe/src/workload.rs

// SPDX-License-Identifier: AGPL-3.0-only
// Copyright (C) 2026 Ignalina ApS and Vetra Automation AB

//! A workload: `profiles/<workload>/workload.toml` (the binary per libc and
//! the probe corpus), and the probe / gen / check steps over it that
//! `heimdall profile` and a Build Thing step call. Heimdall is a defensive
//! sandbox; the corpus runs only the workload's own tests.
//!
//! ```toml
//! workload = "heimdall-wasm-host"
//! timeout_secs = 60          # per run
//! repeat = 2                 # every run this many times (union)
//! exact_limit = 16
//! allow_wx = false
//! [binary]
//! glibc = "{target}/release/heimdall"
//! musl = "{target}/x86_64-unknown-linux-musl/release/heimdall"
//! [env]
//! RUST_BACKTRACE = "0"
//! [[run]]
//! name = "good_guest_is_accepted"
//! args = ["run", "--job", "wasm", "--module", "{corpus}/good.wasm", "--out", "{tmp}/out"]
//! expect_exit = 0
//! [[run]]
//! name = "trapping_guest_is_refused"
//! error_path = true
//! args = ["..."]
//! expect_exit = 4
//! ```
//!
//! Placeholders: `{dir}` the workload dir, `{corpus}` its `corpus/`,
//! `{tmp}` a fresh empty dir per run (also the run's cwd), `{target}`
//! `$CARGO_TARGET_DIR` or `<profiles>/../target`.

use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::Duration;

use heimdall_seccomp::{Arch, Libc, Profile};
use serde::Deserialize;

use crate::check::{check, Violation};
use crate::codegen::files;
use crate::learn::{learn, ProbeRun};
use crate::minimise::{generate, GenOptions, Generated};
use crate::observe::Observations;

/// Why a probe step failed.
#[derive(Debug, thiserror::Error)]
pub enum ProbeError {
    #[error("{path}: {err}")]
    Io { path: PathBuf, err: std::io::Error },
    #[error("{path}: {msg}")]
    Manifest { path: PathBuf, msg: String },
    #[error("workload {workload}: no binary for {libc} in workload.toml [binary]")]
    NoBinary { workload: String, libc: Libc },
    #[error("{binary}: the binary is {found}, not {wanted}")]
    WrongLibc {
        binary: PathBuf,
        found: String,
        wanted: Libc,
    },
    #[error("probe run {run}: {msg}")]
    Run { run: String, msg: String },
    #[error("this arch is not supported by heimdall-seccomp")]
    Arch,
    #[error("{path}: {msg}")]
    Profile { path: PathBuf, msg: String },
}

fn io(path: &Path) -> impl FnOnce(std::io::Error) -> ProbeError + '_ {
    move |err| ProbeError::Io {
        path: path.to_path_buf(),
        err,
    }
}

/// `workload.toml`.
#[derive(Clone, Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Manifest {
    pub workload: String,
    #[serde(default)]
    pub binary: BTreeMap<String, String>,
    #[serde(default = "default_timeout")]
    pub timeout_secs: u64,
    #[serde(default = "default_repeat")]
    pub repeat: u32,
    #[serde(default = "default_exact")]
    pub exact_limit: usize,
    #[serde(default)]
    pub allow_wx: bool,
    #[serde(default)]
    pub env: BTreeMap<String, String>,
    #[serde(rename = "run", default)]
    pub runs: Vec<RunSpec>,
}

fn default_timeout() -> u64 {
    60
}
fn default_repeat() -> u32 {
    1
}
fn default_exact() -> usize {
    16
}

/// One `[[run]]`.
#[derive(Clone, Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct RunSpec {
    pub name: String,
    #[serde(default)]
    pub args: Vec<String>,
    #[serde(default)]
    pub error_path: bool,
    /// The exit code the run must end with (a corpus that stops working
    /// fails the probe instead of shrinking the profile).
    #[serde(default)]
    pub expect_exit: Option<i32>,
    #[serde(default)]
    pub stdin: Option<String>,
    #[serde(default)]
    pub env: BTreeMap<String, String>,
}

/// A loaded workload.
#[derive(Clone, Debug)]
pub struct Workload {
    pub dir: PathBuf,
    pub manifest: Manifest,
    target: PathBuf,
}

/// The files of one workload/arch/libc.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Paths {
    pub probe: PathBuf,
    pub rs: PathBuf,
    pub json: PathBuf,
    pub oci: PathBuf,
}

/// Options of one probe.
#[derive(Clone, Debug, Default)]
pub struct ProbeOptions {
    pub libc: Option<Libc>,
    /// Use this binary instead of the manifest's.
    pub binary: Option<PathBuf>,
    /// Only runs whose name contains one of these.
    pub only: Vec<String>,
}

static NEXT_TMP: AtomicUsize = AtomicUsize::new(0);

impl Workload {
    /// Load `<profiles>/<name>/workload.toml`.
    pub fn load(profiles: &Path, name: &str) -> Result<Workload, ProbeError> {
        if name.is_empty() || name.contains('/') || name.starts_with('.') {
            return Err(ProbeError::Manifest {
                path: profiles.to_path_buf(),
                msg: format!("{name:?} is not a workload name"),
            });
        }
        // Absolute: every run has its own cwd.
        let manifest_path = profiles.join(name).join("workload.toml");
        let dir = std::fs::canonicalize(profiles.join(name)).map_err(io(&manifest_path))?;
        let path = dir.join("workload.toml");
        let text = std::fs::read_to_string(&path).map_err(io(&path))?;
        let manifest: Manifest = toml::from_str(&text).map_err(|e| ProbeError::Manifest {
            path: path.clone(),
            msg: e.to_string(),
        })?;
        if manifest.workload != name {
            return Err(ProbeError::Manifest {
                path,
                msg: format!(
                    "workload = {:?}, but the dir is {name:?}",
                    manifest.workload
                ),
            });
        }
        let target = std::env::var_os("CARGO_TARGET_DIR")
            .map(PathBuf::from)
            .unwrap_or_else(|| {
                dir.parent()
                    .and_then(Path::parent)
                    .unwrap_or(Path::new("."))
                    .join("target")
            });
        Ok(Workload {
            dir,
            manifest,
            target,
        })
    }

    /// The generated files for an arch and libc.
    pub fn paths(&self, arch: Arch, libc: Libc) -> Paths {
        let base = format!("{arch}-{libc}");
        Paths {
            probe: self.dir.join(format!("{base}.probe.json")),
            rs: self.dir.join(format!("{base}.rs")),
            json: self.dir.join(format!("{base}.json")),
            oci: self.dir.join(format!("{base}.oci.json")),
        }
    }

    fn expand(&self, s: &str, tmp: &Path) -> String {
        s.replace("{dir}", &self.dir.display().to_string())
            .replace("{corpus}", &self.dir.join("corpus").display().to_string())
            .replace("{tmp}", &tmp.display().to_string())
            .replace("{target}", &self.target.display().to_string())
    }

    /// The binary and its libc.
    pub fn binary(&self, opts: &ProbeOptions) -> Result<(PathBuf, Libc), ProbeError> {
        let bin =
            match (&opts.binary, opts.libc) {
                (Some(b), _) => b.clone(),
                (None, Some(l)) => {
                    PathBuf::from(self.expand(
                        self.manifest.binary.get(l.as_str()).ok_or_else(|| {
                            ProbeError::NoBinary {
                                workload: self.manifest.workload.clone(),
                                libc: l,
                            }
                        })?,
                        Path::new("/nonexistent"),
                    ))
                }
                (None, None) => {
                    let first = self.manifest.binary.values().next().ok_or_else(|| {
                        ProbeError::NoBinary {
                            workload: self.manifest.workload.clone(),
                            libc: Libc::Glibc,
                        }
                    })?;
                    PathBuf::from(self.expand(first, Path::new("/nonexistent")))
                }
            };
        let elf = std::fs::read(&bin).map_err(io(&bin))?;
        let found = Libc::detect(&elf);
        let libc = match (opts.libc, found) {
            (Some(w), Some(f)) if w != f => {
                return Err(ProbeError::WrongLibc {
                    binary: bin,
                    found: f.to_string(),
                    wanted: w,
                })
            }
            (Some(w), _) => w,
            (None, Some(f)) => f,
            (None, None) => {
                return Err(ProbeError::WrongLibc {
                    binary: bin,
                    found: "of unknown libc".into(),
                    wanted: Libc::Glibc,
                })
            }
        };
        Ok((bin, libc))
    }

    /// Probe: run the corpus (every run `repeat` times) in learning mode.
    pub fn probe(&self, opts: &ProbeOptions) -> Result<Observations, ProbeError> {
        let arch = Arch::native().ok_or(ProbeError::Arch)?;
        let (bin, libc) = self.binary(opts)?;
        let mut obs = Observations::new(&self.manifest.workload, arch, libc);
        let runs: Vec<&RunSpec> = self
            .manifest
            .runs
            .iter()
            .filter(|r| opts.only.is_empty() || opts.only.iter().any(|o| r.name.contains(o)))
            .collect();
        if runs.is_empty() {
            return Err(ProbeError::Manifest {
                path: self.dir.join("workload.toml"),
                msg: "no [[run]] to probe".into(),
            });
        }
        for spec in runs {
            for _ in 0..self.manifest.repeat.max(1) {
                self.probe_one(spec, &bin, &mut obs)?;
            }
        }
        Ok(obs)
    }

    fn probe_one(
        &self,
        spec: &RunSpec,
        bin: &Path,
        obs: &mut Observations,
    ) -> Result<(), ProbeError> {
        let n = NEXT_TMP.fetch_add(1, Ordering::SeqCst);
        let tmp = std::env::temp_dir().join(format!(
            "heimdall-probe-{}-{}-{n}",
            std::process::id(),
            self.manifest.workload
        ));
        let _ = std::fs::remove_dir_all(&tmp);
        std::fs::create_dir_all(&tmp).map_err(io(&tmp))?;
        let out = tmp.join(".probe-output");
        let mut run = ProbeRun::new(&spec.name, bin);
        run.args = spec
            .args
            .iter()
            .map(|a| self.expand(a, &tmp).into())
            .collect();
        let mut env: BTreeMap<String, String> = BTreeMap::new();
        env.insert("PATH".into(), "/usr/bin:/bin".into());
        env.extend(self.manifest.env.clone());
        env.extend(spec.env.clone());
        run.env = env
            .into_iter()
            .map(|(k, v)| (k.into(), self.expand(&v, &tmp).into()))
            .collect();
        run.cwd = Some(tmp.clone());
        run.stdin = spec
            .stdin
            .as_ref()
            .map(|s| PathBuf::from(self.expand(s, &tmp)));
        run.output = Some(out.clone());
        run.error_path = spec.error_path;
        run.timeout = Duration::from_secs(self.manifest.timeout_secs);
        let r = learn(&run, obs).map_err(|e| ProbeError::Run {
            run: spec.name.clone(),
            msg: e.to_string(),
        });
        let tail = || {
            let s = std::fs::read_to_string(&out).unwrap_or_default();
            let lines: Vec<&str> = s.lines().collect();
            lines[lines.len().saturating_sub(8)..].join("\n")
        };
        let result = match r {
            Err(e) => Err(e),
            Ok(o) if o.timed_out => Err(ProbeError::Run {
                run: spec.name.clone(),
                msg: format!(
                    "timed out after {} s\n{}",
                    self.manifest.timeout_secs,
                    tail()
                ),
            }),
            Ok(o) => match spec.expect_exit {
                Some(want) if o.exit != heimdall_seccomp::launch::Exit::Code(want) => {
                    Err(ProbeError::Run {
                        run: spec.name.clone(),
                        msg: format!("expected exit {want}, got {}\n{}", o.exit, tail()),
                    })
                }
                _ => Ok(()),
            },
        };
        let _ = std::fs::remove_dir_all(&tmp);
        result
    }

    /// The generator options from the manifest.
    pub fn gen_options(&self) -> GenOptions {
        GenOptions {
            exact_limit: self.manifest.exact_limit,
            allow_wx: self.manifest.allow_wx,
        }
    }
}

/// `heimdall profile probe`: probe and write `<arch>-<libc>.probe.json`.
pub fn probe_and_write(
    w: &Workload,
    opts: &ProbeOptions,
) -> Result<(Observations, PathBuf), ProbeError> {
    let obs = w.probe(opts)?;
    let p = w.paths(obs.arch, obs.libc).probe;
    std::fs::write(&p, obs.to_json()).map_err(io(&p))?;
    Ok((obs, p))
}

/// `heimdall profile gen`: read the probe file, write `.rs`, `.json`, `.oci.json`.
pub fn gen_and_write(
    w: &Workload,
    arch: Arch,
    libc: Libc,
) -> Result<(Generated, Paths), ProbeError> {
    let paths = w.paths(arch, libc);
    let text = std::fs::read_to_string(&paths.probe).map_err(io(&paths.probe))?;
    let obs = Observations::from_json(&text).map_err(|msg| ProbeError::Profile {
        path: paths.probe.clone(),
        msg,
    })?;
    let g = generate(&obs, w.gen_options());
    let (rs, json, oci) = files(&g);
    for (p, t) in [(&paths.rs, rs), (&paths.json, json), (&paths.oci, oci)] {
        std::fs::write(p, t).map_err(io(p))?;
    }
    Ok((g, paths))
}

/// The result of `heimdall profile check`.
#[derive(Debug)]
pub struct CheckReport {
    pub paths: Paths,
    /// Calls the checked-in profile does not allow.
    pub violations: Vec<Violation>,
    /// Generated files that do not match the `.json` (hand-edited or stale).
    pub out_of_sync: Vec<PathBuf>,
    pub near_miss: usize,
}

impl CheckReport {
    pub fn ok(&self) -> bool {
        self.violations.is_empty() && self.out_of_sync.is_empty()
    }
}

/// Load the checked-in generated profile of an arch/libc.
pub fn load_generated(w: &Workload, arch: Arch, libc: Libc) -> Result<Generated, ProbeError> {
    let paths = w.paths(arch, libc);
    let text = std::fs::read_to_string(&paths.json).map_err(io(&paths.json))?;
    Generated::from_json(&text).map_err(|e| ProbeError::Profile {
        path: paths.json.clone(),
        msg: e.to_string(),
    })
}

/// `heimdall profile check`: re-probe and hold the result to the checked-in
/// `.json`; also check that `.rs` and `.oci.json` are what `.json` generates.
pub fn check_workload(w: &Workload, opts: &ProbeOptions) -> Result<CheckReport, ProbeError> {
    let obs = w.probe(opts)?;
    let g = load_generated(w, obs.arch, obs.libc)?;
    let paths = w.paths(obs.arch, obs.libc);
    let (rs, _, oci) = files(&g);
    let mut out_of_sync = Vec::new();
    for (p, want) in [(&paths.rs, rs), (&paths.oci, oci)] {
        if std::fs::read_to_string(p).ok().as_deref() != Some(want.as_str()) {
            out_of_sync.push(p.clone());
        }
    }
    let profile: &Profile = &g.profile;
    Ok(CheckReport {
        violations: check(&obs, profile),
        near_miss: g.review.near_miss.len(),
        out_of_sync,
        paths,
    })
}