sync from monorepo @ 2452e92e
This commit is contained in:
@@ -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"));
|
||||
}
|
||||
@@ -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: ∑ ∫ ∂ ∇ ∞
|
||||
Reference in New Issue
Block a user