use std::path::{Path, PathBuf};
use crate::coding_agent::error::CodingAgentError;
#[derive(Debug, Clone)]
pub struct WorkspaceValidator {
allowed_workspaces: Vec<PathBuf>,
}
impl WorkspaceValidator {
pub fn new(workspaces: &[PathBuf]) -> Self {
let allowed_workspaces = workspaces
.iter()
.filter_map(|p| match p.canonicalize() {
Ok(canonical) => Some(canonical),
Err(e) => {
tracing::warn!(
path = %p.display(),
error = %e,
"Failed to canonicalize workspace path, skipping"
);
None
}
})
.collect();
Self { allowed_workspaces }
}
pub fn from_canonical(workspaces: Vec<PathBuf>) -> Self {
Self {
allowed_workspaces: workspaces,
}
}
pub fn allowed_workspaces(&self) -> &[PathBuf] {
&self.allowed_workspaces
}
pub fn validate_path(&self, path: &Path) -> Result<(), CodingAgentError> {
let canonical = path.canonicalize().map_err(|_| {
CodingAgentError::WorkspaceViolation {
path: path.display().to_string(),
}
})?;
if self.is_within_workspace(&canonical) {
Ok(())
} else {
Err(CodingAgentError::WorkspaceViolation {
path: path.display().to_string(),
})
}
}
pub fn validate_paths(&self, paths: &[PathBuf]) -> Result<(), CodingAgentError> {
for path in paths {
self.validate_path(path)?;
}
Ok(())
}
fn is_within_workspace(&self, canonical_path: &Path) -> bool {
self.allowed_workspaces
.iter()
.any(|workspace| canonical_path.starts_with(workspace))
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
fn setup_workspace() -> (TempDir, PathBuf) {
let dir = TempDir::new().unwrap();
let workspace = dir.path().to_path_buf();
fs::create_dir_all(workspace.join("src")).unwrap();
fs::create_dir_all(workspace.join("src/nested")).unwrap();
fs::write(workspace.join("src/main.rs"), "fn main() {}").unwrap();
fs::write(workspace.join("src/nested/lib.rs"), "// lib").unwrap();
(dir, workspace)
}
#[test]
fn test_validate_path_within_workspace() {
let (_dir, workspace) = setup_workspace();
let validator = WorkspaceValidator::new(&[workspace.clone()]);
assert!(validator.validate_path(&workspace.join("src/main.rs")).is_ok());
assert!(validator
.validate_path(&workspace.join("src/nested/lib.rs"))
.is_ok());
assert!(validator.validate_path(&workspace).is_ok());
assert!(validator.validate_path(&workspace.join("src")).is_ok());
}
#[test]
fn test_validate_path_outside_workspace() {
let (_dir, workspace) = setup_workspace();
let validator = WorkspaceValidator::new(&[workspace.clone()]);
let outside_path = PathBuf::from("/tmp");
let result = validator.validate_path(&outside_path);
assert!(result.is_err());
match result.unwrap_err() {
CodingAgentError::WorkspaceViolation { path } => {
assert_eq!(path, "/tmp");
}
other => panic!("Expected WorkspaceViolation, got: {:?}", other),
}
}
#[test]
fn test_validate_path_dot_dot_traversal() {
let (_dir, workspace) = setup_workspace();
let validator = WorkspaceValidator::new(&[workspace.clone()]);
let traversal_path = workspace.join("src/../../../../../../tmp");
let result = validator.validate_path(&traversal_path);
assert!(result.is_err());
}
#[test]
fn test_validate_path_nonexistent_path() {
let (_dir, workspace) = setup_workspace();
let validator = WorkspaceValidator::new(&[workspace.clone()]);
let nonexistent = workspace.join("does/not/exist.rs");
let result = validator.validate_path(&nonexistent);
assert!(result.is_err());
match result.unwrap_err() {
CodingAgentError::WorkspaceViolation { path } => {
assert!(path.contains("does/not/exist.rs"));
}
other => panic!("Expected WorkspaceViolation, got: {:?}", other),
}
}
#[test]
fn test_validate_path_symlink_within_workspace() {
let (_dir, workspace) = setup_workspace();
let validator = WorkspaceValidator::new(&[workspace.clone()]);
let target = workspace.join("src/main.rs");
let link = workspace.join("src/link_to_main.rs");
#[cfg(unix)]
{
std::os::unix::fs::symlink(&target, &link).unwrap();
assert!(validator.validate_path(&link).is_ok());
}
}
#[test]
#[cfg(unix)]
fn test_validate_path_symlink_escaping_workspace() {
let (_dir, workspace) = setup_workspace();
let validator = WorkspaceValidator::new(&[workspace.clone()]);
let link = workspace.join("src/escape_link");
std::os::unix::fs::symlink("/tmp", &link).unwrap();
let result = validator.validate_path(&link);
assert!(result.is_err());
}
#[test]
fn test_validate_paths_all_valid() {
let (_dir, workspace) = setup_workspace();
let validator = WorkspaceValidator::new(&[workspace.clone()]);
let paths = vec![
workspace.join("src/main.rs"),
workspace.join("src/nested/lib.rs"),
];
assert!(validator.validate_paths(&paths).is_ok());
}
#[test]
fn test_validate_paths_one_invalid() {
let (_dir, workspace) = setup_workspace();
let validator = WorkspaceValidator::new(&[workspace.clone()]);
let paths = vec![
workspace.join("src/main.rs"),
PathBuf::from("/tmp"), ];
let result = validator.validate_paths(&paths);
assert!(result.is_err());
}
#[test]
fn test_multiple_workspaces() {
let dir1 = TempDir::new().unwrap();
let dir2 = TempDir::new().unwrap();
let ws1 = dir1.path().to_path_buf();
let ws2 = dir2.path().to_path_buf();
fs::write(ws1.join("file1.rs"), "// ws1").unwrap();
fs::write(ws2.join("file2.rs"), "// ws2").unwrap();
let validator = WorkspaceValidator::new(&[ws1.clone(), ws2.clone()]);
assert!(validator.validate_path(&ws1.join("file1.rs")).is_ok());
assert!(validator.validate_path(&ws2.join("file2.rs")).is_ok());
let result = validator.validate_path(&PathBuf::from("/tmp"));
assert!(result.is_err());
}
#[test]
fn test_empty_workspaces_rejects_all() {
let validator = WorkspaceValidator::from_canonical(vec![]);
let result = validator.validate_path(&PathBuf::from("/tmp"));
assert!(result.is_err());
}
#[test]
fn test_workspace_root_itself_is_valid() {
let dir = TempDir::new().unwrap();
let workspace = dir.path().to_path_buf();
let validator = WorkspaceValidator::new(&[workspace.clone()]);
assert!(validator.validate_path(&workspace).is_ok());
}
#[test]
fn test_from_canonical_constructor() {
let dir = TempDir::new().unwrap();
let workspace = dir.path().canonicalize().unwrap();
fs::write(workspace.join("test.rs"), "// test").unwrap();
let validator = WorkspaceValidator::from_canonical(vec![workspace.clone()]);
assert!(validator.validate_path(&workspace.join("test.rs")).is_ok());
assert_eq!(validator.allowed_workspaces().len(), 1);
}
#[test]
fn test_nonexistent_workspace_is_skipped() {
let dir = TempDir::new().unwrap();
let valid_workspace = dir.path().to_path_buf();
let invalid_workspace = PathBuf::from("/nonexistent/workspace/path/xyz123");
let validator = WorkspaceValidator::new(&[valid_workspace.clone(), invalid_workspace]);
assert_eq!(validator.allowed_workspaces().len(), 1);
assert!(validator.validate_path(&valid_workspace).is_ok());
}
#[test]
fn test_relative_path_with_dot_dot() {
let (_dir, workspace) = setup_workspace();
let validator = WorkspaceValidator::new(&[workspace.clone()]);
let path_with_dots = workspace.join("src/../src/main.rs");
assert!(validator.validate_path(&path_with_dots).is_ok());
}
}