1use 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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
47pub struct PathEntry {
48 pub path: PathBuf,
51 pub category: String,
53 pub sha256: String,
55 pub size: u64,
57}
58
59fn universal_target_roots() -> Vec<(String, PathBuf)> {
64 vec![
65 ("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 ("env_project".into(), PathBuf::from(".env")),
76 ]
77}
78
79pub fn collect(paths: &Paths, probes: &[&dyn PlatformProbe]) -> Result<Vec<PathEntry>> {
95 let mut out = Vec::new();
96 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
129const 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
147const 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 }
182 Ok(())
183}
184
185fn 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 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
225fn 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 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 assert_eq!(entries[0].size, 1);
335 assert_eq!(entries[1].size, 2);
336 }
337
338 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}