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

nordisk/heimdall

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