1use std::path::{Path, PathBuf};
5
6use schemars::JsonSchema;
7use serde::Deserialize;
8
9use crate::executor::{
10 DiffData, ToolCall, ToolError, ToolExecutor, ToolOutput, deserialize_params,
11};
12use crate::registry::{InvocationHint, ToolDef};
13
14#[derive(Deserialize, JsonSchema)]
15pub(crate) struct ReadParams {
16 path: String,
18 offset: Option<u32>,
20 limit: Option<u32>,
22}
23
24#[derive(Deserialize, JsonSchema)]
25struct WriteParams {
26 path: String,
28 content: String,
30}
31
32#[derive(Deserialize, JsonSchema)]
33struct EditParams {
34 path: String,
36 old_string: String,
38 new_string: String,
40}
41
42#[derive(Deserialize, JsonSchema)]
43struct FindPathParams {
44 pattern: String,
46}
47
48#[derive(Deserialize, JsonSchema)]
49struct GrepParams {
50 pattern: String,
52 path: Option<String>,
54 case_sensitive: Option<bool>,
56}
57
58#[derive(Deserialize, JsonSchema)]
59struct ListDirectoryParams {
60 path: String,
62}
63
64#[derive(Deserialize, JsonSchema)]
65struct CreateDirectoryParams {
66 path: String,
68}
69
70#[derive(Deserialize, JsonSchema)]
71struct DeletePathParams {
72 path: String,
74 #[serde(default)]
76 recursive: bool,
77}
78
79#[derive(Deserialize, JsonSchema)]
80struct MovePathParams {
81 source: String,
83 destination: String,
85}
86
87#[derive(Deserialize, JsonSchema)]
88struct CopyPathParams {
89 source: String,
91 destination: String,
93}
94
95#[derive(Debug)]
97pub struct FileExecutor {
98 allowed_paths: Vec<PathBuf>,
99}
100
101fn expand_tilde(path: PathBuf) -> PathBuf {
102 let s = path.to_string_lossy();
103 if let Some(rest) = s
104 .strip_prefix("~/")
105 .or_else(|| if s == "~" { Some("") } else { None })
106 && let Some(home) = dirs::home_dir()
107 {
108 return home.join(rest);
109 }
110 path
111}
112
113impl FileExecutor {
114 #[must_use]
115 pub fn new(allowed_paths: Vec<PathBuf>) -> Self {
116 let paths = if allowed_paths.is_empty() {
117 vec![std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))]
118 } else {
119 allowed_paths.into_iter().map(expand_tilde).collect()
120 };
121 Self {
122 allowed_paths: paths
123 .into_iter()
124 .map(|p| p.canonicalize().unwrap_or(p))
125 .collect(),
126 }
127 }
128
129 fn validate_path(&self, path: &Path) -> Result<PathBuf, ToolError> {
130 let resolved = if path.is_absolute() {
131 path.to_path_buf()
132 } else {
133 std::env::current_dir()
134 .unwrap_or_else(|_| PathBuf::from("."))
135 .join(path)
136 };
137 let normalized = normalize_path(&resolved);
138 let canonical = resolve_via_ancestors(&normalized);
139 if !self.allowed_paths.iter().any(|a| canonical.starts_with(a)) {
140 return Err(ToolError::SandboxViolation {
141 path: canonical.display().to_string(),
142 });
143 }
144 Ok(canonical)
145 }
146
147 pub fn execute_file_tool(
153 &self,
154 tool_id: &str,
155 params: &serde_json::Map<String, serde_json::Value>,
156 ) -> Result<Option<ToolOutput>, ToolError> {
157 match tool_id {
158 "read" => {
159 let p: ReadParams = deserialize_params(params)?;
160 self.handle_read(&p)
161 }
162 "write" => {
163 let p: WriteParams = deserialize_params(params)?;
164 self.handle_write(&p)
165 }
166 "edit" => {
167 let p: EditParams = deserialize_params(params)?;
168 self.handle_edit(&p)
169 }
170 "find_path" => {
171 let p: FindPathParams = deserialize_params(params)?;
172 self.handle_find_path(&p)
173 }
174 "grep" => {
175 let p: GrepParams = deserialize_params(params)?;
176 self.handle_grep(&p)
177 }
178 "list_directory" => {
179 let p: ListDirectoryParams = deserialize_params(params)?;
180 self.handle_list_directory(&p)
181 }
182 "create_directory" => {
183 let p: CreateDirectoryParams = deserialize_params(params)?;
184 self.handle_create_directory(&p)
185 }
186 "delete_path" => {
187 let p: DeletePathParams = deserialize_params(params)?;
188 self.handle_delete_path(&p)
189 }
190 "move_path" => {
191 let p: MovePathParams = deserialize_params(params)?;
192 self.handle_move_path(&p)
193 }
194 "copy_path" => {
195 let p: CopyPathParams = deserialize_params(params)?;
196 self.handle_copy_path(&p)
197 }
198 _ => Ok(None),
199 }
200 }
201
202 fn handle_read(&self, params: &ReadParams) -> Result<Option<ToolOutput>, ToolError> {
203 let path = self.validate_path(Path::new(¶ms.path))?;
204 let content = std::fs::read_to_string(&path)?;
205
206 let offset = params.offset.unwrap_or(0) as usize;
207 let limit = params.limit.map_or(usize::MAX, |l| l as usize);
208
209 let selected: Vec<String> = content
210 .lines()
211 .skip(offset)
212 .take(limit)
213 .enumerate()
214 .map(|(i, line)| format!("{:>4}\t{line}", offset + i + 1))
215 .collect();
216
217 Ok(Some(ToolOutput {
218 tool_name: "read".to_owned(),
219 summary: selected.join("\n"),
220 blocks_executed: 1,
221 filter_stats: None,
222 diff: None,
223 streamed: false,
224 terminal_id: None,
225 locations: None,
226 raw_response: None,
227 }))
228 }
229
230 fn handle_write(&self, params: &WriteParams) -> Result<Option<ToolOutput>, ToolError> {
231 let path = self.validate_path(Path::new(¶ms.path))?;
232 let old_content = std::fs::read_to_string(&path).unwrap_or_default();
233
234 if let Some(parent) = path.parent() {
235 std::fs::create_dir_all(parent)?;
236 }
237 std::fs::write(&path, ¶ms.content)?;
238
239 Ok(Some(ToolOutput {
240 tool_name: "write".to_owned(),
241 summary: format!("Wrote {} bytes to {}", params.content.len(), params.path),
242 blocks_executed: 1,
243 filter_stats: None,
244 diff: Some(DiffData {
245 file_path: params.path.clone(),
246 old_content,
247 new_content: params.content.clone(),
248 }),
249 streamed: false,
250 terminal_id: None,
251 locations: None,
252 raw_response: None,
253 }))
254 }
255
256 fn handle_edit(&self, params: &EditParams) -> Result<Option<ToolOutput>, ToolError> {
257 let path = self.validate_path(Path::new(¶ms.path))?;
258 let content = std::fs::read_to_string(&path)?;
259
260 if !content.contains(¶ms.old_string) {
261 return Err(ToolError::Execution(std::io::Error::new(
262 std::io::ErrorKind::NotFound,
263 format!("old_string not found in {}", params.path),
264 )));
265 }
266
267 let new_content = content.replacen(¶ms.old_string, ¶ms.new_string, 1);
268 std::fs::write(&path, &new_content)?;
269
270 Ok(Some(ToolOutput {
271 tool_name: "edit".to_owned(),
272 summary: format!("Edited {}", params.path),
273 blocks_executed: 1,
274 filter_stats: None,
275 diff: Some(DiffData {
276 file_path: params.path.clone(),
277 old_content: content,
278 new_content,
279 }),
280 streamed: false,
281 terminal_id: None,
282 locations: None,
283 raw_response: None,
284 }))
285 }
286
287 fn handle_find_path(&self, params: &FindPathParams) -> Result<Option<ToolOutput>, ToolError> {
288 let matches: Vec<String> = glob::glob(¶ms.pattern)
289 .map_err(|e| {
290 ToolError::Execution(std::io::Error::new(
291 std::io::ErrorKind::InvalidInput,
292 e.to_string(),
293 ))
294 })?
295 .filter_map(Result::ok)
296 .filter(|p| {
297 let canonical = p.canonicalize().unwrap_or_else(|_| p.clone());
298 self.allowed_paths.iter().any(|a| canonical.starts_with(a))
299 })
300 .map(|p| p.display().to_string())
301 .collect();
302
303 Ok(Some(ToolOutput {
304 tool_name: "find_path".to_owned(),
305 summary: if matches.is_empty() {
306 format!("No files matching: {}", params.pattern)
307 } else {
308 matches.join("\n")
309 },
310 blocks_executed: 1,
311 filter_stats: None,
312 diff: None,
313 streamed: false,
314 terminal_id: None,
315 locations: None,
316 raw_response: None,
317 }))
318 }
319
320 fn handle_grep(&self, params: &GrepParams) -> Result<Option<ToolOutput>, ToolError> {
321 let search_path = params.path.as_deref().unwrap_or(".");
322 let case_sensitive = params.case_sensitive.unwrap_or(true);
323 let path = self.validate_path(Path::new(search_path))?;
324
325 let regex = if case_sensitive {
326 regex::Regex::new(¶ms.pattern)
327 } else {
328 regex::RegexBuilder::new(¶ms.pattern)
329 .case_insensitive(true)
330 .build()
331 }
332 .map_err(|e| {
333 ToolError::Execution(std::io::Error::new(
334 std::io::ErrorKind::InvalidInput,
335 e.to_string(),
336 ))
337 })?;
338
339 let mut results = Vec::new();
340 grep_recursive(&path, ®ex, &mut results, 100)?;
341
342 Ok(Some(ToolOutput {
343 tool_name: "grep".to_owned(),
344 summary: if results.is_empty() {
345 format!("No matches for: {}", params.pattern)
346 } else {
347 results.join("\n")
348 },
349 blocks_executed: 1,
350 filter_stats: None,
351 diff: None,
352 streamed: false,
353 terminal_id: None,
354 locations: None,
355 raw_response: None,
356 }))
357 }
358
359 fn handle_list_directory(
360 &self,
361 params: &ListDirectoryParams,
362 ) -> Result<Option<ToolOutput>, ToolError> {
363 let path = self.validate_path(Path::new(¶ms.path))?;
364
365 if !path.is_dir() {
366 return Err(ToolError::Execution(std::io::Error::new(
367 std::io::ErrorKind::NotADirectory,
368 format!("{} is not a directory", params.path),
369 )));
370 }
371
372 let mut dirs = Vec::new();
373 let mut files = Vec::new();
374 let mut symlinks = Vec::new();
375
376 for entry in std::fs::read_dir(&path)? {
377 let entry = entry?;
378 let name = entry.file_name().to_string_lossy().into_owned();
379 let meta = std::fs::symlink_metadata(entry.path())?;
381 if meta.is_symlink() {
382 symlinks.push(format!("[symlink] {name}"));
383 } else if meta.is_dir() {
384 dirs.push(format!("[dir] {name}"));
385 } else {
386 files.push(format!("[file] {name}"));
387 }
388 }
389
390 dirs.sort();
391 files.sort();
392 symlinks.sort();
393
394 let mut entries = dirs;
395 entries.extend(files);
396 entries.extend(symlinks);
397
398 Ok(Some(ToolOutput {
399 tool_name: "list_directory".to_owned(),
400 summary: if entries.is_empty() {
401 format!("Empty directory: {}", params.path)
402 } else {
403 entries.join("\n")
404 },
405 blocks_executed: 1,
406 filter_stats: None,
407 diff: None,
408 streamed: false,
409 terminal_id: None,
410 locations: None,
411 raw_response: None,
412 }))
413 }
414
415 fn handle_create_directory(
416 &self,
417 params: &CreateDirectoryParams,
418 ) -> Result<Option<ToolOutput>, ToolError> {
419 let path = self.validate_path(Path::new(¶ms.path))?;
420 std::fs::create_dir_all(&path)?;
421
422 Ok(Some(ToolOutput {
423 tool_name: "create_directory".to_owned(),
424 summary: format!("Created directory: {}", params.path),
425 blocks_executed: 1,
426 filter_stats: None,
427 diff: None,
428 streamed: false,
429 terminal_id: None,
430 locations: None,
431 raw_response: None,
432 }))
433 }
434
435 fn handle_delete_path(
436 &self,
437 params: &DeletePathParams,
438 ) -> Result<Option<ToolOutput>, ToolError> {
439 let path = self.validate_path(Path::new(¶ms.path))?;
440
441 if self.allowed_paths.iter().any(|a| &path == a) {
443 return Err(ToolError::SandboxViolation {
444 path: path.display().to_string(),
445 });
446 }
447
448 if path.is_dir() {
449 if params.recursive {
450 std::fs::remove_dir_all(&path)?;
453 } else {
454 std::fs::remove_dir(&path)?;
456 }
457 } else {
458 std::fs::remove_file(&path)?;
459 }
460
461 Ok(Some(ToolOutput {
462 tool_name: "delete_path".to_owned(),
463 summary: format!("Deleted: {}", params.path),
464 blocks_executed: 1,
465 filter_stats: None,
466 diff: None,
467 streamed: false,
468 terminal_id: None,
469 locations: None,
470 raw_response: None,
471 }))
472 }
473
474 fn handle_move_path(&self, params: &MovePathParams) -> Result<Option<ToolOutput>, ToolError> {
475 let src = self.validate_path(Path::new(¶ms.source))?;
476 let dst = self.validate_path(Path::new(¶ms.destination))?;
477 std::fs::rename(&src, &dst)?;
478
479 Ok(Some(ToolOutput {
480 tool_name: "move_path".to_owned(),
481 summary: format!("Moved: {} -> {}", params.source, params.destination),
482 blocks_executed: 1,
483 filter_stats: None,
484 diff: None,
485 streamed: false,
486 terminal_id: None,
487 locations: None,
488 raw_response: None,
489 }))
490 }
491
492 fn handle_copy_path(&self, params: &CopyPathParams) -> Result<Option<ToolOutput>, ToolError> {
493 let src = self.validate_path(Path::new(¶ms.source))?;
494 let dst = self.validate_path(Path::new(¶ms.destination))?;
495
496 if src.is_dir() {
497 copy_dir_recursive(&src, &dst)?;
498 } else {
499 if let Some(parent) = dst.parent() {
500 std::fs::create_dir_all(parent)?;
501 }
502 std::fs::copy(&src, &dst)?;
503 }
504
505 Ok(Some(ToolOutput {
506 tool_name: "copy_path".to_owned(),
507 summary: format!("Copied: {} -> {}", params.source, params.destination),
508 blocks_executed: 1,
509 filter_stats: None,
510 diff: None,
511 streamed: false,
512 terminal_id: None,
513 locations: None,
514 raw_response: None,
515 }))
516 }
517}
518
519impl ToolExecutor for FileExecutor {
520 async fn execute(&self, _response: &str) -> Result<Option<ToolOutput>, ToolError> {
521 Ok(None)
522 }
523
524 async fn execute_tool_call(&self, call: &ToolCall) -> Result<Option<ToolOutput>, ToolError> {
525 self.execute_file_tool(&call.tool_id, &call.params)
526 }
527
528 fn tool_definitions(&self) -> Vec<ToolDef> {
529 vec![
530 ToolDef {
531 id: "read".into(),
532 description: "Read file contents with line numbers.\n\nParameters: path (string, required) - absolute or relative file path; offset (integer, optional) - start line (0-based); limit (integer, optional) - max lines to return\nReturns: file content with line numbers, or error if file not found\nErrors: SandboxViolation if path outside allowed dirs; Execution if file not found or unreadable\nExample: {\"path\": \"src/main.rs\", \"offset\": 10, \"limit\": 50}".into(),
533 schema: schemars::schema_for!(ReadParams),
534 invocation: InvocationHint::ToolCall,
535 },
536 ToolDef {
537 id: "write".into(),
538 description: "Create or overwrite a file with the given content.\n\nParameters: path (string, required) - file path; content (string, required) - full file content\nReturns: confirmation message with bytes written\nErrors: SandboxViolation if path outside allowed dirs; Execution on I/O failure\nExample: {\"path\": \"output.txt\", \"content\": \"Hello, world!\"}".into(),
539 schema: schemars::schema_for!(WriteParams),
540 invocation: InvocationHint::ToolCall,
541 },
542 ToolDef {
543 id: "edit".into(),
544 description: "Find and replace a text substring in a file.\n\nParameters: path (string, required) - file path; old_string (string, required) - exact text to find; new_string (string, required) - replacement text\nReturns: confirmation with match count, or error if old_string not found\nErrors: SandboxViolation; Execution if file not found or old_string has no matches\nExample: {\"path\": \"config.toml\", \"old_string\": \"debug = true\", \"new_string\": \"debug = false\"}".into(),
545 schema: schemars::schema_for!(EditParams),
546 invocation: InvocationHint::ToolCall,
547 },
548 ToolDef {
549 id: "find_path".into(),
550 description: "Find files and directories matching a glob pattern.\n\nParameters: pattern (string, required) - glob pattern (e.g. \"**/*.rs\", \"src/*.toml\")\nReturns: newline-separated list of matching paths, or \"(no matches)\" if none found\nErrors: SandboxViolation if search root is outside allowed dirs\nExample: {\"pattern\": \"**/*.rs\"}".into(),
551 schema: schemars::schema_for!(FindPathParams),
552 invocation: InvocationHint::ToolCall,
553 },
554 ToolDef {
555 id: "grep".into(),
556 description: "Search file contents for lines matching a regex pattern.\n\nParameters: pattern (string, required) - regex pattern; path (string, optional) - directory or file to search (default: cwd); case_sensitive (boolean, optional) - default true\nReturns: matching lines with file paths and line numbers, or \"(no matches)\"\nErrors: SandboxViolation; InvalidParams if regex is invalid\nExample: {\"pattern\": \"fn main\", \"path\": \"src/\"}".into(),
557 schema: schemars::schema_for!(GrepParams),
558 invocation: InvocationHint::ToolCall,
559 },
560 ToolDef {
561 id: "list_directory".into(),
562 description: "List files and subdirectories in a directory.\n\nParameters: path (string, required) - directory path\nReturns: sorted listing with [dir]/[file] prefixes, or \"Empty directory\" if empty\nErrors: SandboxViolation; Execution if path is not a directory or does not exist\nExample: {\"path\": \"src/\"}".into(),
563 schema: schemars::schema_for!(ListDirectoryParams),
564 invocation: InvocationHint::ToolCall,
565 },
566 ToolDef {
567 id: "create_directory".into(),
568 description: "Create a directory, including any missing parent directories.\n\nParameters: path (string, required) - directory path to create\nReturns: confirmation message\nErrors: SandboxViolation; Execution on I/O failure\nExample: {\"path\": \"src/utils/helpers\"}".into(),
569 schema: schemars::schema_for!(CreateDirectoryParams),
570 invocation: InvocationHint::ToolCall,
571 },
572 ToolDef {
573 id: "delete_path".into(),
574 description: "Delete a file or directory.\n\nParameters: path (string, required) - path to delete; recursive (boolean, optional) - if true, delete non-empty directories recursively (default: false)\nReturns: confirmation message\nErrors: SandboxViolation; Execution if path not found or directory non-empty without recursive=true\nExample: {\"path\": \"tmp/old_file.txt\"}".into(),
575 schema: schemars::schema_for!(DeletePathParams),
576 invocation: InvocationHint::ToolCall,
577 },
578 ToolDef {
579 id: "move_path".into(),
580 description: "Move or rename a file or directory.\n\nParameters: source (string, required) - current path; destination (string, required) - new path\nReturns: confirmation message\nErrors: SandboxViolation if either path is outside allowed dirs; Execution if source not found\nExample: {\"source\": \"old_name.rs\", \"destination\": \"new_name.rs\"}".into(),
581 schema: schemars::schema_for!(MovePathParams),
582 invocation: InvocationHint::ToolCall,
583 },
584 ToolDef {
585 id: "copy_path".into(),
586 description: "Copy a file or directory to a new location.\n\nParameters: source (string, required) - path to copy; destination (string, required) - target path\nReturns: confirmation message\nErrors: SandboxViolation; Execution if source not found or I/O failure\nExample: {\"source\": \"template.rs\", \"destination\": \"new_module.rs\"}".into(),
587 schema: schemars::schema_for!(CopyPathParams),
588 invocation: InvocationHint::ToolCall,
589 },
590 ]
591 }
592}
593
594fn normalize_path(path: &Path) -> PathBuf {
598 use std::path::Component;
599 let mut stack: Vec<std::ffi::OsString> = Vec::new();
600 for component in path.components() {
601 match component {
602 Component::CurDir => {}
603 Component::ParentDir => {
604 stack.pop();
605 }
606 Component::Normal(name) => stack.push(name.to_owned()),
607 Component::RootDir => {
608 stack.clear();
609 stack.push(std::ffi::OsString::from("/"));
610 }
611 Component::Prefix(prefix) => {
612 stack.clear();
613 stack.push(prefix.as_os_str().to_owned());
614 }
615 }
616 }
617 let mut result = PathBuf::new();
618 for (i, part) in stack.iter().enumerate() {
619 if i == 0 && part == "/" {
620 result.push("/");
621 } else {
622 result.push(part);
623 }
624 }
625 result
626}
627
628fn resolve_via_ancestors(path: &Path) -> PathBuf {
635 let mut existing = path;
636 let mut suffix = PathBuf::new();
637 while !existing.exists() {
638 if let Some(parent) = existing.parent() {
639 if let Some(name) = existing.file_name() {
640 if suffix.as_os_str().is_empty() {
641 suffix = PathBuf::from(name);
642 } else {
643 suffix = PathBuf::from(name).join(&suffix);
644 }
645 }
646 existing = parent;
647 } else {
648 break;
649 }
650 }
651 let base = existing.canonicalize().unwrap_or(existing.to_path_buf());
652 if suffix.as_os_str().is_empty() {
653 base
654 } else {
655 base.join(&suffix)
656 }
657}
658
659const IGNORED_DIRS: &[&str] = &[".git", "target", "node_modules", ".hg"];
660
661fn grep_recursive(
662 path: &Path,
663 regex: ®ex::Regex,
664 results: &mut Vec<String>,
665 limit: usize,
666) -> Result<(), ToolError> {
667 if results.len() >= limit {
668 return Ok(());
669 }
670 if path.is_file() {
671 if let Ok(content) = std::fs::read_to_string(path) {
672 for (i, line) in content.lines().enumerate() {
673 if regex.is_match(line) {
674 results.push(format!("{}:{}: {line}", path.display(), i + 1));
675 if results.len() >= limit {
676 return Ok(());
677 }
678 }
679 }
680 }
681 } else if path.is_dir() {
682 let entries = std::fs::read_dir(path)?;
683 for entry in entries.flatten() {
684 let p = entry.path();
685 let name = p.file_name().and_then(|n| n.to_str());
686 if name.is_some_and(|n| n.starts_with('.') || IGNORED_DIRS.contains(&n)) {
687 continue;
688 }
689 grep_recursive(&p, regex, results, limit)?;
690 }
691 }
692 Ok(())
693}
694
695fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<(), ToolError> {
696 std::fs::create_dir_all(dst)?;
697 for entry in std::fs::read_dir(src)? {
698 let entry = entry?;
699 let meta = std::fs::symlink_metadata(entry.path())?;
703 let src_path = entry.path();
704 let dst_path = dst.join(entry.file_name());
705 if meta.is_dir() {
706 copy_dir_recursive(&src_path, &dst_path)?;
707 } else if meta.is_file() {
708 std::fs::copy(&src_path, &dst_path)?;
709 }
710 }
712 Ok(())
713}
714
715#[cfg(test)]
716mod tests {
717 use super::*;
718 use std::fs;
719
720 fn temp_dir() -> tempfile::TempDir {
721 tempfile::tempdir().unwrap()
722 }
723
724 fn make_params(
725 pairs: &[(&str, serde_json::Value)],
726 ) -> serde_json::Map<String, serde_json::Value> {
727 pairs
728 .iter()
729 .map(|(k, v)| ((*k).to_owned(), v.clone()))
730 .collect()
731 }
732
733 #[test]
734 fn read_file() {
735 let dir = temp_dir();
736 let file = dir.path().join("test.txt");
737 fs::write(&file, "line1\nline2\nline3\n").unwrap();
738
739 let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
740 let params = make_params(&[("path", serde_json::json!(file.to_str().unwrap()))]);
741 let result = exec.execute_file_tool("read", ¶ms).unwrap().unwrap();
742 assert_eq!(result.tool_name, "read");
743 assert!(result.summary.contains("line1"));
744 assert!(result.summary.contains("line3"));
745 }
746
747 #[test]
748 fn read_with_offset_and_limit() {
749 let dir = temp_dir();
750 let file = dir.path().join("test.txt");
751 fs::write(&file, "a\nb\nc\nd\ne\n").unwrap();
752
753 let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
754 let params = make_params(&[
755 ("path", serde_json::json!(file.to_str().unwrap())),
756 ("offset", serde_json::json!(1)),
757 ("limit", serde_json::json!(2)),
758 ]);
759 let result = exec.execute_file_tool("read", ¶ms).unwrap().unwrap();
760 assert!(result.summary.contains('b'));
761 assert!(result.summary.contains('c'));
762 assert!(!result.summary.contains('a'));
763 assert!(!result.summary.contains('d'));
764 }
765
766 #[test]
767 fn write_file() {
768 let dir = temp_dir();
769 let file = dir.path().join("out.txt");
770
771 let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
772 let params = make_params(&[
773 ("path", serde_json::json!(file.to_str().unwrap())),
774 ("content", serde_json::json!("hello world")),
775 ]);
776 let result = exec.execute_file_tool("write", ¶ms).unwrap().unwrap();
777 assert!(result.summary.contains("11 bytes"));
778 assert_eq!(fs::read_to_string(&file).unwrap(), "hello world");
779 }
780
781 #[test]
782 fn edit_file() {
783 let dir = temp_dir();
784 let file = dir.path().join("edit.txt");
785 fs::write(&file, "foo bar baz").unwrap();
786
787 let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
788 let params = make_params(&[
789 ("path", serde_json::json!(file.to_str().unwrap())),
790 ("old_string", serde_json::json!("bar")),
791 ("new_string", serde_json::json!("qux")),
792 ]);
793 let result = exec.execute_file_tool("edit", ¶ms).unwrap().unwrap();
794 assert!(result.summary.contains("Edited"));
795 assert_eq!(fs::read_to_string(&file).unwrap(), "foo qux baz");
796 }
797
798 #[test]
799 fn edit_not_found() {
800 let dir = temp_dir();
801 let file = dir.path().join("edit.txt");
802 fs::write(&file, "foo bar").unwrap();
803
804 let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
805 let params = make_params(&[
806 ("path", serde_json::json!(file.to_str().unwrap())),
807 ("old_string", serde_json::json!("nonexistent")),
808 ("new_string", serde_json::json!("x")),
809 ]);
810 let result = exec.execute_file_tool("edit", ¶ms);
811 assert!(result.is_err());
812 }
813
814 #[test]
815 fn sandbox_violation() {
816 let dir = temp_dir();
817 let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
818 let params = make_params(&[("path", serde_json::json!("/etc/passwd"))]);
819 let result = exec.execute_file_tool("read", ¶ms);
820 assert!(matches!(result, Err(ToolError::SandboxViolation { .. })));
821 }
822
823 #[test]
824 fn unknown_tool_returns_none() {
825 let exec = FileExecutor::new(vec![]);
826 let params = serde_json::Map::new();
827 let result = exec.execute_file_tool("unknown", ¶ms).unwrap();
828 assert!(result.is_none());
829 }
830
831 #[test]
832 fn find_path_finds_files() {
833 let dir = temp_dir();
834 fs::write(dir.path().join("a.rs"), "").unwrap();
835 fs::write(dir.path().join("b.rs"), "").unwrap();
836
837 let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
838 let pattern = format!("{}/*.rs", dir.path().display());
839 let params = make_params(&[("pattern", serde_json::json!(pattern))]);
840 let result = exec
841 .execute_file_tool("find_path", ¶ms)
842 .unwrap()
843 .unwrap();
844 assert!(result.summary.contains("a.rs"));
845 assert!(result.summary.contains("b.rs"));
846 }
847
848 #[test]
849 fn grep_finds_matches() {
850 let dir = temp_dir();
851 fs::write(
852 dir.path().join("test.txt"),
853 "hello world\nfoo bar\nhello again\n",
854 )
855 .unwrap();
856
857 let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
858 let params = make_params(&[
859 ("pattern", serde_json::json!("hello")),
860 ("path", serde_json::json!(dir.path().to_str().unwrap())),
861 ]);
862 let result = exec.execute_file_tool("grep", ¶ms).unwrap().unwrap();
863 assert!(result.summary.contains("hello world"));
864 assert!(result.summary.contains("hello again"));
865 assert!(!result.summary.contains("foo bar"));
866 }
867
868 #[test]
869 fn write_sandbox_bypass_nonexistent_path() {
870 let dir = temp_dir();
871 let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
872 let params = make_params(&[
873 ("path", serde_json::json!("/tmp/evil/escape.txt")),
874 ("content", serde_json::json!("pwned")),
875 ]);
876 let result = exec.execute_file_tool("write", ¶ms);
877 assert!(matches!(result, Err(ToolError::SandboxViolation { .. })));
878 assert!(!Path::new("/tmp/evil/escape.txt").exists());
879 }
880
881 #[test]
882 fn find_path_filters_outside_sandbox() {
883 let sandbox = temp_dir();
884 let outside = temp_dir();
885 fs::write(outside.path().join("secret.rs"), "secret").unwrap();
886
887 let exec = FileExecutor::new(vec![sandbox.path().to_path_buf()]);
888 let pattern = format!("{}/*.rs", outside.path().display());
889 let params = make_params(&[("pattern", serde_json::json!(pattern))]);
890 let result = exec
891 .execute_file_tool("find_path", ¶ms)
892 .unwrap()
893 .unwrap();
894 assert!(!result.summary.contains("secret.rs"));
895 }
896
897 #[tokio::test]
898 async fn tool_executor_execute_tool_call_delegates() {
899 let dir = temp_dir();
900 let file = dir.path().join("test.txt");
901 fs::write(&file, "content").unwrap();
902
903 let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
904 let call = ToolCall {
905 tool_id: "read".to_owned(),
906 params: make_params(&[("path", serde_json::json!(file.to_str().unwrap()))]),
907 };
908 let result = exec.execute_tool_call(&call).await.unwrap().unwrap();
909 assert_eq!(result.tool_name, "read");
910 assert!(result.summary.contains("content"));
911 }
912
913 #[test]
914 fn tool_executor_tool_definitions_lists_all() {
915 let exec = FileExecutor::new(vec![]);
916 let defs = exec.tool_definitions();
917 let ids: Vec<&str> = defs.iter().map(|d| d.id.as_ref()).collect();
918 assert!(ids.contains(&"read"));
919 assert!(ids.contains(&"write"));
920 assert!(ids.contains(&"edit"));
921 assert!(ids.contains(&"find_path"));
922 assert!(ids.contains(&"grep"));
923 assert!(ids.contains(&"list_directory"));
924 assert!(ids.contains(&"create_directory"));
925 assert!(ids.contains(&"delete_path"));
926 assert!(ids.contains(&"move_path"));
927 assert!(ids.contains(&"copy_path"));
928 assert_eq!(defs.len(), 10);
929 }
930
931 #[test]
932 fn grep_relative_path_validated() {
933 let sandbox = temp_dir();
934 let exec = FileExecutor::new(vec![sandbox.path().to_path_buf()]);
935 let params = make_params(&[
936 ("pattern", serde_json::json!("password")),
937 ("path", serde_json::json!("../../etc")),
938 ]);
939 let result = exec.execute_file_tool("grep", ¶ms);
940 assert!(matches!(result, Err(ToolError::SandboxViolation { .. })));
941 }
942
943 #[test]
944 fn tool_definitions_returns_ten_tools() {
945 let exec = FileExecutor::new(vec![]);
946 let defs = exec.tool_definitions();
947 assert_eq!(defs.len(), 10);
948 let ids: Vec<&str> = defs.iter().map(|d| d.id.as_ref()).collect();
949 assert_eq!(
950 ids,
951 vec![
952 "read",
953 "write",
954 "edit",
955 "find_path",
956 "grep",
957 "list_directory",
958 "create_directory",
959 "delete_path",
960 "move_path",
961 "copy_path",
962 ]
963 );
964 }
965
966 #[test]
967 fn tool_definitions_all_use_tool_call() {
968 let exec = FileExecutor::new(vec![]);
969 for def in exec.tool_definitions() {
970 assert_eq!(def.invocation, InvocationHint::ToolCall);
971 }
972 }
973
974 #[test]
975 fn tool_definitions_read_schema_has_params() {
976 let exec = FileExecutor::new(vec![]);
977 let defs = exec.tool_definitions();
978 let read = defs.iter().find(|d| d.id.as_ref() == "read").unwrap();
979 let obj = read.schema.as_object().unwrap();
980 let props = obj["properties"].as_object().unwrap();
981 assert!(props.contains_key("path"));
982 assert!(props.contains_key("offset"));
983 assert!(props.contains_key("limit"));
984 }
985
986 #[test]
987 fn missing_required_path_returns_invalid_params() {
988 let dir = temp_dir();
989 let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
990 let params = serde_json::Map::new();
991 let result = exec.execute_file_tool("read", ¶ms);
992 assert!(matches!(result, Err(ToolError::InvalidParams { .. })));
993 }
994
995 #[test]
998 fn list_directory_returns_entries() {
999 let dir = temp_dir();
1000 fs::write(dir.path().join("file.txt"), "").unwrap();
1001 fs::create_dir(dir.path().join("subdir")).unwrap();
1002
1003 let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1004 let params = make_params(&[("path", serde_json::json!(dir.path().to_str().unwrap()))]);
1005 let result = exec
1006 .execute_file_tool("list_directory", ¶ms)
1007 .unwrap()
1008 .unwrap();
1009 assert!(result.summary.contains("[dir] subdir"));
1010 assert!(result.summary.contains("[file] file.txt"));
1011 let dir_pos = result.summary.find("[dir]").unwrap();
1013 let file_pos = result.summary.find("[file]").unwrap();
1014 assert!(dir_pos < file_pos);
1015 }
1016
1017 #[test]
1018 fn list_directory_empty_dir() {
1019 let dir = temp_dir();
1020 let subdir = dir.path().join("empty");
1021 fs::create_dir(&subdir).unwrap();
1022
1023 let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1024 let params = make_params(&[("path", serde_json::json!(subdir.to_str().unwrap()))]);
1025 let result = exec
1026 .execute_file_tool("list_directory", ¶ms)
1027 .unwrap()
1028 .unwrap();
1029 assert!(result.summary.contains("Empty directory"));
1030 }
1031
1032 #[test]
1033 fn list_directory_sandbox_violation() {
1034 let dir = temp_dir();
1035 let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1036 let params = make_params(&[("path", serde_json::json!("/etc"))]);
1037 let result = exec.execute_file_tool("list_directory", ¶ms);
1038 assert!(matches!(result, Err(ToolError::SandboxViolation { .. })));
1039 }
1040
1041 #[test]
1042 fn list_directory_nonexistent_returns_error() {
1043 let dir = temp_dir();
1044 let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1045 let missing = dir.path().join("nonexistent");
1046 let params = make_params(&[("path", serde_json::json!(missing.to_str().unwrap()))]);
1047 let result = exec.execute_file_tool("list_directory", ¶ms);
1048 assert!(result.is_err());
1049 }
1050
1051 #[test]
1052 fn list_directory_on_file_returns_error() {
1053 let dir = temp_dir();
1054 let file = dir.path().join("file.txt");
1055 fs::write(&file, "content").unwrap();
1056
1057 let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1058 let params = make_params(&[("path", serde_json::json!(file.to_str().unwrap()))]);
1059 let result = exec.execute_file_tool("list_directory", ¶ms);
1060 assert!(result.is_err());
1061 }
1062
1063 #[test]
1066 fn create_directory_creates_nested() {
1067 let dir = temp_dir();
1068 let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1069 let nested = dir.path().join("a/b/c");
1070 let params = make_params(&[("path", serde_json::json!(nested.to_str().unwrap()))]);
1071 let result = exec
1072 .execute_file_tool("create_directory", ¶ms)
1073 .unwrap()
1074 .unwrap();
1075 assert!(result.summary.contains("Created"));
1076 assert!(nested.is_dir());
1077 }
1078
1079 #[test]
1080 fn create_directory_sandbox_violation() {
1081 let dir = temp_dir();
1082 let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1083 let params = make_params(&[("path", serde_json::json!("/tmp/evil_dir"))]);
1084 let result = exec.execute_file_tool("create_directory", ¶ms);
1085 assert!(matches!(result, Err(ToolError::SandboxViolation { .. })));
1086 }
1087
1088 #[test]
1091 fn delete_path_file() {
1092 let dir = temp_dir();
1093 let file = dir.path().join("del.txt");
1094 fs::write(&file, "bye").unwrap();
1095
1096 let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1097 let params = make_params(&[("path", serde_json::json!(file.to_str().unwrap()))]);
1098 exec.execute_file_tool("delete_path", ¶ms)
1099 .unwrap()
1100 .unwrap();
1101 assert!(!file.exists());
1102 }
1103
1104 #[test]
1105 fn delete_path_empty_directory() {
1106 let dir = temp_dir();
1107 let subdir = dir.path().join("empty_sub");
1108 fs::create_dir(&subdir).unwrap();
1109
1110 let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1111 let params = make_params(&[("path", serde_json::json!(subdir.to_str().unwrap()))]);
1112 exec.execute_file_tool("delete_path", ¶ms)
1113 .unwrap()
1114 .unwrap();
1115 assert!(!subdir.exists());
1116 }
1117
1118 #[test]
1119 fn delete_path_non_empty_dir_without_recursive_fails() {
1120 let dir = temp_dir();
1121 let subdir = dir.path().join("nonempty");
1122 fs::create_dir(&subdir).unwrap();
1123 fs::write(subdir.join("file.txt"), "x").unwrap();
1124
1125 let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1126 let params = make_params(&[("path", serde_json::json!(subdir.to_str().unwrap()))]);
1127 let result = exec.execute_file_tool("delete_path", ¶ms);
1128 assert!(result.is_err());
1129 }
1130
1131 #[test]
1132 fn delete_path_recursive() {
1133 let dir = temp_dir();
1134 let subdir = dir.path().join("recurse");
1135 fs::create_dir(&subdir).unwrap();
1136 fs::write(subdir.join("f.txt"), "x").unwrap();
1137
1138 let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1139 let params = make_params(&[
1140 ("path", serde_json::json!(subdir.to_str().unwrap())),
1141 ("recursive", serde_json::json!(true)),
1142 ]);
1143 exec.execute_file_tool("delete_path", ¶ms)
1144 .unwrap()
1145 .unwrap();
1146 assert!(!subdir.exists());
1147 }
1148
1149 #[test]
1150 fn delete_path_sandbox_violation() {
1151 let dir = temp_dir();
1152 let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1153 let params = make_params(&[("path", serde_json::json!("/etc/hosts"))]);
1154 let result = exec.execute_file_tool("delete_path", ¶ms);
1155 assert!(matches!(result, Err(ToolError::SandboxViolation { .. })));
1156 }
1157
1158 #[test]
1159 fn delete_path_refuses_sandbox_root() {
1160 let dir = temp_dir();
1161 let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1162 let params = make_params(&[
1163 ("path", serde_json::json!(dir.path().to_str().unwrap())),
1164 ("recursive", serde_json::json!(true)),
1165 ]);
1166 let result = exec.execute_file_tool("delete_path", ¶ms);
1167 assert!(matches!(result, Err(ToolError::SandboxViolation { .. })));
1168 }
1169
1170 #[test]
1173 fn move_path_renames_file() {
1174 let dir = temp_dir();
1175 let src = dir.path().join("src.txt");
1176 let dst = dir.path().join("dst.txt");
1177 fs::write(&src, "data").unwrap();
1178
1179 let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1180 let params = make_params(&[
1181 ("source", serde_json::json!(src.to_str().unwrap())),
1182 ("destination", serde_json::json!(dst.to_str().unwrap())),
1183 ]);
1184 exec.execute_file_tool("move_path", ¶ms)
1185 .unwrap()
1186 .unwrap();
1187 assert!(!src.exists());
1188 assert_eq!(fs::read_to_string(&dst).unwrap(), "data");
1189 }
1190
1191 #[test]
1192 fn move_path_cross_sandbox_denied() {
1193 let sandbox = temp_dir();
1194 let outside = temp_dir();
1195 let src = sandbox.path().join("src.txt");
1196 fs::write(&src, "x").unwrap();
1197
1198 let exec = FileExecutor::new(vec![sandbox.path().to_path_buf()]);
1199 let dst = outside.path().join("dst.txt");
1200 let params = make_params(&[
1201 ("source", serde_json::json!(src.to_str().unwrap())),
1202 ("destination", serde_json::json!(dst.to_str().unwrap())),
1203 ]);
1204 let result = exec.execute_file_tool("move_path", ¶ms);
1205 assert!(matches!(result, Err(ToolError::SandboxViolation { .. })));
1206 }
1207
1208 #[test]
1211 fn copy_path_file() {
1212 let dir = temp_dir();
1213 let src = dir.path().join("src.txt");
1214 let dst = dir.path().join("dst.txt");
1215 fs::write(&src, "hello").unwrap();
1216
1217 let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1218 let params = make_params(&[
1219 ("source", serde_json::json!(src.to_str().unwrap())),
1220 ("destination", serde_json::json!(dst.to_str().unwrap())),
1221 ]);
1222 exec.execute_file_tool("copy_path", ¶ms)
1223 .unwrap()
1224 .unwrap();
1225 assert_eq!(fs::read_to_string(&src).unwrap(), "hello");
1226 assert_eq!(fs::read_to_string(&dst).unwrap(), "hello");
1227 }
1228
1229 #[test]
1230 fn copy_path_directory_recursive() {
1231 let dir = temp_dir();
1232 let src_dir = dir.path().join("src_dir");
1233 fs::create_dir(&src_dir).unwrap();
1234 fs::write(src_dir.join("a.txt"), "aaa").unwrap();
1235
1236 let dst_dir = dir.path().join("dst_dir");
1237
1238 let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1239 let params = make_params(&[
1240 ("source", serde_json::json!(src_dir.to_str().unwrap())),
1241 ("destination", serde_json::json!(dst_dir.to_str().unwrap())),
1242 ]);
1243 exec.execute_file_tool("copy_path", ¶ms)
1244 .unwrap()
1245 .unwrap();
1246 assert_eq!(fs::read_to_string(dst_dir.join("a.txt")).unwrap(), "aaa");
1247 }
1248
1249 #[test]
1250 fn copy_path_sandbox_violation() {
1251 let sandbox = temp_dir();
1252 let outside = temp_dir();
1253 let src = sandbox.path().join("src.txt");
1254 fs::write(&src, "x").unwrap();
1255
1256 let exec = FileExecutor::new(vec![sandbox.path().to_path_buf()]);
1257 let dst = outside.path().join("dst.txt");
1258 let params = make_params(&[
1259 ("source", serde_json::json!(src.to_str().unwrap())),
1260 ("destination", serde_json::json!(dst.to_str().unwrap())),
1261 ]);
1262 let result = exec.execute_file_tool("copy_path", ¶ms);
1263 assert!(matches!(result, Err(ToolError::SandboxViolation { .. })));
1264 }
1265
1266 #[test]
1268 fn find_path_invalid_pattern_returns_error() {
1269 let dir = temp_dir();
1270 let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1271 let params = make_params(&[("pattern", serde_json::json!("[invalid"))]);
1272 let result = exec.execute_file_tool("find_path", ¶ms);
1273 assert!(result.is_err());
1274 }
1275
1276 #[test]
1278 fn create_directory_idempotent() {
1279 let dir = temp_dir();
1280 let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1281 let target = dir.path().join("exists");
1282 fs::create_dir(&target).unwrap();
1283
1284 let params = make_params(&[("path", serde_json::json!(target.to_str().unwrap()))]);
1285 let result = exec.execute_file_tool("create_directory", ¶ms);
1286 assert!(result.is_ok());
1287 assert!(target.is_dir());
1288 }
1289
1290 #[test]
1292 fn move_path_source_sandbox_violation() {
1293 let sandbox = temp_dir();
1294 let outside = temp_dir();
1295 let src = outside.path().join("src.txt");
1296 fs::write(&src, "x").unwrap();
1297
1298 let exec = FileExecutor::new(vec![sandbox.path().to_path_buf()]);
1299 let dst = sandbox.path().join("dst.txt");
1300 let params = make_params(&[
1301 ("source", serde_json::json!(src.to_str().unwrap())),
1302 ("destination", serde_json::json!(dst.to_str().unwrap())),
1303 ]);
1304 let result = exec.execute_file_tool("move_path", ¶ms);
1305 assert!(matches!(result, Err(ToolError::SandboxViolation { .. })));
1306 }
1307
1308 #[test]
1310 fn copy_path_source_sandbox_violation() {
1311 let sandbox = temp_dir();
1312 let outside = temp_dir();
1313 let src = outside.path().join("src.txt");
1314 fs::write(&src, "x").unwrap();
1315
1316 let exec = FileExecutor::new(vec![sandbox.path().to_path_buf()]);
1317 let dst = sandbox.path().join("dst.txt");
1318 let params = make_params(&[
1319 ("source", serde_json::json!(src.to_str().unwrap())),
1320 ("destination", serde_json::json!(dst.to_str().unwrap())),
1321 ]);
1322 let result = exec.execute_file_tool("copy_path", ¶ms);
1323 assert!(matches!(result, Err(ToolError::SandboxViolation { .. })));
1324 }
1325
1326 #[cfg(unix)]
1328 #[test]
1329 fn copy_dir_skips_symlinks() {
1330 let dir = temp_dir();
1331 let src_dir = dir.path().join("src");
1332 fs::create_dir(&src_dir).unwrap();
1333 fs::write(src_dir.join("real.txt"), "real").unwrap();
1334
1335 let outside = temp_dir();
1337 std::os::unix::fs::symlink(outside.path(), src_dir.join("link")).unwrap();
1338
1339 let dst_dir = dir.path().join("dst");
1340 let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1341 let params = make_params(&[
1342 ("source", serde_json::json!(src_dir.to_str().unwrap())),
1343 ("destination", serde_json::json!(dst_dir.to_str().unwrap())),
1344 ]);
1345 exec.execute_file_tool("copy_path", ¶ms)
1346 .unwrap()
1347 .unwrap();
1348 assert_eq!(
1350 fs::read_to_string(dst_dir.join("real.txt")).unwrap(),
1351 "real"
1352 );
1353 assert!(!dst_dir.join("link").exists());
1355 }
1356
1357 #[cfg(unix)]
1359 #[test]
1360 fn list_directory_shows_symlinks() {
1361 let dir = temp_dir();
1362 let target = dir.path().join("target.txt");
1363 fs::write(&target, "x").unwrap();
1364 std::os::unix::fs::symlink(&target, dir.path().join("link")).unwrap();
1365
1366 let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1367 let params = make_params(&[("path", serde_json::json!(dir.path().to_str().unwrap()))]);
1368 let result = exec
1369 .execute_file_tool("list_directory", ¶ms)
1370 .unwrap()
1371 .unwrap();
1372 assert!(result.summary.contains("[symlink] link"));
1373 assert!(result.summary.contains("[file] target.txt"));
1374 }
1375
1376 #[test]
1377 fn tilde_path_is_expanded() {
1378 let exec = FileExecutor::new(vec![PathBuf::from("~/nonexistent_subdir_for_test")]);
1379 assert!(
1380 !exec.allowed_paths[0].to_string_lossy().starts_with('~'),
1381 "tilde was not expanded: {:?}",
1382 exec.allowed_paths[0]
1383 );
1384 }
1385
1386 #[test]
1387 fn absolute_path_unchanged() {
1388 let exec = FileExecutor::new(vec![PathBuf::from("/tmp")]);
1389 let p = exec.allowed_paths[0].to_string_lossy();
1392 assert!(
1393 p.starts_with('/'),
1394 "expected absolute path, got: {:?}",
1395 exec.allowed_paths[0]
1396 );
1397 assert!(
1398 !p.starts_with('~'),
1399 "tilde must not appear in result: {:?}",
1400 exec.allowed_paths[0]
1401 );
1402 }
1403
1404 #[test]
1405 fn tilde_only_expands_to_home() {
1406 let exec = FileExecutor::new(vec![PathBuf::from("~")]);
1407 assert!(
1408 !exec.allowed_paths[0].to_string_lossy().starts_with('~'),
1409 "bare tilde was not expanded: {:?}",
1410 exec.allowed_paths[0]
1411 );
1412 }
1413
1414 #[test]
1415 fn empty_allowed_paths_uses_cwd() {
1416 let exec = FileExecutor::new(vec![]);
1417 assert!(
1418 !exec.allowed_paths.is_empty(),
1419 "expected cwd fallback, got empty allowed_paths"
1420 );
1421 }
1422
1423 #[test]
1426 fn normalize_path_normal_path() {
1427 assert_eq!(
1428 normalize_path(Path::new("/tmp/sandbox/file.txt")),
1429 PathBuf::from("/tmp/sandbox/file.txt")
1430 );
1431 }
1432
1433 #[test]
1434 fn normalize_path_collapses_dot() {
1435 assert_eq!(
1436 normalize_path(Path::new("/tmp/sandbox/./file.txt")),
1437 PathBuf::from("/tmp/sandbox/file.txt")
1438 );
1439 }
1440
1441 #[test]
1442 fn normalize_path_collapses_dotdot() {
1443 assert_eq!(
1444 normalize_path(Path::new("/tmp/sandbox/nonexistent/../../etc/passwd")),
1445 PathBuf::from("/tmp/etc/passwd")
1446 );
1447 }
1448
1449 #[test]
1450 fn normalize_path_nested_dotdot() {
1451 assert_eq!(
1452 normalize_path(Path::new("/tmp/sandbox/a/b/../../../etc/passwd")),
1453 PathBuf::from("/tmp/etc/passwd")
1454 );
1455 }
1456
1457 #[test]
1458 fn normalize_path_at_sandbox_boundary() {
1459 assert_eq!(
1460 normalize_path(Path::new("/tmp/sandbox")),
1461 PathBuf::from("/tmp/sandbox")
1462 );
1463 }
1464
1465 #[test]
1468 fn validate_path_dotdot_bypass_nonexistent_blocked() {
1469 let dir = temp_dir();
1470 let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1471 let escape = format!("{}/nonexistent/../../etc/passwd", dir.path().display());
1473 let params = make_params(&[("path", serde_json::json!(escape))]);
1474 let result = exec.execute_file_tool("read", ¶ms);
1475 assert!(
1476 matches!(result, Err(ToolError::SandboxViolation { .. })),
1477 "expected SandboxViolation for dotdot bypass, got {:?}",
1478 result
1479 );
1480 }
1481
1482 #[test]
1483 fn validate_path_dotdot_nested_bypass_blocked() {
1484 let dir = temp_dir();
1485 let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1486 let escape = format!("{}/a/b/../../../etc/shadow", dir.path().display());
1487 let params = make_params(&[("path", serde_json::json!(escape))]);
1488 let result = exec.execute_file_tool("read", ¶ms);
1489 assert!(matches!(result, Err(ToolError::SandboxViolation { .. })));
1490 }
1491
1492 #[test]
1493 fn validate_path_inside_sandbox_passes() {
1494 let dir = temp_dir();
1495 let file = dir.path().join("allowed.txt");
1496 fs::write(&file, "ok").unwrap();
1497 let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1498 let params = make_params(&[("path", serde_json::json!(file.to_str().unwrap()))]);
1499 let result = exec.execute_file_tool("read", ¶ms);
1500 assert!(result.is_ok());
1501 }
1502
1503 #[test]
1504 fn validate_path_dot_components_inside_sandbox_passes() {
1505 let dir = temp_dir();
1506 let file = dir.path().join("sub/file.txt");
1507 fs::create_dir_all(dir.path().join("sub")).unwrap();
1508 fs::write(&file, "ok").unwrap();
1509 let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1510 let dotpath = format!("{}/sub/./file.txt", dir.path().display());
1511 let params = make_params(&[("path", serde_json::json!(dotpath))]);
1512 let result = exec.execute_file_tool("read", ¶ms);
1513 assert!(result.is_ok());
1514 }
1515}