use std::path::{Component, Path, PathBuf};
use serde::Deserialize;
use crate::Glob;
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct AgentConfig {
pub protected: Vec<String>,
pub exclude: Vec<String>,
pub enforce: bool,
}
#[derive(Debug, Clone)]
pub struct AgentPolicy {
base: PathBuf,
protected: Vec<Glob>,
exclude: Vec<Glob>,
enforce: bool,
}
impl AgentPolicy {
pub fn new(config: &AgentConfig, base: impl Into<PathBuf>) -> Self {
let base = base.into();
Self {
base: std::fs::canonicalize(&base).unwrap_or(base),
protected: config.protected.iter().map(|p| compile(p)).collect(),
exclude: config.exclude.iter().map(|p| compile(p)).collect(),
enforce: config.enforce,
}
}
pub fn base(&self) -> &Path {
&self.base
}
pub fn enforce(&self) -> bool {
self.enforce
}
pub fn is_protected(&self, path: &Path) -> bool {
let Ok(absolute) = std::path::absolute(path) else {
return false;
};
let lexical = normalize(&absolute);
let resolved = std::fs::canonicalize(&absolute).ok();
std::iter::once(lexical)
.chain(resolved)
.any(|candidate| self.matches(&candidate))
}
fn matches(&self, absolute: &Path) -> bool {
let Ok(relative) = absolute.strip_prefix(&self.base) else {
return false;
};
let relative: Vec<_> = relative
.components()
.map(|c| c.as_os_str().to_string_lossy())
.collect();
let relative = relative.join("/");
if relative.is_empty() {
return false;
}
let hit = |globs: &[Glob]| globs.iter().any(|glob| glob.is_match(&relative));
hit(&self.protected) && !hit(&self.exclude)
}
}
fn compile(pattern: &str) -> Glob {
let pattern = pattern.trim();
let anchored = pattern.trim_end_matches('/').contains('/');
let mut pattern = pattern.trim_start_matches('/').to_owned();
if pattern.ends_with('/') {
pattern.push_str("**");
}
if anchored {
Glob::path(&pattern)
} else {
Glob::path(&format!("**/{pattern}"))
}
}
fn normalize(path: &Path) -> PathBuf {
let mut out = PathBuf::new();
for component in path.components() {
match component {
Component::CurDir => {}
Component::ParentDir => {
out.pop();
}
other => out.push(other),
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
fn policy(protected: &[&str], exclude: &[&str]) -> AgentPolicy {
let config = AgentConfig {
protected: protected.iter().map(|s| s.to_string()).collect(),
exclude: exclude.iter().map(|s| s.to_string()).collect(),
enforce: false,
};
AgentPolicy::new(&config, "/project")
}
fn protected(policy: &AgentPolicy, path: &str) -> bool {
policy.is_protected(&Path::new("/project").join(path))
}
#[test]
fn a_bare_name_matches_at_any_depth() {
let p = policy(&[".env*", "*.pem"], &[]);
assert!(protected(&p, ".env"));
assert!(protected(&p, ".env.production"));
assert!(protected(&p, "services/api/.env"));
assert!(protected(&p, "certs/server.pem"));
assert!(!protected(&p, "src/env.rs"));
}
#[test]
fn a_pattern_with_a_slash_is_anchored() {
let p = policy(&["config/prod.yml", "/data/*.csv"], &[]);
assert!(protected(&p, "config/prod.yml"));
assert!(!protected(&p, "app/config/prod.yml"));
assert!(protected(&p, "data/users.csv"));
assert!(!protected(&p, "data/2026/users.csv"));
}
#[test]
fn a_directory_pattern_covers_its_contents() {
let p = policy(&["secrets/", "dumps/**"], &[]);
assert!(protected(&p, "secrets/a.txt"));
assert!(protected(&p, "nested/secrets/a/b.txt"));
assert!(protected(&p, "dumps/2026/db.sql"));
assert!(!protected(&p, "nested/dumps/db.sql"));
}
#[test]
fn exclusions_win() {
let p = policy(&[".env*"], &[".env.example"]);
assert!(protected(&p, ".env"));
assert!(!protected(&p, ".env.example"));
}
#[test]
fn dot_dot_cannot_escape_a_match() {
let p = policy(&[".env"], &[]);
assert!(protected(&p, "src/../.env"));
assert!(protected(&p, "./.env"));
}
#[test]
fn paths_outside_the_project_are_not_protected() {
let p = policy(&["*"], &[]);
assert!(!p.is_protected(Path::new("/elsewhere/.env")));
assert!(!p.is_protected(Path::new("/project")));
}
}