Files
2026-05-08 01:59:04 +02:00

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"));
}