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