sync from monorepo @ 2452e92e

This commit is contained in:
2026-05-08 01:59:04 +02:00
commit b03dc15371
459 changed files with 129586 additions and 0 deletions
+108
View File
@@ -0,0 +1,108 @@
//! Common test utilities for dirigent_tools.
//!
//! This module provides shared test utilities used across all test files.
use std::path::{Path, PathBuf};
use tempfile::TempDir;
/// Create a temporary directory for testing.
///
/// The directory is automatically cleaned up when the TempDir is dropped.
pub fn create_temp_dir() -> TempDir {
tempfile::tempdir().expect("Failed to create temp directory")
}
/// Create a temporary directory with a specific prefix.
pub fn create_temp_dir_with_prefix(prefix: &str) -> TempDir {
tempfile::Builder::new()
.prefix(prefix)
.tempdir()
.expect("Failed to create temp directory with prefix")
}
/// Create a file in a directory with given content.
pub fn create_test_file(dir: &Path, filename: &str, content: &str) -> PathBuf {
let file_path = dir.join(filename);
std::fs::write(&file_path, content).expect("Failed to write test file");
file_path
}
/// Read a file and return its content.
pub fn read_file_content(path: &Path) -> String {
std::fs::read_to_string(path).expect("Failed to read file")
}
/// Create a sandboxed test environment with configured allowed roots.
pub struct SandboxedTestEnv {
// RAII: TempDir must be kept alive to prevent premature cleanup
pub _temp_dir: TempDir,
pub allowed_root: PathBuf,
pub blocked_dir: PathBuf,
pub outside_dir: TempDir,
}
impl SandboxedTestEnv {
/// Create a new sandboxed test environment.
pub fn new() -> Self {
let temp_dir = create_temp_dir();
let allowed_root = temp_dir.path().to_path_buf();
let blocked_dir = allowed_root.join("blocked");
let outside_dir = create_temp_dir_with_prefix("outside");
std::fs::create_dir_all(&blocked_dir).expect("Failed to create blocked directory");
Self {
_temp_dir: temp_dir,
allowed_root,
blocked_dir,
outside_dir,
}
}
/// Create a file inside the allowed root.
pub fn create_allowed_file(&self, filename: &str, content: &str) -> PathBuf {
create_test_file(&self.allowed_root, filename, content)
}
/// Create a file outside the allowed root.
pub fn create_outside_file(&self, filename: &str, content: &str) -> PathBuf {
create_test_file(self.outside_dir.path(), filename, content)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_create_temp_dir() {
let temp_dir = create_temp_dir();
assert!(temp_dir.path().exists());
}
#[test]
fn test_create_test_file() {
let temp_dir = create_temp_dir();
let file_path = create_test_file(temp_dir.path(), "test.txt", "Hello, world!");
assert!(file_path.exists());
assert_eq!(read_file_content(&file_path), "Hello, world!");
}
#[test]
fn test_sandboxed_test_env() {
let env = SandboxedTestEnv::new();
assert!(env.allowed_root.exists());
assert!(env.blocked_dir.exists());
assert!(env.outside_dir.path().exists());
let allowed_file = env.create_allowed_file("test.txt", "allowed");
let outside_file = env.create_outside_file("test.txt", "outside");
assert!(allowed_file.exists());
assert!(outside_file.exists());
assert_ne!(
allowed_file.parent().unwrap(),
outside_file.parent().unwrap()
);
}
}
@@ -0,0 +1,14 @@
use dirigent_tools::policy::{Decision, Op, Policy};
use std::fs;
use tempfile::TempDir;
#[test]
fn dirigent_tools_can_use_fermata_policy() {
let tmp = TempDir::new().unwrap();
fs::write(tmp.path().join(".botignore"), ".env\n").unwrap();
let target = tmp.path().join(".env");
fs::write(&target, "").unwrap();
let policy = Policy::load(tmp.path()).unwrap();
let d = policy.check(Op::Read, &target).unwrap();
assert!(matches!(d, Decision::Deny(_)));
}
@@ -0,0 +1,541 @@
//! Integration tests for file operations (read, write, diff, edit).
//!
//! These tests use real filesystem operations with temporary directories
//! to verify end-to-end functionality including sandboxing and error handling.
use dirigent_tools::config::{EolPolicy, PermissionConfig, SandboxConfig};
use dirigent_tools::error::ToolError;
use dirigent_tools::fs::diff::generate_diff;
use dirigent_tools::fs::edit::{edit_file, EditFileRequest, EditOperation};
use dirigent_tools::fs::read::{read_text_file, ReadTextFileRequest};
use dirigent_tools::fs::write::{write_text_file, WriteTextFileRequest};
use dirigent_tools::permission::check::PermissionContext;
use dirigent_tools::permission::whitelist::CompiledWhitelist;
use std::path::PathBuf;
use tempfile::TempDir;
/// Create a test configuration with a single allowed root.
fn test_config(allowed_root: PathBuf) -> SandboxConfig {
let mut config = SandboxConfig::default();
// Ensure the root is canonical
let canonical_root = dunce::canonicalize(&allowed_root)
.unwrap_or_else(|_| panic!("Failed to canonicalize test root: {:?}", allowed_root));
config.allowed_roots = vec![canonical_root];
config.write_enabled = true;
config.read_enabled = true;
config
}
/// Create a test permission config with YOLO mode (no prompts).
fn test_permission_config() -> PermissionConfig {
PermissionConfig::default()
}
/// Create a test permission context for a test connector.
fn test_permission_context() -> PermissionContext {
let whitelist = CompiledWhitelist::compile(&Default::default()).unwrap();
PermissionContext::new(
"test-connector".to_string(),
Some("test-session".to_string()),
whitelist,
)
}
// =============================================================================
// Read Operation Tests
// =============================================================================
#[tokio::test]
async fn test_read_entire_file() {
let temp_dir = TempDir::new().unwrap();
let file_path = temp_dir.path().join("test.txt");
// Create test file
std::fs::write(&file_path, "line1\nline2\nline3").unwrap();
let config = test_config(temp_dir.path().to_path_buf());
let request = ReadTextFileRequest {
path: file_path.to_string_lossy().to_string(),
line: None,
limit: None,
};
let response = read_text_file(request, &config).await.unwrap();
assert_eq!(response.content, "line1\nline2\nline3");
}
#[tokio::test]
async fn test_read_with_line_limit() {
let temp_dir = TempDir::new().unwrap();
let file_path = temp_dir.path().join("test.txt");
// Create test file
std::fs::write(&file_path, "line1\nline2\nline3\nline4\nline5").unwrap();
let config = test_config(temp_dir.path().to_path_buf());
// Read from line 2, limit 2 lines
let request = ReadTextFileRequest {
path: file_path.to_string_lossy().to_string(),
line: Some(2),
limit: Some(2),
};
let response = read_text_file(request, &config).await.unwrap();
assert_eq!(response.content, "line2\nline3");
}
#[tokio::test]
async fn test_read_only_limit() {
let temp_dir = TempDir::new().unwrap();
let file_path = temp_dir.path().join("test.txt");
std::fs::write(&file_path, "line1\nline2\nline3\nline4").unwrap();
let config = test_config(temp_dir.path().to_path_buf());
let request = ReadTextFileRequest {
path: file_path.to_string_lossy().to_string(),
line: None,
limit: Some(2),
};
let response = read_text_file(request, &config).await.unwrap();
assert_eq!(response.content, "line1\nline2");
}
#[tokio::test]
async fn test_read_file_not_found() {
let temp_dir = TempDir::new().unwrap();
let file_path = temp_dir.path().join("nonexistent.txt");
let config = test_config(temp_dir.path().to_path_buf());
let request = ReadTextFileRequest {
path: file_path.to_string_lossy().to_string(),
line: None,
limit: None,
};
let result = read_text_file(request, &config).await;
assert!(matches!(result, Err(ToolError::NotFound { .. })));
}
#[tokio::test]
async fn test_read_non_utf8() {
let temp_dir = TempDir::new().unwrap();
let file_path = temp_dir.path().join("binary.bin");
// Write invalid UTF-8
std::fs::write(&file_path, &[0xFF, 0xFE, 0xFD]).unwrap();
let config = test_config(temp_dir.path().to_path_buf());
let request = ReadTextFileRequest {
path: file_path.to_string_lossy().to_string(),
line: None,
limit: None,
};
let result = read_text_file(request, &config).await;
assert!(matches!(result, Err(ToolError::EncodingUnsupported { .. })));
}
#[tokio::test]
async fn test_read_outside_sandbox() {
let temp_dir = TempDir::new().unwrap();
let other_temp_dir = TempDir::new().unwrap();
let file_path = other_temp_dir.path().join("test.txt");
std::fs::write(&file_path, "content").unwrap();
// Config only allows temp_dir, not other_temp_dir
let config = test_config(temp_dir.path().to_path_buf());
let request = ReadTextFileRequest {
path: file_path.to_string_lossy().to_string(),
line: None,
limit: None,
};
let result = read_text_file(request, &config).await;
assert!(matches!(result, Err(ToolError::SandboxViolation { .. })));
}
#[tokio::test]
async fn test_read_disabled() {
let temp_dir = TempDir::new().unwrap();
let file_path = temp_dir.path().join("test.txt");
std::fs::write(&file_path, "content").unwrap();
let mut config = test_config(temp_dir.path().to_path_buf());
config.read_enabled = false;
let request = ReadTextFileRequest {
path: file_path.to_string_lossy().to_string(),
line: None,
limit: None,
};
let result = read_text_file(request, &config).await;
assert!(matches!(result, Err(ToolError::PermissionDenied { .. })));
}
// =============================================================================
// Write Operation Tests
// =============================================================================
#[tokio::test]
async fn test_write_new_file() {
let temp_dir = TempDir::new().unwrap();
let file_path = temp_dir.path().join("new.txt");
let config = test_config(temp_dir.path().to_path_buf());
let request = WriteTextFileRequest {
path: file_path.to_string_lossy().to_string(),
content: "hello world".to_string(),
};
write_text_file(request, &config, &test_permission_config(), &test_permission_context()).await.unwrap();
// Verify file was written
let content = std::fs::read_to_string(&file_path).unwrap();
assert_eq!(content, "hello world");
}
#[tokio::test]
async fn test_write_overwrite_existing() {
let temp_dir = TempDir::new().unwrap();
let file_path = temp_dir.path().join("existing.txt");
std::fs::write(&file_path, "old content").unwrap();
let config = test_config(temp_dir.path().to_path_buf());
let request = WriteTextFileRequest {
path: file_path.to_string_lossy().to_string(),
content: "new content".to_string(),
};
write_text_file(request, &config, &test_permission_config(), &test_permission_context()).await.unwrap();
let content = std::fs::read_to_string(&file_path).unwrap();
assert_eq!(content, "new content");
}
#[tokio::test]
async fn test_write_create_parent_directories() {
let temp_dir = TempDir::new().unwrap();
let file_path = temp_dir.path().join("subdir").join("nested").join("file.txt");
let config = test_config(temp_dir.path().to_path_buf());
let request = WriteTextFileRequest {
path: file_path.to_string_lossy().to_string(),
content: "nested content".to_string(),
};
write_text_file(request, &config, &test_permission_config(), &test_permission_context()).await.unwrap();
let content = std::fs::read_to_string(&file_path).unwrap();
assert_eq!(content, "nested content");
}
#[tokio::test]
async fn test_write_eol_normalization_lf() {
let temp_dir = TempDir::new().unwrap();
let file_path = temp_dir.path().join("eol_lf.txt");
let mut config = test_config(temp_dir.path().to_path_buf());
config.eol_policy = EolPolicy::Lf;
let request = WriteTextFileRequest {
path: file_path.to_string_lossy().to_string(),
content: "line1\r\nline2\rline3\n".to_string(),
};
write_text_file(request, &config, &test_permission_config(), &test_permission_context()).await.unwrap();
let content = std::fs::read_to_string(&file_path).unwrap();
assert_eq!(content, "line1\nline2\nline3\n");
}
#[tokio::test]
async fn test_write_eol_normalization_crlf() {
let temp_dir = TempDir::new().unwrap();
let file_path = temp_dir.path().join("eol_crlf.txt");
let mut config = test_config(temp_dir.path().to_path_buf());
config.eol_policy = EolPolicy::Crlf;
let request = WriteTextFileRequest {
path: file_path.to_string_lossy().to_string(),
content: "line1\nline2\rline3\r\n".to_string(),
};
write_text_file(request, &config, &test_permission_config(), &test_permission_context()).await.unwrap();
let content = std::fs::read_to_string(&file_path).unwrap();
assert_eq!(content, "line1\r\nline2\r\nline3\r\n");
}
#[tokio::test]
async fn test_write_size_limit_exceeded() {
let temp_dir = TempDir::new().unwrap();
let file_path = temp_dir.path().join("large.txt");
let mut config = test_config(temp_dir.path().to_path_buf());
config.max_write_bytes = 10;
let request = WriteTextFileRequest {
path: file_path.to_string_lossy().to_string(),
content: "this is way too much content".to_string(),
};
let result = write_text_file(request, &config, &test_permission_config(), &test_permission_context()).await;
assert!(matches!(result, Err(ToolError::FileTooLarge { .. })));
}
#[tokio::test]
async fn test_write_outside_sandbox() {
let temp_dir = TempDir::new().unwrap();
let other_temp_dir = TempDir::new().unwrap();
let file_path = other_temp_dir.path().join("test.txt");
let config = test_config(temp_dir.path().to_path_buf());
let request = WriteTextFileRequest {
path: file_path.to_string_lossy().to_string(),
content: "content".to_string(),
};
let result = write_text_file(request, &config, &test_permission_config(), &test_permission_context()).await;
assert!(matches!(result, Err(ToolError::SandboxViolation { .. })));
}
#[tokio::test]
async fn test_write_disabled() {
let temp_dir = TempDir::new().unwrap();
let file_path = temp_dir.path().join("test.txt");
let mut config = test_config(temp_dir.path().to_path_buf());
config.write_enabled = false;
let request = WriteTextFileRequest {
path: file_path.to_string_lossy().to_string(),
content: "content".to_string(),
};
let result = write_text_file(request, &config, &test_permission_config(), &test_permission_context()).await;
assert!(matches!(result, Err(ToolError::PermissionDenied { .. })));
}
// =============================================================================
// Diff Generation Tests
// =============================================================================
#[test]
fn test_diff_new_file() {
let old = "";
let new = "line1\nline2\nline3";
let path = PathBuf::from("test.txt");
let diff = generate_diff(old, new, &path);
assert!(diff.contains("--- /dev/null"));
assert!(diff.contains("+++ test.txt"));
assert!(diff.contains("+line1"));
assert!(diff.contains("+line2"));
assert!(diff.contains("+line3"));
}
#[test]
fn test_diff_deleted_file() {
let old = "line1\nline2\nline3";
let new = "";
let path = PathBuf::from("test.txt");
let diff = generate_diff(old, new, &path);
assert!(diff.contains("--- test.txt"));
assert!(diff.contains("+++ /dev/null"));
assert!(diff.contains("-line1"));
assert!(diff.contains("-line2"));
assert!(diff.contains("-line3"));
}
#[test]
fn test_diff_modification() {
let old = "line1\nline2\nline3";
let new = "line1\nmodified\nline3";
let path = PathBuf::from("test.txt");
let diff = generate_diff(old, new, &path);
assert!(diff.contains("--- test.txt"));
assert!(diff.contains("+++ test.txt"));
assert!(diff.contains("-line2"));
assert!(diff.contains("+modified"));
}
// =============================================================================
// Edit Operation Tests
// =============================================================================
#[tokio::test]
async fn test_edit_replace_once() {
let temp_dir = TempDir::new().unwrap();
let file_path = temp_dir.path().join("edit.txt");
std::fs::write(&file_path, "foo bar foo baz").unwrap();
let config = test_config(temp_dir.path().to_path_buf());
let request = EditFileRequest {
path: file_path.to_string_lossy().to_string(),
edits: vec![EditOperation::Replace {
old_text: "foo".to_string(),
new_text: "qux".to_string(),
replace_all: false,
}],
};
let response = edit_file(request, &config, &test_permission_config(), &test_permission_context()).await.unwrap();
// Verify file was edited
let content = std::fs::read_to_string(&file_path).unwrap();
assert_eq!(content, "qux bar foo baz");
// Verify diff was generated
assert!(response.diff.contains("-foo"));
assert!(response.diff.contains("+qux"));
}
#[tokio::test]
async fn test_edit_replace_all() {
let temp_dir = TempDir::new().unwrap();
let file_path = temp_dir.path().join("edit.txt");
std::fs::write(&file_path, "foo bar foo baz foo").unwrap();
let config = test_config(temp_dir.path().to_path_buf());
let request = EditFileRequest {
path: file_path.to_string_lossy().to_string(),
edits: vec![EditOperation::Replace {
old_text: "foo".to_string(),
new_text: "qux".to_string(),
replace_all: true,
}],
};
edit_file(request, &config, &test_permission_config(), &test_permission_context()).await.unwrap();
let content = std::fs::read_to_string(&file_path).unwrap();
assert_eq!(content, "qux bar qux baz qux");
}
#[tokio::test]
async fn test_edit_multiple_operations() {
let temp_dir = TempDir::new().unwrap();
let file_path = temp_dir.path().join("edit.txt");
std::fs::write(&file_path, "hello world\nhello rust").unwrap();
let config = test_config(temp_dir.path().to_path_buf());
let request = EditFileRequest {
path: file_path.to_string_lossy().to_string(),
edits: vec![
EditOperation::Replace {
old_text: "hello".to_string(),
new_text: "goodbye".to_string(),
replace_all: true,
},
EditOperation::Replace {
old_text: "world".to_string(),
new_text: "universe".to_string(),
replace_all: false,
},
],
};
edit_file(request, &config, &test_permission_config(), &test_permission_context()).await.unwrap();
let content = std::fs::read_to_string(&file_path).unwrap();
assert_eq!(content, "goodbye universe\ngoodbye rust");
}
#[tokio::test]
async fn test_edit_nonexistent_file() {
let temp_dir = TempDir::new().unwrap();
let file_path = temp_dir.path().join("nonexistent.txt");
let config = test_config(temp_dir.path().to_path_buf());
let request = EditFileRequest {
path: file_path.to_string_lossy().to_string(),
edits: vec![EditOperation::Replace {
old_text: "foo".to_string(),
new_text: "bar".to_string(),
replace_all: false,
}],
};
let result = edit_file(request, &config, &test_permission_config(), &test_permission_context()).await;
assert!(matches!(result, Err(ToolError::NotFound { .. })));
}
#[tokio::test]
async fn test_edit_outside_sandbox() {
let temp_dir = TempDir::new().unwrap();
let other_temp_dir = TempDir::new().unwrap();
let file_path = other_temp_dir.path().join("test.txt");
std::fs::write(&file_path, "content").unwrap();
let config = test_config(temp_dir.path().to_path_buf());
let request = EditFileRequest {
path: file_path.to_string_lossy().to_string(),
edits: vec![EditOperation::Replace {
old_text: "content".to_string(),
new_text: "modified".to_string(),
replace_all: false,
}],
};
let result = edit_file(request, &config, &test_permission_config(), &test_permission_context()).await;
assert!(matches!(result, Err(ToolError::SandboxViolation { .. })));
}
// =============================================================================
// Windows-Specific Tests
// =============================================================================
#[cfg(windows)]
#[tokio::test]
async fn test_read_write_windows_line_endings() {
let temp_dir = TempDir::new().unwrap();
let file_path = temp_dir.path().join("windows.txt");
// Write file with CRLF
std::fs::write(&file_path, "line1\r\nline2\r\nline3").unwrap();
let config = test_config(temp_dir.path().to_path_buf());
// Read file
let read_request = ReadTextFileRequest {
path: file_path.to_string_lossy().to_string(),
line: None,
limit: None,
};
let response = read_text_file(read_request, &config).await.unwrap();
// Content should preserve CRLF when read
assert!(response.content.contains("\r\n"));
// Write with LF normalization
let mut write_config = config.clone();
write_config.eol_policy = EolPolicy::Lf;
let write_request = WriteTextFileRequest {
path: file_path.to_string_lossy().to_string(),
content: response.content,
};
write_text_file(write_request, &write_config, &test_permission_config(), &test_permission_context()).await.unwrap();
// Verify normalization happened
let final_content = std::fs::read_to_string(&file_path).unwrap();
assert!(!final_content.contains("\r\n"));
assert!(final_content.contains("\n"));
}
+47
View File
@@ -0,0 +1,47 @@
//! Basic compilation and infrastructure tests for dirigent_tools.
mod common;
use common::*;
#[test]
fn test_package_compiles() {
// Basic smoke test to ensure the package compiles
assert!(true);
}
#[test]
fn test_temp_dir_creation() {
let temp_dir = create_temp_dir();
assert!(temp_dir.path().exists());
assert!(temp_dir.path().is_dir());
}
#[test]
fn test_test_file_creation() {
let temp_dir = create_temp_dir();
let file_path = create_test_file(temp_dir.path(), "test.txt", "test content");
assert!(file_path.exists());
assert_eq!(read_file_content(&file_path), "test content");
}
#[test]
fn test_sandboxed_env_creation() {
let env = SandboxedTestEnv::new();
assert!(env.allowed_root.exists());
assert!(env.blocked_dir.exists());
assert!(env.outside_dir.path().exists());
}
#[test]
fn test_fixture_files_exist() {
// Verify test fixtures are present
let fixtures_dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("tests")
.join("test_fixtures");
assert!(fixtures_dir.join("sample_text.txt").exists());
assert!(fixtures_dir.join("unicode_text.txt").exists());
assert!(fixtures_dir.join("large_text.txt").exists());
assert!(fixtures_dir.join("README.md").exists());
}
@@ -0,0 +1,190 @@
//! Integration tests for path normalization and containment.
//!
//! These tests use temporary directories to test real filesystem operations.
use dirigent_tools::config::SandboxConfig;
use dirigent_tools::path::{canonicalize_path, check_containment, validate_path, SymlinkPolicy};
use dirigent_tools::ToolError;
use std::fs;
use std::path::PathBuf;
use tempfile::TempDir;
#[test]
fn test_validate_path_with_real_filesystem() {
// Create a temp directory structure
let temp_dir = TempDir::new().unwrap();
let project_dir = temp_dir.path().join("project");
let src_dir = project_dir.join("src");
fs::create_dir_all(&src_dir).unwrap();
// Create a test file
let test_file = src_dir.join("main.rs");
fs::write(&test_file, "fn main() {}").unwrap();
// Create config
let mut config = SandboxConfig::default();
config.allowed_roots = vec![project_dir.clone()];
config.blocked_paths = vec!["**/.env".to_string()];
// Test: Valid path within allowed root
let result = validate_path(test_file.to_str().unwrap(), &config);
assert!(result.is_ok());
// Test: Path at the root level (should be allowed)
let root_file = project_dir.join("README.md");
fs::write(&root_file, "# Project").unwrap();
let result = validate_path(root_file.to_str().unwrap(), &config);
assert!(result.is_ok());
// Test: Path outside allowed roots
let outside_path = temp_dir.path().join("outside.txt");
fs::write(&outside_path, "outside").unwrap();
let result = validate_path(outside_path.to_str().unwrap(), &config);
assert!(result.is_err());
assert!(matches!(result, Err(ToolError::SandboxViolation { .. })));
}
#[test]
fn test_validate_path_blocked() {
let temp_dir = TempDir::new().unwrap();
let project_dir = temp_dir.path().join("project");
fs::create_dir_all(&project_dir).unwrap();
// Create a .env file
let env_file = project_dir.join(".env");
fs::write(&env_file, "SECRET=value").unwrap();
// Create config with blocklist
let mut config = SandboxConfig::default();
config.allowed_roots = vec![project_dir.clone()];
config.blocked_paths = vec!["**/.env".to_string()];
// Test: Blocked path
let result = validate_path(env_file.to_str().unwrap(), &config);
assert!(result.is_err());
assert!(matches!(result, Err(ToolError::BlockedPath { .. })));
}
#[test]
fn test_validate_path_non_existent_for_write() {
let temp_dir = TempDir::new().unwrap();
let project_dir = temp_dir.path().join("project");
fs::create_dir_all(&project_dir).unwrap();
// Non-existent file (for write operations)
let new_file = project_dir.join("new_file.txt");
let mut config = SandboxConfig::default();
config.allowed_roots = vec![project_dir.clone()];
// Test: Non-existent file within allowed root
let result = validate_path(new_file.to_str().unwrap(), &config);
assert!(result.is_ok());
}
#[test]
fn test_canonicalize_path_with_real_filesystem() {
let temp_dir = TempDir::new().unwrap();
let project_dir = temp_dir.path().join("project");
fs::create_dir_all(&project_dir).unwrap();
let test_file = project_dir.join("test.txt");
fs::write(&test_file, "content").unwrap();
let policy = SymlinkPolicy::default();
// Test: Canonicalize existing file
let canonical = canonicalize_path(&test_file, &policy).unwrap();
assert!(canonical.is_absolute());
assert!(canonical.exists());
// Verify no ".." components
assert!(!canonical.to_string_lossy().contains(".."));
}
#[test]
fn test_containment_with_real_filesystem() {
let temp_dir = TempDir::new().unwrap();
let root_dir = temp_dir.path().join("root");
let subdir = root_dir.join("subdir");
fs::create_dir_all(&subdir).unwrap();
let file = subdir.join("file.txt");
fs::write(&file, "content").unwrap();
// Canonicalize paths
let canonical_root = dunce::canonicalize(&root_dir).unwrap();
let canonical_file = dunce::canonicalize(&file).unwrap();
// Test: File is contained in root
let result = check_containment(&canonical_file, &[canonical_root.clone()]);
assert!(result.is_ok());
// Test: Root is not strictly contained in itself
let result = check_containment(&canonical_root, &[canonical_root.clone()]);
assert!(result.is_err());
}
#[cfg(unix)]
#[test]
fn test_symlink_handling_unix() {
use std::os::unix::fs::symlink;
let temp_dir = TempDir::new().unwrap();
let allowed_dir = temp_dir.path().join("allowed");
let outside_dir = temp_dir.path().join("outside");
fs::create_dir_all(&allowed_dir).unwrap();
fs::create_dir_all(&outside_dir).unwrap();
// Create a file outside the allowed root
let outside_file = outside_dir.join("secret.txt");
fs::write(&outside_file, "secret").unwrap();
// Create a symlink inside allowed root pointing outside
let symlink_path = allowed_dir.join("link_to_secret");
symlink(&outside_file, &symlink_path).unwrap();
let mut config = SandboxConfig::default();
config.allowed_roots = vec![dunce::canonicalize(&allowed_dir).unwrap()];
config.allow_symlink_escape = false; // Don't allow escapes
// Test: Symlink escape should be blocked
let result = validate_path(symlink_path.to_str().unwrap(), &config);
assert!(result.is_err());
}
#[cfg(windows)]
#[test]
fn test_windows_reserved_device_names() {
let policy = SymlinkPolicy::default();
// Reserved device names should be rejected
let reserved_names = vec!["CON", "PRN", "AUX", "NUL", "COM1", "COM2", "LPT1", "LPT2"];
for name in reserved_names {
let path = PathBuf::from(format!("C:\\{}", name));
let result = canonicalize_path(&path, &policy);
assert!(result.is_err(), "Should reject reserved name: {}", name);
}
}
#[cfg(windows)]
#[test]
fn test_windows_path_forms() {
// Test various Windows path forms with temp directory
let temp_dir = TempDir::new().unwrap();
let test_file = temp_dir.path().join("test.txt");
fs::write(&test_file, "content").unwrap();
let policy = SymlinkPolicy::default();
// Test: Standard absolute path
let canonical = canonicalize_path(&test_file, &policy).unwrap();
assert!(canonical.is_absolute());
// Test: Drive letter normalization (uppercase)
let path_str = canonical.to_string_lossy();
if path_str.len() >= 2 && path_str.chars().nth(1) == Some(':') {
assert!(path_str.chars().nth(0).unwrap().is_ascii_uppercase());
}
}
@@ -0,0 +1,437 @@
//! Integration tests for the permission system (TOOLS-PERM-01 through TOOLS-PERM-04).
//!
//! This test suite validates:
//! - Permission modes (ask, whitelist, yolo)
//! - Decision caching with TTL and scope
//! - Whitelist pattern matching
//! - ACP integration (stubbed for now)
//! - Integration with file operations
use dirigent_tools::config::{
DecisionScope, PermissionConfig, PermissionMode, SandboxConfig, WhitelistConfig,
};
use dirigent_tools::error::ToolError;
use dirigent_tools::fs::write::{write_text_file, WriteTextFileRequest};
use dirigent_tools::permission::check::{check_permission, PermissionContext};
use dirigent_tools::permission::cache::{CacheKey, PermissionDecision};
use dirigent_tools::permission::whitelist::{CompiledWhitelist, PermissionOperation};
use std::time::Duration;
use tempfile::TempDir;
/// Create a test sandbox configuration.
fn create_test_sandbox(temp_dir: &TempDir) -> SandboxConfig {
let mut config = SandboxConfig {
allowed_roots: vec![temp_dir.path().to_path_buf()],
blocked_paths: vec![],
allow_symlink_escape: false,
follow_symlinks_within_roots: true,
read_enabled: true,
write_enabled: true,
max_read_bytes: 1_000_000,
max_write_bytes: 1_000_000,
eol_policy: dirigent_tools::config::EolPolicy::Preserve,
encoding: "utf-8".to_string(),
};
config.normalize_roots();
config
}
#[tokio::test]
async fn test_yolo_mode_allows_everything() {
let temp_dir = TempDir::new().unwrap();
let config = PermissionConfig {
mode: PermissionMode::Yolo,
remember_decisions: true,
remember_ttl_secs: 300,
scope: DecisionScope::PerConnector,
whitelist: WhitelistConfig::default(),
};
let whitelist = CompiledWhitelist::compile(&config.whitelist).unwrap();
let context = PermissionContext::new(
"test-connector".to_string(),
Some("test-session".to_string()),
whitelist,
);
// All operations should be allowed in yolo mode
let operations = vec![
PermissionOperation::Read {
path: temp_dir.path().join("file.txt").display().to_string(),
},
PermissionOperation::Write {
path: temp_dir.path().join("file.txt").display().to_string(),
},
PermissionOperation::Execute {
command: "dangerous_command".to_string(),
cwd: temp_dir.path().display().to_string(),
},
];
for operation in operations {
let decision = check_permission(&operation, &context, &config).await.unwrap();
assert_eq!(decision, PermissionDecision::Allowed);
}
}
#[tokio::test]
async fn test_whitelist_mode_auto_approves_matching() {
let temp_dir = TempDir::new().unwrap();
let temp_path = temp_dir.path().display().to_string();
let config = PermissionConfig {
mode: PermissionMode::Whitelist,
remember_decisions: true,
remember_ttl_secs: 300,
scope: DecisionScope::PerConnector,
whitelist: WhitelistConfig {
write_paths: vec![format!("{}/**", temp_path)],
execute_commands: vec!["cargo".to_string(), "npm".to_string()],
},
};
let whitelist = CompiledWhitelist::compile(&config.whitelist).unwrap();
let context = PermissionContext::new(
"test-connector".to_string(),
None,
whitelist,
);
// Read should always be allowed
let read_op = PermissionOperation::Read {
path: "/any/path/file.txt".to_string(),
};
let decision = check_permission(&read_op, &context, &config).await.unwrap();
assert_eq!(decision, PermissionDecision::Allowed);
// Write within whitelist should be allowed
let write_ok = PermissionOperation::Write {
path: temp_dir.path().join("file.txt").display().to_string(),
};
let decision = check_permission(&write_ok, &context, &config).await.unwrap();
assert_eq!(decision, PermissionDecision::Allowed);
// Execute whitelisted command should be allowed
let exec_ok = PermissionOperation::Execute {
command: "cargo".to_string(),
cwd: temp_dir.path().display().to_string(),
};
let decision = check_permission(&exec_ok, &context, &config).await.unwrap();
assert_eq!(decision, PermissionDecision::Allowed);
}
#[tokio::test]
async fn test_decision_cache_per_connector() {
// Note: This test manually adds cache entries to verify cache separation.
// In production, cache entries are only added when users select "always" options in ACP prompts.
let config = PermissionConfig {
mode: PermissionMode::Ask,
remember_decisions: true,
remember_ttl_secs: 300,
scope: DecisionScope::PerConnector,
whitelist: WhitelistConfig::default(),
};
let whitelist = CompiledWhitelist::compile(&config.whitelist).unwrap();
let context1 = PermissionContext::new(
"connector-1".to_string(),
Some("session-1".to_string()),
whitelist.clone(),
);
let context2 = PermissionContext::new(
"connector-2".to_string(),
Some("session-2".to_string()),
whitelist,
);
// Manually add cache entries to test separation
{
let mut cache1 = context1.cache.lock().unwrap();
let key = CacheKey::write("/test/path", "connector-1", Some("session-1"), DecisionScope::PerConnector);
cache1.insert(key, PermissionDecision::Allowed, Duration::from_secs(300));
}
assert_eq!(context1.cache_size(), 1);
// Second connector should have separate cache
assert_eq!(context2.cache_size(), 0);
{
let mut cache2 = context2.cache.lock().unwrap();
let key = CacheKey::write("/test/path", "connector-2", Some("session-2"), DecisionScope::PerConnector);
cache2.insert(key, PermissionDecision::Allowed, Duration::from_secs(300));
}
assert_eq!(context2.cache_size(), 1);
}
#[tokio::test]
async fn test_decision_cache_per_session() {
// Note: This test manually adds cache entries to verify per-session scoping.
// In production, cache entries are only added when users select "always" options in ACP prompts.
let config = PermissionConfig {
mode: PermissionMode::Ask,
remember_decisions: true,
remember_ttl_secs: 300,
scope: DecisionScope::PerSession,
whitelist: WhitelistConfig::default(),
};
let whitelist = CompiledWhitelist::compile(&config.whitelist).unwrap();
// Same connector, different sessions - they share the same cache object but have different keys
let context1 = PermissionContext::new(
"connector-1".to_string(),
Some("session-1".to_string()),
whitelist.clone(),
);
let context2 = PermissionContext::new(
"connector-1".to_string(),
Some("session-2".to_string()),
whitelist,
);
// Manually add entries for each session with per-session scope
{
let mut cache1 = context1.cache.lock().unwrap();
let key1 = CacheKey::write("/test/path", "connector-1", Some("session-1"), DecisionScope::PerSession);
cache1.insert(key1, PermissionDecision::Allowed, Duration::from_secs(300));
}
{
let mut cache2 = context2.cache.lock().unwrap();
let key2 = CacheKey::write("/test/path", "connector-1", Some("session-2"), DecisionScope::PerSession);
cache2.insert(key2, PermissionDecision::Allowed, Duration::from_secs(300));
}
// Each context has its own cache object, so they each have 1 entry
assert_eq!(context1.cache_size(), 1);
assert_eq!(context2.cache_size(), 1);
}
#[tokio::test]
async fn test_cache_ttl_expiration() {
// Note: This test manually adds a cache entry with short TTL to test expiration.
let whitelist = CompiledWhitelist::compile(&WhitelistConfig::default()).unwrap();
let context = PermissionContext::new(
"test-connector".to_string(),
None,
whitelist,
);
// Manually add entry with very short TTL
{
let mut cache = context.cache.lock().unwrap();
let key = CacheKey::write("/test/path", "test-connector", None, DecisionScope::PerConnector);
cache.insert(key.clone(), PermissionDecision::Allowed, Duration::from_millis(1));
}
// Cache entry should be present
let initial_size = context.cache_size();
assert_eq!(initial_size, 1);
// Wait for expiration
tokio::time::sleep(Duration::from_millis(10)).await;
// Try to get the entry (should be expired and removed)
{
let mut cache = context.cache.lock().unwrap();
let key = CacheKey::write("/test/path", "test-connector", None, DecisionScope::PerConnector);
let result = cache.get(&key);
assert_eq!(result, None); // Should be expired
}
// Cache should now be empty
assert_eq!(context.cache_size(), 0);
}
#[tokio::test]
async fn test_write_file_with_permission_yolo() {
let temp_dir = TempDir::new().unwrap();
let sandbox_config = create_test_sandbox(&temp_dir);
let permission_config = PermissionConfig {
mode: PermissionMode::Yolo,
remember_decisions: true,
remember_ttl_secs: 300,
scope: DecisionScope::PerConnector,
whitelist: WhitelistConfig::default(),
};
let whitelist = CompiledWhitelist::compile(&permission_config.whitelist).unwrap();
let permission_context = PermissionContext::new(
"test-connector".to_string(),
None,
whitelist,
);
let file_path = temp_dir.path().join("test_file.txt");
let request = WriteTextFileRequest {
path: file_path.display().to_string(),
content: "Hello, world!".to_string(),
};
// Should succeed in yolo mode
let result = write_text_file(
request,
&sandbox_config,
&permission_config,
&permission_context,
)
.await;
assert!(result.is_ok());
assert!(file_path.exists());
let content = std::fs::read_to_string(&file_path).unwrap();
assert_eq!(content, "Hello, world!");
}
#[tokio::test]
async fn test_write_file_with_permission_whitelist() {
let temp_dir = TempDir::new().unwrap();
let sandbox_config = create_test_sandbox(&temp_dir);
let temp_path = temp_dir.path().display().to_string();
let permission_config = PermissionConfig {
mode: PermissionMode::Whitelist,
remember_decisions: true,
remember_ttl_secs: 300,
scope: DecisionScope::PerConnector,
whitelist: WhitelistConfig {
write_paths: vec![format!("{}/**", temp_path)],
execute_commands: vec![],
},
};
let whitelist = CompiledWhitelist::compile(&permission_config.whitelist).unwrap();
let permission_context = PermissionContext::new(
"test-connector".to_string(),
None,
whitelist,
);
let file_path = temp_dir.path().join("test_file.txt");
let request = WriteTextFileRequest {
path: file_path.display().to_string(),
content: "Whitelisted write".to_string(),
};
// Should succeed because path matches whitelist
let result = write_text_file(
request,
&sandbox_config,
&permission_config,
&permission_context,
)
.await;
assert!(result.is_ok());
assert!(file_path.exists());
let content = std::fs::read_to_string(&file_path).unwrap();
assert_eq!(content, "Whitelisted write");
}
#[tokio::test]
async fn test_cache_key_equality() {
// Test that cache keys are correctly generated for different scopes
let key1 = CacheKey::write(
"/path/to/file",
"conn-1",
Some("session-1"),
DecisionScope::PerConnector,
);
let key2 = CacheKey::write(
"/path/to/file",
"conn-1",
Some("session-1"),
DecisionScope::PerConnector,
);
let key3 = CacheKey::write(
"/path/to/file",
"conn-1",
Some("session-2"),
DecisionScope::PerConnector,
);
// Same connector and path should produce equal keys for per-connector scope
assert_eq!(key1, key2);
// Different sessions should still be equal for per-connector scope
assert_eq!(key1, key3);
// Different scope should produce different keys
let key4 = CacheKey::write(
"/path/to/file",
"conn-1",
Some("session-1"),
DecisionScope::PerSession,
);
assert_ne!(key1, key4);
}
#[test]
fn test_whitelist_compilation() {
let config = WhitelistConfig {
write_paths: vec![
"C:/work/**".to_string(),
"/home/user/**".to_string(),
],
execute_commands: vec![
"cargo".to_string(),
"npm*".to_string(),
],
};
let whitelist = CompiledWhitelist::compile(&config);
assert!(whitelist.is_ok());
let whitelist = whitelist.unwrap();
assert!(whitelist.has_write_patterns());
assert!(whitelist.has_execute_patterns());
}
#[test]
fn test_invalid_whitelist_pattern() {
let config = WhitelistConfig {
write_paths: vec!["[invalid".to_string()], // Unclosed bracket
execute_commands: vec![],
};
let result = CompiledWhitelist::compile(&config);
assert!(result.is_err());
match result {
Err(ToolError::InvalidConfig(_)) => {
// Expected error
}
_ => panic!("Expected InvalidConfig error"),
}
}
#[tokio::test]
async fn test_permission_context_sharing() {
let whitelist = CompiledWhitelist::compile(&WhitelistConfig::default()).unwrap();
let context = PermissionContext::new(
"test-connector".to_string(),
None,
whitelist,
);
// Clone should share the same cache
let context_clone = context.clone();
// Add entry via original context
{
let mut cache = context.cache.lock().unwrap();
let key = CacheKey::write("/path", "test", None, DecisionScope::PerConnector);
cache.insert(key, PermissionDecision::Allowed, Duration::from_secs(300));
}
// Clone should see the same entry
assert_eq!(context.cache_size(), 1);
assert_eq!(context_clone.cache_size(), 1);
}
@@ -0,0 +1,421 @@
//! Integration tests for search operations (ls, glob, grep).
//!
//! These tests verify:
//! - Directory listing with filtering
//! - Glob pattern matching
//! - Regex content search with context
//! - Result limits enforcement
//! - Binary file detection
//! - Windows path support
use dirigent_tools::config::SearchConfig;
use dirigent_tools::search::{
glob_search, grep_search, ls, FileKind, GlobRequest, GrepRequest, LsRequest,
};
use std::fs;
use std::io::Write;
use tempfile::TempDir;
/// Helper to create test directory structure
fn create_test_structure() -> TempDir {
let temp_dir = TempDir::new().unwrap();
let base = temp_dir.path();
// Create directory structure
fs::create_dir(base.join("src")).unwrap();
fs::create_dir(base.join("tests")).unwrap();
fs::create_dir(base.join("target")).unwrap(); // Should be excluded by default
fs::create_dir(base.join(".git")).unwrap(); // Should be excluded by default
// Create some files
fs::write(base.join("README.md"), "# Test Project\n").unwrap();
fs::write(base.join("Cargo.toml"), "[package]\nname = \"test\"\n").unwrap();
fs::write(
base.join("src/main.rs"),
"fn main() {\n println!(\"Hello, world!\");\n}\n",
)
.unwrap();
fs::write(
base.join("src/lib.rs"),
"pub fn add(a: i32, b: i32) -> i32 {\n a + b\n}\n",
)
.unwrap();
fs::write(
base.join("tests/integration.rs"),
"#[test]\nfn test_add() {\n assert_eq!(add(2, 2), 4);\n}\n",
)
.unwrap();
// Create a file in excluded directory (should not appear in searches)
fs::write(base.join("target/debug.log"), "debug info\n").unwrap();
temp_dir
}
#[tokio::test]
async fn test_ls_basic() {
let temp_dir = create_test_structure();
let base = temp_dir.path();
let config = SearchConfig::default();
let request = LsRequest {
path: base.to_string_lossy().to_string(),
};
let response = ls(request, &config).await.unwrap();
// Should have files and directories, but not .git or target (excluded by default)
assert!(response.entries.len() >= 4); // README, Cargo.toml, src, tests
// Check we have both files and directories
let has_file = response.entries.iter().any(|e| e.kind == FileKind::File);
let has_dir = response.entries.iter().any(|e| e.kind == FileKind::Dir);
assert!(has_file);
assert!(has_dir);
// Check that excluded directories are not present
let has_target = response
.entries
.iter()
.any(|e| e.path.file_name().unwrap() == "target");
let has_git = response
.entries
.iter()
.any(|e| e.path.file_name().unwrap() == ".git");
assert!(!has_target, "target/ should be excluded by default");
assert!(!has_git, ".git/ should be excluded by default");
}
#[tokio::test]
async fn test_ls_file_sizes() {
let temp_dir = create_test_structure();
let base = temp_dir.path();
let config = SearchConfig::default();
let request = LsRequest {
path: base.to_string_lossy().to_string(),
};
let response = ls(request, &config).await.unwrap();
// Files should have sizes, directories should not
for entry in &response.entries {
match entry.kind {
FileKind::File => assert!(entry.size.is_some(), "Files should have sizes"),
FileKind::Dir => assert!(entry.size.is_none(), "Directories should not have sizes"),
FileKind::Symlink => {} // Symlinks may or may not have sizes
}
}
}
#[tokio::test]
async fn test_glob_basic() {
let temp_dir = create_test_structure();
let base = temp_dir.path();
let config = SearchConfig::default();
// Search for all Rust files
let request = GlobRequest {
path: base.to_string_lossy().to_string(),
pattern: "**/*.rs".to_string(),
exclude: None,
max_results: None,
};
let response = glob_search(request, &config).await.unwrap();
// Should find main.rs, lib.rs, and integration.rs (3 files)
assert_eq!(response.matches.len(), 3);
assert!(!response.truncated);
// Verify all matches end with .rs
for path in &response.matches {
assert!(path.extension().unwrap() == "rs");
}
}
#[tokio::test]
async fn test_glob_pattern_variations() {
let temp_dir = create_test_structure();
let base = temp_dir.path();
let config = SearchConfig::default();
// Test single-level wildcard
let request = GlobRequest {
path: base.to_string_lossy().to_string(),
pattern: "*.md".to_string(),
exclude: None,
max_results: None,
};
let response = glob_search(request, &config).await.unwrap();
assert_eq!(response.matches.len(), 1); // README.md
assert!(!response.truncated);
// Test specific directory
let request = GlobRequest {
path: base.to_string_lossy().to_string(),
pattern: "src/*.rs".to_string(),
exclude: None,
max_results: None,
};
let response = glob_search(request, &config).await.unwrap();
assert_eq!(response.matches.len(), 2); // main.rs, lib.rs
assert!(!response.truncated);
}
#[tokio::test]
async fn test_glob_max_results() {
let temp_dir = create_test_structure();
let base = temp_dir.path();
let config = SearchConfig::default();
// Limit to 2 results
let request = GlobRequest {
path: base.to_string_lossy().to_string(),
pattern: "**/*.rs".to_string(),
exclude: None,
max_results: Some(2),
};
let response = glob_search(request, &config).await.unwrap();
assert_eq!(response.matches.len(), 2);
assert!(response.truncated); // Should be truncated since there are 3 .rs files
}
#[tokio::test]
async fn test_glob_exclude_patterns() {
let temp_dir = create_test_structure();
let base = temp_dir.path();
let config = SearchConfig::default();
// Search for all .rs files but exclude tests
let request = GlobRequest {
path: base.to_string_lossy().to_string(),
pattern: "**/*.rs".to_string(),
exclude: Some(vec!["**/tests/**".to_string()]),
max_results: None,
};
let response = glob_search(request, &config).await.unwrap();
assert_eq!(response.matches.len(), 2); // Only main.rs and lib.rs, not integration.rs
assert!(!response.truncated);
}
#[tokio::test]
async fn test_grep_basic() {
let temp_dir = create_test_structure();
let base = temp_dir.path();
let config = SearchConfig::default();
// Search for "main" in all files
let request = GrepRequest {
path: base.to_string_lossy().to_string(),
pattern: "main".to_string(),
file_pattern: None,
ignore_case: false,
context_before: 0,
context_after: 0,
max_results: None,
};
let response = grep_search(request, &config).await.unwrap();
// Should find "main" in src/main.rs
assert!(response.matches.len() >= 1);
assert!(!response.truncated);
// Verify line numbers are 1-indexed
for m in &response.matches {
assert!(m.line_number > 0);
}
}
#[tokio::test]
async fn test_grep_case_insensitive() {
let temp_dir = create_test_structure();
let base = temp_dir.path();
let config = SearchConfig::default();
// Search for "HELLO" (case insensitive)
let request = GrepRequest {
path: base.to_string_lossy().to_string(),
pattern: "HELLO".to_string(),
file_pattern: Some("**/*.rs".to_string()),
ignore_case: true,
context_before: 0,
context_after: 0,
max_results: None,
};
let response = grep_search(request, &config).await.unwrap();
// Should find "Hello" in main.rs
assert!(response.matches.len() >= 1);
}
#[tokio::test]
async fn test_grep_context_lines() {
let temp_dir = create_test_structure();
let base = temp_dir.path();
// Create a file with more content
fs::write(
base.join("src/context_test.rs"),
"line 1\nline 2\nMATCH HERE\nline 4\nline 5\n",
)
.unwrap();
let config = SearchConfig::default();
// Search with context
let request = GrepRequest {
path: base.to_string_lossy().to_string(),
pattern: "MATCH".to_string(),
file_pattern: Some("**/context_test.rs".to_string()),
ignore_case: false,
context_before: 2,
context_after: 2,
max_results: None,
};
let response = grep_search(request, &config).await.unwrap();
assert_eq!(response.matches.len(), 1);
let m = &response.matches[0];
// Should have 2 lines before and 2 lines after
assert_eq!(m.context_before.len(), 2);
assert_eq!(m.context_after.len(), 2);
assert_eq!(m.context_before[0], "line 1");
assert_eq!(m.context_before[1], "line 2");
assert_eq!(m.context_after[0], "line 4");
assert_eq!(m.context_after[1], "line 5");
}
#[tokio::test]
async fn test_grep_max_results() {
let temp_dir = create_test_structure();
let base = temp_dir.path();
// Create a file with multiple matches
fs::write(
base.join("many_matches.txt"),
"match\nmatch\nmatch\nmatch\nmatch\n",
)
.unwrap();
let config = SearchConfig::default();
// Limit to 3 results
let request = GrepRequest {
path: base.to_string_lossy().to_string(),
pattern: "match".to_string(),
file_pattern: None,
ignore_case: false,
context_before: 0,
context_after: 0,
max_results: Some(3),
};
let response = grep_search(request, &config).await.unwrap();
assert_eq!(response.matches.len(), 3);
assert!(response.truncated);
}
#[tokio::test]
async fn test_grep_binary_file_skip() {
let temp_dir = TempDir::new().unwrap();
let base = temp_dir.path();
// Create a binary file with null bytes
let binary_path = base.join("binary.bin");
let mut file = fs::File::create(&binary_path).unwrap();
file.write_all(&[0x00, 0x01, 0x02, 0x03]).unwrap();
// Create a text file
fs::write(base.join("text.txt"), "some text\n").unwrap();
let config = SearchConfig::default();
// Search for any character
let request = GrepRequest {
path: base.to_string_lossy().to_string(),
pattern: ".".to_string(),
file_pattern: None,
ignore_case: false,
context_before: 0,
context_after: 0,
max_results: None,
};
let response = grep_search(request, &config).await.unwrap();
// Should only find matches in text.txt, not binary.bin
assert!(response.matches.len() > 0);
for m in &response.matches {
assert!(
m.path.to_string_lossy().contains("text.txt"),
"Should not match binary files"
);
}
}
#[tokio::test]
async fn test_grep_regex_patterns() {
let temp_dir = create_test_structure();
let base = temp_dir.path();
let config = SearchConfig::default();
// Test regex with character classes
let request = GrepRequest {
path: base.to_string_lossy().to_string(),
pattern: r"\d+".to_string(), // Match numbers
file_pattern: None,
ignore_case: false,
context_before: 0,
context_after: 0,
max_results: None,
};
let response = grep_search(request, &config).await.unwrap();
// Should find numbers in test files (e.g., "2, 2" in assert)
assert!(response.matches.len() > 0);
}
#[cfg(windows)]
#[tokio::test]
async fn test_windows_paths() {
let temp_dir = create_test_structure();
let base = temp_dir.path();
let config = SearchConfig::default();
// Test with Windows path separators
let path_str = base.to_string_lossy().to_string();
// Test ls
let request = LsRequest { path: path_str.clone() };
let response = ls(request, &config).await.unwrap();
assert!(response.entries.len() > 0);
// Test glob
let request = GlobRequest {
path: path_str.clone(),
pattern: "**\\*.rs".to_string(), // Windows-style pattern
exclude: None,
max_results: None,
};
let response = glob_search(request, &config).await.unwrap();
assert!(response.matches.len() > 0);
}
@@ -0,0 +1,296 @@
//! Integration tests for terminal operations.
use dirigent_tools::config::TerminalConfig;
use dirigent_tools::terminal::{
create_terminal, get_terminal_output, kill_terminal, release_terminal,
wait_for_terminal_exit, CreateTerminalRequest, KillTerminalCommandRequest,
ReleaseTerminalRequest, TerminalOutputRequest, WaitForTerminalExitRequest,
};
#[tokio::test]
async fn test_terminal_echo_command() {
let config = TerminalConfig {
enabled: true,
default_cwd: Some(std::env::current_dir().unwrap()),
env_allowlist: vec![],
command_blocklist: vec![],
output_byte_limit: 10_000,
max_runtime_secs: 30,
};
// Create terminal with echo command
#[cfg(windows)]
let request = CreateTerminalRequest {
command: "cmd".to_string(),
args: vec!["/C".to_string(), "echo".to_string(), "Hello World".to_string()],
cwd: None,
env: None,
output_byte_limit: None,
};
#[cfg(not(windows))]
let request = CreateTerminalRequest {
command: "echo".to_string(),
args: vec!["Hello World".to_string()],
cwd: None,
env: None,
output_byte_limit: None,
};
let create_response = create_terminal(request, &config).await.unwrap();
let terminal_id = create_response.terminal_id;
// Wait for command to complete
tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
// Get output
let output_response = get_terminal_output(
TerminalOutputRequest {
terminal_id: terminal_id.clone(),
},
&config,
)
.await
.unwrap();
assert!(output_response.output.contains("Hello World"));
// Release terminal
let _ = release_terminal(
ReleaseTerminalRequest {
terminal_id: terminal_id.clone(),
},
&config,
)
.await;
}
#[tokio::test]
async fn test_terminal_wait_for_exit() {
let config = TerminalConfig {
enabled: true,
default_cwd: Some(std::env::current_dir().unwrap()),
env_allowlist: vec![],
command_blocklist: vec![],
output_byte_limit: 10_000,
max_runtime_secs: 30,
};
// Create terminal with a quick command
#[cfg(windows)]
let request = CreateTerminalRequest {
command: "cmd".to_string(),
args: vec!["/C".to_string(), "exit".to_string(), "0".to_string()],
cwd: None,
env: None,
output_byte_limit: None,
};
#[cfg(not(windows))]
let request = CreateTerminalRequest {
command: "true".to_string(),
args: vec![],
cwd: None,
env: None,
output_byte_limit: None,
};
let create_response = create_terminal(request, &config).await.unwrap();
let terminal_id = create_response.terminal_id;
// Wait for exit
let wait_response = wait_for_terminal_exit(
WaitForTerminalExitRequest {
terminal_id: terminal_id.clone(),
},
&config,
)
.await
.unwrap();
assert_eq!(wait_response.exit_status, 0);
// Release terminal
let _ = release_terminal(
ReleaseTerminalRequest {
terminal_id: terminal_id.clone(),
},
&config,
)
.await;
}
#[tokio::test]
async fn test_terminal_kill() {
let config = TerminalConfig {
enabled: true,
default_cwd: Some(std::env::current_dir().unwrap()),
env_allowlist: vec![],
command_blocklist: vec![],
output_byte_limit: 10_000,
max_runtime_secs: 30,
};
// Create terminal with a long-running command
#[cfg(windows)]
let request = CreateTerminalRequest {
command: "cmd".to_string(),
args: vec!["/C".to_string(), "timeout".to_string(), "/t".to_string(), "10".to_string()],
cwd: None,
env: None,
output_byte_limit: None,
};
#[cfg(not(windows))]
let request = CreateTerminalRequest {
command: "sleep".to_string(),
args: vec!["10".to_string()],
cwd: None,
env: None,
output_byte_limit: None,
};
let create_response = create_terminal(request, &config).await.unwrap();
let terminal_id = create_response.terminal_id;
// Wait a bit to ensure process is running
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
// Kill the terminal
let kill_response = kill_terminal(
KillTerminalCommandRequest {
terminal_id: terminal_id.clone(),
},
&config,
)
.await;
assert!(kill_response.is_ok());
// Release terminal
let _ = release_terminal(
ReleaseTerminalRequest {
terminal_id: terminal_id.clone(),
},
&config,
)
.await;
}
#[tokio::test]
async fn test_terminal_disabled() {
let config = TerminalConfig {
enabled: false,
default_cwd: Some(std::env::current_dir().unwrap()),
env_allowlist: vec![],
command_blocklist: vec![],
output_byte_limit: 10_000,
max_runtime_secs: 30,
};
let request = CreateTerminalRequest {
command: "echo".to_string(),
args: vec!["test".to_string()],
cwd: None,
env: None,
output_byte_limit: None,
};
let result = create_terminal(request, &config).await;
assert!(result.is_err());
}
#[tokio::test]
async fn test_terminal_command_blocklist() {
let config = TerminalConfig {
enabled: true,
default_cwd: Some(std::env::current_dir().unwrap()),
env_allowlist: vec![],
command_blocklist: vec!["rm".to_string(), "del".to_string()],
output_byte_limit: 10_000,
max_runtime_secs: 30,
};
let request = CreateTerminalRequest {
command: "rm".to_string(),
args: vec!["-rf".to_string(), "/".to_string()],
cwd: None,
env: None,
output_byte_limit: None,
};
let result = create_terminal(request, &config).await;
assert!(result.is_err());
}
#[tokio::test]
async fn test_terminal_output_truncation() {
let config = TerminalConfig {
enabled: true,
default_cwd: Some(std::env::current_dir().unwrap()),
env_allowlist: vec![],
command_blocklist: vec![],
output_byte_limit: 100, // Very small buffer
max_runtime_secs: 30,
};
// Create terminal that generates lots of output
#[cfg(windows)]
let request = CreateTerminalRequest {
command: "cmd".to_string(),
args: vec![
"/C".to_string(),
"for".to_string(),
"/L".to_string(),
"%i".to_string(),
"in".to_string(),
"(1,1,100)".to_string(),
"do".to_string(),
"@echo".to_string(),
"Line %i".to_string(),
],
cwd: None,
env: None,
output_byte_limit: Some(100),
};
#[cfg(not(windows))]
let request = CreateTerminalRequest {
command: "sh".to_string(),
args: vec![
"-c".to_string(),
"for i in $(seq 1 100); do echo Line $i; done".to_string(),
],
cwd: None,
env: None,
output_byte_limit: Some(100),
};
let create_response = create_terminal(request, &config).await.unwrap();
let terminal_id = create_response.terminal_id;
// Wait for command to complete
tokio::time::sleep(tokio::time::Duration::from_millis(1000)).await;
// Get output
let output_response = get_terminal_output(
TerminalOutputRequest {
terminal_id: terminal_id.clone(),
},
&config,
)
.await
.unwrap();
// Buffer should be truncated
assert!(output_response.truncated || output_response.output.len() <= 100);
// Release terminal
let _ = release_terminal(
ReleaseTerminalRequest {
terminal_id: terminal_id.clone(),
},
&config,
)
.await;
}
@@ -0,0 +1,17 @@
# Test Fixtures
This directory contains test fixtures for dirigent_tools tests.
## Files
- **sample_text.txt** - Simple multi-line text file
- **unicode_text.txt** - File with Unicode characters (emoji, CJK, special chars)
- **large_text.txt** - File for testing size limits (initially small, can be expanded in tests)
- **binary_sample.bin** - Binary file (non-UTF-8) - to be created programmatically in tests
## Usage
These fixtures are used by integration tests in `packages/dirigent_tools/tests/`.
Test utilities in `tests/common/mod.rs` provide helpers for creating temporary copies
and working with these fixtures.
@@ -0,0 +1 @@
This is a large text file for testing size limits.
@@ -0,0 +1,5 @@
This is a sample text file for testing.
It has multiple lines.
Line 3
Line 4
Line 5
@@ -0,0 +1,7 @@
Unicode test file with various characters:
ASCII: Hello, World!
Emoji: 👋 🌍 🚀 ✨
CJK: 你好世界 こんにちは世界
Special: Ñoño Crème brûlée
Math: ∑ ∫ ∂ ∇ ∞