1use std::path::{Path, PathBuf};
5
6use schemars::JsonSchema;
7use serde::Deserialize;
8
9use crate::executor::{
10 ClaimSource, 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 claim_source: Some(ClaimSource::FileSystem),
228 }))
229 }
230
231 fn handle_write(&self, params: &WriteParams) -> Result<Option<ToolOutput>, ToolError> {
232 let path = self.validate_path(Path::new(¶ms.path))?;
233 let old_content = std::fs::read_to_string(&path).unwrap_or_default();
234
235 if let Some(parent) = path.parent() {
236 std::fs::create_dir_all(parent)?;
237 }
238 std::fs::write(&path, ¶ms.content)?;
239
240 Ok(Some(ToolOutput {
241 tool_name: "write".to_owned(),
242 summary: format!("Wrote {} bytes to {}", params.content.len(), params.path),
243 blocks_executed: 1,
244 filter_stats: None,
245 diff: Some(DiffData {
246 file_path: params.path.clone(),
247 old_content,
248 new_content: params.content.clone(),
249 }),
250 streamed: false,
251 terminal_id: None,
252 locations: None,
253 raw_response: None,
254 claim_source: Some(ClaimSource::FileSystem),
255 }))
256 }
257
258 fn handle_edit(&self, params: &EditParams) -> Result<Option<ToolOutput>, ToolError> {
259 let path = self.validate_path(Path::new(¶ms.path))?;
260 let content = std::fs::read_to_string(&path)?;
261
262 if !content.contains(¶ms.old_string) {
263 return Err(ToolError::Execution(std::io::Error::new(
264 std::io::ErrorKind::NotFound,
265 format!("old_string not found in {}", params.path),
266 )));
267 }
268
269 let new_content = content.replacen(¶ms.old_string, ¶ms.new_string, 1);
270 std::fs::write(&path, &new_content)?;
271
272 Ok(Some(ToolOutput {
273 tool_name: "edit".to_owned(),
274 summary: format!("Edited {}", params.path),
275 blocks_executed: 1,
276 filter_stats: None,
277 diff: Some(DiffData {
278 file_path: params.path.clone(),
279 old_content: content,
280 new_content,
281 }),
282 streamed: false,
283 terminal_id: None,
284 locations: None,
285 raw_response: None,
286 claim_source: Some(ClaimSource::FileSystem),
287 }))
288 }
289
290 fn handle_find_path(&self, params: &FindPathParams) -> Result<Option<ToolOutput>, ToolError> {
291 let matches: Vec<String> = glob::glob(¶ms.pattern)
292 .map_err(|e| {
293 ToolError::Execution(std::io::Error::new(
294 std::io::ErrorKind::InvalidInput,
295 e.to_string(),
296 ))
297 })?
298 .filter_map(Result::ok)
299 .filter(|p| {
300 let canonical = p.canonicalize().unwrap_or_else(|_| p.clone());
301 self.allowed_paths.iter().any(|a| canonical.starts_with(a))
302 })
303 .map(|p| p.display().to_string())
304 .collect();
305
306 Ok(Some(ToolOutput {
307 tool_name: "find_path".to_owned(),
308 summary: if matches.is_empty() {
309 format!("No files matching: {}", params.pattern)
310 } else {
311 matches.join("\n")
312 },
313 blocks_executed: 1,
314 filter_stats: None,
315 diff: None,
316 streamed: false,
317 terminal_id: None,
318 locations: None,
319 raw_response: None,
320 claim_source: Some(ClaimSource::FileSystem),
321 }))
322 }
323
324 fn handle_grep(&self, params: &GrepParams) -> Result<Option<ToolOutput>, ToolError> {
325 let search_path = params.path.as_deref().unwrap_or(".");
326 let case_sensitive = params.case_sensitive.unwrap_or(true);
327 let path = self.validate_path(Path::new(search_path))?;
328
329 let regex = if case_sensitive {
330 regex::Regex::new(¶ms.pattern)
331 } else {
332 regex::RegexBuilder::new(¶ms.pattern)
333 .case_insensitive(true)
334 .build()
335 }
336 .map_err(|e| {
337 ToolError::Execution(std::io::Error::new(
338 std::io::ErrorKind::InvalidInput,
339 e.to_string(),
340 ))
341 })?;
342
343 let mut results = Vec::new();
344 grep_recursive(&path, ®ex, &mut results, 100)?;
345
346 Ok(Some(ToolOutput {
347 tool_name: "grep".to_owned(),
348 summary: if results.is_empty() {
349 format!("No matches for: {}", params.pattern)
350 } else {
351 results.join("\n")
352 },
353 blocks_executed: 1,
354 filter_stats: None,
355 diff: None,
356 streamed: false,
357 terminal_id: None,
358 locations: None,
359 raw_response: None,
360 claim_source: Some(ClaimSource::FileSystem),
361 }))
362 }
363
364 fn handle_list_directory(
365 &self,
366 params: &ListDirectoryParams,
367 ) -> Result<Option<ToolOutput>, ToolError> {
368 let path = self.validate_path(Path::new(¶ms.path))?;
369
370 if !path.is_dir() {
371 return Err(ToolError::Execution(std::io::Error::new(
372 std::io::ErrorKind::NotADirectory,
373 format!("{} is not a directory", params.path),
374 )));
375 }
376
377 let mut dirs = Vec::new();
378 let mut files = Vec::new();
379 let mut symlinks = Vec::new();
380
381 for entry in std::fs::read_dir(&path)? {
382 let entry = entry?;
383 let name = entry.file_name().to_string_lossy().into_owned();
384 let meta = std::fs::symlink_metadata(entry.path())?;
386 if meta.is_symlink() {
387 symlinks.push(format!("[symlink] {name}"));
388 } else if meta.is_dir() {
389 dirs.push(format!("[dir] {name}"));
390 } else {
391 files.push(format!("[file] {name}"));
392 }
393 }
394
395 dirs.sort();
396 files.sort();
397 symlinks.sort();
398
399 let mut entries = dirs;
400 entries.extend(files);
401 entries.extend(symlinks);
402
403 Ok(Some(ToolOutput {
404 tool_name: "list_directory".to_owned(),
405 summary: if entries.is_empty() {
406 format!("Empty directory: {}", params.path)
407 } else {
408 entries.join("\n")
409 },
410 blocks_executed: 1,
411 filter_stats: None,
412 diff: None,
413 streamed: false,
414 terminal_id: None,
415 locations: None,
416 raw_response: None,
417 claim_source: Some(ClaimSource::FileSystem),
418 }))
419 }
420
421 fn handle_create_directory(
422 &self,
423 params: &CreateDirectoryParams,
424 ) -> Result<Option<ToolOutput>, ToolError> {
425 let path = self.validate_path(Path::new(¶ms.path))?;
426 std::fs::create_dir_all(&path)?;
427
428 Ok(Some(ToolOutput {
429 tool_name: "create_directory".to_owned(),
430 summary: format!("Created directory: {}", params.path),
431 blocks_executed: 1,
432 filter_stats: None,
433 diff: None,
434 streamed: false,
435 terminal_id: None,
436 locations: None,
437 raw_response: None,
438 claim_source: Some(ClaimSource::FileSystem),
439 }))
440 }
441
442 fn handle_delete_path(
443 &self,
444 params: &DeletePathParams,
445 ) -> Result<Option<ToolOutput>, ToolError> {
446 let path = self.validate_path(Path::new(¶ms.path))?;
447
448 if self.allowed_paths.iter().any(|a| &path == a) {
450 return Err(ToolError::SandboxViolation {
451 path: path.display().to_string(),
452 });
453 }
454
455 if path.is_dir() {
456 if params.recursive {
457 std::fs::remove_dir_all(&path)?;
460 } else {
461 std::fs::remove_dir(&path)?;
463 }
464 } else {
465 std::fs::remove_file(&path)?;
466 }
467
468 Ok(Some(ToolOutput {
469 tool_name: "delete_path".to_owned(),
470 summary: format!("Deleted: {}", params.path),
471 blocks_executed: 1,
472 filter_stats: None,
473 diff: None,
474 streamed: false,
475 terminal_id: None,
476 locations: None,
477 raw_response: None,
478 claim_source: Some(ClaimSource::FileSystem),
479 }))
480 }
481
482 fn handle_move_path(&self, params: &MovePathParams) -> Result<Option<ToolOutput>, ToolError> {
483 let src = self.validate_path(Path::new(¶ms.source))?;
484 let dst = self.validate_path(Path::new(¶ms.destination))?;
485 std::fs::rename(&src, &dst)?;
486
487 Ok(Some(ToolOutput {
488 tool_name: "move_path".to_owned(),
489 summary: format!("Moved: {} -> {}", params.source, params.destination),
490 blocks_executed: 1,
491 filter_stats: None,
492 diff: None,
493 streamed: false,
494 terminal_id: None,
495 locations: None,
496 raw_response: None,
497 claim_source: Some(ClaimSource::FileSystem),
498 }))
499 }
500
501 fn handle_copy_path(&self, params: &CopyPathParams) -> Result<Option<ToolOutput>, ToolError> {
502 let src = self.validate_path(Path::new(¶ms.source))?;
503 let dst = self.validate_path(Path::new(¶ms.destination))?;
504
505 if src.is_dir() {
506 copy_dir_recursive(&src, &dst)?;
507 } else {
508 if let Some(parent) = dst.parent() {
509 std::fs::create_dir_all(parent)?;
510 }
511 std::fs::copy(&src, &dst)?;
512 }
513
514 Ok(Some(ToolOutput {
515 tool_name: "copy_path".to_owned(),
516 summary: format!("Copied: {} -> {}", params.source, params.destination),
517 blocks_executed: 1,
518 filter_stats: None,
519 diff: None,
520 streamed: false,
521 terminal_id: None,
522 locations: None,
523 raw_response: None,
524 claim_source: Some(ClaimSource::FileSystem),
525 }))
526 }
527}
528
529impl ToolExecutor for FileExecutor {
530 async fn execute(&self, _response: &str) -> Result<Option<ToolOutput>, ToolError> {
531 Ok(None)
532 }
533
534 async fn execute_tool_call(&self, call: &ToolCall) -> Result<Option<ToolOutput>, ToolError> {
535 self.execute_file_tool(&call.tool_id, &call.params)
536 }
537
538 fn tool_definitions(&self) -> Vec<ToolDef> {
539 vec![
540 ToolDef {
541 id: "read".into(),
542 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(),
543 schema: schemars::schema_for!(ReadParams),
544 invocation: InvocationHint::ToolCall,
545 },
546 ToolDef {
547 id: "write".into(),
548 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(),
549 schema: schemars::schema_for!(WriteParams),
550 invocation: InvocationHint::ToolCall,
551 },
552 ToolDef {
553 id: "edit".into(),
554 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(),
555 schema: schemars::schema_for!(EditParams),
556 invocation: InvocationHint::ToolCall,
557 },
558 ToolDef {
559 id: "find_path".into(),
560 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(),
561 schema: schemars::schema_for!(FindPathParams),
562 invocation: InvocationHint::ToolCall,
563 },
564 ToolDef {
565 id: "grep".into(),
566 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(),
567 schema: schemars::schema_for!(GrepParams),
568 invocation: InvocationHint::ToolCall,
569 },
570 ToolDef {
571 id: "list_directory".into(),
572 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(),
573 schema: schemars::schema_for!(ListDirectoryParams),
574 invocation: InvocationHint::ToolCall,
575 },
576 ToolDef {
577 id: "create_directory".into(),
578 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(),
579 schema: schemars::schema_for!(CreateDirectoryParams),
580 invocation: InvocationHint::ToolCall,
581 },
582 ToolDef {
583 id: "delete_path".into(),
584 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(),
585 schema: schemars::schema_for!(DeletePathParams),
586 invocation: InvocationHint::ToolCall,
587 },
588 ToolDef {
589 id: "move_path".into(),
590 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(),
591 schema: schemars::schema_for!(MovePathParams),
592 invocation: InvocationHint::ToolCall,
593 },
594 ToolDef {
595 id: "copy_path".into(),
596 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(),
597 schema: schemars::schema_for!(CopyPathParams),
598 invocation: InvocationHint::ToolCall,
599 },
600 ]
601 }
602}
603
604fn normalize_path(path: &Path) -> PathBuf {
608 use std::path::Component;
609 let mut prefix: Option<std::ffi::OsString> = None;
613 let mut stack: Vec<std::ffi::OsString> = Vec::new();
614 for component in path.components() {
615 match component {
616 Component::CurDir => {}
617 Component::ParentDir => {
618 if stack.last().is_some_and(|s| s != "/") {
620 stack.pop();
621 }
622 }
623 Component::Normal(name) => stack.push(name.to_owned()),
624 Component::RootDir => {
625 if prefix.is_none() {
626 stack.clear();
628 stack.push(std::ffi::OsString::from("/"));
629 }
630 }
633 Component::Prefix(p) => {
634 stack.clear();
635 prefix = Some(p.as_os_str().to_owned());
636 }
637 }
638 }
639 if let Some(drive) = prefix {
640 let mut s = drive.to_string_lossy().into_owned();
642 s.push('\\');
643 let mut result = PathBuf::from(s);
644 for part in &stack {
645 result.push(part);
646 }
647 result
648 } else {
649 let mut result = PathBuf::new();
650 for (i, part) in stack.iter().enumerate() {
651 if i == 0 && part == "/" {
652 result.push("/");
653 } else {
654 result.push(part);
655 }
656 }
657 result
658 }
659}
660
661fn resolve_via_ancestors(path: &Path) -> PathBuf {
668 let mut existing = path;
669 let mut suffix = PathBuf::new();
670 while !existing.exists() {
671 if let Some(parent) = existing.parent() {
672 if let Some(name) = existing.file_name() {
673 if suffix.as_os_str().is_empty() {
674 suffix = PathBuf::from(name);
675 } else {
676 suffix = PathBuf::from(name).join(&suffix);
677 }
678 }
679 existing = parent;
680 } else {
681 break;
682 }
683 }
684 let base = existing.canonicalize().unwrap_or(existing.to_path_buf());
685 if suffix.as_os_str().is_empty() {
686 base
687 } else {
688 base.join(&suffix)
689 }
690}
691
692const IGNORED_DIRS: &[&str] = &[".git", "target", "node_modules", ".hg"];
693
694fn grep_recursive(
695 path: &Path,
696 regex: ®ex::Regex,
697 results: &mut Vec<String>,
698 limit: usize,
699) -> Result<(), ToolError> {
700 if results.len() >= limit {
701 return Ok(());
702 }
703 if path.is_file() {
704 if let Ok(content) = std::fs::read_to_string(path) {
705 for (i, line) in content.lines().enumerate() {
706 if regex.is_match(line) {
707 results.push(format!("{}:{}: {line}", path.display(), i + 1));
708 if results.len() >= limit {
709 return Ok(());
710 }
711 }
712 }
713 }
714 } else if path.is_dir() {
715 let entries = std::fs::read_dir(path)?;
716 for entry in entries.flatten() {
717 let p = entry.path();
718 let name = p.file_name().and_then(|n| n.to_str());
719 if name.is_some_and(|n| n.starts_with('.') || IGNORED_DIRS.contains(&n)) {
720 continue;
721 }
722 grep_recursive(&p, regex, results, limit)?;
723 }
724 }
725 Ok(())
726}
727
728fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<(), ToolError> {
729 std::fs::create_dir_all(dst)?;
730 for entry in std::fs::read_dir(src)? {
731 let entry = entry?;
732 let meta = std::fs::symlink_metadata(entry.path())?;
736 let src_path = entry.path();
737 let dst_path = dst.join(entry.file_name());
738 if meta.is_dir() {
739 copy_dir_recursive(&src_path, &dst_path)?;
740 } else if meta.is_file() {
741 std::fs::copy(&src_path, &dst_path)?;
742 }
743 }
745 Ok(())
746}
747
748#[cfg(test)]
749mod tests {
750 use super::*;
751 use std::fs;
752
753 fn temp_dir() -> tempfile::TempDir {
754 tempfile::tempdir().unwrap()
755 }
756
757 fn make_params(
758 pairs: &[(&str, serde_json::Value)],
759 ) -> serde_json::Map<String, serde_json::Value> {
760 pairs
761 .iter()
762 .map(|(k, v)| ((*k).to_owned(), v.clone()))
763 .collect()
764 }
765
766 #[test]
767 fn read_file() {
768 let dir = temp_dir();
769 let file = dir.path().join("test.txt");
770 fs::write(&file, "line1\nline2\nline3\n").unwrap();
771
772 let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
773 let params = make_params(&[("path", serde_json::json!(file.to_str().unwrap()))]);
774 let result = exec.execute_file_tool("read", ¶ms).unwrap().unwrap();
775 assert_eq!(result.tool_name, "read");
776 assert!(result.summary.contains("line1"));
777 assert!(result.summary.contains("line3"));
778 }
779
780 #[test]
781 fn read_with_offset_and_limit() {
782 let dir = temp_dir();
783 let file = dir.path().join("test.txt");
784 fs::write(&file, "a\nb\nc\nd\ne\n").unwrap();
785
786 let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
787 let params = make_params(&[
788 ("path", serde_json::json!(file.to_str().unwrap())),
789 ("offset", serde_json::json!(1)),
790 ("limit", serde_json::json!(2)),
791 ]);
792 let result = exec.execute_file_tool("read", ¶ms).unwrap().unwrap();
793 assert!(result.summary.contains('b'));
794 assert!(result.summary.contains('c'));
795 assert!(!result.summary.contains('a'));
796 assert!(!result.summary.contains('d'));
797 }
798
799 #[test]
800 fn write_file() {
801 let dir = temp_dir();
802 let file = dir.path().join("out.txt");
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 ("content", serde_json::json!("hello world")),
808 ]);
809 let result = exec.execute_file_tool("write", ¶ms).unwrap().unwrap();
810 assert!(result.summary.contains("11 bytes"));
811 assert_eq!(fs::read_to_string(&file).unwrap(), "hello world");
812 }
813
814 #[test]
815 fn edit_file() {
816 let dir = temp_dir();
817 let file = dir.path().join("edit.txt");
818 fs::write(&file, "foo bar baz").unwrap();
819
820 let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
821 let params = make_params(&[
822 ("path", serde_json::json!(file.to_str().unwrap())),
823 ("old_string", serde_json::json!("bar")),
824 ("new_string", serde_json::json!("qux")),
825 ]);
826 let result = exec.execute_file_tool("edit", ¶ms).unwrap().unwrap();
827 assert!(result.summary.contains("Edited"));
828 assert_eq!(fs::read_to_string(&file).unwrap(), "foo qux baz");
829 }
830
831 #[test]
832 fn edit_not_found() {
833 let dir = temp_dir();
834 let file = dir.path().join("edit.txt");
835 fs::write(&file, "foo bar").unwrap();
836
837 let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
838 let params = make_params(&[
839 ("path", serde_json::json!(file.to_str().unwrap())),
840 ("old_string", serde_json::json!("nonexistent")),
841 ("new_string", serde_json::json!("x")),
842 ]);
843 let result = exec.execute_file_tool("edit", ¶ms);
844 assert!(result.is_err());
845 }
846
847 #[test]
848 fn sandbox_violation() {
849 let dir = temp_dir();
850 let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
851 let params = make_params(&[("path", serde_json::json!("/etc/passwd"))]);
852 let result = exec.execute_file_tool("read", ¶ms);
853 assert!(matches!(result, Err(ToolError::SandboxViolation { .. })));
854 }
855
856 #[test]
857 fn unknown_tool_returns_none() {
858 let exec = FileExecutor::new(vec![]);
859 let params = serde_json::Map::new();
860 let result = exec.execute_file_tool("unknown", ¶ms).unwrap();
861 assert!(result.is_none());
862 }
863
864 #[test]
865 fn find_path_finds_files() {
866 let dir = temp_dir();
867 fs::write(dir.path().join("a.rs"), "").unwrap();
868 fs::write(dir.path().join("b.rs"), "").unwrap();
869
870 let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
871 let pattern = format!("{}/*.rs", dir.path().display());
872 let params = make_params(&[("pattern", serde_json::json!(pattern))]);
873 let result = exec
874 .execute_file_tool("find_path", ¶ms)
875 .unwrap()
876 .unwrap();
877 assert!(result.summary.contains("a.rs"));
878 assert!(result.summary.contains("b.rs"));
879 }
880
881 #[test]
882 fn grep_finds_matches() {
883 let dir = temp_dir();
884 fs::write(
885 dir.path().join("test.txt"),
886 "hello world\nfoo bar\nhello again\n",
887 )
888 .unwrap();
889
890 let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
891 let params = make_params(&[
892 ("pattern", serde_json::json!("hello")),
893 ("path", serde_json::json!(dir.path().to_str().unwrap())),
894 ]);
895 let result = exec.execute_file_tool("grep", ¶ms).unwrap().unwrap();
896 assert!(result.summary.contains("hello world"));
897 assert!(result.summary.contains("hello again"));
898 assert!(!result.summary.contains("foo bar"));
899 }
900
901 #[test]
902 fn write_sandbox_bypass_nonexistent_path() {
903 let dir = temp_dir();
904 let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
905 let params = make_params(&[
906 ("path", serde_json::json!("/tmp/evil/escape.txt")),
907 ("content", serde_json::json!("pwned")),
908 ]);
909 let result = exec.execute_file_tool("write", ¶ms);
910 assert!(matches!(result, Err(ToolError::SandboxViolation { .. })));
911 assert!(!Path::new("/tmp/evil/escape.txt").exists());
912 }
913
914 #[test]
915 fn find_path_filters_outside_sandbox() {
916 let sandbox = temp_dir();
917 let outside = temp_dir();
918 fs::write(outside.path().join("secret.rs"), "secret").unwrap();
919
920 let exec = FileExecutor::new(vec![sandbox.path().to_path_buf()]);
921 let pattern = format!("{}/*.rs", outside.path().display());
922 let params = make_params(&[("pattern", serde_json::json!(pattern))]);
923 let result = exec
924 .execute_file_tool("find_path", ¶ms)
925 .unwrap()
926 .unwrap();
927 assert!(!result.summary.contains("secret.rs"));
928 }
929
930 #[tokio::test]
931 async fn tool_executor_execute_tool_call_delegates() {
932 let dir = temp_dir();
933 let file = dir.path().join("test.txt");
934 fs::write(&file, "content").unwrap();
935
936 let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
937 let call = ToolCall {
938 tool_id: "read".to_owned(),
939 params: make_params(&[("path", serde_json::json!(file.to_str().unwrap()))]),
940 };
941 let result = exec.execute_tool_call(&call).await.unwrap().unwrap();
942 assert_eq!(result.tool_name, "read");
943 assert!(result.summary.contains("content"));
944 }
945
946 #[test]
947 fn tool_executor_tool_definitions_lists_all() {
948 let exec = FileExecutor::new(vec![]);
949 let defs = exec.tool_definitions();
950 let ids: Vec<&str> = defs.iter().map(|d| d.id.as_ref()).collect();
951 assert!(ids.contains(&"read"));
952 assert!(ids.contains(&"write"));
953 assert!(ids.contains(&"edit"));
954 assert!(ids.contains(&"find_path"));
955 assert!(ids.contains(&"grep"));
956 assert!(ids.contains(&"list_directory"));
957 assert!(ids.contains(&"create_directory"));
958 assert!(ids.contains(&"delete_path"));
959 assert!(ids.contains(&"move_path"));
960 assert!(ids.contains(&"copy_path"));
961 assert_eq!(defs.len(), 10);
962 }
963
964 #[test]
965 fn grep_relative_path_validated() {
966 let sandbox = temp_dir();
967 let exec = FileExecutor::new(vec![sandbox.path().to_path_buf()]);
968 let params = make_params(&[
969 ("pattern", serde_json::json!("password")),
970 ("path", serde_json::json!("../../etc")),
971 ]);
972 let result = exec.execute_file_tool("grep", ¶ms);
973 assert!(matches!(result, Err(ToolError::SandboxViolation { .. })));
974 }
975
976 #[test]
977 fn tool_definitions_returns_ten_tools() {
978 let exec = FileExecutor::new(vec![]);
979 let defs = exec.tool_definitions();
980 assert_eq!(defs.len(), 10);
981 let ids: Vec<&str> = defs.iter().map(|d| d.id.as_ref()).collect();
982 assert_eq!(
983 ids,
984 vec![
985 "read",
986 "write",
987 "edit",
988 "find_path",
989 "grep",
990 "list_directory",
991 "create_directory",
992 "delete_path",
993 "move_path",
994 "copy_path",
995 ]
996 );
997 }
998
999 #[test]
1000 fn tool_definitions_all_use_tool_call() {
1001 let exec = FileExecutor::new(vec![]);
1002 for def in exec.tool_definitions() {
1003 assert_eq!(def.invocation, InvocationHint::ToolCall);
1004 }
1005 }
1006
1007 #[test]
1008 fn tool_definitions_read_schema_has_params() {
1009 let exec = FileExecutor::new(vec![]);
1010 let defs = exec.tool_definitions();
1011 let read = defs.iter().find(|d| d.id.as_ref() == "read").unwrap();
1012 let obj = read.schema.as_object().unwrap();
1013 let props = obj["properties"].as_object().unwrap();
1014 assert!(props.contains_key("path"));
1015 assert!(props.contains_key("offset"));
1016 assert!(props.contains_key("limit"));
1017 }
1018
1019 #[test]
1020 fn missing_required_path_returns_invalid_params() {
1021 let dir = temp_dir();
1022 let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1023 let params = serde_json::Map::new();
1024 let result = exec.execute_file_tool("read", ¶ms);
1025 assert!(matches!(result, Err(ToolError::InvalidParams { .. })));
1026 }
1027
1028 #[test]
1031 fn list_directory_returns_entries() {
1032 let dir = temp_dir();
1033 fs::write(dir.path().join("file.txt"), "").unwrap();
1034 fs::create_dir(dir.path().join("subdir")).unwrap();
1035
1036 let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1037 let params = make_params(&[("path", serde_json::json!(dir.path().to_str().unwrap()))]);
1038 let result = exec
1039 .execute_file_tool("list_directory", ¶ms)
1040 .unwrap()
1041 .unwrap();
1042 assert!(result.summary.contains("[dir] subdir"));
1043 assert!(result.summary.contains("[file] file.txt"));
1044 let dir_pos = result.summary.find("[dir]").unwrap();
1046 let file_pos = result.summary.find("[file]").unwrap();
1047 assert!(dir_pos < file_pos);
1048 }
1049
1050 #[test]
1051 fn list_directory_empty_dir() {
1052 let dir = temp_dir();
1053 let subdir = dir.path().join("empty");
1054 fs::create_dir(&subdir).unwrap();
1055
1056 let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1057 let params = make_params(&[("path", serde_json::json!(subdir.to_str().unwrap()))]);
1058 let result = exec
1059 .execute_file_tool("list_directory", ¶ms)
1060 .unwrap()
1061 .unwrap();
1062 assert!(result.summary.contains("Empty directory"));
1063 }
1064
1065 #[test]
1066 fn list_directory_sandbox_violation() {
1067 let dir = temp_dir();
1068 let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1069 let params = make_params(&[("path", serde_json::json!("/etc"))]);
1070 let result = exec.execute_file_tool("list_directory", ¶ms);
1071 assert!(matches!(result, Err(ToolError::SandboxViolation { .. })));
1072 }
1073
1074 #[test]
1075 fn list_directory_nonexistent_returns_error() {
1076 let dir = temp_dir();
1077 let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1078 let missing = dir.path().join("nonexistent");
1079 let params = make_params(&[("path", serde_json::json!(missing.to_str().unwrap()))]);
1080 let result = exec.execute_file_tool("list_directory", ¶ms);
1081 assert!(result.is_err());
1082 }
1083
1084 #[test]
1085 fn list_directory_on_file_returns_error() {
1086 let dir = temp_dir();
1087 let file = dir.path().join("file.txt");
1088 fs::write(&file, "content").unwrap();
1089
1090 let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1091 let params = make_params(&[("path", serde_json::json!(file.to_str().unwrap()))]);
1092 let result = exec.execute_file_tool("list_directory", ¶ms);
1093 assert!(result.is_err());
1094 }
1095
1096 #[test]
1099 fn create_directory_creates_nested() {
1100 let dir = temp_dir();
1101 let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1102 let nested = dir.path().join("a/b/c");
1103 let params = make_params(&[("path", serde_json::json!(nested.to_str().unwrap()))]);
1104 let result = exec
1105 .execute_file_tool("create_directory", ¶ms)
1106 .unwrap()
1107 .unwrap();
1108 assert!(result.summary.contains("Created"));
1109 assert!(nested.is_dir());
1110 }
1111
1112 #[test]
1113 fn create_directory_sandbox_violation() {
1114 let dir = temp_dir();
1115 let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1116 let params = make_params(&[("path", serde_json::json!("/tmp/evil_dir"))]);
1117 let result = exec.execute_file_tool("create_directory", ¶ms);
1118 assert!(matches!(result, Err(ToolError::SandboxViolation { .. })));
1119 }
1120
1121 #[test]
1124 fn delete_path_file() {
1125 let dir = temp_dir();
1126 let file = dir.path().join("del.txt");
1127 fs::write(&file, "bye").unwrap();
1128
1129 let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1130 let params = make_params(&[("path", serde_json::json!(file.to_str().unwrap()))]);
1131 exec.execute_file_tool("delete_path", ¶ms)
1132 .unwrap()
1133 .unwrap();
1134 assert!(!file.exists());
1135 }
1136
1137 #[test]
1138 fn delete_path_empty_directory() {
1139 let dir = temp_dir();
1140 let subdir = dir.path().join("empty_sub");
1141 fs::create_dir(&subdir).unwrap();
1142
1143 let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1144 let params = make_params(&[("path", serde_json::json!(subdir.to_str().unwrap()))]);
1145 exec.execute_file_tool("delete_path", ¶ms)
1146 .unwrap()
1147 .unwrap();
1148 assert!(!subdir.exists());
1149 }
1150
1151 #[test]
1152 fn delete_path_non_empty_dir_without_recursive_fails() {
1153 let dir = temp_dir();
1154 let subdir = dir.path().join("nonempty");
1155 fs::create_dir(&subdir).unwrap();
1156 fs::write(subdir.join("file.txt"), "x").unwrap();
1157
1158 let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1159 let params = make_params(&[("path", serde_json::json!(subdir.to_str().unwrap()))]);
1160 let result = exec.execute_file_tool("delete_path", ¶ms);
1161 assert!(result.is_err());
1162 }
1163
1164 #[test]
1165 fn delete_path_recursive() {
1166 let dir = temp_dir();
1167 let subdir = dir.path().join("recurse");
1168 fs::create_dir(&subdir).unwrap();
1169 fs::write(subdir.join("f.txt"), "x").unwrap();
1170
1171 let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1172 let params = make_params(&[
1173 ("path", serde_json::json!(subdir.to_str().unwrap())),
1174 ("recursive", serde_json::json!(true)),
1175 ]);
1176 exec.execute_file_tool("delete_path", ¶ms)
1177 .unwrap()
1178 .unwrap();
1179 assert!(!subdir.exists());
1180 }
1181
1182 #[test]
1183 fn delete_path_sandbox_violation() {
1184 let dir = temp_dir();
1185 let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1186 let params = make_params(&[("path", serde_json::json!("/etc/hosts"))]);
1187 let result = exec.execute_file_tool("delete_path", ¶ms);
1188 assert!(matches!(result, Err(ToolError::SandboxViolation { .. })));
1189 }
1190
1191 #[test]
1192 fn delete_path_refuses_sandbox_root() {
1193 let dir = temp_dir();
1194 let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1195 let params = make_params(&[
1196 ("path", serde_json::json!(dir.path().to_str().unwrap())),
1197 ("recursive", serde_json::json!(true)),
1198 ]);
1199 let result = exec.execute_file_tool("delete_path", ¶ms);
1200 assert!(matches!(result, Err(ToolError::SandboxViolation { .. })));
1201 }
1202
1203 #[test]
1206 fn move_path_renames_file() {
1207 let dir = temp_dir();
1208 let src = dir.path().join("src.txt");
1209 let dst = dir.path().join("dst.txt");
1210 fs::write(&src, "data").unwrap();
1211
1212 let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1213 let params = make_params(&[
1214 ("source", serde_json::json!(src.to_str().unwrap())),
1215 ("destination", serde_json::json!(dst.to_str().unwrap())),
1216 ]);
1217 exec.execute_file_tool("move_path", ¶ms)
1218 .unwrap()
1219 .unwrap();
1220 assert!(!src.exists());
1221 assert_eq!(fs::read_to_string(&dst).unwrap(), "data");
1222 }
1223
1224 #[test]
1225 fn move_path_cross_sandbox_denied() {
1226 let sandbox = temp_dir();
1227 let outside = temp_dir();
1228 let src = sandbox.path().join("src.txt");
1229 fs::write(&src, "x").unwrap();
1230
1231 let exec = FileExecutor::new(vec![sandbox.path().to_path_buf()]);
1232 let dst = outside.path().join("dst.txt");
1233 let params = make_params(&[
1234 ("source", serde_json::json!(src.to_str().unwrap())),
1235 ("destination", serde_json::json!(dst.to_str().unwrap())),
1236 ]);
1237 let result = exec.execute_file_tool("move_path", ¶ms);
1238 assert!(matches!(result, Err(ToolError::SandboxViolation { .. })));
1239 }
1240
1241 #[test]
1244 fn copy_path_file() {
1245 let dir = temp_dir();
1246 let src = dir.path().join("src.txt");
1247 let dst = dir.path().join("dst.txt");
1248 fs::write(&src, "hello").unwrap();
1249
1250 let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1251 let params = make_params(&[
1252 ("source", serde_json::json!(src.to_str().unwrap())),
1253 ("destination", serde_json::json!(dst.to_str().unwrap())),
1254 ]);
1255 exec.execute_file_tool("copy_path", ¶ms)
1256 .unwrap()
1257 .unwrap();
1258 assert_eq!(fs::read_to_string(&src).unwrap(), "hello");
1259 assert_eq!(fs::read_to_string(&dst).unwrap(), "hello");
1260 }
1261
1262 #[test]
1263 fn copy_path_directory_recursive() {
1264 let dir = temp_dir();
1265 let src_dir = dir.path().join("src_dir");
1266 fs::create_dir(&src_dir).unwrap();
1267 fs::write(src_dir.join("a.txt"), "aaa").unwrap();
1268
1269 let dst_dir = dir.path().join("dst_dir");
1270
1271 let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1272 let params = make_params(&[
1273 ("source", serde_json::json!(src_dir.to_str().unwrap())),
1274 ("destination", serde_json::json!(dst_dir.to_str().unwrap())),
1275 ]);
1276 exec.execute_file_tool("copy_path", ¶ms)
1277 .unwrap()
1278 .unwrap();
1279 assert_eq!(fs::read_to_string(dst_dir.join("a.txt")).unwrap(), "aaa");
1280 }
1281
1282 #[test]
1283 fn copy_path_sandbox_violation() {
1284 let sandbox = temp_dir();
1285 let outside = temp_dir();
1286 let src = sandbox.path().join("src.txt");
1287 fs::write(&src, "x").unwrap();
1288
1289 let exec = FileExecutor::new(vec![sandbox.path().to_path_buf()]);
1290 let dst = outside.path().join("dst.txt");
1291 let params = make_params(&[
1292 ("source", serde_json::json!(src.to_str().unwrap())),
1293 ("destination", serde_json::json!(dst.to_str().unwrap())),
1294 ]);
1295 let result = exec.execute_file_tool("copy_path", ¶ms);
1296 assert!(matches!(result, Err(ToolError::SandboxViolation { .. })));
1297 }
1298
1299 #[test]
1301 fn find_path_invalid_pattern_returns_error() {
1302 let dir = temp_dir();
1303 let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1304 let params = make_params(&[("pattern", serde_json::json!("[invalid"))]);
1305 let result = exec.execute_file_tool("find_path", ¶ms);
1306 assert!(result.is_err());
1307 }
1308
1309 #[test]
1311 fn create_directory_idempotent() {
1312 let dir = temp_dir();
1313 let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1314 let target = dir.path().join("exists");
1315 fs::create_dir(&target).unwrap();
1316
1317 let params = make_params(&[("path", serde_json::json!(target.to_str().unwrap()))]);
1318 let result = exec.execute_file_tool("create_directory", ¶ms);
1319 assert!(result.is_ok());
1320 assert!(target.is_dir());
1321 }
1322
1323 #[test]
1325 fn move_path_source_sandbox_violation() {
1326 let sandbox = temp_dir();
1327 let outside = temp_dir();
1328 let src = outside.path().join("src.txt");
1329 fs::write(&src, "x").unwrap();
1330
1331 let exec = FileExecutor::new(vec![sandbox.path().to_path_buf()]);
1332 let dst = sandbox.path().join("dst.txt");
1333 let params = make_params(&[
1334 ("source", serde_json::json!(src.to_str().unwrap())),
1335 ("destination", serde_json::json!(dst.to_str().unwrap())),
1336 ]);
1337 let result = exec.execute_file_tool("move_path", ¶ms);
1338 assert!(matches!(result, Err(ToolError::SandboxViolation { .. })));
1339 }
1340
1341 #[test]
1343 fn copy_path_source_sandbox_violation() {
1344 let sandbox = temp_dir();
1345 let outside = temp_dir();
1346 let src = outside.path().join("src.txt");
1347 fs::write(&src, "x").unwrap();
1348
1349 let exec = FileExecutor::new(vec![sandbox.path().to_path_buf()]);
1350 let dst = sandbox.path().join("dst.txt");
1351 let params = make_params(&[
1352 ("source", serde_json::json!(src.to_str().unwrap())),
1353 ("destination", serde_json::json!(dst.to_str().unwrap())),
1354 ]);
1355 let result = exec.execute_file_tool("copy_path", ¶ms);
1356 assert!(matches!(result, Err(ToolError::SandboxViolation { .. })));
1357 }
1358
1359 #[cfg(unix)]
1361 #[test]
1362 fn copy_dir_skips_symlinks() {
1363 let dir = temp_dir();
1364 let src_dir = dir.path().join("src");
1365 fs::create_dir(&src_dir).unwrap();
1366 fs::write(src_dir.join("real.txt"), "real").unwrap();
1367
1368 let outside = temp_dir();
1370 std::os::unix::fs::symlink(outside.path(), src_dir.join("link")).unwrap();
1371
1372 let dst_dir = dir.path().join("dst");
1373 let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1374 let params = make_params(&[
1375 ("source", serde_json::json!(src_dir.to_str().unwrap())),
1376 ("destination", serde_json::json!(dst_dir.to_str().unwrap())),
1377 ]);
1378 exec.execute_file_tool("copy_path", ¶ms)
1379 .unwrap()
1380 .unwrap();
1381 assert_eq!(
1383 fs::read_to_string(dst_dir.join("real.txt")).unwrap(),
1384 "real"
1385 );
1386 assert!(!dst_dir.join("link").exists());
1388 }
1389
1390 #[cfg(unix)]
1392 #[test]
1393 fn list_directory_shows_symlinks() {
1394 let dir = temp_dir();
1395 let target = dir.path().join("target.txt");
1396 fs::write(&target, "x").unwrap();
1397 std::os::unix::fs::symlink(&target, dir.path().join("link")).unwrap();
1398
1399 let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1400 let params = make_params(&[("path", serde_json::json!(dir.path().to_str().unwrap()))]);
1401 let result = exec
1402 .execute_file_tool("list_directory", ¶ms)
1403 .unwrap()
1404 .unwrap();
1405 assert!(result.summary.contains("[symlink] link"));
1406 assert!(result.summary.contains("[file] target.txt"));
1407 }
1408
1409 #[test]
1410 fn tilde_path_is_expanded() {
1411 let exec = FileExecutor::new(vec![PathBuf::from("~/nonexistent_subdir_for_test")]);
1412 assert!(
1413 !exec.allowed_paths[0].to_string_lossy().starts_with('~'),
1414 "tilde was not expanded: {:?}",
1415 exec.allowed_paths[0]
1416 );
1417 }
1418
1419 #[test]
1420 fn absolute_path_unchanged() {
1421 let exec = FileExecutor::new(vec![PathBuf::from("/tmp")]);
1422 let p = exec.allowed_paths[0].to_string_lossy();
1425 assert!(
1426 p.starts_with('/'),
1427 "expected absolute path, got: {:?}",
1428 exec.allowed_paths[0]
1429 );
1430 assert!(
1431 !p.starts_with('~'),
1432 "tilde must not appear in result: {:?}",
1433 exec.allowed_paths[0]
1434 );
1435 }
1436
1437 #[test]
1438 fn tilde_only_expands_to_home() {
1439 let exec = FileExecutor::new(vec![PathBuf::from("~")]);
1440 assert!(
1441 !exec.allowed_paths[0].to_string_lossy().starts_with('~'),
1442 "bare tilde was not expanded: {:?}",
1443 exec.allowed_paths[0]
1444 );
1445 }
1446
1447 #[test]
1448 fn empty_allowed_paths_uses_cwd() {
1449 let exec = FileExecutor::new(vec![]);
1450 assert!(
1451 !exec.allowed_paths.is_empty(),
1452 "expected cwd fallback, got empty allowed_paths"
1453 );
1454 }
1455
1456 #[test]
1459 fn normalize_path_normal_path() {
1460 assert_eq!(
1461 normalize_path(Path::new("/tmp/sandbox/file.txt")),
1462 PathBuf::from("/tmp/sandbox/file.txt")
1463 );
1464 }
1465
1466 #[test]
1467 fn normalize_path_collapses_dot() {
1468 assert_eq!(
1469 normalize_path(Path::new("/tmp/sandbox/./file.txt")),
1470 PathBuf::from("/tmp/sandbox/file.txt")
1471 );
1472 }
1473
1474 #[test]
1475 fn normalize_path_collapses_dotdot() {
1476 assert_eq!(
1477 normalize_path(Path::new("/tmp/sandbox/nonexistent/../../etc/passwd")),
1478 PathBuf::from("/tmp/etc/passwd")
1479 );
1480 }
1481
1482 #[test]
1483 fn normalize_path_nested_dotdot() {
1484 assert_eq!(
1485 normalize_path(Path::new("/tmp/sandbox/a/b/../../../etc/passwd")),
1486 PathBuf::from("/tmp/etc/passwd")
1487 );
1488 }
1489
1490 #[test]
1491 fn normalize_path_at_sandbox_boundary() {
1492 assert_eq!(
1493 normalize_path(Path::new("/tmp/sandbox")),
1494 PathBuf::from("/tmp/sandbox")
1495 );
1496 }
1497
1498 #[test]
1501 fn validate_path_dotdot_bypass_nonexistent_blocked() {
1502 let dir = temp_dir();
1503 let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1504 let escape = format!("{}/nonexistent/../../etc/passwd", dir.path().display());
1506 let params = make_params(&[("path", serde_json::json!(escape))]);
1507 let result = exec.execute_file_tool("read", ¶ms);
1508 assert!(
1509 matches!(result, Err(ToolError::SandboxViolation { .. })),
1510 "expected SandboxViolation for dotdot bypass, got {result:?}"
1511 );
1512 }
1513
1514 #[test]
1515 fn validate_path_dotdot_nested_bypass_blocked() {
1516 let dir = temp_dir();
1517 let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1518 let escape = format!("{}/a/b/../../../etc/shadow", dir.path().display());
1519 let params = make_params(&[("path", serde_json::json!(escape))]);
1520 let result = exec.execute_file_tool("read", ¶ms);
1521 assert!(matches!(result, Err(ToolError::SandboxViolation { .. })));
1522 }
1523
1524 #[test]
1525 fn validate_path_inside_sandbox_passes() {
1526 let dir = temp_dir();
1527 let file = dir.path().join("allowed.txt");
1528 fs::write(&file, "ok").unwrap();
1529 let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1530 let params = make_params(&[("path", serde_json::json!(file.to_str().unwrap()))]);
1531 let result = exec.execute_file_tool("read", ¶ms);
1532 assert!(result.is_ok());
1533 }
1534
1535 #[test]
1536 fn validate_path_dot_components_inside_sandbox_passes() {
1537 let dir = temp_dir();
1538 let file = dir.path().join("sub/file.txt");
1539 fs::create_dir_all(dir.path().join("sub")).unwrap();
1540 fs::write(&file, "ok").unwrap();
1541 let exec = FileExecutor::new(vec![dir.path().to_path_buf()]);
1542 let dotpath = format!("{}/sub/./file.txt", dir.path().display());
1543 let params = make_params(&[("path", serde_json::json!(dotpath))]);
1544 let result = exec.execute_file_tool("read", ¶ms);
1545 assert!(result.is_ok());
1546 }
1547}