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    ClaimSource, 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            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(&params.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, &params.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(&params.path))?;
260        let content = std::fs::read_to_string(&path)?;
261
262        if !content.contains(&params.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(&params.old_string, &params.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(&params.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(&params.pattern)
331        } else {
332            regex::RegexBuilder::new(&params.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, &regex, &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(&params.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            // Use symlink_metadata (lstat) to detect symlinks without following them.
385            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(&params.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(&params.path))?;
447
448        // Refuse to delete the sandbox root itself
449        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                // Accepted risk: remove_dir_all has no depth/size guard within the sandbox.
458                // Resource exhaustion is bounded by the filesystem and OS limits.
459                std::fs::remove_dir_all(&path)?;
460            } else {
461                // remove_dir only succeeds on empty dirs
462                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(&params.source))?;
484        let dst = self.validate_path(Path::new(&params.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(&params.source))?;
503        let dst = self.validate_path(Path::new(&params.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
604/// Lexically normalize a path by collapsing `.` and `..` components without
605/// any filesystem access. This prevents `..` components from bypassing the
606/// sandbox check inside `validate_path`.
607fn normalize_path(path: &Path) -> PathBuf {
608    use std::path::Component;
609    // On Windows, paths may have a drive prefix (e.g. `D:` or `\\?\D:`).
610    // We track it separately so that `RootDir` (the `\` after the drive letter)
611    // does not accidentally clear the prefix from the stack.
612    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                // Never pop the sentinel "/" root entry.
619                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                    // Unix absolute path: treat "/" as the root sentinel.
627                    stack.clear();
628                    stack.push(std::ffi::OsString::from("/"));
629                }
630                // On Windows, RootDir follows the drive Prefix and is just the
631                // path separator — the prefix is already recorded, so skip it.
632            }
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        // Windows: reconstruct "DRIVE:\" (absolute) then append normal components.
641        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
661/// Canonicalize a path by walking up to the nearest existing ancestor.
662///
663/// Walks up `path` until an existing ancestor is found, calls `canonicalize()` on it
664/// (which follows symlinks), then re-appends the non-existing suffix. The sandbox check
665/// in `validate_path` uses `starts_with` on the resulting canonical path, so symlinks
666/// that resolve outside `allowed_paths` are correctly rejected.
667fn 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: &regex::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        // Use symlink_metadata (lstat) so we classify symlinks without following them.
733        // Symlinks are skipped to prevent escaping the sandbox via a symlink pointing
734        // to a path outside allowed_paths.
735        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        // Symlinks are intentionally skipped.
744    }
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", &params).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", &params).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", &params).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", &params).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", &params);
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", &params);
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", &params).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", &params)
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", &params).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", &params);
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", &params)
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", &params);
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", &params);
1025        assert!(matches!(result, Err(ToolError::InvalidParams { .. })));
1026    }
1027
1028    // --- list_directory tests ---
1029
1030    #[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", &params)
1040            .unwrap()
1041            .unwrap();
1042        assert!(result.summary.contains("[dir]  subdir"));
1043        assert!(result.summary.contains("[file] file.txt"));
1044        // dirs listed before files
1045        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", &params)
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", &params);
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", &params);
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", &params);
1093        assert!(result.is_err());
1094    }
1095
1096    // --- create_directory tests ---
1097
1098    #[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", &params)
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", &params);
1118        assert!(matches!(result, Err(ToolError::SandboxViolation { .. })));
1119    }
1120
1121    // --- delete_path tests ---
1122
1123    #[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", &params)
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", &params)
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", &params);
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", &params)
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", &params);
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", &params);
1200        assert!(matches!(result, Err(ToolError::SandboxViolation { .. })));
1201    }
1202
1203    // --- move_path tests ---
1204
1205    #[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", &params)
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", &params);
1238        assert!(matches!(result, Err(ToolError::SandboxViolation { .. })));
1239    }
1240
1241    // --- copy_path tests ---
1242
1243    #[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", &params)
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", &params)
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", &params);
1296        assert!(matches!(result, Err(ToolError::SandboxViolation { .. })));
1297    }
1298
1299    // CR-11: invalid glob pattern returns error
1300    #[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", &params);
1306        assert!(result.is_err());
1307    }
1308
1309    // CR-12: create_directory is idempotent on existing dir
1310    #[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", &params);
1319        assert!(result.is_ok());
1320        assert!(target.is_dir());
1321    }
1322
1323    // CR-13: move_path source sandbox violation
1324    #[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", &params);
1338        assert!(matches!(result, Err(ToolError::SandboxViolation { .. })));
1339    }
1340
1341    // CR-13: copy_path source sandbox violation
1342    #[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", &params);
1356        assert!(matches!(result, Err(ToolError::SandboxViolation { .. })));
1357    }
1358
1359    // CR-01: copy_dir_recursive skips symlinks
1360    #[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        // Create a symlink inside src pointing outside sandbox
1369        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", &params)
1379            .unwrap()
1380            .unwrap();
1381        // Real file copied
1382        assert_eq!(
1383            fs::read_to_string(dst_dir.join("real.txt")).unwrap(),
1384            "real"
1385        );
1386        // Symlink not copied
1387        assert!(!dst_dir.join("link").exists());
1388    }
1389
1390    // CR-04: list_directory detects symlinks
1391    #[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", &params)
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        // On macOS /tmp is a symlink to /private/tmp; canonicalize resolves it.
1423        // The invariant is that the result is absolute and tilde-free.
1424        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    // --- normalize_path tests ---
1457
1458    #[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    // --- validate_path dotdot bypass tests ---
1499
1500    #[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        // /sandbox/nonexistent/../../etc/passwd normalizes to /etc/passwd — must be blocked
1505        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", &params);
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", &params);
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", &params);
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", &params);
1545        assert!(result.is_ok());
1546    }
1547}