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 prefix: Option<std::ffi::OsString> = None;
603 let mut stack: Vec<std::ffi::OsString> = Vec::new();
604 for component in path.components() {
605 match component {
606 Component::CurDir => {}
607 Component::ParentDir => {
608 if stack.last().is_some_and(|s| s != "/") {
610 stack.pop();
611 }
612 }
613 Component::Normal(name) => stack.push(name.to_owned()),
614 Component::RootDir => {
615 if prefix.is_none() {
616 stack.clear();
618 stack.push(std::ffi::OsString::from("/"));
619 }
620 }
623 Component::Prefix(p) => {
624 stack.clear();
625 prefix = Some(p.as_os_str().to_owned());
626 }
627 }
628 }
629 if let Some(drive) = prefix {
630 let mut s = drive.to_string_lossy().into_owned();
632 s.push('\\');
633 let mut result = PathBuf::from(s);
634 for part in &stack {
635 result.push(part);
636 }
637 result
638 } else {
639 let mut result = PathBuf::new();
640 for (i, part) in stack.iter().enumerate() {
641 if i == 0 && part == "/" {
642 result.push("/");
643 } else {
644 result.push(part);
645 }
646 }
647 result
648 }
649}
650
651fn resolve_via_ancestors(path: &Path) -> PathBuf {
658 let mut existing = path;
659 let mut suffix = PathBuf::new();
660 while !existing.exists() {
661 if let Some(parent) = existing.parent() {
662 if let Some(name) = existing.file_name() {
663 if suffix.as_os_str().is_empty() {
664 suffix = PathBuf::from(name);
665 } else {
666 suffix = PathBuf::from(name).join(&suffix);
667 }
668 }
669 existing = parent;
670 } else {
671 break;
672 }
673 }
674 let base = existing.canonicalize().unwrap_or(existing.to_path_buf());
675 if suffix.as_os_str().is_empty() {
676 base
677 } else {
678 base.join(&suffix)
679 }
680}
681
682const IGNORED_DIRS: &[&str] = &[".git", "target", "node_modules", ".hg"];
683
684fn grep_recursive(
685 path: &Path,
686 regex: ®ex::Regex,
687 results: &mut Vec<String>,
688 limit: usize,
689) -> Result<(), ToolError> {
690 if results.len() >= limit {
691 return Ok(());
692 }
693 if path.is_file() {
694 if let Ok(content) = std::fs::read_to_string(path) {
695 for (i, line) in content.lines().enumerate() {
696 if regex.is_match(line) {
697 results.push(format!("{}:{}: {line}", path.display(), i + 1));
698 if results.len() >= limit {
699 return Ok(());
700 }
701 }
702 }
703 }
704 } else if path.is_dir() {
705 let entries = std::fs::read_dir(path)?;
706 for entry in entries.flatten() {
707 let p = entry.path();
708 let name = p.file_name().and_then(|n| n.to_str());
709 if name.is_some_and(|n| n.starts_with('.') || IGNORED_DIRS.contains(&n)) {
710 continue;
711 }
712 grep_recursive(&p, regex, results, limit)?;
713 }
714 }
715 Ok(())
716}
717
718fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<(), ToolError> {
719 std::fs::create_dir_all(dst)?;
720 for entry in std::fs::read_dir(src)? {
721 let entry = entry?;
722 let meta = std::fs::symlink_metadata(entry.path())?;
726 let src_path = entry.path();
727 let dst_path = dst.join(entry.file_name());
728 if meta.is_dir() {
729 copy_dir_recursive(&src_path, &dst_path)?;
730 } else if meta.is_file() {
731 std::fs::copy(&src_path, &dst_path)?;
732 }
733 }
735 Ok(())
736}
737
738#[cfg(test)]
739mod tests {
740 use super::*;
741 use std::fs;
742
743 fn temp_dir() -> tempfile::TempDir {
744 tempfile::tempdir().unwrap()
745 }
746
747 fn make_params(
748 pairs: &[(&str, serde_json::Value)],
749 ) -> serde_json::Map<String, serde_json::Value> {
750 pairs
751 .iter()
752 .map(|(k, v)| ((*k).to_owned(), v.clone()))
753 .collect()
754 }
755
756 #[test]
757 fn read_file() {
758 let dir = temp_dir();
759 let file = dir.path().join("test.txt");
760 fs::write(&file, "line1\nline2\nline3\n").unwrap();
761
762 let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
763 let params = make_params(&[("path", serde_json::json!(file.to_str().unwrap()))]);
764 let result = exec.execute_file_tool("read", ¶ms).unwrap().unwrap();
765 assert_eq!(result.tool_name, "read");
766 assert!(result.summary.contains("line1"));
767 assert!(result.summary.contains("line3"));
768 }
769
770 #[test]
771 fn read_with_offset_and_limit() {
772 let dir = temp_dir();
773 let file = dir.path().join("test.txt");
774 fs::write(&file, "a\nb\nc\nd\ne\n").unwrap();
775
776 let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
777 let params = make_params(&[
778 ("path", serde_json::json!(file.to_str().unwrap())),
779 ("offset", serde_json::json!(1)),
780 ("limit", serde_json::json!(2)),
781 ]);
782 let result = exec.execute_file_tool("read", ¶ms).unwrap().unwrap();
783 assert!(result.summary.contains('b'));
784 assert!(result.summary.contains('c'));
785 assert!(!result.summary.contains('a'));
786 assert!(!result.summary.contains('d'));
787 }
788
789 #[test]
790 fn write_file() {
791 let dir = temp_dir();
792 let file = dir.path().join("out.txt");
793
794 let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
795 let params = make_params(&[
796 ("path", serde_json::json!(file.to_str().unwrap())),
797 ("content", serde_json::json!("hello world")),
798 ]);
799 let result = exec.execute_file_tool("write", ¶ms).unwrap().unwrap();
800 assert!(result.summary.contains("11 bytes"));
801 assert_eq!(fs::read_to_string(&file).unwrap(), "hello world");
802 }
803
804 #[test]
805 fn edit_file() {
806 let dir = temp_dir();
807 let file = dir.path().join("edit.txt");
808 fs::write(&file, "foo bar baz").unwrap();
809
810 let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
811 let params = make_params(&[
812 ("path", serde_json::json!(file.to_str().unwrap())),
813 ("old_string", serde_json::json!("bar")),
814 ("new_string", serde_json::json!("qux")),
815 ]);
816 let result = exec.execute_file_tool("edit", ¶ms).unwrap().unwrap();
817 assert!(result.summary.contains("Edited"));
818 assert_eq!(fs::read_to_string(&file).unwrap(), "foo qux baz");
819 }
820
821 #[test]
822 fn edit_not_found() {
823 let dir = temp_dir();
824 let file = dir.path().join("edit.txt");
825 fs::write(&file, "foo bar").unwrap();
826
827 let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
828 let params = make_params(&[
829 ("path", serde_json::json!(file.to_str().unwrap())),
830 ("old_string", serde_json::json!("nonexistent")),
831 ("new_string", serde_json::json!("x")),
832 ]);
833 let result = exec.execute_file_tool("edit", ¶ms);
834 assert!(result.is_err());
835 }
836
837 #[test]
838 fn sandbox_violation() {
839 let dir = temp_dir();
840 let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
841 let params = make_params(&[("path", serde_json::json!("/etc/passwd"))]);
842 let result = exec.execute_file_tool("read", ¶ms);
843 assert!(matches!(result, Err(ToolError::SandboxViolation { .. })));
844 }
845
846 #[test]
847 fn unknown_tool_returns_none() {
848 let exec = FileExecutor::new(vec![]);
849 let params = serde_json::Map::new();
850 let result = exec.execute_file_tool("unknown", ¶ms).unwrap();
851 assert!(result.is_none());
852 }
853
854 #[test]
855 fn find_path_finds_files() {
856 let dir = temp_dir();
857 fs::write(dir.path().join("a.rs"), "").unwrap();
858 fs::write(dir.path().join("b.rs"), "").unwrap();
859
860 let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
861 let pattern = format!("{}/*.rs", dir.path().display());
862 let params = make_params(&[("pattern", serde_json::json!(pattern))]);
863 let result = exec
864 .execute_file_tool("find_path", ¶ms)
865 .unwrap()
866 .unwrap();
867 assert!(result.summary.contains("a.rs"));
868 assert!(result.summary.contains("b.rs"));
869 }
870
871 #[test]
872 fn grep_finds_matches() {
873 let dir = temp_dir();
874 fs::write(
875 dir.path().join("test.txt"),
876 "hello world\nfoo bar\nhello again\n",
877 )
878 .unwrap();
879
880 let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
881 let params = make_params(&[
882 ("pattern", serde_json::json!("hello")),
883 ("path", serde_json::json!(dir.path().to_str().unwrap())),
884 ]);
885 let result = exec.execute_file_tool("grep", ¶ms).unwrap().unwrap();
886 assert!(result.summary.contains("hello world"));
887 assert!(result.summary.contains("hello again"));
888 assert!(!result.summary.contains("foo bar"));
889 }
890
891 #[test]
892 fn write_sandbox_bypass_nonexistent_path() {
893 let dir = temp_dir();
894 let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
895 let params = make_params(&[
896 ("path", serde_json::json!("/tmp/evil/escape.txt")),
897 ("content", serde_json::json!("pwned")),
898 ]);
899 let result = exec.execute_file_tool("write", ¶ms);
900 assert!(matches!(result, Err(ToolError::SandboxViolation { .. })));
901 assert!(!Path::new("/tmp/evil/escape.txt").exists());
902 }
903
904 #[test]
905 fn find_path_filters_outside_sandbox() {
906 let sandbox = temp_dir();
907 let outside = temp_dir();
908 fs::write(outside.path().join("secret.rs"), "secret").unwrap();
909
910 let exec = FileExecutor::new(vec![sandbox.path().to_path_buf()]);
911 let pattern = format!("{}/*.rs", outside.path().display());
912 let params = make_params(&[("pattern", serde_json::json!(pattern))]);
913 let result = exec
914 .execute_file_tool("find_path", ¶ms)
915 .unwrap()
916 .unwrap();
917 assert!(!result.summary.contains("secret.rs"));
918 }
919
920 #[tokio::test]
921 async fn tool_executor_execute_tool_call_delegates() {
922 let dir = temp_dir();
923 let file = dir.path().join("test.txt");
924 fs::write(&file, "content").unwrap();
925
926 let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
927 let call = ToolCall {
928 tool_id: "read".to_owned(),
929 params: make_params(&[("path", serde_json::json!(file.to_str().unwrap()))]),
930 };
931 let result = exec.execute_tool_call(&call).await.unwrap().unwrap();
932 assert_eq!(result.tool_name, "read");
933 assert!(result.summary.contains("content"));
934 }
935
936 #[test]
937 fn tool_executor_tool_definitions_lists_all() {
938 let exec = FileExecutor::new(vec![]);
939 let defs = exec.tool_definitions();
940 let ids: Vec<&str> = defs.iter().map(|d| d.id.as_ref()).collect();
941 assert!(ids.contains(&"read"));
942 assert!(ids.contains(&"write"));
943 assert!(ids.contains(&"edit"));
944 assert!(ids.contains(&"find_path"));
945 assert!(ids.contains(&"grep"));
946 assert!(ids.contains(&"list_directory"));
947 assert!(ids.contains(&"create_directory"));
948 assert!(ids.contains(&"delete_path"));
949 assert!(ids.contains(&"move_path"));
950 assert!(ids.contains(&"copy_path"));
951 assert_eq!(defs.len(), 10);
952 }
953
954 #[test]
955 fn grep_relative_path_validated() {
956 let sandbox = temp_dir();
957 let exec = FileExecutor::new(vec![sandbox.path().to_path_buf()]);
958 let params = make_params(&[
959 ("pattern", serde_json::json!("password")),
960 ("path", serde_json::json!("../../etc")),
961 ]);
962 let result = exec.execute_file_tool("grep", ¶ms);
963 assert!(matches!(result, Err(ToolError::SandboxViolation { .. })));
964 }
965
966 #[test]
967 fn tool_definitions_returns_ten_tools() {
968 let exec = FileExecutor::new(vec![]);
969 let defs = exec.tool_definitions();
970 assert_eq!(defs.len(), 10);
971 let ids: Vec<&str> = defs.iter().map(|d| d.id.as_ref()).collect();
972 assert_eq!(
973 ids,
974 vec![
975 "read",
976 "write",
977 "edit",
978 "find_path",
979 "grep",
980 "list_directory",
981 "create_directory",
982 "delete_path",
983 "move_path",
984 "copy_path",
985 ]
986 );
987 }
988
989 #[test]
990 fn tool_definitions_all_use_tool_call() {
991 let exec = FileExecutor::new(vec![]);
992 for def in exec.tool_definitions() {
993 assert_eq!(def.invocation, InvocationHint::ToolCall);
994 }
995 }
996
997 #[test]
998 fn tool_definitions_read_schema_has_params() {
999 let exec = FileExecutor::new(vec![]);
1000 let defs = exec.tool_definitions();
1001 let read = defs.iter().find(|d| d.id.as_ref() == "read").unwrap();
1002 let obj = read.schema.as_object().unwrap();
1003 let props = obj["properties"].as_object().unwrap();
1004 assert!(props.contains_key("path"));
1005 assert!(props.contains_key("offset"));
1006 assert!(props.contains_key("limit"));
1007 }
1008
1009 #[test]
1010 fn missing_required_path_returns_invalid_params() {
1011 let dir = temp_dir();
1012 let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1013 let params = serde_json::Map::new();
1014 let result = exec.execute_file_tool("read", ¶ms);
1015 assert!(matches!(result, Err(ToolError::InvalidParams { .. })));
1016 }
1017
1018 #[test]
1021 fn list_directory_returns_entries() {
1022 let dir = temp_dir();
1023 fs::write(dir.path().join("file.txt"), "").unwrap();
1024 fs::create_dir(dir.path().join("subdir")).unwrap();
1025
1026 let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1027 let params = make_params(&[("path", serde_json::json!(dir.path().to_str().unwrap()))]);
1028 let result = exec
1029 .execute_file_tool("list_directory", ¶ms)
1030 .unwrap()
1031 .unwrap();
1032 assert!(result.summary.contains("[dir] subdir"));
1033 assert!(result.summary.contains("[file] file.txt"));
1034 let dir_pos = result.summary.find("[dir]").unwrap();
1036 let file_pos = result.summary.find("[file]").unwrap();
1037 assert!(dir_pos < file_pos);
1038 }
1039
1040 #[test]
1041 fn list_directory_empty_dir() {
1042 let dir = temp_dir();
1043 let subdir = dir.path().join("empty");
1044 fs::create_dir(&subdir).unwrap();
1045
1046 let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1047 let params = make_params(&[("path", serde_json::json!(subdir.to_str().unwrap()))]);
1048 let result = exec
1049 .execute_file_tool("list_directory", ¶ms)
1050 .unwrap()
1051 .unwrap();
1052 assert!(result.summary.contains("Empty directory"));
1053 }
1054
1055 #[test]
1056 fn list_directory_sandbox_violation() {
1057 let dir = temp_dir();
1058 let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1059 let params = make_params(&[("path", serde_json::json!("/etc"))]);
1060 let result = exec.execute_file_tool("list_directory", ¶ms);
1061 assert!(matches!(result, Err(ToolError::SandboxViolation { .. })));
1062 }
1063
1064 #[test]
1065 fn list_directory_nonexistent_returns_error() {
1066 let dir = temp_dir();
1067 let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1068 let missing = dir.path().join("nonexistent");
1069 let params = make_params(&[("path", serde_json::json!(missing.to_str().unwrap()))]);
1070 let result = exec.execute_file_tool("list_directory", ¶ms);
1071 assert!(result.is_err());
1072 }
1073
1074 #[test]
1075 fn list_directory_on_file_returns_error() {
1076 let dir = temp_dir();
1077 let file = dir.path().join("file.txt");
1078 fs::write(&file, "content").unwrap();
1079
1080 let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1081 let params = make_params(&[("path", serde_json::json!(file.to_str().unwrap()))]);
1082 let result = exec.execute_file_tool("list_directory", ¶ms);
1083 assert!(result.is_err());
1084 }
1085
1086 #[test]
1089 fn create_directory_creates_nested() {
1090 let dir = temp_dir();
1091 let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1092 let nested = dir.path().join("a/b/c");
1093 let params = make_params(&[("path", serde_json::json!(nested.to_str().unwrap()))]);
1094 let result = exec
1095 .execute_file_tool("create_directory", ¶ms)
1096 .unwrap()
1097 .unwrap();
1098 assert!(result.summary.contains("Created"));
1099 assert!(nested.is_dir());
1100 }
1101
1102 #[test]
1103 fn create_directory_sandbox_violation() {
1104 let dir = temp_dir();
1105 let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1106 let params = make_params(&[("path", serde_json::json!("/tmp/evil_dir"))]);
1107 let result = exec.execute_file_tool("create_directory", ¶ms);
1108 assert!(matches!(result, Err(ToolError::SandboxViolation { .. })));
1109 }
1110
1111 #[test]
1114 fn delete_path_file() {
1115 let dir = temp_dir();
1116 let file = dir.path().join("del.txt");
1117 fs::write(&file, "bye").unwrap();
1118
1119 let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1120 let params = make_params(&[("path", serde_json::json!(file.to_str().unwrap()))]);
1121 exec.execute_file_tool("delete_path", ¶ms)
1122 .unwrap()
1123 .unwrap();
1124 assert!(!file.exists());
1125 }
1126
1127 #[test]
1128 fn delete_path_empty_directory() {
1129 let dir = temp_dir();
1130 let subdir = dir.path().join("empty_sub");
1131 fs::create_dir(&subdir).unwrap();
1132
1133 let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1134 let params = make_params(&[("path", serde_json::json!(subdir.to_str().unwrap()))]);
1135 exec.execute_file_tool("delete_path", ¶ms)
1136 .unwrap()
1137 .unwrap();
1138 assert!(!subdir.exists());
1139 }
1140
1141 #[test]
1142 fn delete_path_non_empty_dir_without_recursive_fails() {
1143 let dir = temp_dir();
1144 let subdir = dir.path().join("nonempty");
1145 fs::create_dir(&subdir).unwrap();
1146 fs::write(subdir.join("file.txt"), "x").unwrap();
1147
1148 let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1149 let params = make_params(&[("path", serde_json::json!(subdir.to_str().unwrap()))]);
1150 let result = exec.execute_file_tool("delete_path", ¶ms);
1151 assert!(result.is_err());
1152 }
1153
1154 #[test]
1155 fn delete_path_recursive() {
1156 let dir = temp_dir();
1157 let subdir = dir.path().join("recurse");
1158 fs::create_dir(&subdir).unwrap();
1159 fs::write(subdir.join("f.txt"), "x").unwrap();
1160
1161 let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1162 let params = make_params(&[
1163 ("path", serde_json::json!(subdir.to_str().unwrap())),
1164 ("recursive", serde_json::json!(true)),
1165 ]);
1166 exec.execute_file_tool("delete_path", ¶ms)
1167 .unwrap()
1168 .unwrap();
1169 assert!(!subdir.exists());
1170 }
1171
1172 #[test]
1173 fn delete_path_sandbox_violation() {
1174 let dir = temp_dir();
1175 let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1176 let params = make_params(&[("path", serde_json::json!("/etc/hosts"))]);
1177 let result = exec.execute_file_tool("delete_path", ¶ms);
1178 assert!(matches!(result, Err(ToolError::SandboxViolation { .. })));
1179 }
1180
1181 #[test]
1182 fn delete_path_refuses_sandbox_root() {
1183 let dir = temp_dir();
1184 let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1185 let params = make_params(&[
1186 ("path", serde_json::json!(dir.path().to_str().unwrap())),
1187 ("recursive", serde_json::json!(true)),
1188 ]);
1189 let result = exec.execute_file_tool("delete_path", ¶ms);
1190 assert!(matches!(result, Err(ToolError::SandboxViolation { .. })));
1191 }
1192
1193 #[test]
1196 fn move_path_renames_file() {
1197 let dir = temp_dir();
1198 let src = dir.path().join("src.txt");
1199 let dst = dir.path().join("dst.txt");
1200 fs::write(&src, "data").unwrap();
1201
1202 let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1203 let params = make_params(&[
1204 ("source", serde_json::json!(src.to_str().unwrap())),
1205 ("destination", serde_json::json!(dst.to_str().unwrap())),
1206 ]);
1207 exec.execute_file_tool("move_path", ¶ms)
1208 .unwrap()
1209 .unwrap();
1210 assert!(!src.exists());
1211 assert_eq!(fs::read_to_string(&dst).unwrap(), "data");
1212 }
1213
1214 #[test]
1215 fn move_path_cross_sandbox_denied() {
1216 let sandbox = temp_dir();
1217 let outside = temp_dir();
1218 let src = sandbox.path().join("src.txt");
1219 fs::write(&src, "x").unwrap();
1220
1221 let exec = FileExecutor::new(vec![sandbox.path().to_path_buf()]);
1222 let dst = outside.path().join("dst.txt");
1223 let params = make_params(&[
1224 ("source", serde_json::json!(src.to_str().unwrap())),
1225 ("destination", serde_json::json!(dst.to_str().unwrap())),
1226 ]);
1227 let result = exec.execute_file_tool("move_path", ¶ms);
1228 assert!(matches!(result, Err(ToolError::SandboxViolation { .. })));
1229 }
1230
1231 #[test]
1234 fn copy_path_file() {
1235 let dir = temp_dir();
1236 let src = dir.path().join("src.txt");
1237 let dst = dir.path().join("dst.txt");
1238 fs::write(&src, "hello").unwrap();
1239
1240 let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1241 let params = make_params(&[
1242 ("source", serde_json::json!(src.to_str().unwrap())),
1243 ("destination", serde_json::json!(dst.to_str().unwrap())),
1244 ]);
1245 exec.execute_file_tool("copy_path", ¶ms)
1246 .unwrap()
1247 .unwrap();
1248 assert_eq!(fs::read_to_string(&src).unwrap(), "hello");
1249 assert_eq!(fs::read_to_string(&dst).unwrap(), "hello");
1250 }
1251
1252 #[test]
1253 fn copy_path_directory_recursive() {
1254 let dir = temp_dir();
1255 let src_dir = dir.path().join("src_dir");
1256 fs::create_dir(&src_dir).unwrap();
1257 fs::write(src_dir.join("a.txt"), "aaa").unwrap();
1258
1259 let dst_dir = dir.path().join("dst_dir");
1260
1261 let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1262 let params = make_params(&[
1263 ("source", serde_json::json!(src_dir.to_str().unwrap())),
1264 ("destination", serde_json::json!(dst_dir.to_str().unwrap())),
1265 ]);
1266 exec.execute_file_tool("copy_path", ¶ms)
1267 .unwrap()
1268 .unwrap();
1269 assert_eq!(fs::read_to_string(dst_dir.join("a.txt")).unwrap(), "aaa");
1270 }
1271
1272 #[test]
1273 fn copy_path_sandbox_violation() {
1274 let sandbox = temp_dir();
1275 let outside = temp_dir();
1276 let src = sandbox.path().join("src.txt");
1277 fs::write(&src, "x").unwrap();
1278
1279 let exec = FileExecutor::new(vec![sandbox.path().to_path_buf()]);
1280 let dst = outside.path().join("dst.txt");
1281 let params = make_params(&[
1282 ("source", serde_json::json!(src.to_str().unwrap())),
1283 ("destination", serde_json::json!(dst.to_str().unwrap())),
1284 ]);
1285 let result = exec.execute_file_tool("copy_path", ¶ms);
1286 assert!(matches!(result, Err(ToolError::SandboxViolation { .. })));
1287 }
1288
1289 #[test]
1291 fn find_path_invalid_pattern_returns_error() {
1292 let dir = temp_dir();
1293 let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1294 let params = make_params(&[("pattern", serde_json::json!("[invalid"))]);
1295 let result = exec.execute_file_tool("find_path", ¶ms);
1296 assert!(result.is_err());
1297 }
1298
1299 #[test]
1301 fn create_directory_idempotent() {
1302 let dir = temp_dir();
1303 let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1304 let target = dir.path().join("exists");
1305 fs::create_dir(&target).unwrap();
1306
1307 let params = make_params(&[("path", serde_json::json!(target.to_str().unwrap()))]);
1308 let result = exec.execute_file_tool("create_directory", ¶ms);
1309 assert!(result.is_ok());
1310 assert!(target.is_dir());
1311 }
1312
1313 #[test]
1315 fn move_path_source_sandbox_violation() {
1316 let sandbox = temp_dir();
1317 let outside = temp_dir();
1318 let src = outside.path().join("src.txt");
1319 fs::write(&src, "x").unwrap();
1320
1321 let exec = FileExecutor::new(vec![sandbox.path().to_path_buf()]);
1322 let dst = sandbox.path().join("dst.txt");
1323 let params = make_params(&[
1324 ("source", serde_json::json!(src.to_str().unwrap())),
1325 ("destination", serde_json::json!(dst.to_str().unwrap())),
1326 ]);
1327 let result = exec.execute_file_tool("move_path", ¶ms);
1328 assert!(matches!(result, Err(ToolError::SandboxViolation { .. })));
1329 }
1330
1331 #[test]
1333 fn copy_path_source_sandbox_violation() {
1334 let sandbox = temp_dir();
1335 let outside = temp_dir();
1336 let src = outside.path().join("src.txt");
1337 fs::write(&src, "x").unwrap();
1338
1339 let exec = FileExecutor::new(vec![sandbox.path().to_path_buf()]);
1340 let dst = sandbox.path().join("dst.txt");
1341 let params = make_params(&[
1342 ("source", serde_json::json!(src.to_str().unwrap())),
1343 ("destination", serde_json::json!(dst.to_str().unwrap())),
1344 ]);
1345 let result = exec.execute_file_tool("copy_path", ¶ms);
1346 assert!(matches!(result, Err(ToolError::SandboxViolation { .. })));
1347 }
1348
1349 #[cfg(unix)]
1351 #[test]
1352 fn copy_dir_skips_symlinks() {
1353 let dir = temp_dir();
1354 let src_dir = dir.path().join("src");
1355 fs::create_dir(&src_dir).unwrap();
1356 fs::write(src_dir.join("real.txt"), "real").unwrap();
1357
1358 let outside = temp_dir();
1360 std::os::unix::fs::symlink(outside.path(), src_dir.join("link")).unwrap();
1361
1362 let dst_dir = dir.path().join("dst");
1363 let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1364 let params = make_params(&[
1365 ("source", serde_json::json!(src_dir.to_str().unwrap())),
1366 ("destination", serde_json::json!(dst_dir.to_str().unwrap())),
1367 ]);
1368 exec.execute_file_tool("copy_path", ¶ms)
1369 .unwrap()
1370 .unwrap();
1371 assert_eq!(
1373 fs::read_to_string(dst_dir.join("real.txt")).unwrap(),
1374 "real"
1375 );
1376 assert!(!dst_dir.join("link").exists());
1378 }
1379
1380 #[cfg(unix)]
1382 #[test]
1383 fn list_directory_shows_symlinks() {
1384 let dir = temp_dir();
1385 let target = dir.path().join("target.txt");
1386 fs::write(&target, "x").unwrap();
1387 std::os::unix::fs::symlink(&target, dir.path().join("link")).unwrap();
1388
1389 let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1390 let params = make_params(&[("path", serde_json::json!(dir.path().to_str().unwrap()))]);
1391 let result = exec
1392 .execute_file_tool("list_directory", ¶ms)
1393 .unwrap()
1394 .unwrap();
1395 assert!(result.summary.contains("[symlink] link"));
1396 assert!(result.summary.contains("[file] target.txt"));
1397 }
1398
1399 #[test]
1400 fn tilde_path_is_expanded() {
1401 let exec = FileExecutor::new(vec![PathBuf::from("~/nonexistent_subdir_for_test")]);
1402 assert!(
1403 !exec.allowed_paths[0].to_string_lossy().starts_with('~'),
1404 "tilde was not expanded: {:?}",
1405 exec.allowed_paths[0]
1406 );
1407 }
1408
1409 #[test]
1410 fn absolute_path_unchanged() {
1411 let exec = FileExecutor::new(vec![PathBuf::from("/tmp")]);
1412 let p = exec.allowed_paths[0].to_string_lossy();
1415 assert!(
1416 p.starts_with('/'),
1417 "expected absolute path, got: {:?}",
1418 exec.allowed_paths[0]
1419 );
1420 assert!(
1421 !p.starts_with('~'),
1422 "tilde must not appear in result: {:?}",
1423 exec.allowed_paths[0]
1424 );
1425 }
1426
1427 #[test]
1428 fn tilde_only_expands_to_home() {
1429 let exec = FileExecutor::new(vec![PathBuf::from("~")]);
1430 assert!(
1431 !exec.allowed_paths[0].to_string_lossy().starts_with('~'),
1432 "bare tilde was not expanded: {:?}",
1433 exec.allowed_paths[0]
1434 );
1435 }
1436
1437 #[test]
1438 fn empty_allowed_paths_uses_cwd() {
1439 let exec = FileExecutor::new(vec![]);
1440 assert!(
1441 !exec.allowed_paths.is_empty(),
1442 "expected cwd fallback, got empty allowed_paths"
1443 );
1444 }
1445
1446 #[test]
1449 fn normalize_path_normal_path() {
1450 assert_eq!(
1451 normalize_path(Path::new("/tmp/sandbox/file.txt")),
1452 PathBuf::from("/tmp/sandbox/file.txt")
1453 );
1454 }
1455
1456 #[test]
1457 fn normalize_path_collapses_dot() {
1458 assert_eq!(
1459 normalize_path(Path::new("/tmp/sandbox/./file.txt")),
1460 PathBuf::from("/tmp/sandbox/file.txt")
1461 );
1462 }
1463
1464 #[test]
1465 fn normalize_path_collapses_dotdot() {
1466 assert_eq!(
1467 normalize_path(Path::new("/tmp/sandbox/nonexistent/../../etc/passwd")),
1468 PathBuf::from("/tmp/etc/passwd")
1469 );
1470 }
1471
1472 #[test]
1473 fn normalize_path_nested_dotdot() {
1474 assert_eq!(
1475 normalize_path(Path::new("/tmp/sandbox/a/b/../../../etc/passwd")),
1476 PathBuf::from("/tmp/etc/passwd")
1477 );
1478 }
1479
1480 #[test]
1481 fn normalize_path_at_sandbox_boundary() {
1482 assert_eq!(
1483 normalize_path(Path::new("/tmp/sandbox")),
1484 PathBuf::from("/tmp/sandbox")
1485 );
1486 }
1487
1488 #[test]
1491 fn validate_path_dotdot_bypass_nonexistent_blocked() {
1492 let dir = temp_dir();
1493 let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1494 let escape = format!("{}/nonexistent/../../etc/passwd", dir.path().display());
1496 let params = make_params(&[("path", serde_json::json!(escape))]);
1497 let result = exec.execute_file_tool("read", ¶ms);
1498 assert!(
1499 matches!(result, Err(ToolError::SandboxViolation { .. })),
1500 "expected SandboxViolation for dotdot bypass, got {:?}",
1501 result
1502 );
1503 }
1504
1505 #[test]
1506 fn validate_path_dotdot_nested_bypass_blocked() {
1507 let dir = temp_dir();
1508 let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1509 let escape = format!("{}/a/b/../../../etc/shadow", dir.path().display());
1510 let params = make_params(&[("path", serde_json::json!(escape))]);
1511 let result = exec.execute_file_tool("read", ¶ms);
1512 assert!(matches!(result, Err(ToolError::SandboxViolation { .. })));
1513 }
1514
1515 #[test]
1516 fn validate_path_inside_sandbox_passes() {
1517 let dir = temp_dir();
1518 let file = dir.path().join("allowed.txt");
1519 fs::write(&file, "ok").unwrap();
1520 let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1521 let params = make_params(&[("path", serde_json::json!(file.to_str().unwrap()))]);
1522 let result = exec.execute_file_tool("read", ¶ms);
1523 assert!(result.is_ok());
1524 }
1525
1526 #[test]
1527 fn validate_path_dot_components_inside_sandbox_passes() {
1528 let dir = temp_dir();
1529 let file = dir.path().join("sub/file.txt");
1530 fs::create_dir_all(dir.path().join("sub")).unwrap();
1531 fs::write(&file, "ok").unwrap();
1532 let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1533 let dotpath = format!("{}/sub/./file.txt", dir.path().display());
1534 let params = make_params(&[("path", serde_json::json!(dotpath))]);
1535 let result = exec.execute_file_tool("read", ¶ms);
1536 assert!(result.is_ok());
1537 }
1538}