542 lines
18 KiB
Rust
542 lines
18 KiB
Rust
//! 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"));
|
|
}
|