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