mod create_directory;
mod file_info;
mod list_directory;
mod move_file;
mod read_file;
mod read_multiple_files;
mod write_file;
pub use create_directory::CreateDirectoryTool;
pub use file_info::FileInfoTool;
pub use list_directory::ListDirectoryTool;
pub use move_file::MoveFileTool;
pub use read_file::ReadFileTool;
pub use read_multiple_files::ReadMultipleFilesTool;
pub use write_file::WriteFileTool;
use mixtape_core::tool::{box_tool, DynTool};
use mixtape_core::ToolError;
use std::path::{Path, PathBuf};
pub fn validate_path(base_path: &Path, target_path: &Path) -> Result<PathBuf, ToolError> {
let full_path = if target_path.is_absolute() {
target_path.to_path_buf()
} else {
base_path.join(target_path)
};
if full_path.exists() {
let canonical = full_path.canonicalize().map_err(|e| {
ToolError::PathValidation(format!(
"Failed to canonicalize '{}': {}",
full_path.display(),
e
))
})?;
let canonical_base = base_path.canonicalize().map_err(|e| {
ToolError::PathValidation(format!(
"Failed to canonicalize base path '{}': {}",
base_path.display(),
e
))
})?;
if !canonical.starts_with(&canonical_base) {
return Err(ToolError::PathValidation(format!(
"Path '{}' escapes base directory '{}' (resolved to '{}')",
target_path.display(),
canonical_base.display(),
canonical.display()
)));
}
Ok(canonical)
} else {
let mut check_path = full_path.clone();
while !check_path.exists() {
match check_path.parent() {
Some(parent) => check_path = parent.to_path_buf(),
None => {
return Err(ToolError::PathValidation(format!(
"Invalid path '{}': no valid parent directory exists",
target_path.display()
)))
}
}
}
let canonical_ancestor = check_path.canonicalize().map_err(|e| {
ToolError::PathValidation(format!(
"Failed to canonicalize ancestor '{}': {}",
check_path.display(),
e
))
})?;
let canonical_base = base_path.canonicalize().map_err(|e| {
ToolError::PathValidation(format!(
"Failed to canonicalize base path '{}': {}",
base_path.display(),
e
))
})?;
if !canonical_ancestor.starts_with(&canonical_base) {
return Err(ToolError::PathValidation(format!(
"Path '{}' escapes base directory '{}' (nearest ancestor '{}' is outside)",
target_path.display(),
canonical_base.display(),
canonical_ancestor.display()
)));
}
Ok(full_path)
}
}
pub fn read_only_tools() -> Vec<Box<dyn DynTool>> {
vec![
box_tool(ReadFileTool::default()),
box_tool(ReadMultipleFilesTool::default()),
box_tool(ListDirectoryTool::default()),
box_tool(FileInfoTool::default()),
]
}
pub fn mutative_tools() -> Vec<Box<dyn DynTool>> {
vec![
box_tool(WriteFileTool::default()),
box_tool(CreateDirectoryTool::default()),
box_tool(MoveFileTool::default()),
]
}
pub fn all_tools() -> Vec<Box<dyn DynTool>> {
let mut tools = read_only_tools();
tools.extend(mutative_tools());
tools
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
#[test]
fn test_validate_path_accepts_relative_path_to_existing_file() {
let temp_dir = TempDir::new().unwrap();
fs::write(temp_dir.path().join("test.txt"), "content").unwrap();
let result = validate_path(temp_dir.path(), Path::new("test.txt"));
assert!(result.is_ok());
let path = result.unwrap();
assert!(path.ends_with("test.txt"));
}
#[test]
fn test_validate_path_accepts_relative_path_to_nonexistent_file() {
let temp_dir = TempDir::new().unwrap();
let result = validate_path(temp_dir.path(), Path::new("new_file.txt"));
assert!(result.is_ok());
let path = result.unwrap();
assert!(path.ends_with("new_file.txt"));
}
#[test]
fn test_validate_path_accepts_nested_nonexistent_path() {
let temp_dir = TempDir::new().unwrap();
fs::create_dir(temp_dir.path().join("subdir")).unwrap();
let result = validate_path(temp_dir.path(), Path::new("subdir/new_file.txt"));
assert!(result.is_ok());
}
#[test]
fn test_validate_path_rejects_traversal_existing_file() {
let temp_dir = TempDir::new().unwrap();
let sibling_dir = TempDir::new().unwrap();
fs::write(sibling_dir.path().join("secret.txt"), "secret").unwrap();
let evil_path = format!(
"../{}/secret.txt",
sibling_dir.path().file_name().unwrap().to_str().unwrap()
);
let result = validate_path(temp_dir.path(), Path::new(&evil_path));
assert!(result.is_err());
let err = result.unwrap_err();
assert!(
err.to_string().contains("escapes") || err.to_string().contains("Invalid"),
"Error should mention path escape: {}",
err
);
}
#[test]
fn test_validate_path_rejects_absolute_path_outside_base() {
let temp_dir = TempDir::new().unwrap();
let other_dir = TempDir::new().unwrap();
fs::write(other_dir.path().join("file.txt"), "content").unwrap();
let result = validate_path(temp_dir.path(), other_dir.path().join("file.txt").as_path());
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("escapes"));
}
#[test]
fn test_validate_path_accepts_absolute_path_inside_base() {
let temp_dir = TempDir::new().unwrap();
fs::write(temp_dir.path().join("file.txt"), "content").unwrap();
let absolute_path = temp_dir.path().join("file.txt");
let result = validate_path(temp_dir.path(), &absolute_path);
assert!(result.is_ok());
}
#[test]
fn test_validate_path_rejects_nonexistent_with_traversal() {
let temp_dir = TempDir::new().unwrap();
let result = validate_path(temp_dir.path(), Path::new("../../../etc/shadow"));
assert!(result.is_err());
}
#[test]
fn test_validate_path_handles_symlink_inside_base() {
let temp_dir = TempDir::new().unwrap();
let real_file = temp_dir.path().join("real.txt");
let symlink = temp_dir.path().join("link.txt");
fs::write(&real_file, "content").unwrap();
#[cfg(unix)]
{
std::os::unix::fs::symlink(&real_file, &symlink).unwrap();
let result = validate_path(temp_dir.path(), Path::new("link.txt"));
assert!(result.is_ok(), "Symlink within base should be allowed");
}
}
#[test]
fn test_validate_path_rejects_symlink_escaping_base() {
let temp_dir = TempDir::new().unwrap();
let outside_dir = TempDir::new().unwrap();
let outside_file = outside_dir.path().join("secret.txt");
fs::write(&outside_file, "secret").unwrap();
let symlink = temp_dir.path().join("escape_link.txt");
#[cfg(unix)]
{
std::os::unix::fs::symlink(&outside_file, &symlink).unwrap();
let result = validate_path(temp_dir.path(), Path::new("escape_link.txt"));
assert!(result.is_err(), "Symlink escaping base should be rejected");
}
}
#[test]
fn test_validate_path_deep_nesting() {
let temp_dir = TempDir::new().unwrap();
fs::create_dir_all(temp_dir.path().join("a/b/c/d/e")).unwrap();
fs::write(temp_dir.path().join("a/b/c/d/e/deep.txt"), "deep").unwrap();
let result = validate_path(temp_dir.path(), Path::new("a/b/c/d/e/deep.txt"));
assert!(result.is_ok());
}
#[test]
fn test_validate_path_dot_components() {
let temp_dir = TempDir::new().unwrap();
fs::create_dir(temp_dir.path().join("subdir")).unwrap();
fs::write(temp_dir.path().join("subdir/file.txt"), "content").unwrap();
let result = validate_path(temp_dir.path(), Path::new("./subdir/./file.txt"));
assert!(result.is_ok());
}
#[test]
fn test_validate_path_nonexistent_with_ancestor_escaping_base() {
let base_dir = TempDir::new().unwrap();
let outside_dir = TempDir::new().unwrap();
fs::create_dir(outside_dir.path().join("existing_subdir")).unwrap();
let nonexistent_file = outside_dir.path().join("existing_subdir/new_file.txt");
let result = validate_path(base_dir.path(), &nonexistent_file);
assert!(
result.is_err(),
"Non-existent path with ancestor outside base should be rejected"
);
assert!(
result.unwrap_err().to_string().contains("escapes"),
"Error should mention path escape"
);
}
#[test]
fn test_validate_path_deeply_nested_nonexistent() {
let temp_dir = TempDir::new().unwrap();
let result = validate_path(temp_dir.path(), Path::new("a/b/c/d/e/f/g/new_file.txt"));
assert!(result.is_ok());
let path = result.unwrap();
assert!(path.ends_with("a/b/c/d/e/f/g/new_file.txt"));
}
#[test]
fn test_validate_path_nonexistent_relative_traversal_to_outside() {
let base_dir = TempDir::new().unwrap();
let sibling_dir = TempDir::new().unwrap();
fs::create_dir(sibling_dir.path().join("subdir")).unwrap();
let evil_path = format!(
"../{}/subdir/nonexistent.txt",
sibling_dir.path().file_name().unwrap().to_str().unwrap()
);
let result = validate_path(base_dir.path(), Path::new(&evil_path));
assert!(
result.is_err(),
"Traversal to outside ancestor should be rejected"
);
}
#[test]
fn test_validate_path_error_includes_path_details() {
let temp_dir = TempDir::new().unwrap();
let other_dir = TempDir::new().unwrap();
fs::write(other_dir.path().join("file.txt"), "content").unwrap();
let result = validate_path(temp_dir.path(), other_dir.path().join("file.txt").as_path());
let err = result.unwrap_err();
let err_msg = err.to_string();
assert!(
err_msg.contains("file.txt"),
"Error should include the target path: {}",
err_msg
);
assert!(
err_msg.contains("escapes"),
"Error should mention 'escapes': {}",
err_msg
);
assert!(
err_msg.contains("resolved to"),
"Error should show resolved path: {}",
err_msg
);
}
}