1use 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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
49pub struct PathEntry {
50 pub path: PathBuf,
53 pub category: String,
55 pub sha256: String,
57 pub size: u64,
59}
60
61fn universal_target_roots() -> Vec<(String, PathBuf)> {
66 vec![
67 ("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 ("env_project".into(), PathBuf::from(".env")),
78 ]
79}
80
81pub fn collect(paths: &Paths, probes: &[&dyn PlatformProbe]) -> Result<Vec<PathEntry>> {
97 let mut out = Vec::new();
98 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
131const 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
149const 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 }
184 Ok(())
185}
186
187fn 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 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
227fn 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 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 assert_eq!(entries[0].size, 1);
337 assert_eq!(entries[1].size, 2);
338 }
339
340 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}