mixtape_tools/filesystem/
mod.rs

1//! Filesystem tools with path traversal protection.
2//!
3//! All tools in this module operate within a configured `base_path` directory,
4//! preventing access to files outside this boundary. This security model protects
5//! against directory traversal attacks where malicious input like `../../../etc/passwd`
6//! attempts to escape the intended directory.
7//!
8//! # Security Model
9//!
10//! Every file operation validates paths using [`validate_path`] before execution:
11//!
12//! - Paths are resolved relative to `base_path` (or used directly if absolute)
13//! - The resolved path is canonicalized to eliminate `..`, `.`, and symlinks
14//! - The canonical path must start with the canonical `base_path`
15//! - For non-existent paths, the nearest existing ancestor is validated instead
16//!
17//! This means symlinks that point outside `base_path` are rejected, and crafted
18//! paths like `subdir/../../../etc/passwd` are caught after canonicalization.
19//!
20//! # Defense in Depth
21//!
22//! Path validation provides **guardrails for AI agents**, not a complete security
23//! boundary. Error messages intentionally include path details to help agents
24//! understand and correct invalid requests.
25//!
26//! For production deployments with untrusted input, use defense in depth:
27//!
28//! - **Docker isolation**: Run tools in containers with only necessary directories mounted
29//! - **OS-level permissions**: Use a dedicated user with minimal filesystem access
30//! - **Network isolation**: Restrict container network access where possible
31//!
32//! These tools are one layer in a security stack, not a standalone sandbox.
33//!
34//! # Available Tools
35//!
36//! | Tool | Description |
37//! |------|-------------|
38//! | [`ReadFileTool`] | Read file contents with optional offset/limit |
39//! | [`ReadMultipleFilesTool`] | Read multiple files concurrently |
40//! | [`WriteFileTool`] | Write or append to files |
41//! | [`CreateDirectoryTool`] | Create directories (including parents) |
42//! | [`ListDirectoryTool`] | List directory contents recursively |
43//! | [`MoveFileTool`] | Move or rename files and directories |
44//! | [`FileInfoTool`] | Get file metadata (size, timestamps, type) |
45//!
46//! # Building Custom Tools
47//!
48//! Use [`validate_path`] when building your own filesystem tools:
49//!
50//! ```
51//! use mixtape_tools::filesystem::validate_path;
52//! use std::path::Path;
53//!
54//! let base = Path::new("/app/data");
55//! let user_input = Path::new("../etc/passwd");
56//!
57//! // This will return an error because the path escapes base
58//! assert!(validate_path(base, user_input).is_err());
59//! ```
60
61mod create_directory;
62mod file_info;
63mod list_directory;
64mod move_file;
65mod read_file;
66mod read_multiple_files;
67mod write_file;
68
69pub use create_directory::CreateDirectoryTool;
70pub use file_info::FileInfoTool;
71pub use list_directory::ListDirectoryTool;
72pub use move_file::MoveFileTool;
73pub use read_file::ReadFileTool;
74pub use read_multiple_files::ReadMultipleFilesTool;
75pub use write_file::WriteFileTool;
76
77use mixtape_core::ToolError;
78use std::path::{Path, PathBuf};
79
80/// Validates that a path is within the base directory, preventing directory traversal attacks.
81///
82/// This function is the security foundation for all filesystem tools. It ensures that
83/// user-provided paths cannot escape the configured base directory, even when using
84/// tricks like `..` components, absolute paths, or symlinks.
85///
86/// # Arguments
87///
88/// * `base_path` - The root directory that all paths must stay within
89/// * `target_path` - The user-provided path to validate (relative or absolute)
90///
91/// # Returns
92///
93/// * `Ok(PathBuf)` - The validated path, canonicalized if the file exists
94/// * `Err(ToolError::PathValidation)` - If the path escapes the base directory
95///
96/// # Security Properties
97///
98/// - **Symlink resolution**: Symlinks are resolved via canonicalization, so a symlink
99///   pointing outside `base_path` will be rejected
100/// - **Parent traversal**: Paths like `foo/../../../etc` are caught after canonicalization
101/// - **Absolute paths**: Absolute paths outside `base_path` are rejected
102/// - **Non-existent paths**: For paths that don't exist yet (e.g., for write operations),
103///   the nearest existing ancestor is validated instead
104///
105/// # Example
106///
107/// ```
108/// use mixtape_tools::filesystem::validate_path;
109/// use std::path::Path;
110///
111/// let base = Path::new("/home/user/documents");
112///
113/// // Relative path within base - OK
114/// let result = validate_path(base, Path::new("report.txt"));
115/// // Returns Ok with resolved path
116///
117/// // Traversal attempt - REJECTED
118/// let result = validate_path(base, Path::new("../../../etc/passwd"));
119/// assert!(result.is_err());
120///
121/// // Absolute path outside base - REJECTED
122/// let result = validate_path(base, Path::new("/etc/passwd"));
123/// assert!(result.is_err());
124/// ```
125pub fn validate_path(base_path: &Path, target_path: &Path) -> Result<PathBuf, ToolError> {
126    let full_path = if target_path.is_absolute() {
127        target_path.to_path_buf()
128    } else {
129        base_path.join(target_path)
130    };
131
132    // Try to canonicalize if the file exists
133    if full_path.exists() {
134        let canonical = full_path.canonicalize().map_err(|e| {
135            ToolError::PathValidation(format!(
136                "Failed to canonicalize '{}': {}",
137                full_path.display(),
138                e
139            ))
140        })?;
141
142        // Canonicalize base path for comparison
143        let canonical_base = base_path.canonicalize().map_err(|e| {
144            ToolError::PathValidation(format!(
145                "Failed to canonicalize base path '{}': {}",
146                base_path.display(),
147                e
148            ))
149        })?;
150
151        if !canonical.starts_with(&canonical_base) {
152            return Err(ToolError::PathValidation(format!(
153                "Path '{}' escapes base directory '{}' (resolved to '{}')",
154                target_path.display(),
155                canonical_base.display(),
156                canonical.display()
157            )));
158        }
159
160        Ok(canonical)
161    } else {
162        // For non-existent paths, verify the parent is within base
163        let mut check_path = full_path.clone();
164
165        // Find the first existing ancestor
166        while !check_path.exists() {
167            match check_path.parent() {
168                Some(parent) => check_path = parent.to_path_buf(),
169                None => {
170                    return Err(ToolError::PathValidation(format!(
171                        "Invalid path '{}': no valid parent directory exists",
172                        target_path.display()
173                    )))
174                }
175            }
176        }
177
178        // Canonicalize the existing ancestor and verify it's within base
179        let canonical_ancestor = check_path.canonicalize().map_err(|e| {
180            ToolError::PathValidation(format!(
181                "Failed to canonicalize ancestor '{}': {}",
182                check_path.display(),
183                e
184            ))
185        })?;
186
187        let canonical_base = base_path.canonicalize().map_err(|e| {
188            ToolError::PathValidation(format!(
189                "Failed to canonicalize base path '{}': {}",
190                base_path.display(),
191                e
192            ))
193        })?;
194
195        if !canonical_ancestor.starts_with(&canonical_base) {
196            return Err(ToolError::PathValidation(format!(
197                "Path '{}' escapes base directory '{}' (nearest ancestor '{}' is outside)",
198                target_path.display(),
199                canonical_base.display(),
200                canonical_ancestor.display()
201            )));
202        }
203
204        Ok(full_path)
205    }
206}
207
208#[cfg(test)]
209mod tests {
210    use super::*;
211    use std::fs;
212    use tempfile::TempDir;
213
214    #[test]
215    fn test_validate_path_accepts_relative_path_to_existing_file() {
216        let temp_dir = TempDir::new().unwrap();
217        fs::write(temp_dir.path().join("test.txt"), "content").unwrap();
218
219        let result = validate_path(temp_dir.path(), Path::new("test.txt"));
220        assert!(result.is_ok());
221        let path = result.unwrap();
222        assert!(path.ends_with("test.txt"));
223    }
224
225    #[test]
226    fn test_validate_path_accepts_relative_path_to_nonexistent_file() {
227        let temp_dir = TempDir::new().unwrap();
228
229        let result = validate_path(temp_dir.path(), Path::new("new_file.txt"));
230        assert!(result.is_ok());
231        let path = result.unwrap();
232        assert!(path.ends_with("new_file.txt"));
233    }
234
235    #[test]
236    fn test_validate_path_accepts_nested_nonexistent_path() {
237        let temp_dir = TempDir::new().unwrap();
238        fs::create_dir(temp_dir.path().join("subdir")).unwrap();
239
240        let result = validate_path(temp_dir.path(), Path::new("subdir/new_file.txt"));
241        assert!(result.is_ok());
242    }
243
244    #[test]
245    fn test_validate_path_rejects_traversal_existing_file() {
246        let temp_dir = TempDir::new().unwrap();
247        let sibling_dir = TempDir::new().unwrap();
248        fs::write(sibling_dir.path().join("secret.txt"), "secret").unwrap();
249
250        // Try to escape via ..
251        let evil_path = format!(
252            "../{}/secret.txt",
253            sibling_dir.path().file_name().unwrap().to_str().unwrap()
254        );
255        let result = validate_path(temp_dir.path(), Path::new(&evil_path));
256
257        assert!(result.is_err());
258        let err = result.unwrap_err();
259        assert!(
260            err.to_string().contains("escapes") || err.to_string().contains("Invalid"),
261            "Error should mention path escape: {}",
262            err
263        );
264    }
265
266    #[test]
267    fn test_validate_path_rejects_absolute_path_outside_base() {
268        let temp_dir = TempDir::new().unwrap();
269        let other_dir = TempDir::new().unwrap();
270        fs::write(other_dir.path().join("file.txt"), "content").unwrap();
271
272        let result = validate_path(temp_dir.path(), other_dir.path().join("file.txt").as_path());
273
274        assert!(result.is_err());
275        assert!(result.unwrap_err().to_string().contains("escapes"));
276    }
277
278    #[test]
279    fn test_validate_path_accepts_absolute_path_inside_base() {
280        let temp_dir = TempDir::new().unwrap();
281        fs::write(temp_dir.path().join("file.txt"), "content").unwrap();
282
283        let absolute_path = temp_dir.path().join("file.txt");
284        let result = validate_path(temp_dir.path(), &absolute_path);
285
286        assert!(result.is_ok());
287    }
288
289    #[test]
290    fn test_validate_path_rejects_nonexistent_with_traversal() {
291        let temp_dir = TempDir::new().unwrap();
292
293        // Path doesn't exist but tries to escape
294        let result = validate_path(temp_dir.path(), Path::new("../../../etc/shadow"));
295
296        assert!(result.is_err());
297    }
298
299    #[test]
300    fn test_validate_path_handles_symlink_inside_base() {
301        let temp_dir = TempDir::new().unwrap();
302        let real_file = temp_dir.path().join("real.txt");
303        let symlink = temp_dir.path().join("link.txt");
304
305        fs::write(&real_file, "content").unwrap();
306
307        #[cfg(unix)]
308        {
309            std::os::unix::fs::symlink(&real_file, &symlink).unwrap();
310
311            let result = validate_path(temp_dir.path(), Path::new("link.txt"));
312            assert!(result.is_ok(), "Symlink within base should be allowed");
313        }
314    }
315
316    #[test]
317    fn test_validate_path_rejects_symlink_escaping_base() {
318        let temp_dir = TempDir::new().unwrap();
319        let outside_dir = TempDir::new().unwrap();
320        let outside_file = outside_dir.path().join("secret.txt");
321        fs::write(&outside_file, "secret").unwrap();
322
323        let symlink = temp_dir.path().join("escape_link.txt");
324
325        #[cfg(unix)]
326        {
327            std::os::unix::fs::symlink(&outside_file, &symlink).unwrap();
328
329            let result = validate_path(temp_dir.path(), Path::new("escape_link.txt"));
330            // After canonicalization, the symlink resolves outside base
331            assert!(result.is_err(), "Symlink escaping base should be rejected");
332        }
333    }
334
335    #[test]
336    fn test_validate_path_deep_nesting() {
337        let temp_dir = TempDir::new().unwrap();
338        fs::create_dir_all(temp_dir.path().join("a/b/c/d/e")).unwrap();
339        fs::write(temp_dir.path().join("a/b/c/d/e/deep.txt"), "deep").unwrap();
340
341        let result = validate_path(temp_dir.path(), Path::new("a/b/c/d/e/deep.txt"));
342        assert!(result.is_ok());
343    }
344
345    #[test]
346    fn test_validate_path_dot_components() {
347        let temp_dir = TempDir::new().unwrap();
348        fs::create_dir(temp_dir.path().join("subdir")).unwrap();
349        fs::write(temp_dir.path().join("subdir/file.txt"), "content").unwrap();
350
351        // Path with . component
352        let result = validate_path(temp_dir.path(), Path::new("./subdir/./file.txt"));
353        assert!(result.is_ok());
354    }
355
356    #[test]
357    fn test_validate_path_nonexistent_with_ancestor_escaping_base() {
358        // This tests the branch at lines 71-75: when a non-existent path's
359        // existing ancestor is outside the base directory
360        let base_dir = TempDir::new().unwrap();
361        let outside_dir = TempDir::new().unwrap();
362
363        // Create a subdirectory outside base that will be our existing ancestor
364        fs::create_dir(outside_dir.path().join("existing_subdir")).unwrap();
365
366        // Try to access a non-existent file inside outside_dir using an absolute path
367        // The file doesn't exist, but its ancestor (outside_dir/existing_subdir) does
368        // and is outside base_dir
369        let nonexistent_file = outside_dir.path().join("existing_subdir/new_file.txt");
370
371        let result = validate_path(base_dir.path(), &nonexistent_file);
372
373        assert!(
374            result.is_err(),
375            "Non-existent path with ancestor outside base should be rejected"
376        );
377        assert!(
378            result.unwrap_err().to_string().contains("escapes"),
379            "Error should mention path escape"
380        );
381    }
382
383    #[test]
384    fn test_validate_path_deeply_nested_nonexistent() {
385        // Test deeply nested non-existent path where we walk up multiple levels
386        let temp_dir = TempDir::new().unwrap();
387
388        // Only the base exists, but we're trying to access deeply nested non-existent path
389        let result = validate_path(temp_dir.path(), Path::new("a/b/c/d/e/f/g/new_file.txt"));
390
391        // Should succeed because ancestor (temp_dir) is within base
392        assert!(result.is_ok());
393        let path = result.unwrap();
394        assert!(path.ends_with("a/b/c/d/e/f/g/new_file.txt"));
395    }
396
397    #[test]
398    fn test_validate_path_nonexistent_relative_traversal_to_outside() {
399        // Test traversal that ends up with existing ancestor outside base
400        let base_dir = TempDir::new().unwrap();
401        let sibling_dir = TempDir::new().unwrap();
402
403        // Create a subdir in sibling so it's the ancestor found
404        fs::create_dir(sibling_dir.path().join("subdir")).unwrap();
405
406        // Try: ../sibling_temp_name/subdir/nonexistent.txt
407        // The existing ancestor will be sibling_dir/subdir which is outside base
408        let evil_path = format!(
409            "../{}/subdir/nonexistent.txt",
410            sibling_dir.path().file_name().unwrap().to_str().unwrap()
411        );
412
413        let result = validate_path(base_dir.path(), Path::new(&evil_path));
414
415        assert!(
416            result.is_err(),
417            "Traversal to outside ancestor should be rejected"
418        );
419    }
420
421    #[test]
422    fn test_validate_path_error_includes_path_details() {
423        // Verify error messages include actionable details for debugging
424        let temp_dir = TempDir::new().unwrap();
425        let other_dir = TempDir::new().unwrap();
426        fs::write(other_dir.path().join("file.txt"), "content").unwrap();
427
428        let result = validate_path(temp_dir.path(), other_dir.path().join("file.txt").as_path());
429
430        let err = result.unwrap_err();
431        let err_msg = err.to_string();
432
433        // Error should mention the attempted path
434        assert!(
435            err_msg.contains("file.txt"),
436            "Error should include the target path: {}",
437            err_msg
438        );
439
440        // Error should mention escaping
441        assert!(
442            err_msg.contains("escapes"),
443            "Error should mention 'escapes': {}",
444            err_msg
445        );
446
447        // Error should include "resolved to" showing the canonical path
448        assert!(
449            err_msg.contains("resolved to"),
450            "Error should show resolved path: {}",
451            err_msg
452        );
453    }
454}