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    /// SHA-256 from the previous snapshot.
33    pub prev_sha256: String,
34    /// SHA-256 from the current snapshot.
35    pub curr_sha256: String,
36    /// Size in bytes from the previous snapshot.
37    pub prev_size: u64,
38    /// Size in bytes from the current snapshot.
39    pub curr_size: u64,
40}
41
42/// Compute the [`DiffReport`] from `prev` (older) to `curr` (newer).
43///
44/// Pure function: no I/O, no allocation of foreign resources. Comparing a
45/// report against itself yields an empty diff ([`DiffReport::is_empty`]
46/// returns `true`).
47pub fn compute(prev: &ScanReport, curr: &ScanReport) -> DiffReport {
48    let prev_map: HashMap<&PathBuf, &PathEntry> = prev.paths.iter().map(|e| (&e.path, e)).collect();
49    let curr_map: HashMap<&PathBuf, &PathEntry> = curr.paths.iter().map(|e| (&e.path, e)).collect();
50
51    let mut added = Vec::new();
52    let mut modified = Vec::new();
53    let mut removed = Vec::new();
54
55    for (path, curr_entry) in &curr_map {
56        match prev_map.get(path) {
57            None => added.push((*curr_entry).clone()),
58            Some(prev_entry) if prev_entry.sha256 != curr_entry.sha256 => {
59                modified.push(Change {
60                    path: (*path).clone(),
61                    prev_sha256: prev_entry.sha256.clone(),
62                    curr_sha256: curr_entry.sha256.clone(),
63                    prev_size: prev_entry.size,
64                    curr_size: curr_entry.size,
65                });
66            }
67            _ => {}
68        }
69    }
70
71    for (path, prev_entry) in &prev_map {
72        if !curr_map.contains_key(path) {
73            removed.push((*prev_entry).clone());
74        }
75    }
76
77    added.sort_by(|a, b| a.path.cmp(&b.path));
78    modified.sort_by(|a, b| a.path.cmp(&b.path));
79    removed.sort_by(|a, b| a.path.cmp(&b.path));
80
81    DiffReport {
82        added,
83        modified,
84        removed,
85    }
86}
87
88impl DiffReport {
89    /// `true` if all three vectors (added / modified / removed) are empty.
90    pub fn is_empty(&self) -> bool {
91        self.added.is_empty() && self.modified.is_empty() && self.removed.is_empty()
92    }
93}
94
95#[cfg(test)]
96mod tests {
97    use super::*;
98
99    fn entry(p: &str, sha: &str, size: u64) -> PathEntry {
100        PathEntry {
101            path: p.into(),
102            category: "x".into(),
103            sha256: sha.repeat(8),
104            size,
105        }
106    }
107
108    fn report(entries: Vec<PathEntry>) -> ScanReport {
109        ScanReport {
110            scanned_at: chrono::Utc::now(),
111            paths: entries,
112        }
113    }
114
115    #[test]
116    fn empty_diff() {
117        let r = report(vec![entry("/a", "11111111", 1)]);
118        let d = compute(&r, &r);
119        assert!(d.is_empty());
120    }
121
122    #[test]
123    fn detects_added_modified_removed() {
124        let prev = report(vec![entry("/a", "11111111", 1), entry("/b", "22222222", 2)]);
125        let curr = report(vec![
126            entry("/a", "33333333", 1), // modified
127            entry("/c", "44444444", 3), // added
128                                        // /b removed
129        ]);
130        let d = compute(&prev, &curr);
131        assert_eq!(d.added.len(), 1);
132        assert_eq!(d.added[0].path, PathBuf::from("/c"));
133        assert_eq!(d.modified.len(), 1);
134        assert_eq!(d.modified[0].path, PathBuf::from("/a"));
135        assert_eq!(d.removed.len(), 1);
136        assert_eq!(d.removed[0].path, PathBuf::from("/b"));
137    }
138}