crates/heimdall-trace/src/doc.rs
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright (C) 2026 Ignalina ApS and Vetra Automation AB
//! The trace document (`heimdall.trace.v1`, the job's one output
//! `trace.json`). Heimdall is a defensive sandbox: this is what a package
//! did while it was installed or imported with no network, as the in-job
//! tracer saw it.
//!
//! The document is **canonical JSON**: compact, the fields in the order of
//! these structs, every list a sorted set without duplicates. One trace has
//! exactly one encoding; [`Trace::from_canonical`] re-encodes what it parsed
//! and refuses anything that is not byte-identical, so a reader never acts on
//! a document two parsers could read differently. Its integrity comes from
//! the signed result: the file is the job's only output, listed with its
//! SHA-256 in the [`heimdall_core::SignedResult`] (see [`crate::verify_trace`]).
//!
//! Nothing in it carries a pid, a time or a count per entry, so two runs of
//! the same package give the same sets (what [`crate::diff`] compares).
use std::collections::{BTreeMap, BTreeSet};
use serde::{Deserialize, Serialize};
/// The format name every trace starts with.
pub const FORMAT: &str = "heimdall.trace.v1";
/// The output name of the trace in the job's `/out`.
pub const TRACE_FILE: &str = "trace.json";
/// The default size bound of a trace document (4 MiB).
pub const DEFAULT_MAX_BYTES: u64 = 4 << 20;
/// The smallest size bound the tracer accepts (room for the header fields).
pub const MIN_MAX_BYTES: u64 = 4096;
/// The longest string (a path, an argument) a trace keeps; longer ones are
/// cut and end in [`CUT`].
pub const MAX_STR: usize = 1024;
/// The most arguments one exec entry keeps.
pub const MAX_ARGS: usize = 64;
/// What a cut string ends with.
pub const CUT: &str = "...[cut]";
/// Who held the job's seccomp USER_NOTIF listener: the tracer. The kernel
/// allows one listener per filter chain, so a trace job runs without the
/// broker's (its result says `broker: false`); file access stays with the
/// mounts and Landlock.
pub const LISTENER: &str = "heimdall-trace";
/// How complete a trace is.
#[derive(Copy, Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Status {
/// Every call of every traced process was seen and recorded.
Complete,
/// Every call was seen and answered, but the size bound dropped entries
/// (`dropped` says how many): new behaviour may be among them.
Truncated,
/// The tracer could not trace (the reason is in `incomplete`): the
/// document says nothing about the package. A reader treats it as
/// Unknown, never as clean.
Incomplete,
}
/// One `execve`/`execveat` a traced process asked for (whether it then
/// succeeded is not recorded: a missing program is an attempt too).
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ExecEvent {
/// The program that asked (`/proc/<tid>/exe` at the call).
pub program: String,
/// The path it asked to execute, made absolute.
pub path: String,
/// The argument vector (at most [`MAX_ARGS`], each at most [`MAX_STR`]).
pub argv: Vec<String>,
/// More arguments than [`MAX_ARGS`], or one could not be read.
pub argv_cut: bool,
}
/// One `connect`, `bind`, `sendto` or `sendmsg` with an address.
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct NetEvent {
pub program: String,
/// `connect`, `bind`, `sendto` or `sendmsg`.
pub op: String,
/// `inet`, `inet6`, `unix`, `netlink`, `packet`, or `family <n>`;
/// `unreadable` when the address could not be read.
pub family: String,
/// `10.0.0.1:443`, `[::1]:53`, a unix path (`@name` when abstract),
/// `pid <p> groups <g>` for netlink, empty when unreadable.
pub address: String,
/// The tracer refused the call (`EACCES`). Every call outside AF_UNIX is
/// refused (a trace job has no network), and a unix `bind` whose socket
/// file would land outside the writable paths.
pub refused: bool,
}
/// One file call: an open, a create, a remove, a rename, a link, a mode or
/// owner change, a truncate.
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct FileEvent {
pub program: String,
/// The call's name (`openat`, `mkdir`, `unlinkat`, `renameat2`, ...).
pub op: String,
/// The path, absolute and lexically normalised; for a write the parent
/// directory's symlinks are resolved. `unreadable` when it could not be
/// read.
pub path: String,
/// The open flags as hex (`0x80241`), empty for calls without them.
pub flags: String,
/// The call writes, creates, removes or changes something.
pub write: bool,
/// A write outside `/tmp` (the job's scratch) and `/dev/null`.
pub outside_tmp: bool,
/// The tracer refused the call (`EACCES`): every write outside the
/// writable paths (`/tmp` unless the job said otherwise, and
/// `/dev/null`), every write into `/out` (the trace's own place), and
/// every write whose path could not be read.
pub refused: bool,
}
/// One distinct call with its argument facts, as heimdall-probe records them
/// (`heimdall_probe::facts::SPECS`: socket domain/type/protocol, open flags,
/// ioctl requests, mmap/mprotect protections, clone flags, prctl options,
/// fcntl commands).
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct SyscallFact {
pub syscall: String,
/// Argument index -> value as `0x` hex.
pub args: BTreeMap<String, String>,
}
impl SyscallFact {
/// The fact of one probe call.
pub fn of(c: &heimdall_probe::Call) -> SyscallFact {
SyscallFact {
syscall: c.syscall.clone(),
args: c
.args
.iter()
.map(|(i, v)| (i.to_string(), format!("{v:#x}")))
.collect(),
}
}
}
/// The whole trace.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Trace {
/// Always [`FORMAT`].
pub format: String,
/// The traced command.
pub argv: Vec<String>,
pub status: Status,
/// Why the trace is [`Status::Incomplete`] (empty otherwise).
pub incomplete: String,
/// How the traced command ended: `exit 0`, `killed by signal 9`,
/// `killed by seccomp (SIGSYS)`, `timed out`, `not started`.
pub exit: String,
/// The tracer's own deadline passed and it killed every process.
pub timed_out: bool,
/// Processes still there when the command ended (or at the deadline),
/// killed by the tracer before the trace was written.
pub leftover_killed: u64,
/// Notifications the tracer answered (every call of every traced process).
pub notifications: u64,
/// Entries left out by the size bound.
pub dropped: u64,
/// The size bound this document was written under.
pub max_bytes: u64,
/// Who held the seccomp listener: always [`LISTENER`].
pub listener: String,
pub exec: BTreeSet<ExecEvent>,
pub net: BTreeSet<NetEvent>,
pub files: BTreeSet<FileEvent>,
/// Program -> every distinct call it made.
pub syscalls: BTreeMap<String, BTreeSet<SyscallFact>>,
/// Calls of a foreign arch or with a number the arch table does not name
/// (`arch 0x40000003 nr 5`); the tracer refused them (`ENOSYS`).
pub unknown: BTreeSet<String>,
}
/// Why a document is not a trace.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DocError {
/// Not JSON of this shape.
Parse(String),
/// Valid, but not the one canonical encoding.
NotCanonical,
/// Another format name.
Format(String),
/// Larger than its own (or the reader's) bound.
TooLarge { bytes: u64, max: u64 },
/// Fields that contradict each other.
Invalid(String),
}
impl std::fmt::Display for DocError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
DocError::Parse(e) => write!(f, "the trace does not parse: {e}"),
DocError::NotCanonical => f.write_str("the trace is not in its canonical encoding"),
DocError::Format(v) => write!(f, "the trace format is {v:?}, not {FORMAT:?}"),
DocError::TooLarge { bytes, max } => {
write!(f, "the trace is {bytes} bytes, over its bound of {max}")
}
DocError::Invalid(e) => write!(f, "the trace is not valid: {e}"),
}
}
}
impl std::error::Error for DocError {}
impl Trace {
/// An empty, complete trace of `argv` under `max_bytes`.
pub fn new(argv: Vec<String>, max_bytes: u64) -> Trace {
Trace {
format: FORMAT.to_string(),
argv,
status: Status::Complete,
incomplete: String::new(),
exit: "not started".to_string(),
timed_out: false,
leftover_killed: 0,
notifications: 0,
dropped: 0,
max_bytes,
listener: LISTENER.to_string(),
exec: BTreeSet::new(),
net: BTreeSet::new(),
files: BTreeSet::new(),
syscalls: BTreeMap::new(),
unknown: BTreeSet::new(),
}
}
/// The canonical encoding.
pub fn to_canonical(&self) -> Vec<u8> {
serde_json::to_vec(self).expect("a trace serializes")
}
/// Read a trace, refusing anything but its canonical encoding, a foreign
/// format, a document over its own `max_bytes` or over `reader_max`.
pub fn from_canonical(bytes: &[u8], reader_max: u64) -> Result<Trace, DocError> {
let n = bytes.len() as u64;
if n > reader_max {
return Err(DocError::TooLarge {
bytes: n,
max: reader_max,
});
}
let t: Trace = serde_json::from_slice(bytes).map_err(|e| DocError::Parse(e.to_string()))?;
if t.format != FORMAT {
return Err(DocError::Format(t.format));
}
if t.to_canonical() != bytes {
return Err(DocError::NotCanonical);
}
if n > t.max_bytes {
return Err(DocError::TooLarge {
bytes: n,
max: t.max_bytes,
});
}
t.validate()?;
Ok(t)
}
/// The fields agree with each other.
pub fn validate(&self) -> Result<(), DocError> {
let bad = |s: &str| Err(DocError::Invalid(s.to_string()));
match self.status {
Status::Incomplete if self.incomplete.is_empty() => {
return bad("incomplete without a reason")
}
Status::Complete | Status::Truncated if !self.incomplete.is_empty() => {
return bad("a reason without status incomplete")
}
Status::Complete if self.dropped > 0 => return bad("complete but entries dropped"),
Status::Truncated if self.dropped == 0 => return bad("truncated but nothing dropped"),
_ => {}
}
if self.argv.is_empty() {
return bad("no argv");
}
if self.listener != LISTENER {
return bad("the listener is not the tracer");
}
if self.max_bytes < MIN_MAX_BYTES {
return bad("max_bytes under the minimum");
}
if self.exec.iter().any(|e| e.argv.len() > MAX_ARGS) {
return bad("an exec entry with too many arguments");
}
if self.files.iter().any(|f| f.outside_tmp && !f.write) {
return bad("outside_tmp on a read");
}
Ok(())
}
/// The trace says something about the package: complete or truncated.
pub fn is_usable(&self) -> bool {
self.status != Status::Incomplete
}
}
/// `s`, cut to [`MAX_STR`] bytes (on a char boundary) ending in [`CUT`].
pub fn cut(s: String) -> String {
if s.len() <= MAX_STR {
return s;
}
let mut end = MAX_STR - CUT.len();
while !s.is_char_boundary(end) {
end -= 1;
}
let mut t = s[..end].to_string();
t.push_str(CUT);
t
}