crates/heimdall-trace/src/diff.rs
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright (C) 2026 Ignalina ApS and Vetra Automation AB
//! A trace against a baseline (the trace of a clean package run the same
//! way): what the new trace does that the baseline does not. Heimdall is a
//! defensive sandbox; Holger holds a package on a diff that is not clean.
//!
//! Compared, per section:
//! - exec: (program, path); the arguments vary with temp names and are
//! shown, not compared;
//! - net: (op, family, address, refused);
//! - files: (op, path, write, refused), leaving out calls inside `/tmp`
//! that were not refused (the job's scratch, full of random names);
//! - syscalls: (program, call with its argument facts);
//! - unknown calls.
//!
//! A trace (or baseline) that is not complete is never clean:
//! `not_complete` says why.
use std::collections::BTreeSet;
use serde::{Deserialize, Serialize};
use crate::classify::under;
use crate::doc::{ExecEvent, FileEvent, NetEvent, Status, SyscallFact, Trace};
/// What a trace does beyond its baseline.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct TraceDiff {
pub exec: Vec<ExecEvent>,
pub net: Vec<NetEvent>,
pub files: Vec<FileEvent>,
/// (program, call).
pub syscalls: Vec<(String, SyscallFact)>,
pub unknown: Vec<String>,
/// The trace (or the baseline) is truncated or incomplete: the reason.
pub not_complete: Option<String>,
}
impl TraceDiff {
/// Nothing new and both traces complete.
pub fn is_clean(&self) -> bool {
self.not_complete.is_none()
&& self.exec.is_empty()
&& self.net.is_empty()
&& self.files.is_empty()
&& self.syscalls.is_empty()
&& self.unknown.is_empty()
}
}
fn scratch(f: &FileEvent) -> bool {
under(&f.path, "/tmp") && !f.refused
}
type NetKey<'a> = (&'a str, &'a str, &'a str, bool);
type FileKey<'a> = (&'a str, &'a str, bool, bool);
fn net_key(e: &NetEvent) -> NetKey<'_> {
(&e.op, &e.family, &e.address, e.refused)
}
fn file_key(f: &FileEvent) -> FileKey<'_> {
(&f.op, &f.path, f.write, f.refused)
}
/// What `trace` does that `baseline` does not.
pub fn diff(baseline: &Trace, trace: &Trace) -> TraceDiff {
let not_complete = match (baseline.status, trace.status) {
(_, Status::Incomplete) => Some(format!("the trace is incomplete: {}", trace.incomplete)),
(_, Status::Truncated) => Some(format!(
"the trace is truncated ({} entries dropped)",
trace.dropped
)),
(Status::Incomplete, _) => Some(format!(
"the baseline is incomplete: {}",
baseline.incomplete
)),
(Status::Truncated, _) => Some("the baseline is truncated".to_string()),
(Status::Complete, Status::Complete) => None,
};
let base_exec: BTreeSet<(&str, &str)> = baseline
.exec
.iter()
.map(|e| (e.program.as_str(), e.path.as_str()))
.collect();
let mut seen_exec = BTreeSet::new();
let exec = trace
.exec
.iter()
.filter(|e| {
let k = (e.program.as_str(), e.path.as_str());
!base_exec.contains(&k) && seen_exec.insert(k)
})
.cloned()
.collect();
let base_net: BTreeSet<NetKey> = baseline.net.iter().map(net_key).collect();
let net = trace
.net
.iter()
.filter(|e| !base_net.contains(&net_key(e)))
.cloned()
.collect();
let base_files: BTreeSet<FileKey> = baseline
.files
.iter()
.filter(|f| !scratch(f))
.map(file_key)
.collect();
let mut seen_files = BTreeSet::new();
let files = trace
.files
.iter()
.filter(|f| {
!scratch(f) && !base_files.contains(&file_key(f)) && seen_files.insert(file_key(f))
})
.cloned()
.collect();
let mut syscalls = Vec::new();
for (prog, facts) in &trace.syscalls {
let base = baseline.syscalls.get(prog);
for f in facts {
if !base.is_some_and(|b| b.contains(f)) {
syscalls.push((prog.clone(), f.clone()));
}
}
}
let unknown = trace
.unknown
.difference(&baseline.unknown)
.cloned()
.collect();
TraceDiff {
exec,
net,
files,
syscalls,
unknown,
not_complete,
}
}