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