crates/heimdall-probe/src/observe.rs
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright (C) 2026 Ignalina ApS and Vetra Automation AB
//! What the probe saw: every distinct call (syscall + recorded argument
//! facts) and the runs (tests) that made it. Heimdall is a defensive
//! sandbox; this is the reviewable evidence behind a generated profile.
use std::collections::{BTreeMap, BTreeSet};
use heimdall_seccomp::{Arch, Libc};
use serde::{Deserialize, Serialize};
/// One distinct call: the syscall and its recorded argument facts.
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct Call {
pub syscall: String,
/// (argument index, value), sorted by index; only the facts in
/// [`crate::facts::SPECS`].
pub args: Vec<(u8, u64)>,
}
impl Call {
/// The value of argument `i`, when recorded.
pub fn arg(&self, i: u8) -> Option<u64> {
self.args.iter().find(|(a, _)| *a == i).map(|(_, v)| *v)
}
/// `openat flags=0x80000` style, for messages.
pub fn describe(&self) -> String {
let mut s = self.syscall.clone();
for (i, v) in &self.args {
s.push_str(&format!(" arg{i}={v:#x}"));
}
s
}
}
/// One probe run (one test of the corpus).
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
pub struct RunInfo {
pub name: String,
/// The run drives an error path (fuzz input, refusal, limit hit).
pub error_path: bool,
/// How it ended, e.g. `exit 0`.
pub exit: String,
}
/// Everything one or more probe runs saw.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Observations {
pub workload: String,
pub arch: Arch,
pub libc: Libc,
pub runs: BTreeSet<RunInfo>,
/// Call -> the runs that made it.
pub calls: BTreeMap<Call, BTreeSet<String>>,
/// Calls with a number this arch's table does not name, or a foreign
/// arch (`nr 1234` / `arch 0x40000003 nr 5`) -> runs.
pub unknown: BTreeMap<String, BTreeSet<String>>,
}
impl Observations {
pub fn new(workload: &str, arch: Arch, libc: Libc) -> Observations {
Observations {
workload: workload.to_string(),
arch,
libc,
runs: BTreeSet::new(),
calls: BTreeMap::new(),
unknown: BTreeMap::new(),
}
}
/// Record one call made by `run`.
pub fn record(&mut self, call: Call, run: &str) {
self.calls.entry(call).or_default().insert(run.to_string());
}
/// The union with another set of observations of the same workload,
/// arch and libc (spec §3.1 step 3).
pub fn merge(&mut self, other: &Observations) {
assert_eq!(
(self.arch, self.libc),
(other.arch, other.libc),
"merging observations of different arch/libc"
);
self.runs.extend(other.runs.iter().cloned());
for (c, runs) in &other.calls {
self.calls
.entry(c.clone())
.or_default()
.extend(runs.iter().cloned());
}
for (u, runs) in &other.unknown {
self.unknown
.entry(u.clone())
.or_default()
.extend(runs.iter().cloned());
}
}
/// The names of the error-path runs.
pub fn error_runs(&self) -> BTreeSet<&str> {
self.runs
.iter()
.filter(|r| r.error_path)
.map(|r| r.name.as_str())
.collect()
}
/// Deterministic pretty JSON (`<arch>-<libc>.probe.json`).
pub fn to_json(&self) -> String {
let f = ObsFile {
workload: self.workload.clone(),
arch: self.arch,
libc: self.libc,
runs: self.runs.iter().cloned().collect(),
calls: self
.calls
.iter()
.map(|(c, runs)| CallRecord {
syscall: c.syscall.clone(),
args: c
.args
.iter()
.map(|(i, v)| (i.to_string(), format!("{v:#x}")))
.collect(),
runs: runs.iter().cloned().collect(),
})
.collect(),
unknown: self
.unknown
.iter()
.map(|(u, runs)| UnknownRecord {
call: u.clone(),
runs: runs.iter().cloned().collect(),
})
.collect(),
};
serde_json::to_string_pretty(&f).expect("observations serialize") + "\n"
}
/// Read `<arch>-<libc>.probe.json`.
pub fn from_json(s: &str) -> Result<Observations, String> {
let f: ObsFile = serde_json::from_str(s).map_err(|e| e.to_string())?;
let mut o = Observations::new(&f.workload, f.arch, f.libc);
o.runs = f.runs.into_iter().collect();
for c in f.calls {
let mut args = Vec::new();
for (i, v) in c.args {
let i: u8 = i.parse().map_err(|_| format!("bad arg index {i:?}"))?;
let h = v
.strip_prefix("0x")
.ok_or_else(|| format!("{v:?} is not 0x-hex"))?;
let v = u64::from_str_radix(h, 16).map_err(|e| e.to_string())?;
args.push((i, v));
}
args.sort_unstable();
o.calls.insert(
Call {
syscall: c.syscall,
args,
},
c.runs.into_iter().collect(),
);
}
for u in f.unknown {
o.unknown.insert(u.call, u.runs.into_iter().collect());
}
Ok(o)
}
}
#[derive(Serialize, Deserialize)]
struct ObsFile {
workload: String,
arch: Arch,
libc: Libc,
runs: Vec<RunInfo>,
calls: Vec<CallRecord>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
unknown: Vec<UnknownRecord>,
}
#[derive(Serialize, Deserialize)]
struct CallRecord {
syscall: String,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
args: BTreeMap<String, String>,
runs: Vec<String>,
}
#[derive(Serialize, Deserialize)]
struct UnknownRecord {
call: String,
runs: Vec<String>,
}