agentsec-core 0.4.0

AgentSec core library — scan / web / paste logic, pure Rust
Documentation
//! Set-difference between two [`ScanReport`]s, keyed on [`PathEntry::path`].
//!
//! Membership is decided by path identity (including any `#<fragment>`
//! suffix appended by probe-driven decomposition, see
//! [`crate::scan::inventory`] §Per-file decomposition), and content
//! equality is decided by [`PathEntry::sha256`]. Entries appearing
//! only in `curr` are `added`; entries appearing only in `prev` are
//! `removed`; entries with the same path but different sha are
//! `modified`. Output vectors are sorted by path so the diff is
//! reproducible.

use crate::scan::ScanReport;
use crate::scan::inventory::PathEntry;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::PathBuf;

/// Classified difference between two snapshots.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct DiffReport {
    /// Paths present in `curr` but not in `prev`.
    pub added: Vec<PathEntry>,
    /// Paths present in both with different content hashes.
    pub modified: Vec<Change>,
    /// Paths present in `prev` but not in `curr`.
    pub removed: Vec<PathEntry>,
}

/// One modified-file row in [`DiffReport::modified`].
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct Change {
    /// Path identity (same in both `prev` and `curr`).
    pub path: PathBuf,
    /// Target-root category label (carried through from
    /// [`PathEntry::category`] so diff renderers can route the entry
    /// through [`crate::platform::PlatformProbe::critical_categories`]
    /// without re-parsing the path string).
    pub category: String,
    /// SHA-256 from the previous snapshot.
    pub prev_sha256: String,
    /// SHA-256 from the current snapshot.
    pub curr_sha256: String,
    /// Size in bytes from the previous snapshot.
    pub prev_size: u64,
    /// Size in bytes from the current snapshot.
    pub curr_size: u64,
}

/// Compute the [`DiffReport`] from `prev` (older) to `curr` (newer).
///
/// Pure function: no I/O, no allocation of foreign resources. Comparing a
/// report against itself yields an empty diff ([`DiffReport::is_empty`]
/// returns `true`).
pub fn compute(prev: &ScanReport, curr: &ScanReport) -> DiffReport {
    let prev_map: HashMap<&PathBuf, &PathEntry> = prev.paths.iter().map(|e| (&e.path, e)).collect();
    let curr_map: HashMap<&PathBuf, &PathEntry> = curr.paths.iter().map(|e| (&e.path, e)).collect();

    let mut added = Vec::new();
    let mut modified = Vec::new();
    let mut removed = Vec::new();

    for (path, curr_entry) in &curr_map {
        match prev_map.get(path) {
            None => added.push((*curr_entry).clone()),
            Some(prev_entry) if prev_entry.sha256 != curr_entry.sha256 => {
                modified.push(Change {
                    path: (*path).clone(),
                    // Path identity == category identity in the
                    // current scan; if a future probe ever rewrites a
                    // file's category we'll need to surface that
                    // explicitly. Until then, take the curr-side
                    // value so the diff reflects today's classification.
                    category: curr_entry.category.clone(),
                    prev_sha256: prev_entry.sha256.clone(),
                    curr_sha256: curr_entry.sha256.clone(),
                    prev_size: prev_entry.size,
                    curr_size: curr_entry.size,
                });
            }
            _ => {}
        }
    }

    for (path, prev_entry) in &prev_map {
        if !curr_map.contains_key(path) {
            removed.push((*prev_entry).clone());
        }
    }

    added.sort_by(|a, b| a.path.cmp(&b.path));
    modified.sort_by(|a, b| a.path.cmp(&b.path));
    removed.sort_by(|a, b| a.path.cmp(&b.path));

    DiffReport {
        added,
        modified,
        removed,
    }
}

impl DiffReport {
    /// `true` if all three vectors (added / modified / removed) are empty.
    pub fn is_empty(&self) -> bool {
        self.added.is_empty() && self.modified.is_empty() && self.removed.is_empty()
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    fn entry(p: &str, sha: &str, size: u64) -> PathEntry {
        PathEntry {
            path: p.into(),
            category: "x".into(),
            sha256: sha.repeat(8),
            size,
        }
    }

    fn report(entries: Vec<PathEntry>) -> ScanReport {
        ScanReport {
            scanned_at: chrono::Utc::now(),
            paths: entries,
        }
    }

    #[test]
    fn empty_diff() {
        let r = report(vec![entry("/a", "11111111", 1)]);
        let d = compute(&r, &r);
        assert!(d.is_empty());
    }

    fn entry_with_category(p: &str, sha: &str, size: u64, category: &str) -> PathEntry {
        PathEntry {
            path: p.into(),
            category: category.into(),
            sha256: sha.repeat(8),
            size,
        }
    }

    #[test]
    fn modified_change_carries_category_through() {
        let prev = report(vec![entry_with_category(
            "/home/u/.claude.json",
            "11111111",
            10,
            "local_config",
        )]);
        let curr = report(vec![entry_with_category(
            "/home/u/.claude.json",
            "22222222",
            12,
            "local_config",
        )]);
        let d = compute(&prev, &curr);
        assert_eq!(d.modified.len(), 1);
        assert_eq!(d.modified[0].category, "local_config");
    }

    #[test]
    fn detects_added_modified_removed() {
        let prev = report(vec![entry("/a", "11111111", 1), entry("/b", "22222222", 2)]);
        let curr = report(vec![
            entry("/a", "33333333", 1), // modified
            entry("/c", "44444444", 3), // added
                                        // /b removed
        ]);
        let d = compute(&prev, &curr);
        assert_eq!(d.added.len(), 1);
        assert_eq!(d.added[0].path, PathBuf::from("/c"));
        assert_eq!(d.modified.len(), 1);
        assert_eq!(d.modified[0].path, PathBuf::from("/a"));
        assert_eq!(d.removed.len(), 1);
        assert_eq!(d.removed[0].path, PathBuf::from("/b"));
    }
}