agentsec-core 0.3.0

AgentSec core library — scan / web / paste logic, pure Rust
Documentation
//! Inventory enumeration: turn the fixed target-root list into hashed
//! [`PathEntry`] rows.
//!
//! ## Target roots
//!
//! Built from two sources, concatenated in this order:
//!
//! 1. **Platform-specific** — provided by a [`PlatformProbe`]
//!    implementation. For Claude Code this is [`ClaudeCodePlatform`],
//!    yielding `~/.claude/*`, `~/.claude.json`, and the project-local
//!    `.claude/settings.json` / `.mcp.json` categories.
//! 2. **Universal** — hard-coded in [`universal_target_roots`]:
//!    dependency manifests + lockfiles (supply-chain) and `.env`,
//!    all resolved relative to the current working directory.
//!
//! Missing targets are silently skipped (no error). Directories are walked
//! recursively, with `SKIP_DIRS` / `SKIP_FILES` noise filtered out.
//! Symlinks are not followed.
//!
//! ## Per-file decomposition
//!
//! [`path_entries`] delegates to the owning probe's
//! [`PlatformProbe::decompose_file`]: when it returns `Some(fragments)`,
//! the file produces one virtual `<file>#<fragment>` entry per element
//! (each hashed over its own payload); when it returns `None` the file
//! produces a single whole-file SHA-256 row. The mechanism lets a
//! probe split noisy configs (e.g. Claude Code's `~/.claude.json`
//! gets per-block fragments so unrelated background writes don't
//! show as Modified) without core needing to know the schema.

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};

/// One hashed file or virtual JSON fragment.
///
/// For most files this represents `sha256(full bytes)`; for files a
/// probe decomposes via [`PlatformProbe::decompose_file`] it represents
/// `sha256(fragment payload)` and the `path` field carries a
/// `<file>#<fragment>` virtual suffix (see module docs
/// §Per-file decomposition).
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct PathEntry {
    /// Absolute path of the file, optionally suffixed with `#<fragment>`
    /// for virtual JSON-block entries.
    pub path: PathBuf,
    /// Target-root category label from `target_roots`.
    pub category: String,
    /// Lowercase hex SHA-256 of the file bytes (or canonical fragment bytes).
    pub sha256: String,
    /// Size in bytes of the hashed payload.
    pub size: u64,
}

/// Platform-independent inventory targets: dependency manifests +
/// lockfiles (supply chain) and the project `.env` (sha256 only).
/// All entries are resolved relative to the current working directory
/// and are not owned by any [`PlatformProbe`].
fn universal_target_roots() -> Vec<(String, PathBuf)> {
    vec![
        // ── Dependency manifests + lockfiles (supply chain) ───────────────
        ("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")),
        // ── Secrets dotfile (sha256 only, contents never persisted) ───────
        ("env_project".into(), PathBuf::from(".env")),
    ]
}

/// Walk every probe's target roots plus the universal list and return
/// a sorted list of [`PathEntry`].
///
/// `paths.user_home` is used to construct home-rooted absolute paths.
/// Missing target roots are skipped silently. Files yield one entry
/// each (or one entry per fragment if their owning probe decomposes
/// them — see module docs §Per-file decomposition); directories are
/// walked recursively with `SKIP_DIRS` / `SKIP_FILES` filtered out.
/// The returned list is sorted by [`PathEntry::path`] so the snapshot
/// is reproducible and diffable across runs.
///
/// # Errors
///
/// Returns [`crate::Error::Io`] if a target exists but cannot be read
/// (permission denied, vanished mid-walk, etc.).
pub fn collect(paths: &Paths, probes: &[&dyn PlatformProbe]) -> Result<Vec<PathEntry>> {
    let mut out = Vec::new();
    // Walk per probe so we can consult `decompose_file` with the
    // owning probe for each Claude/Cursor/etc. file. Universal
    // (supply-chain + `.env`) targets have no owning probe and always
    // produce a single whole-file SHA-256 row.
    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(())
}

/// Directory names we never descend into. Cuts scan noise from VCS / build /
/// dependency caches that aren't AgentSec's domain.
const SKIP_DIRS: &[&str] = &[
    ".git",
    "node_modules",
    "target",
    ".venv",
    "venv",
    "__pycache__",
    ".cache",
    ".idea",
    ".vscode",
    "dist",
    "build",
    ".next",
    ".turbo",
];

/// File names we never hash.
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)?);
        }
        // symlinks are intentionally skipped (no follow) to keep scan read-only safe.
    }
    Ok(())
}

/// Compute one or more `PathEntry` rows for a file.
///
/// If the owning probe's [`PlatformProbe::decompose_file`] returns
/// `Some(fragments)`, the file produces one virtual `<path>#<fragment>`
/// entry per element (each hashed over its own payload). Otherwise
/// the file produces a single whole-file SHA-256 row. Universal
/// (probe-less) targets always take the latter path.
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());
        }
    }

    // Canonicalize the path so that symlinks and `..` components are resolved
    // to their absolute real path. Fall back to the original path on error
    // (e.g. if the file is a symlink whose target has been removed between
    // the `metadata` call and `canonicalize`).
    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(),
    }])
}

/// Build a virtual `<path>#<fragment>` entry from a [`crate::platform::FragmentEntry`]
/// payload. The probe owns the canonical-bytes choice (typically
/// `serde_json::to_string` of the watched JSON sub-value).
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);
    }

    /// Probe that splits any file into two fixed fragments. Used to
    /// verify the inventory walk consults `decompose_file` and routes
    /// the returned fragments through `virtual_entry`.
    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"]);
        // Fragment payloads, not the file body, drive size + sha256.
        assert_eq!(entries[0].size, 1);
        assert_eq!(entries[1].size, 2);
    }

    /// Probe whose `decompose_file` returns Ok(None) — the inventory
    /// walk should fall back to whole-file SHA-256.
    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('#'));
    }
}