Skip to main content

agentsec_core/scan/
mod.rs

1//! Inventory scan over agent config / dependency manifests / secrets dotfile.
2//! Covers the **L1 × V3** cell (cf. *crate root §Threat surface × vector*).
3//!
4//! ## Pipeline
5//!
6//! [`run`] composes the three sub-modules in order:
7//!
8//! 1. [`inventory::collect`] — walk a fixed list of target roots, hash each
9//!    file with SHA-256, decompose `~/.claude.json` into virtual fragments
10//!    so background writes do not show as modifications.
11//! 2. [`snapshot::save`] — persist the report as
12//!    `<home>/snapshots/<UTC-ts>.json` (cf. *crate root §Runtime data root*).
13//! 3. [`diff::compute`] — if a previous snapshot exists, classify the new
14//!    report into added / modified / removed.
15//!
16//! The function is **idempotent under no-change**: re-running [`run`] when no
17//! tracked file has changed produces a new snapshot file but an empty
18//! [`diff::DiffReport`].
19//!
20//! ## Read-only invariant
21//!
22//! No path under [`inventory::collect`]'s target roots is ever mutated.
23//! Writes are scoped to `<home>/snapshots/`. Symlinks are not followed.
24
25pub mod diff;
26pub mod inventory;
27pub mod snapshot;
28pub mod unknown;
29
30use crate::Paths;
31use crate::error::Result;
32use serde::{Deserialize, Serialize};
33use std::path::PathBuf;
34
35/// One scan's worth of inventory data, before any diffing.
36#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
37pub struct ScanReport {
38    /// UTC timestamp of when this report was constructed.
39    pub scanned_at: chrono::DateTime<chrono::Utc>,
40    /// All path entries collected, sorted by [`inventory::PathEntry::path`].
41    pub paths: Vec<inventory::PathEntry>,
42}
43
44/// Full output of one [`run`] call: the inventory, the path of the persisted
45/// snapshot, and (if a previous snapshot existed) the diff against it.
46#[derive(Debug, Clone, Serialize, Deserialize)]
47pub struct ScanOutcome {
48    /// The inventory report just produced.
49    pub report: ScanReport,
50    /// Absolute path of the snapshot file written under
51    /// `<home>/snapshots/<UTC-ts>.json`.
52    pub snapshot_path: PathBuf,
53    /// `Some` when a previous snapshot was found and compared against;
54    /// `None` for the first-ever scan (baseline run).
55    pub diff: Option<diff::DiffReport>,
56}
57
58/// Run a full scan: enumerate inventory, persist a snapshot, and (if a
59/// previous snapshot exists) compute a diff against it.
60///
61/// `paths.user_home` drives inventory target roots; `paths.home` drives
62/// snapshot persistence.
63///
64/// # Errors
65///
66/// Returns [`crate::Error::Io`] if the snapshot directory cannot be
67/// created, [`crate::Error::Json`] on snapshot serialization failure, or
68/// [`crate::Error::Scan`] on snapshot parse failure when loading the
69/// previous snapshot.
70pub fn run(paths: &Paths) -> Result<ScanOutcome> {
71    let prev = snapshot::load_latest(paths)?;
72    let report = ScanReport {
73        scanned_at: chrono::Utc::now(),
74        paths: inventory::collect(paths)?,
75    };
76    let snapshot_path = snapshot::save(paths, &report)?;
77    let diff = prev.as_ref().map(|p| diff::compute(p, &report));
78    Ok(ScanOutcome {
79        report,
80        snapshot_path,
81        diff,
82    })
83}
84
85/// Compute the diff between the current inventory and the latest snapshot
86/// **without persisting a new snapshot**.
87///
88/// Use this when you want a read-only "what changed since the last
89/// `scan::run`" view — repeated calls compare against the same baseline.
90/// Returns `Ok(None)` when no previous snapshot exists yet.
91///
92/// `paths.user_home` drives inventory target roots; `paths.home` drives
93/// snapshot lookup. No I/O is performed under `paths.home/snapshots/`.
94///
95/// # Errors
96///
97/// Returns [`crate::Error::Io`] on inventory walk failure or
98/// [`crate::Error::Scan`] on snapshot parse failure.
99pub fn diff_against_latest(paths: &Paths) -> Result<Option<diff::DiffReport>> {
100    let Some(prev) = snapshot::load_latest(paths)? else {
101        return Ok(None);
102    };
103    let curr = ScanReport {
104        scanned_at: chrono::Utc::now(),
105        paths: inventory::collect(paths)?,
106    };
107    Ok(Some(diff::compute(&prev, &curr)))
108}
109
110#[cfg(test)]
111mod tests {
112    use super::*;
113
114    fn paths_for(tmp: &tempfile::TempDir) -> Paths {
115        Paths {
116            home: tmp.path().to_path_buf(),
117            user_home: tmp.path().to_path_buf(),
118        }
119    }
120
121    #[test]
122    fn diff_against_latest_returns_none_for_baseline() {
123        let tmp = tempfile::tempdir().unwrap();
124        let paths = paths_for(&tmp);
125        // No snapshot has been written yet ⇒ no baseline to diff against.
126        assert!(diff_against_latest(&paths).unwrap().is_none());
127    }
128
129    #[test]
130    fn diff_against_latest_does_not_create_new_snapshot() {
131        let tmp = tempfile::tempdir().unwrap();
132        let paths = paths_for(&tmp);
133        // Run once to establish a baseline; record the snapshot count.
134        run(&paths).unwrap();
135        let snapshot_dir = paths.snapshots();
136        let before = std::fs::read_dir(&snapshot_dir).unwrap().count();
137        // diff_against_latest should NOT add another snapshot file.
138        let diff = diff_against_latest(&paths).unwrap();
139        let after = std::fs::read_dir(&snapshot_dir).unwrap().count();
140        assert_eq!(before, after, "diff_against_latest must not persist");
141        // And the diff itself should be Some(empty), not None, because a
142        // baseline now exists and nothing has changed.
143        let d = diff.expect("diff exists when baseline exists");
144        assert!(d.is_empty(), "no-op diff must be empty");
145    }
146}