Skip to main content

agentsec_core/scan/
diff.rs

1//! Set-difference between two [`ScanReport`]s, keyed on [`PathEntry::path`].
2//!
3//! Membership is decided by path identity (including any `#<fragment>`
4//! suffix appended by probe-driven decomposition, see
5//! [`crate::scan::inventory`] §Per-file decomposition), and content
6//! equality is decided by [`PathEntry::sha256`]. Entries appearing
7//! only in `curr` are `added`; entries appearing only in `prev` are
8//! `removed`; entries with the same path but different sha are
9//! `modified`. Output vectors are sorted by path so the diff is
10//! reproducible.
11
12use crate::scan::ScanReport;
13use crate::scan::inventory::PathEntry;
14use serde::{Deserialize, Serialize};
15use std::collections::HashMap;
16use std::path::PathBuf;
17
18/// Classified difference between two snapshots.
19#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
20pub struct DiffReport {
21    /// Paths present in `curr` but not in `prev`.
22    pub added: Vec<PathEntry>,
23    /// Paths present in both with different content hashes.
24    pub modified: Vec<Change>,
25    /// Paths present in `prev` but not in `curr`.
26    pub removed: Vec<PathEntry>,
27}
28
29/// One modified-file row in [`DiffReport::modified`].
30#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
31pub struct Change {
32    /// Path identity (same in both `prev` and `curr`).
33    pub path: PathBuf,
34    /// Target-root category label (carried through from
35    /// [`PathEntry::category`] so diff renderers can route the entry
36    /// through [`crate::platform::PlatformProbe::critical_categories`]
37    /// without re-parsing the path string).
38    pub category: String,
39    /// SHA-256 from the previous snapshot.
40    pub prev_sha256: String,
41    /// SHA-256 from the current snapshot.
42    pub curr_sha256: String,
43    /// Size in bytes from the previous snapshot.
44    pub prev_size: u64,
45    /// Size in bytes from the current snapshot.
46    pub curr_size: u64,
47}
48
49/// Compute the [`DiffReport`] from `prev` (older) to `curr` (newer).
50///
51/// Pure function: no I/O, no allocation of foreign resources. Comparing a
52/// report against itself yields an empty diff ([`DiffReport::is_empty`]
53/// returns `true`).
54pub fn compute(prev: &ScanReport, curr: &ScanReport) -> DiffReport {
55    let prev_map: HashMap<&PathBuf, &PathEntry> = prev.paths.iter().map(|e| (&e.path, e)).collect();
56    let curr_map: HashMap<&PathBuf, &PathEntry> = curr.paths.iter().map(|e| (&e.path, e)).collect();
57
58    let mut added = Vec::new();
59    let mut modified = Vec::new();
60    let mut removed = Vec::new();
61
62    for (path, curr_entry) in &curr_map {
63        match prev_map.get(path) {
64            None => added.push((*curr_entry).clone()),
65            Some(prev_entry) if prev_entry.sha256 != curr_entry.sha256 => {
66                modified.push(Change {
67                    path: (*path).clone(),
68                    // Path identity == category identity in the
69                    // current scan; if a future probe ever rewrites a
70                    // file's category we'll need to surface that
71                    // explicitly. Until then, take the curr-side
72                    // value so the diff reflects today's classification.
73                    category: curr_entry.category.clone(),
74                    prev_sha256: prev_entry.sha256.clone(),
75                    curr_sha256: curr_entry.sha256.clone(),
76                    prev_size: prev_entry.size,
77                    curr_size: curr_entry.size,
78                });
79            }
80            _ => {}
81        }
82    }
83
84    for (path, prev_entry) in &prev_map {
85        if !curr_map.contains_key(path) {
86            removed.push((*prev_entry).clone());
87        }
88    }
89
90    added.sort_by(|a, b| a.path.cmp(&b.path));
91    modified.sort_by(|a, b| a.path.cmp(&b.path));
92    removed.sort_by(|a, b| a.path.cmp(&b.path));
93
94    DiffReport {
95        added,
96        modified,
97        removed,
98    }
99}
100
101impl DiffReport {
102    /// `true` if all three vectors (added / modified / removed) are empty.
103    pub fn is_empty(&self) -> bool {
104        self.added.is_empty() && self.modified.is_empty() && self.removed.is_empty()
105    }
106}
107
108#[cfg(test)]
109mod tests {
110    use super::*;
111
112    fn entry(p: &str, sha: &str, size: u64) -> PathEntry {
113        PathEntry {
114            path: p.into(),
115            category: "x".into(),
116            sha256: sha.repeat(8),
117            size,
118        }
119    }
120
121    fn report(entries: Vec<PathEntry>) -> ScanReport {
122        ScanReport {
123            scanned_at: chrono::Utc::now(),
124            paths: entries,
125        }
126    }
127
128    #[test]
129    fn empty_diff() {
130        let r = report(vec![entry("/a", "11111111", 1)]);
131        let d = compute(&r, &r);
132        assert!(d.is_empty());
133    }
134
135    fn entry_with_category(p: &str, sha: &str, size: u64, category: &str) -> PathEntry {
136        PathEntry {
137            path: p.into(),
138            category: category.into(),
139            sha256: sha.repeat(8),
140            size,
141        }
142    }
143
144    #[test]
145    fn modified_change_carries_category_through() {
146        let prev = report(vec![entry_with_category(
147            "/home/u/.claude.json",
148            "11111111",
149            10,
150            "local_config",
151        )]);
152        let curr = report(vec![entry_with_category(
153            "/home/u/.claude.json",
154            "22222222",
155            12,
156            "local_config",
157        )]);
158        let d = compute(&prev, &curr);
159        assert_eq!(d.modified.len(), 1);
160        assert_eq!(d.modified[0].category, "local_config");
161    }
162
163    #[test]
164    fn detects_added_modified_removed() {
165        let prev = report(vec![entry("/a", "11111111", 1), entry("/b", "22222222", 2)]);
166        let curr = report(vec![
167            entry("/a", "33333333", 1), // modified
168            entry("/c", "44444444", 3), // added
169                                        // /b removed
170        ]);
171        let d = compute(&prev, &curr);
172        assert_eq!(d.added.len(), 1);
173        assert_eq!(d.added[0].path, PathBuf::from("/c"));
174        assert_eq!(d.modified.len(), 1);
175        assert_eq!(d.modified[0].path, PathBuf::from("/a"));
176        assert_eq!(d.removed.len(), 1);
177        assert_eq!(d.removed[0].path, PathBuf::from("/b"));
178    }
179}