Skip to main content

zeph_tools/
file.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4use 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    /// File path
17    path: String,
18    /// Line offset
19    offset: Option<u32>,
20    /// Max lines
21    limit: Option<u32>,
22}
23
24#[derive(Deserialize, JsonSchema)]
25struct WriteParams {
26    /// File path
27    path: String,
28    /// Content to write
29    content: String,
30}
31
32#[derive(Deserialize, JsonSchema)]
33struct EditParams {
34    /// File path
35    path: String,
36    /// Text to find
37    old_string: String,
38    /// Replacement text
39    new_string: String,
40}
41
42#[derive(Deserialize, JsonSchema)]
43struct FindPathParams {
44    /// Glob pattern
45    pattern: String,
46}
47
48#[derive(Deserialize, JsonSchema)]
49struct GrepParams {
50    /// Regex pattern
51    pattern: String,
52    /// Search path
53    path: Option<String>,
54    /// Case sensitive
55    case_sensitive: Option<bool>,
56}
57
58#[derive(Deserialize, JsonSchema)]
59struct ListDirectoryParams {
60    /// Directory path
61    path: String,
62}
63
64#[derive(Deserialize, JsonSchema)]
65struct CreateDirectoryParams {
66    /// Directory path to create (including parents)
67    path: String,
68}
69
70#[derive(Deserialize, JsonSchema)]
71struct DeletePathParams {
72    /// Path to delete
73    path: String,
74    /// Delete non-empty directories recursively
75    #[serde(default)]
76    recursive: bool,
77}
78
79#[derive(Deserialize, JsonSchema)]
80struct MovePathParams {
81    /// Source path
82    source: String,
83    /// Destination path
84    destination: String,
85}
86
87#[derive(Deserialize, JsonSchema)]
88struct CopyPathParams {
89    /// Source path
90    source: String,
91    /// Destination path
92    destination: String,
93}
94
95/// File operations executor sandboxed to allowed paths.
96#[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    /// Execute a tool call by `tool_id` and params.
148    ///
149    /// # Errors
150    ///
151    /// Returns `ToolError` on sandbox violations or I/O failures.
152    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(&params.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(&params.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, &params.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(&params.path))?;
258        let content = std::fs::read_to_string(&path)?;
259
260        if !content.contains(&params.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(&params.old_string, &params.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(&params.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(&params.pattern)
327        } else {
328            regex::RegexBuilder::new(&params.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, &regex, &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(&params.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            // Use symlink_metadata (lstat) to detect symlinks without following them.
380            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(&params.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(&params.path))?;
440
441        // Refuse to delete the sandbox root itself
442        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                // Accepted risk: remove_dir_all has no depth/size guard within the sandbox.
451                // Resource exhaustion is bounded by the filesystem and OS limits.
452                std::fs::remove_dir_all(&path)?;
453            } else {
454                // remove_dir only succeeds on empty dirs
455                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(&params.source))?;
476        let dst = self.validate_path(Path::new(&params.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(&params.source))?;
494        let dst = self.validate_path(Path::new(&params.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
594/// Lexically normalize a path by collapsing `.` and `..` components without
595/// any filesystem access. This prevents `..` components from bypassing the
596/// sandbox check inside `validate_path`.
597fn normalize_path(path: &Path) -> PathBuf {
598    use std::path::Component;
599    // On Windows, paths may have a drive prefix (e.g. `D:` or `\\?\D:`).
600    // We track it separately so that `RootDir` (the `\` after the drive letter)
601    // does not accidentally clear the prefix from the stack.
602    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                // Never pop the sentinel "/" root entry.
609                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                    // Unix absolute path: treat "/" as the root sentinel.
617                    stack.clear();
618                    stack.push(std::ffi::OsString::from("/"));
619                }
620                // On Windows, RootDir follows the drive Prefix and is just the
621                // path separator — the prefix is already recorded, so skip it.
622            }
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        // Windows: reconstruct "DRIVE:\" (absolute) then append normal components.
631        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
651/// Canonicalize a path by walking up to the nearest existing ancestor.
652///
653/// Walks up `path` until an existing ancestor is found, calls `canonicalize()` on it
654/// (which follows symlinks), then re-appends the non-existing suffix. The sandbox check
655/// in `validate_path` uses `starts_with` on the resulting canonical path, so symlinks
656/// that resolve outside `allowed_paths` are correctly rejected.
657fn 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: &regex::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        // Use symlink_metadata (lstat) so we classify symlinks without following them.
723        // Symlinks are skipped to prevent escaping the sandbox via a symlink pointing
724        // to a path outside allowed_paths.
725        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        // Symlinks are intentionally skipped.
734    }
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", &params).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", &params).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", &params).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", &params).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", &params);
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", &params);
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", &params).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", &params)
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", &params).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", &params);
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", &params)
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", &params);
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", &params);
1015        assert!(matches!(result, Err(ToolError::InvalidParams { .. })));
1016    }
1017
1018    // --- list_directory tests ---
1019
1020    #[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", &params)
1030            .unwrap()
1031            .unwrap();
1032        assert!(result.summary.contains("[dir]  subdir"));
1033        assert!(result.summary.contains("[file] file.txt"));
1034        // dirs listed before files
1035        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", &params)
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", &params);
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", &params);
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", &params);
1083        assert!(result.is_err());
1084    }
1085
1086    // --- create_directory tests ---
1087
1088    #[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", &params)
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", &params);
1108        assert!(matches!(result, Err(ToolError::SandboxViolation { .. })));
1109    }
1110
1111    // --- delete_path tests ---
1112
1113    #[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", &params)
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", &params)
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", &params);
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", &params)
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", &params);
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", &params);
1190        assert!(matches!(result, Err(ToolError::SandboxViolation { .. })));
1191    }
1192
1193    // --- move_path tests ---
1194
1195    #[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", &params)
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", &params);
1228        assert!(matches!(result, Err(ToolError::SandboxViolation { .. })));
1229    }
1230
1231    // --- copy_path tests ---
1232
1233    #[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", &params)
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", &params)
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", &params);
1286        assert!(matches!(result, Err(ToolError::SandboxViolation { .. })));
1287    }
1288
1289    // CR-11: invalid glob pattern returns error
1290    #[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", &params);
1296        assert!(result.is_err());
1297    }
1298
1299    // CR-12: create_directory is idempotent on existing dir
1300    #[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", &params);
1309        assert!(result.is_ok());
1310        assert!(target.is_dir());
1311    }
1312
1313    // CR-13: move_path source sandbox violation
1314    #[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", &params);
1328        assert!(matches!(result, Err(ToolError::SandboxViolation { .. })));
1329    }
1330
1331    // CR-13: copy_path source sandbox violation
1332    #[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", &params);
1346        assert!(matches!(result, Err(ToolError::SandboxViolation { .. })));
1347    }
1348
1349    // CR-01: copy_dir_recursive skips symlinks
1350    #[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        // Create a symlink inside src pointing outside sandbox
1359        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", &params)
1369            .unwrap()
1370            .unwrap();
1371        // Real file copied
1372        assert_eq!(
1373            fs::read_to_string(dst_dir.join("real.txt")).unwrap(),
1374            "real"
1375        );
1376        // Symlink not copied
1377        assert!(!dst_dir.join("link").exists());
1378    }
1379
1380    // CR-04: list_directory detects symlinks
1381    #[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", &params)
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        // On macOS /tmp is a symlink to /private/tmp; canonicalize resolves it.
1413        // The invariant is that the result is absolute and tilde-free.
1414        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    // --- normalize_path tests ---
1447
1448    #[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    // --- validate_path dotdot bypass tests ---
1489
1490    #[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        // /sandbox/nonexistent/../../etc/passwd normalizes to /etc/passwd — must be blocked
1495        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", &params);
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", &params);
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", &params);
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", &params);
1536        assert!(result.is_ok());
1537    }
1538}