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 every registered probe's target
9//!    roots plus the universal supply-chain list, hash each file with
10//!    SHA-256, and (when the owning probe opts in via
11//!    [`crate::platform::PlatformProbe::decompose_file`]) split noisy
12//!    configs into virtual JSON-block fragments so unrelated background
13//!    writes do not show as modifications.
14//! 2. [`snapshot::save`] — persist the report as
15//!    `<home>/snapshots/<UTC-ts>.json` (cf. *crate root §Runtime data root*).
16//! 3. [`diff::compute`] — if a previous snapshot exists, classify the new
17//!    report into added / modified / removed.
18//!
19//! The function is **idempotent under no-change**: re-running [`run`] when no
20//! tracked file has changed produces a new snapshot file but an empty
21//! [`diff::DiffReport`].
22//!
23//! ## Read-only invariant
24//!
25//! No path under [`inventory::collect`]'s target roots is ever mutated.
26//! Writes are scoped to `<home>/snapshots/`. Symlinks are not followed.
27
28pub mod diff;
29pub mod inventory;
30pub mod snapshot;
31pub mod unknown;
32
33use crate::Paths;
34use crate::error::Result;
35use crate::platform::PlatformProbe;
36use serde::{Deserialize, Serialize};
37use std::path::PathBuf;
38
39/// One scan's worth of inventory data, before any diffing.
40#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
41pub struct ScanReport {
42    /// UTC timestamp of when this report was constructed.
43    pub scanned_at: chrono::DateTime<chrono::Utc>,
44    /// All path entries collected, sorted by [`inventory::PathEntry::path`].
45    pub paths: Vec<inventory::PathEntry>,
46}
47
48/// Full output of one [`run`] call: the inventory, the path of the persisted
49/// snapshot, and (if a previous snapshot existed) the diff against it.
50#[derive(Debug, Clone, Serialize, Deserialize)]
51pub struct ScanOutcome {
52    /// The inventory report just produced.
53    pub report: ScanReport,
54    /// Absolute path of the snapshot file written under
55    /// `<home>/snapshots/<UTC-ts>.json`.
56    pub snapshot_path: PathBuf,
57    /// `Some` when a previous snapshot was found and compared against;
58    /// `None` for the first-ever scan (baseline run).
59    pub diff: Option<diff::DiffReport>,
60}
61
62/// Run a full scan: enumerate inventory, persist a snapshot, and (if a
63/// previous snapshot exists) compute a diff against it.
64///
65/// `paths.user_home` drives inventory target roots; `paths.home` drives
66/// snapshot persistence. `probes` enumerates the per-platform target
67/// roots (e.g. `&[&ClaudeCodePlatform::new()]` for Claude Code only).
68///
69/// # Errors
70///
71/// Returns [`crate::Error::Io`] if the snapshot directory cannot be
72/// created, [`crate::Error::Json`] on snapshot serialization failure, or
73/// [`crate::Error::Scan`] on snapshot parse failure when loading the
74/// previous snapshot.
75pub fn run(paths: &Paths, probes: &[&dyn PlatformProbe]) -> Result<ScanOutcome> {
76    let prev = snapshot::load_latest(paths)?;
77    let report = ScanReport {
78        scanned_at: chrono::Utc::now(),
79        paths: inventory::collect(paths, probes)?,
80    };
81    let snapshot_path = snapshot::save(paths, &report)?;
82    let diff = prev.as_ref().map(|p| diff::compute(p, &report));
83    Ok(ScanOutcome {
84        report,
85        snapshot_path,
86        diff,
87    })
88}
89
90/// Compute the diff between the current inventory and the latest snapshot
91/// **without persisting a new snapshot**.
92///
93/// Use this when you want a read-only "what changed since the last
94/// `scan::run`" view — repeated calls compare against the same baseline.
95/// Returns `Ok(None)` when no previous snapshot exists yet.
96///
97/// `paths.user_home` drives inventory target roots; `paths.home` drives
98/// snapshot lookup. No I/O is performed under `paths.home/snapshots/`.
99///
100/// # Errors
101///
102/// Returns [`crate::Error::Io`] on inventory walk failure or
103/// [`crate::Error::Scan`] on snapshot parse failure.
104pub fn diff_against_latest(
105    paths: &Paths,
106    probes: &[&dyn PlatformProbe],
107) -> Result<Option<diff::DiffReport>> {
108    let Some(prev) = snapshot::load_latest(paths)? else {
109        return Ok(None);
110    };
111    let curr = ScanReport {
112        scanned_at: chrono::Utc::now(),
113        paths: inventory::collect(paths, probes)?,
114    };
115    Ok(Some(diff::compute(&prev, &curr)))
116}
117
118#[cfg(test)]
119mod tests {
120    use super::*;
121    use crate::platform::McpServerEntry;
122    use std::path::{Path, PathBuf};
123
124    fn paths_for(tmp: &tempfile::TempDir) -> Paths {
125        Paths {
126            home: tmp.path().to_path_buf(),
127            user_home: tmp.path().to_path_buf(),
128        }
129    }
130
131    /// Empty-output stub probe. These tests exercise the snapshot /
132    /// diff plumbing on an isolated temp directory with no platform
133    /// config files present, so the probe only needs to satisfy the
134    /// trait surface — returning empty roots/configs is enough.
135    struct TestNoopProbe;
136
137    impl PlatformProbe for TestNoopProbe {
138        fn id(&self) -> &'static str {
139            "noop"
140        }
141        fn target_roots(&self, _paths: &Paths) -> Vec<(String, PathBuf)> {
142            Vec::new()
143        }
144        fn mcp_config_paths(&self, _paths: &Paths) -> Vec<PathBuf> {
145            Vec::new()
146        }
147        fn extract_mcp_servers(&self, _content: &str, _path: &Path) -> Result<Vec<McpServerEntry>> {
148            Ok(Vec::new())
149        }
150    }
151
152    fn noop_only(probe: &TestNoopProbe) -> [&dyn PlatformProbe; 1] {
153        [probe as &dyn PlatformProbe]
154    }
155
156    #[test]
157    fn diff_against_latest_returns_none_for_baseline() {
158        let tmp = tempfile::tempdir().unwrap();
159        let paths = paths_for(&tmp);
160        let probe = TestNoopProbe;
161        // No snapshot has been written yet ⇒ no baseline to diff against.
162        assert!(
163            diff_against_latest(&paths, &noop_only(&probe))
164                .unwrap()
165                .is_none()
166        );
167    }
168
169    #[test]
170    fn diff_against_latest_does_not_create_new_snapshot() {
171        let tmp = tempfile::tempdir().unwrap();
172        let paths = paths_for(&tmp);
173        let probe = TestNoopProbe;
174        // Run once to establish a baseline; record the snapshot count.
175        run(&paths, &noop_only(&probe)).unwrap();
176        let snapshot_dir = paths.snapshots();
177        let before = std::fs::read_dir(&snapshot_dir).unwrap().count();
178        // diff_against_latest should NOT add another snapshot file.
179        let diff = diff_against_latest(&paths, &noop_only(&probe)).unwrap();
180        let after = std::fs::read_dir(&snapshot_dir).unwrap().count();
181        assert_eq!(before, after, "diff_against_latest must not persist");
182        // And the diff itself should be Some(empty), not None, because a
183        // baseline now exists and nothing has changed.
184        let d = diff.expect("diff exists when baseline exists");
185        assert!(d.is_empty(), "no-op diff must be empty");
186    }
187}