Skip to main content

agentsec_core/scan/
inventory.rs

1//! Inventory enumeration: turn the fixed target-root list into hashed
2//! [`PathEntry`] rows.
3//!
4//! ## Target roots
5//!
6//! Built from two sources, concatenated in this order:
7//!
8//! 1. **Platform-specific** — provided by each registered
9//!    [`PlatformProbe`] (one sibling crate per supported Agent
10//!    platform). Each probe owns its own `(category, path)` list
11//!    plus optional per-file decomposition rules.
12//! 2. **Universal** — hard-coded in [`universal_target_roots`]:
13//!    dependency manifests + lockfiles (supply-chain) and `.env`,
14//!    all resolved relative to the current working directory. These
15//!    are platform-agnostic and have no owning probe.
16//!
17//! Missing targets are silently skipped (no error). Directories are walked
18//! recursively, with `SKIP_DIRS` / `SKIP_FILES` noise filtered out.
19//! Symlinks are not followed.
20//!
21//! ## Per-file decomposition
22//!
23//! [`path_entries`] delegates to the owning probe's
24//! [`PlatformProbe::decompose_file`]: when it returns `Some(fragments)`,
25//! the file produces one virtual `<file>#<fragment>` entry per element
26//! (each hashed over its own payload); when it returns `None` the file
27//! produces a single whole-file SHA-256 row. The mechanism lets a
28//! probe split noisy configs into per-block fragments so that
29//! unrelated background writes (session counters, cache, timestamps)
30//! do not show as Modified noise — without core needing to know the
31//! per-platform schema.
32
33use crate::Paths;
34use crate::error::Result;
35use crate::platform::PlatformProbe;
36use serde::{Deserialize, Serialize};
37use sha2::{Digest, Sha256};
38use std::fs;
39use std::path::{Path, PathBuf};
40
41/// One hashed file or virtual sub-entry.
42///
43/// For most files this represents `sha256(full bytes)`. For files a
44/// probe chooses to decompose via [`PlatformProbe::decompose_file`]
45/// it represents `sha256(fragment payload)` and the `path` field
46/// carries a `<file>#<fragment>` virtual suffix (see module docs
47/// §Per-file decomposition).
48#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
49pub struct PathEntry {
50    /// Absolute path of the file, optionally suffixed with `#<fragment>`
51    /// for virtual JSON-block entries.
52    pub path: PathBuf,
53    /// Target-root category label from `target_roots`.
54    pub category: String,
55    /// Lowercase hex SHA-256 of the file bytes (or canonical fragment bytes).
56    pub sha256: String,
57    /// Size in bytes of the hashed payload.
58    pub size: u64,
59}
60
61/// Platform-independent inventory targets: dependency manifests +
62/// lockfiles (supply chain) and the project `.env` (sha256 only).
63/// All entries are resolved relative to the current working directory
64/// and are not owned by any [`PlatformProbe`].
65fn universal_target_roots() -> Vec<(String, PathBuf)> {
66    vec![
67        // ── Dependency manifests + lockfiles (supply chain) ───────────────
68        ("manifest_npm".into(), PathBuf::from("package.json")),
69        ("manifest_cargo".into(), PathBuf::from("Cargo.toml")),
70        ("manifest_python".into(), PathBuf::from("pyproject.toml")),
71        ("lockfile_npm".into(), PathBuf::from("package-lock.json")),
72        ("lockfile_yarn".into(), PathBuf::from("yarn.lock")),
73        ("lockfile_cargo".into(), PathBuf::from("Cargo.lock")),
74        ("lockfile_poetry".into(), PathBuf::from("poetry.lock")),
75        ("lockfile_uv".into(), PathBuf::from("uv.lock")),
76        // ── Secrets dotfile (sha256 only, contents never persisted) ───────
77        ("env_project".into(), PathBuf::from(".env")),
78    ]
79}
80
81/// Walk every probe's target roots plus the universal list and return
82/// a sorted list of [`PathEntry`].
83///
84/// `paths.user_home` is used to construct home-rooted absolute paths.
85/// Missing target roots are skipped silently. Files yield one entry
86/// each (or one entry per fragment if their owning probe decomposes
87/// them — see module docs §Per-file decomposition); directories are
88/// walked recursively with `SKIP_DIRS` / `SKIP_FILES` filtered out.
89/// The returned list is sorted by [`PathEntry::path`] so the snapshot
90/// is reproducible and diffable across runs.
91///
92/// # Errors
93///
94/// Returns [`crate::Error::Io`] if a target exists but cannot be read
95/// (permission denied, vanished mid-walk, etc.).
96pub fn collect(paths: &Paths, probes: &[&dyn PlatformProbe]) -> Result<Vec<PathEntry>> {
97    let mut out = Vec::new();
98    // Walk per probe so we can consult `decompose_file` with the
99    // owning probe for each Claude/Cursor/etc. file. Universal
100    // (supply-chain + `.env`) targets have no owning probe and always
101    // produce a single whole-file SHA-256 row.
102    for probe in probes {
103        for (category, root) in probe.target_roots(paths) {
104            walk_root(&root, &category, Some(*probe), &mut out)?;
105        }
106    }
107    for (category, root) in universal_target_roots() {
108        walk_root(&root, &category, None, &mut out)?;
109    }
110    out.sort_by(|a, b| a.path.cmp(&b.path));
111    Ok(out)
112}
113
114fn walk_root(
115    root: &Path,
116    category: &str,
117    probe: Option<&dyn PlatformProbe>,
118    out: &mut Vec<PathEntry>,
119) -> Result<()> {
120    if !root.exists() {
121        return Ok(());
122    }
123    if root.is_file() {
124        out.extend(path_entries(root, category, probe)?);
125    } else if root.is_dir() {
126        walk(root, category, probe, out)?;
127    }
128    Ok(())
129}
130
131/// Directory names we never descend into. Cuts scan noise from VCS / build /
132/// dependency caches that aren't AgentSec's domain.
133const SKIP_DIRS: &[&str] = &[
134    ".git",
135    "node_modules",
136    "target",
137    ".venv",
138    "venv",
139    "__pycache__",
140    ".cache",
141    ".idea",
142    ".vscode",
143    "dist",
144    "build",
145    ".next",
146    ".turbo",
147];
148
149/// File names we never hash.
150const SKIP_FILES: &[&str] = &[".DS_Store", "Thumbs.db"];
151
152fn should_skip(name: &str, is_dir: bool) -> bool {
153    if is_dir {
154        SKIP_DIRS.contains(&name)
155    } else {
156        SKIP_FILES.contains(&name)
157    }
158}
159
160fn walk(
161    dir: &Path,
162    category: &str,
163    probe: Option<&dyn PlatformProbe>,
164    out: &mut Vec<PathEntry>,
165) -> Result<()> {
166    for entry in fs::read_dir(dir)? {
167        let entry = entry?;
168        let path = entry.path();
169        let file_type = entry.file_type()?;
170        let name = entry.file_name();
171        let name_str = name.to_string_lossy();
172
173        if should_skip(&name_str, file_type.is_dir()) {
174            continue;
175        }
176
177        if file_type.is_dir() {
178            walk(&path, category, probe, out)?;
179        } else if file_type.is_file() {
180            out.extend(path_entries(&path, category, probe)?);
181        }
182        // symlinks are intentionally skipped (no follow) to keep scan read-only safe.
183    }
184    Ok(())
185}
186
187/// Compute one or more `PathEntry` rows for a file.
188///
189/// If the owning probe's [`PlatformProbe::decompose_file`] returns
190/// `Some(fragments)`, the file produces one virtual `<path>#<fragment>`
191/// entry per element (each hashed over its own payload). Otherwise
192/// the file produces a single whole-file SHA-256 row. Universal
193/// (probe-less) targets always take the latter path.
194fn path_entries(
195    path: &Path,
196    category: &str,
197    probe: Option<&dyn PlatformProbe>,
198) -> Result<Vec<PathEntry>> {
199    let metadata = fs::metadata(path)?;
200    if !metadata.is_file() {
201        return Ok(Vec::new());
202    }
203    let bytes = fs::read(path)?;
204
205    if let Some(probe) = probe {
206        if let Some(fragments) = probe.decompose_file(category, path, &bytes)? {
207            return Ok(fragments
208                .into_iter()
209                .map(|f| virtual_entry(path, category, &f.fragment, &f.payload))
210                .collect());
211        }
212    }
213
214    // Canonicalize the path so that symlinks and `..` components are resolved
215    // to their absolute real path. Fall back to the original path on error
216    // (e.g. if the file is a symlink whose target has been removed between
217    // the `metadata` call and `canonicalize`).
218    let canonical_path = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
219    Ok(vec![PathEntry {
220        path: canonical_path,
221        category: category.to_string(),
222        sha256: sha256_hex(&bytes),
223        size: metadata.len(),
224    }])
225}
226
227/// Build a virtual `<path>#<fragment>` entry from a [`crate::platform::FragmentEntry`]
228/// payload. The probe owns the canonical-bytes choice (typically
229/// `serde_json::to_string` of the watched JSON sub-value).
230fn virtual_entry(path: &Path, category: &str, fragment: &str, payload: &[u8]) -> PathEntry {
231    PathEntry {
232        path: PathBuf::from(format!("{}#{fragment}", path.display())),
233        category: category.to_string(),
234        sha256: sha256_hex(payload),
235        size: payload.len() as u64,
236    }
237}
238
239fn sha256_hex(bytes: &[u8]) -> String {
240    let mut h = Sha256::new();
241    h.update(bytes);
242    format!("{:x}", h.finalize())
243}
244
245#[cfg(test)]
246mod tests {
247    use super::*;
248    use crate::platform::FragmentEntry;
249    use std::io::Write;
250
251    #[test]
252    fn path_entries_computes_sha256_for_regular_file() {
253        let mut tmp = tempfile::NamedTempFile::new().unwrap();
254        tmp.write_all(b"hello agentsec").unwrap();
255        let entries = path_entries(tmp.path(), "test", None).unwrap();
256        assert_eq!(entries.len(), 1);
257        let e = &entries[0];
258        assert_eq!(e.size, 14);
259        assert_eq!(e.sha256.len(), 64);
260        assert_eq!(e.category, "test");
261    }
262
263    #[test]
264    fn walk_collects_files_recursively() {
265        let dir = tempfile::tempdir().unwrap();
266        let sub = dir.path().join("sub");
267        fs::create_dir(&sub).unwrap();
268        fs::write(dir.path().join("a.txt"), "a").unwrap();
269        fs::write(sub.join("b.txt"), "bb").unwrap();
270        let mut out = Vec::new();
271        walk(dir.path(), "x", None, &mut out).unwrap();
272        assert_eq!(out.len(), 2);
273    }
274
275    /// Probe that splits any file into two fixed fragments. Used to
276    /// verify the inventory walk consults `decompose_file` and routes
277    /// the returned fragments through `virtual_entry`.
278    struct TwoFragmentProbe;
279
280    impl PlatformProbe for TwoFragmentProbe {
281        fn id(&self) -> &'static str {
282            "two-fragment"
283        }
284        fn target_roots(&self, _paths: &Paths) -> Vec<(String, PathBuf)> {
285            Vec::new()
286        }
287        fn mcp_config_paths(&self, _paths: &Paths) -> Vec<PathBuf> {
288            Vec::new()
289        }
290        fn extract_mcp_servers(
291            &self,
292            _content: &str,
293            _path: &Path,
294        ) -> Result<Vec<crate::platform::McpServerEntry>> {
295            Ok(Vec::new())
296        }
297        fn decompose_file(
298            &self,
299            _category: &str,
300            _path: &Path,
301            _content: &[u8],
302        ) -> Result<Option<Vec<FragmentEntry>>> {
303            Ok(Some(vec![
304                FragmentEntry {
305                    fragment: "alpha".into(),
306                    payload: b"A".to_vec(),
307                },
308                FragmentEntry {
309                    fragment: "beta".into(),
310                    payload: b"BB".to_vec(),
311                },
312            ]))
313        }
314    }
315
316    #[test]
317    fn path_entries_uses_probe_decompose_when_supplied() {
318        let mut tmp = tempfile::NamedTempFile::new().unwrap();
319        tmp.write_all(b"irrelevant").unwrap();
320        let probe = TwoFragmentProbe;
321        let entries =
322            path_entries(tmp.path(), "any_cat", Some(&probe as &dyn PlatformProbe)).unwrap();
323        assert_eq!(entries.len(), 2);
324        let frags: Vec<String> = entries
325            .iter()
326            .map(|e| {
327                e.path
328                    .to_string_lossy()
329                    .rsplit_once('#')
330                    .map(|(_, f)| f.to_string())
331                    .unwrap_or_default()
332            })
333            .collect();
334        assert_eq!(frags, vec!["alpha", "beta"]);
335        // Fragment payloads, not the file body, drive size + sha256.
336        assert_eq!(entries[0].size, 1);
337        assert_eq!(entries[1].size, 2);
338    }
339
340    /// Probe whose `decompose_file` returns Ok(None) — the inventory
341    /// walk should fall back to whole-file SHA-256.
342    struct NoDecomposeProbe;
343
344    impl PlatformProbe for NoDecomposeProbe {
345        fn id(&self) -> &'static str {
346            "no-decompose"
347        }
348        fn target_roots(&self, _paths: &Paths) -> Vec<(String, PathBuf)> {
349            Vec::new()
350        }
351        fn mcp_config_paths(&self, _paths: &Paths) -> Vec<PathBuf> {
352            Vec::new()
353        }
354        fn extract_mcp_servers(
355            &self,
356            _content: &str,
357            _path: &Path,
358        ) -> Result<Vec<crate::platform::McpServerEntry>> {
359            Ok(Vec::new())
360        }
361    }
362
363    #[test]
364    fn path_entries_falls_back_to_whole_file_when_decompose_returns_none() {
365        let mut tmp = tempfile::NamedTempFile::new().unwrap();
366        tmp.write_all(b"hello").unwrap();
367        let probe = NoDecomposeProbe;
368        let entries =
369            path_entries(tmp.path(), "any_cat", Some(&probe as &dyn PlatformProbe)).unwrap();
370        assert_eq!(entries.len(), 1);
371        assert_eq!(entries[0].size, 5);
372        assert!(!entries[0].path.to_string_lossy().contains('#'));
373    }
374}