use crate::Paths;
use crate::error::Result;
use crate::platform::PlatformProbe;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::fs;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct PathEntry {
pub path: PathBuf,
pub category: String,
pub sha256: String,
pub size: u64,
}
fn universal_target_roots() -> Vec<(String, PathBuf)> {
vec![
("manifest_npm".into(), PathBuf::from("package.json")),
("manifest_cargo".into(), PathBuf::from("Cargo.toml")),
("manifest_python".into(), PathBuf::from("pyproject.toml")),
("lockfile_npm".into(), PathBuf::from("package-lock.json")),
("lockfile_yarn".into(), PathBuf::from("yarn.lock")),
("lockfile_cargo".into(), PathBuf::from("Cargo.lock")),
("lockfile_poetry".into(), PathBuf::from("poetry.lock")),
("lockfile_uv".into(), PathBuf::from("uv.lock")),
("env_project".into(), PathBuf::from(".env")),
]
}
pub fn collect(paths: &Paths, probes: &[&dyn PlatformProbe]) -> Result<Vec<PathEntry>> {
let mut out = Vec::new();
for probe in probes {
for (category, root) in probe.target_roots(paths) {
walk_root(&root, &category, Some(*probe), &mut out)?;
}
}
for (category, root) in universal_target_roots() {
walk_root(&root, &category, None, &mut out)?;
}
out.sort_by(|a, b| a.path.cmp(&b.path));
Ok(out)
}
fn walk_root(
root: &Path,
category: &str,
probe: Option<&dyn PlatformProbe>,
out: &mut Vec<PathEntry>,
) -> Result<()> {
if !root.exists() {
return Ok(());
}
if root.is_file() {
out.extend(path_entries(root, category, probe)?);
} else if root.is_dir() {
walk(root, category, probe, out)?;
}
Ok(())
}
const SKIP_DIRS: &[&str] = &[
".git",
"node_modules",
"target",
".venv",
"venv",
"__pycache__",
".cache",
".idea",
".vscode",
"dist",
"build",
".next",
".turbo",
];
const SKIP_FILES: &[&str] = &[".DS_Store", "Thumbs.db"];
fn should_skip(name: &str, is_dir: bool) -> bool {
if is_dir {
SKIP_DIRS.contains(&name)
} else {
SKIP_FILES.contains(&name)
}
}
fn walk(
dir: &Path,
category: &str,
probe: Option<&dyn PlatformProbe>,
out: &mut Vec<PathEntry>,
) -> Result<()> {
for entry in fs::read_dir(dir)? {
let entry = entry?;
let path = entry.path();
let file_type = entry.file_type()?;
let name = entry.file_name();
let name_str = name.to_string_lossy();
if should_skip(&name_str, file_type.is_dir()) {
continue;
}
if file_type.is_dir() {
walk(&path, category, probe, out)?;
} else if file_type.is_file() {
out.extend(path_entries(&path, category, probe)?);
}
}
Ok(())
}
fn path_entries(
path: &Path,
category: &str,
probe: Option<&dyn PlatformProbe>,
) -> Result<Vec<PathEntry>> {
let metadata = fs::metadata(path)?;
if !metadata.is_file() {
return Ok(Vec::new());
}
let bytes = fs::read(path)?;
if let Some(probe) = probe {
if let Some(fragments) = probe.decompose_file(category, path, &bytes)? {
return Ok(fragments
.into_iter()
.map(|f| virtual_entry(path, category, &f.fragment, &f.payload))
.collect());
}
}
let canonical_path = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
Ok(vec![PathEntry {
path: canonical_path,
category: category.to_string(),
sha256: sha256_hex(&bytes),
size: metadata.len(),
}])
}
fn virtual_entry(path: &Path, category: &str, fragment: &str, payload: &[u8]) -> PathEntry {
PathEntry {
path: PathBuf::from(format!("{}#{fragment}", path.display())),
category: category.to_string(),
sha256: sha256_hex(payload),
size: payload.len() as u64,
}
}
fn sha256_hex(bytes: &[u8]) -> String {
let mut h = Sha256::new();
h.update(bytes);
format!("{:x}", h.finalize())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::platform::FragmentEntry;
use std::io::Write;
#[test]
fn path_entries_computes_sha256_for_regular_file() {
let mut tmp = tempfile::NamedTempFile::new().unwrap();
tmp.write_all(b"hello agentsec").unwrap();
let entries = path_entries(tmp.path(), "test", None).unwrap();
assert_eq!(entries.len(), 1);
let e = &entries[0];
assert_eq!(e.size, 14);
assert_eq!(e.sha256.len(), 64);
assert_eq!(e.category, "test");
}
#[test]
fn walk_collects_files_recursively() {
let dir = tempfile::tempdir().unwrap();
let sub = dir.path().join("sub");
fs::create_dir(&sub).unwrap();
fs::write(dir.path().join("a.txt"), "a").unwrap();
fs::write(sub.join("b.txt"), "bb").unwrap();
let mut out = Vec::new();
walk(dir.path(), "x", None, &mut out).unwrap();
assert_eq!(out.len(), 2);
}
struct TwoFragmentProbe;
impl PlatformProbe for TwoFragmentProbe {
fn id(&self) -> &'static str {
"two-fragment"
}
fn target_roots(&self, _paths: &Paths) -> Vec<(String, PathBuf)> {
Vec::new()
}
fn mcp_config_paths(&self, _paths: &Paths) -> Vec<PathBuf> {
Vec::new()
}
fn extract_mcp_servers(
&self,
_content: &str,
_path: &Path,
) -> Result<Vec<crate::platform::McpServerEntry>> {
Ok(Vec::new())
}
fn decompose_file(
&self,
_category: &str,
_path: &Path,
_content: &[u8],
) -> Result<Option<Vec<FragmentEntry>>> {
Ok(Some(vec![
FragmentEntry {
fragment: "alpha".into(),
payload: b"A".to_vec(),
},
FragmentEntry {
fragment: "beta".into(),
payload: b"BB".to_vec(),
},
]))
}
}
#[test]
fn path_entries_uses_probe_decompose_when_supplied() {
let mut tmp = tempfile::NamedTempFile::new().unwrap();
tmp.write_all(b"irrelevant").unwrap();
let probe = TwoFragmentProbe;
let entries =
path_entries(tmp.path(), "any_cat", Some(&probe as &dyn PlatformProbe)).unwrap();
assert_eq!(entries.len(), 2);
let frags: Vec<String> = entries
.iter()
.map(|e| {
e.path
.to_string_lossy()
.rsplit_once('#')
.map(|(_, f)| f.to_string())
.unwrap_or_default()
})
.collect();
assert_eq!(frags, vec!["alpha", "beta"]);
assert_eq!(entries[0].size, 1);
assert_eq!(entries[1].size, 2);
}
struct NoDecomposeProbe;
impl PlatformProbe for NoDecomposeProbe {
fn id(&self) -> &'static str {
"no-decompose"
}
fn target_roots(&self, _paths: &Paths) -> Vec<(String, PathBuf)> {
Vec::new()
}
fn mcp_config_paths(&self, _paths: &Paths) -> Vec<PathBuf> {
Vec::new()
}
fn extract_mcp_servers(
&self,
_content: &str,
_path: &Path,
) -> Result<Vec<crate::platform::McpServerEntry>> {
Ok(Vec::new())
}
}
#[test]
fn path_entries_falls_back_to_whole_file_when_decompose_returns_none() {
let mut tmp = tempfile::NamedTempFile::new().unwrap();
tmp.write_all(b"hello").unwrap();
let probe = NoDecomposeProbe;
let entries =
path_entries(tmp.path(), "any_cat", Some(&probe as &dyn PlatformProbe)).unwrap();
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].size, 5);
assert!(!entries[0].path.to_string_lossy().contains('#'));
}
}