use globset::{Glob, GlobMatcher};
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use tracing::{debug, warn};
use crate::config::{EscapePolicy, WorkspaceConfig, WorkspaceMode};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PathCheck {
Allowed,
AutoAllowed,
NeverAllowed,
RequiresApproval,
}
impl PathCheck {
#[must_use]
pub fn is_allowed(&self) -> bool {
matches!(self, Self::Allowed | Self::AutoAllowed)
}
#[must_use]
pub fn needs_approval(&self) -> bool {
matches!(self, Self::RequiresApproval)
}
#[must_use]
pub fn is_blocked(&self) -> bool {
matches!(self, Self::NeverAllowed)
}
}
#[derive(Debug)]
pub struct WorkspaceBoundary {
config: WorkspaceConfig,
compiled_matchers: Vec<GlobMatcher>,
}
impl Clone for WorkspaceBoundary {
fn clone(&self) -> Self {
Self::new(self.config.clone())
}
}
impl WorkspaceBoundary {
#[must_use]
pub fn new(config: WorkspaceConfig) -> Self {
let compiled_matchers = config
.auto_allow
.patterns
.iter()
.filter_map(|pattern| match Glob::new(pattern) {
Ok(glob) => Some(glob.compile_matcher()),
Err(e) => {
warn!(pattern = %pattern, error = %e, "Failed to compile glob pattern");
None
},
})
.collect();
Self {
config,
compiled_matchers,
}
}
#[must_use]
pub fn config(&self) -> &WorkspaceConfig {
&self.config
}
#[must_use]
pub fn root(&self) -> &Path {
&self.config.root
}
#[must_use]
pub fn is_in_workspace(&self, path: &Path) -> bool {
let expanded = self.expand_path(path);
expanded.starts_with(&self.config.root)
}
#[must_use]
pub fn is_auto_allowed(&self, path: &Path) -> bool {
let expanded = self.expand_path(path);
for allowed in &self.config.auto_allow.read {
if expanded.starts_with(allowed) {
return true;
}
}
for allowed in &self.config.auto_allow.write {
if expanded.starts_with(allowed) {
return true;
}
}
for matcher in &self.compiled_matchers {
if matcher.is_match(&expanded) {
return true;
}
}
false
}
#[must_use]
pub fn is_never_allowed(&self, path: &Path) -> bool {
let expanded = self.expand_path(path);
for blocked in &self.config.never_allow {
let blocked_expanded = blocked.canonicalize().unwrap_or_else(|_| blocked.clone());
if expanded.starts_with(&blocked_expanded) {
return true;
}
if expanded.starts_with(blocked) {
return true;
}
}
false
}
#[must_use]
pub fn check(&self, path: &Path) -> PathCheck {
let expanded = self.expand_path(path);
debug!(
path = %path.display(),
expanded = %expanded.display(),
"Checking path against workspace"
);
if self.is_never_allowed(&expanded) {
return PathCheck::NeverAllowed;
}
if self.is_in_workspace(&expanded) {
return PathCheck::Allowed;
}
if self.is_auto_allowed(&expanded) {
return PathCheck::AutoAllowed;
}
match self.config.mode {
WorkspaceMode::Autonomous => PathCheck::Allowed,
WorkspaceMode::Guided | WorkspaceMode::Safe => match self.config.escape_policy {
EscapePolicy::Allow => PathCheck::AutoAllowed,
EscapePolicy::Deny => PathCheck::NeverAllowed,
EscapePolicy::Ask => PathCheck::RequiresApproval,
},
}
}
#[must_use]
pub fn expand_path(&self, path: &Path) -> PathBuf {
path.canonicalize().unwrap_or_else(|_| {
if path.is_absolute() {
path.to_path_buf()
} else {
self.config.root.join(path)
}
})
}
#[must_use]
pub fn check_all(&self, paths: &[&Path]) -> PathCheck {
let mut result = PathCheck::Allowed;
for path in paths {
let check = self.check(path);
match check {
PathCheck::NeverAllowed => return PathCheck::NeverAllowed,
PathCheck::RequiresApproval => result = PathCheck::RequiresApproval,
PathCheck::AutoAllowed if result == PathCheck::Allowed => {
result = PathCheck::AutoAllowed;
},
_ => {},
}
}
result
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn test_path_check_helpers() {
assert!(PathCheck::Allowed.is_allowed());
assert!(PathCheck::AutoAllowed.is_allowed());
assert!(!PathCheck::NeverAllowed.is_allowed());
assert!(!PathCheck::RequiresApproval.is_allowed());
assert!(PathCheck::RequiresApproval.needs_approval());
assert!(!PathCheck::Allowed.needs_approval());
assert!(PathCheck::NeverAllowed.is_blocked());
assert!(!PathCheck::Allowed.is_blocked());
}
#[test]
fn test_workspace_boundary_in_workspace() {
let temp_dir = TempDir::new().unwrap();
let config = WorkspaceConfig::new(temp_dir.path());
let boundary = WorkspaceBoundary::new(config);
let in_workspace = temp_dir.path().join("src/main.rs");
assert!(boundary.is_in_workspace(&in_workspace));
let outside = PathBuf::from("/tmp/other");
assert!(!boundary.is_in_workspace(&outside));
}
#[test]
fn test_workspace_boundary_never_allowed() {
let config = WorkspaceConfig::new("/home/user/project").never_allow("/etc");
let boundary = WorkspaceBoundary::new(config);
assert!(boundary.is_never_allowed(Path::new("/etc/passwd")));
assert_eq!(
boundary.check(Path::new("/etc/passwd")),
PathCheck::NeverAllowed
);
}
#[test]
fn test_workspace_boundary_auto_allowed() {
let config = WorkspaceConfig::new("/home/user/project").allow_read("/usr/share/doc");
let boundary = WorkspaceBoundary::new(config);
assert!(boundary.is_auto_allowed(Path::new("/usr/share/doc/readme.txt")));
}
#[test]
fn test_workspace_boundary_autonomous_mode() {
let config =
WorkspaceConfig::new("/home/user/project").with_mode(WorkspaceMode::Autonomous);
let boundary = WorkspaceBoundary::new(config);
assert_eq!(
boundary.check(Path::new("/tmp/random/file")),
PathCheck::Allowed
);
}
}