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/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>,
}