use crate::config::SandboxConfig;
use crate::error::{PathfinderError, SandboxTier};
use ignore::gitignore::{Gitignore, GitignoreBuilder};
use std::path::{Path, PathBuf};
fn path_basename(path_str: &str) -> std::borrow::Cow<'_, str> {
Path::new(path_str)
.file_name()
.map_or_else(|| path_str.into(), |f| f.to_string_lossy())
}
const HARDCODED_DENY_PATTERNS: &[&str] = &[
".git/objects/",
".git/HEAD",
".git/refs/",
".git/index",
".git/config",
".git/hooks/",
];
const HARDCODED_DENY_EXTENSIONS: &[&str] = &["pem", "key", "pfx", "p12"];
const GIT_ALLOWLIST: &[&str] = &[
".gitignore",
".github/workflows/",
".github/actions/",
".gitattributes",
];
const DEFAULT_DENY_PATTERNS: &[&str] = &[
".env",
".env.*",
"secrets/",
"node_modules/",
"vendor/",
"__pycache__/",
"dist/",
"build/",
];
pub struct Sandbox {
workspace_root: PathBuf,
effective_default_deny: Vec<String>,
user_ignore: Option<Gitignore>,
additional_deny: Vec<String>,
}
impl Sandbox {
#[must_use]
pub fn new(workspace_root: &Path, config: &SandboxConfig) -> Self {
let ignore_path = workspace_root.join(".pathfinderignore");
let user_ignore = if ignore_path.exists() {
let mut builder = GitignoreBuilder::new(workspace_root);
let _ = builder.add(&ignore_path);
builder.build().ok()
} else {
None
};
Self::with_user_rules(workspace_root, config, user_ignore)
}
#[must_use]
pub fn with_user_rules(
workspace_root: &Path,
config: &SandboxConfig,
user_ignore: Option<Gitignore>,
) -> Self {
let effective_default_deny: Vec<String> = DEFAULT_DENY_PATTERNS
.iter()
.filter(|pattern| !config.allow_override.iter().any(|a| a == *pattern))
.map(|s| (*s).to_owned())
.collect();
Self {
workspace_root: workspace_root.to_path_buf(),
effective_default_deny,
user_ignore,
additional_deny: config.additional_deny.clone(),
}
}
pub fn check(&self, relative_path: &Path) -> Result<(), PathfinderError> {
let path_to_check = if relative_path.is_absolute() {
if let Ok(stripped) = relative_path.strip_prefix(&self.workspace_root) {
stripped
} else {
return Err(PathfinderError::AccessDenied {
path: relative_path.to_path_buf(),
tier: SandboxTier::HardcodedDeny,
});
}
} else {
relative_path
};
if path_to_check
.components()
.any(|c| matches!(c, std::path::Component::ParentDir))
{
return Err(PathfinderError::AccessDenied {
path: relative_path.to_path_buf(),
tier: SandboxTier::HardcodedDeny,
});
}
let path_str = path_to_check.to_string_lossy();
if Self::is_hardcoded_denied(&path_str, path_to_check) {
return Err(PathfinderError::AccessDenied {
path: relative_path.to_path_buf(),
tier: SandboxTier::HardcodedDeny,
});
}
if self.is_default_denied(&path_str) {
return Err(PathfinderError::AccessDenied {
path: relative_path.to_path_buf(),
tier: SandboxTier::DefaultDeny,
});
}
if self.is_additional_denied(&path_str) {
return Err(PathfinderError::AccessDenied {
path: relative_path.to_path_buf(),
tier: SandboxTier::DefaultDeny,
});
}
if self.is_user_denied(path_to_check) {
return Err(PathfinderError::AccessDenied {
path: relative_path.to_path_buf(),
tier: SandboxTier::UserDefined,
});
}
Ok(())
}
fn is_hardcoded_denied(path_str: &str, path: &Path) -> bool {
for allowed in GIT_ALLOWLIST {
if path_str.starts_with(allowed) || path_str == *allowed {
return false;
}
}
for pattern in HARDCODED_DENY_PATTERNS {
if path_str.starts_with(pattern) {
return true;
}
}
if let Some(ext) = path.extension() {
let ext_str = ext.to_string_lossy();
if HARDCODED_DENY_EXTENSIONS
.iter()
.any(|e| ext_str.eq_ignore_ascii_case(e))
{
return true;
}
}
false
}
fn is_default_denied(&self, path_str: &str) -> bool {
self.effective_default_deny
.iter()
.any(|p| Self::matches_default_pattern(p, path_str))
}
fn matches_default_pattern(pattern: &str, path_str: &str) -> bool {
if pattern.ends_with('/') {
Self::matches_directory_pattern(pattern, path_str)
} else if pattern.contains('*') {
Self::matches_wildcard_pattern(pattern, path_str)
} else {
Self::matches_exact_pattern(pattern, path_str)
}
}
fn matches_directory_pattern(pattern: &str, path_str: &str) -> bool {
let dir_prefix = pattern.trim_end_matches('/');
path_str.starts_with(dir_prefix)
&& (path_str.len() == dir_prefix.len()
|| path_str.as_bytes().get(dir_prefix.len()) == Some(&b'/'))
}
fn matches_wildcard_pattern(pattern: &str, path_str: &str) -> bool {
let Some(prefix) = pattern.strip_suffix('*') else {
return false;
};
let basename = path_basename(path_str);
basename.starts_with(prefix.trim_start_matches('/'))
}
fn matches_exact_pattern(pattern: &str, path_str: &str) -> bool {
let basename = path_basename(path_str);
basename == pattern || path_str == pattern
}
fn is_additional_denied(&self, path_str: &str) -> bool {
for pattern in &self.additional_deny {
if pattern.starts_with("*.") {
let ext = pattern.trim_start_matches("*.");
if path_str.ends_with(&format!(".{ext}")) {
return true;
}
} else if pattern.ends_with('/') {
let dir = pattern.trim_end_matches('/');
if path_str == dir
|| path_str.starts_with(&format!("{dir}/"))
|| path_str.contains(&format!("/{dir}/"))
|| path_str.ends_with(&format!("/{dir}"))
{
return true;
}
} else {
let basename = std::path::Path::new(path_str)
.file_name()
.map_or(path_str, |f| f.to_str().unwrap_or(path_str));
if basename == pattern || path_str == pattern.as_str() {
return true;
}
}
}
false
}
fn is_user_denied(&self, path: &Path) -> bool {
if let Some(ignore) = &self.user_ignore {
let is_dir = path.to_string_lossy().ends_with('/');
ignore.matched(path, is_dir).is_ignore()
} else {
false
}
}
}
#[cfg(test)]
#[allow(clippy::expect_used)]
mod tests {
use super::*;
fn default_sandbox() -> Sandbox {
Sandbox::with_user_rules(
std::env::temp_dir().as_path(),
&SandboxConfig::default(),
None,
)
}
#[test]
fn test_hardcoded_deny_git_objects() {
let sandbox = default_sandbox();
let result = sandbox.check(Path::new(".git/objects/abc123"));
assert!(result.is_err());
if let Err(PathfinderError::AccessDenied { tier, .. }) = result {
assert!(matches!(tier, SandboxTier::HardcodedDeny));
}
}
#[test]
fn test_hardcoded_deny_pem_file() {
let sandbox = default_sandbox();
assert!(sandbox.check(Path::new("certs/server.pem")).is_err());
assert!(sandbox.check(Path::new("keys/private.key")).is_err());
assert!(sandbox.check(Path::new("cert.pfx")).is_err());
}
#[test]
fn test_git_allowlist() {
let sandbox = default_sandbox();
assert!(sandbox.check(Path::new(".gitignore")).is_ok());
assert!(sandbox.check(Path::new(".github/workflows/ci.yml")).is_ok());
assert!(sandbox
.check(Path::new(".github/actions/custom/action.yml"))
.is_ok());
}
#[test]
fn test_default_deny_env() {
let sandbox = default_sandbox();
assert!(sandbox.check(Path::new(".env")).is_err());
}
#[test]
fn test_default_deny_node_modules() {
let sandbox = default_sandbox();
assert!(sandbox
.check(Path::new("node_modules/express/index.js"))
.is_err());
}
#[test]
fn test_default_deny_vendor() {
let sandbox = default_sandbox();
assert!(sandbox.check(Path::new("vendor/github.com/pkg")).is_err());
}
#[test]
fn test_allow_override() {
let config = SandboxConfig {
additional_deny: vec![],
allow_override: vec![".env".to_owned()],
};
let sandbox = Sandbox::with_user_rules(std::env::temp_dir().as_path(), &config, None);
assert!(sandbox.check(Path::new(".env")).is_ok());
}
#[test]
fn test_additional_deny() {
let config = SandboxConfig {
additional_deny: vec!["*.generated.ts".to_owned()],
allow_override: vec![],
};
let sandbox = Sandbox::with_user_rules(std::env::temp_dir().as_path(), &config, None);
assert!(sandbox.check(Path::new("src/schema.generated.ts")).is_err());
assert!(sandbox.check(Path::new("src/auth.ts")).is_ok());
}
#[test]
fn test_additional_deny_bare_word_does_not_substring_match() {
let config = SandboxConfig {
additional_deny: vec!["secret".to_owned()],
allow_override: vec![],
};
let sandbox = Sandbox::with_user_rules(std::env::temp_dir().as_path(), &config, None);
assert!(
sandbox.check(Path::new("src/secretariat/utils.rs")).is_ok(),
"bare-word pattern must not substring-match across path segments"
);
assert!(
sandbox.check(Path::new("src/secret")).is_err(),
"bare-word pattern must deny an exact filename match"
);
}
#[test]
fn test_additional_deny_directory_pattern_no_prefix_leak() {
let config = SandboxConfig {
additional_deny: vec!["temp/".to_owned()],
allow_override: vec![],
};
let sandbox = Sandbox::with_user_rules(std::env::temp_dir().as_path(), &config, None);
assert!(
sandbox.check(Path::new("temp/scratch.txt")).is_err(),
"temp/ pattern must deny paths starting with temp/"
);
assert!(
sandbox.check(Path::new("src/template/index.ts")).is_ok(),
"temp/ pattern must not deny src/template/ (prefix leak)"
);
}
#[test]
fn test_normal_source_files_allowed() {
let sandbox = default_sandbox();
assert!(sandbox.check(Path::new("src/main.rs")).is_ok());
assert!(sandbox.check(Path::new("src/auth.ts")).is_ok());
assert!(sandbox.check(Path::new("README.md")).is_ok());
assert!(sandbox.check(Path::new("Cargo.toml")).is_ok());
}
#[test]
fn test_hardcoded_deny_cannot_be_overridden() {
let config = SandboxConfig {
additional_deny: vec![],
allow_override: vec![".git/objects/".to_owned()],
};
let sandbox = Sandbox::with_user_rules(std::env::temp_dir().as_path(), &config, None);
assert!(sandbox.check(Path::new(".git/objects/abc")).is_err());
}
#[test]
fn test_with_user_rules_none_skips_tier3() {
let sandbox = Sandbox::with_user_rules(
std::env::temp_dir().as_path(),
&SandboxConfig::default(),
None,
);
assert!(sandbox.check(Path::new("some/custom/path.txt")).is_ok());
}
#[test]
fn test_with_user_rules_injected_ignore() {
let workspace = std::env::temp_dir();
let mut builder = GitignoreBuilder::new(&workspace);
builder
.add_line(None, "blocked_by_user.txt")
.expect("valid pattern");
let gitignore = builder.build().expect("valid gitignore");
let sandbox = Sandbox::with_user_rules(
workspace.as_path(),
&SandboxConfig::default(),
Some(gitignore),
);
assert!(sandbox.check(Path::new("blocked_by_user.txt")).is_err());
assert!(sandbox.check(Path::new("src/main.rs")).is_ok());
}
#[test]
fn test_same_workspace_absolute_path_allowed() {
let workspace = std::env::temp_dir();
let sandbox =
Sandbox::with_user_rules(workspace.as_path(), &SandboxConfig::default(), None);
let abs_path = workspace.join("src/main.rs");
assert!(
sandbox.check(&abs_path).is_ok(),
"same-workspace absolute path should be allowed"
);
assert!(sandbox.check(Path::new("src/main.rs")).is_ok());
}
#[test]
fn test_cross_workspace_absolute_path_denied() {
let workspace1 = std::env::temp_dir().join("workspace1");
let sandbox = Sandbox::with_user_rules(&workspace1, &SandboxConfig::default(), None);
let workspace2 = std::env::temp_dir().join("workspace2");
let cross_workspace_path = workspace2.join("src/main.rs");
assert!(
sandbox.check(&cross_workspace_path).is_err(),
"cross-workspace absolute path should be denied"
);
}
}