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