mixtape_tools/filesystem/
mod.rs1mod 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
80pub 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 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 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 let mut check_path = full_path.clone();
164
165 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 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 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 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 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 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 let base_dir = TempDir::new().unwrap();
361 let outside_dir = TempDir::new().unwrap();
362
363 fs::create_dir(outside_dir.path().join("existing_subdir")).unwrap();
365
366 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 let temp_dir = TempDir::new().unwrap();
387
388 let result = validate_path(temp_dir.path(), Path::new("a/b/c/d/e/f/g/new_file.txt"));
390
391 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 let base_dir = TempDir::new().unwrap();
401 let sibling_dir = TempDir::new().unwrap();
402
403 fs::create_dir(sibling_dir.path().join("subdir")).unwrap();
405
406 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 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 assert!(
435 err_msg.contains("file.txt"),
436 "Error should include the target path: {}",
437 err_msg
438 );
439
440 assert!(
442 err_msg.contains("escapes"),
443 "Error should mention 'escapes': {}",
444 err_msg
445 );
446
447 assert!(
449 err_msg.contains("resolved to"),
450 "Error should show resolved path: {}",
451 err_msg
452 );
453 }
454}