use crate::error::VecboostError;
use std::path::{Path, PathBuf};
pub struct PathValidator {
allowed_roots: Vec<PathBuf>,
}
impl PathValidator {
pub fn new() -> Self {
Self {
allowed_roots: Vec::new(),
}
}
pub fn add_allowed_root<P: AsRef<Path>>(mut self, path: P) -> Self {
let path = path
.as_ref()
.canonicalize()
.unwrap_or_else(|_| path.as_ref().to_path_buf());
self.allowed_roots.push(path);
self
}
pub fn add_allowed_roots<P: AsRef<Path>>(mut self, paths: &[P]) -> Self {
for path in paths {
let path = path
.as_ref()
.canonicalize()
.unwrap_or_else(|_| path.as_ref().to_path_buf());
self.allowed_roots.push(path);
}
self
}
pub fn validate_path<P: AsRef<Path>>(&self, path: P) -> Result<PathBuf, VecboostError> {
let path = path.as_ref();
let path_str = path.to_string_lossy();
if path_str.contains("..") || path_str.contains("~") {
return Err(VecboostError::security_error(format!(
"Path traversal attempt detected: {}",
path_str
)));
}
let canonical = path
.canonicalize()
.map_err(|e| VecboostError::security_error(format!("Invalid path: {}", e)))?;
if self.allowed_roots.is_empty() {
return Err(VecboostError::security_error(
"No allowed root directories configured for file access".to_string(),
));
}
let is_allowed = self
.allowed_roots
.iter()
.any(|root| canonical.starts_with(root));
if !is_allowed {
return Err(VecboostError::security_error(format!(
"Access denied: path '{}' is not within allowed directories. Allowed roots: {:?}",
canonical.display(),
self.allowed_roots
.iter()
.map(|p| p.display().to_string())
.collect::<Vec<_>>()
)));
}
Ok(canonical)
}
pub fn validate_file<P: AsRef<Path>>(&self, path: P) -> Result<PathBuf, VecboostError> {
let canonical = self.validate_path(path)?;
if !canonical.is_file() {
return Err(VecboostError::security_error(format!(
"Path is not a file: {}",
canonical.display()
)));
}
Ok(canonical)
}
pub fn validate_directory<P: AsRef<Path>>(&self, path: P) -> Result<PathBuf, VecboostError> {
let canonical = self.validate_path(path)?;
if !canonical.is_dir() {
return Err(VecboostError::security_error(format!(
"Path is not a directory: {}",
canonical.display()
)));
}
Ok(canonical)
}
}
impl Default for PathValidator {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_path_traversal_detection() {
let validator = PathValidator::new().add_allowed_root("/tmp");
let result = validator.validate_path("/etc/passwd");
assert!(result.is_err());
let result = validator.validate_path("/tmp/../../etc/passwd");
assert!(result.is_err());
}
#[test]
fn test_allowed_path() {
let base_dir = std::path::PathBuf::from("./test_allowed_path_temp");
std::fs::create_dir_all(&base_dir).unwrap();
let test_file = base_dir.join("test.txt");
std::fs::write(&test_file, "test").unwrap();
let validator = PathValidator::new().add_allowed_root(&base_dir);
let result = validator.validate_file(&test_file);
std::fs::remove_dir_all(&base_dir).ok();
assert!(result.is_ok());
}
#[test]
fn test_tilde_expansion_blocked() {
let validator = PathValidator::new().add_allowed_root("/tmp");
let result = validator.validate_path("~/secret");
assert!(result.is_err());
}
#[test]
fn test_path_validator_default_has_no_roots() {
let validator = PathValidator::default();
assert!(validator.allowed_roots.is_empty());
}
#[test]
fn test_validate_path_rejects_when_no_roots_configured() {
let dir = std::env::temp_dir();
let temp = dir.join(format!("vecboost_path_test_noroot_{}", std::process::id()));
std::fs::create_dir_all(&temp).unwrap();
let temp = std::fs::canonicalize(&temp).unwrap();
let validator = PathValidator::new(); let result = validator.validate_path(&temp);
std::fs::remove_dir_all(&temp).ok();
assert!(result.is_err());
match result.unwrap_err() {
VecboostError::SecurityError(msg) => {
assert!(msg.contains("No allowed root directories"))
}
_ => panic!("Expected SecurityError"),
}
}
#[cfg(unix)]
#[test]
fn test_validate_path_symlink_outside_allowed_roots_rejected() {
use std::os::unix::fs::symlink;
let base = std::path::PathBuf::from("./test_path_symlink_outside_temp");
std::fs::create_dir_all(&base).unwrap();
let link_path = base.join("link_to_etc");
let _ = symlink("/etc", &link_path);
let attack_path = link_path.join("hostname");
let validator = PathValidator::new().add_allowed_root(&base);
let result = validator.validate_path(&attack_path);
std::fs::remove_dir_all(&base).ok();
assert!(result.is_err(), "symlink attack should be rejected");
match result.unwrap_err() {
VecboostError::SecurityError(msg) => {
assert!(
msg.contains("Access denied"),
"expected Access denied, got: {}",
msg
);
}
_ => panic!("Expected SecurityError for symlink attack"),
}
}
#[cfg(unix)]
#[test]
fn test_validate_path_symlink_inside_allowed_roots_accepted() {
use std::os::unix::fs::symlink;
let base = std::path::PathBuf::from("./test_path_symlink_inside_temp");
let target_dir = base.join("target_dir");
std::fs::create_dir_all(&target_dir).unwrap();
let target_file = target_dir.join("data.txt");
std::fs::write(&target_file, "content").unwrap();
let abs_target = std::fs::canonicalize(&target_file).unwrap();
let link_path = base.join("link_to_target");
let _ = symlink(&abs_target, &link_path);
let validator = PathValidator::new().add_allowed_root(&base);
let result = validator.validate_file(&link_path);
std::fs::remove_dir_all(&base).ok();
assert!(
result.is_ok(),
"legitimate symlink should be accepted, got: {:?}",
result.err()
);
}
#[test]
fn test_validate_path_nonexistent_path_rejected() {
let validator = PathValidator::new().add_allowed_root("/etc");
let result = validator.validate_path("/etc/nonexistent_file_xyz_123");
assert!(result.is_err());
match result.unwrap_err() {
VecboostError::SecurityError(msg) => assert!(msg.contains("Invalid path")),
_ => panic!("Expected SecurityError for nonexistent path"),
}
}
#[test]
fn test_validate_path_outside_allowed_roots_rejected() {
let validator = PathValidator::new().add_allowed_root("/etc");
let result = validator.validate_path("/etc/hostname");
if result.is_ok() {
} else {
}
}
#[test]
fn test_validate_path_in_allowed_root_accepted() {
let base = std::path::PathBuf::from("./test_path_allowed_root_temp");
std::fs::create_dir_all(&base).unwrap();
let file_path = base.join("test.txt");
std::fs::write(&file_path, "content").unwrap();
let validator = PathValidator::new().add_allowed_root(&base);
let result = validator.validate_path(&file_path);
std::fs::remove_dir_all(&base).ok();
assert!(result.is_ok());
}
#[test]
fn test_validate_path_double_dot_rejected() {
let validator = PathValidator::new().add_allowed_root("/etc");
let result = validator.validate_path("/etc/../etc/passwd");
assert!(result.is_err());
match result.unwrap_err() {
VecboostError::SecurityError(msg) => {
assert!(msg.contains("Path traversal attempt"))
}
_ => panic!("Expected SecurityError for path traversal"),
}
}
#[test]
fn test_validate_path_tilde_rejected() {
let validator = PathValidator::new().add_allowed_root("/home");
let result = validator.validate_path("~/.ssh/id_rsa");
assert!(result.is_err());
match result.unwrap_err() {
VecboostError::SecurityError(msg) => {
assert!(msg.contains("Path traversal attempt"))
}
_ => panic!("Expected SecurityError for tilde path"),
}
}
#[test]
fn test_validate_file_returns_canonical_path() {
let base = std::path::PathBuf::from("./test_path_validate_file_temp");
std::fs::create_dir_all(&base).unwrap();
let file_path = base.join("document.txt");
std::fs::write(&file_path, "test content").unwrap();
let validator = PathValidator::new().add_allowed_root(&base);
let result = validator.validate_file(&file_path);
std::fs::remove_dir_all(&base).ok();
assert!(result.is_ok());
let canonical = result.unwrap();
assert!(canonical.is_absolute());
}
#[test]
fn test_validate_file_rejects_directory() {
let base = std::path::PathBuf::from("./test_path_validate_dir_temp");
std::fs::create_dir_all(&base).unwrap();
let validator = PathValidator::new().add_allowed_root(&base);
let result = validator.validate_file(&base);
std::fs::remove_dir_all(&base).ok();
assert!(result.is_err());
match result.unwrap_err() {
VecboostError::SecurityError(msg) => assert!(msg.contains("not a file")),
_ => panic!("Expected SecurityError for directory as file"),
}
}
#[test]
fn test_validate_directory_returns_canonical_path() {
let base = std::path::PathBuf::from("./test_path_validate_directory_temp");
std::fs::create_dir_all(&base).unwrap();
let validator = PathValidator::new().add_allowed_root(&base);
let result = validator.validate_directory(&base);
assert!(result.is_ok());
let canonical = result.unwrap();
assert!(canonical.is_absolute());
assert!(canonical.is_dir());
std::fs::remove_dir_all(&base).ok();
}
#[test]
fn test_validate_directory_rejects_file() {
let base = std::path::PathBuf::from("./test_path_dir_reject_temp");
std::fs::create_dir_all(&base).unwrap();
let file_path = base.join("file.txt");
std::fs::write(&file_path, "content").unwrap();
let validator = PathValidator::new().add_allowed_root(&base);
let result = validator.validate_directory(&file_path);
std::fs::remove_dir_all(&base).ok();
assert!(result.is_err());
match result.unwrap_err() {
VecboostError::SecurityError(msg) => assert!(msg.contains("not a directory")),
_ => panic!("Expected SecurityError for file as directory"),
}
}
#[test]
fn test_add_allowed_roots_multiple() {
let base1 = std::path::PathBuf::from("./test_path_multi_root_1_temp");
let base2 = std::path::PathBuf::from("./test_path_multi_root_2_temp");
std::fs::create_dir_all(&base1).unwrap();
std::fs::create_dir_all(&base2).unwrap();
let file1 = base1.join("f1.txt");
let file2 = base2.join("f2.txt");
std::fs::write(&file1, "1").unwrap();
std::fs::write(&file2, "2").unwrap();
let validator = PathValidator::new().add_allowed_roots(&[&base1, &base2]);
assert_eq!(validator.allowed_roots.len(), 2);
let r1 = validator.validate_file(&file1);
let r2 = validator.validate_file(&file2);
std::fs::remove_dir_all(&base1).ok();
std::fs::remove_dir_all(&base2).ok();
assert!(r1.is_ok(), "file in root1 should be allowed");
assert!(r2.is_ok(), "file in root2 should be allowed");
}
#[test]
fn test_validate_path_rejects_path_outside_all_roots() {
let allowed = std::path::PathBuf::from("./test_path_outside_allowed_temp");
std::fs::create_dir_all(&allowed).unwrap();
let outside = std::path::PathBuf::from("./test_path_outside_other_temp");
std::fs::create_dir_all(&outside).unwrap();
let validator = PathValidator::new().add_allowed_root(&allowed);
let result = validator.validate_path(&outside);
std::fs::remove_dir_all(&allowed).ok();
std::fs::remove_dir_all(&outside).ok();
assert!(result.is_err());
match result.unwrap_err() {
VecboostError::SecurityError(msg) => assert!(msg.contains("Access denied")),
_ => panic!("Expected SecurityError for path outside allowed roots"),
}
}
#[test]
fn test_validate_path_canonicalizes_relative_path() {
let base = std::path::PathBuf::from("./test_path_relative_temp");
std::fs::create_dir_all(&base).unwrap();
let file_path = base.join("rel.txt");
std::fs::write(&file_path, "data").unwrap();
let validator = PathValidator::new().add_allowed_root(&base);
let relative = format!("{}/rel.txt", base.to_string_lossy());
let result = validator.validate_path(&relative);
std::fs::remove_dir_all(&base).ok();
assert!(result.is_ok());
let canonical = result.unwrap();
assert!(canonical.is_absolute());
}
#[test]
fn test_validate_path_nested_subdir_in_allowed_root_accepted() {
let base = std::path::PathBuf::from("./test_path_nested_temp");
let nested = base.join("sub1").join("sub2");
std::fs::create_dir_all(&nested).unwrap();
let file_path = nested.join("deep.txt");
std::fs::write(&file_path, "deep").unwrap();
let validator = PathValidator::new().add_allowed_root(&base);
let result = validator.validate_file(&file_path);
std::fs::remove_dir_all(&base).ok();
assert!(result.is_ok());
}
#[test]
fn test_validate_file_nonexistent_rejected() {
let base = std::path::PathBuf::from("./test_path_nonexist_file_temp");
std::fs::create_dir_all(&base).unwrap();
let validator = PathValidator::new().add_allowed_root(&base);
let result = validator.validate_file(base.join("nonexistent.txt"));
std::fs::remove_dir_all(&base).ok();
assert!(result.is_err());
}
#[test]
fn test_add_allowed_root_builder_chain() {
let base1 = std::path::PathBuf::from("./test_path_chain_1_temp");
let base2 = std::path::PathBuf::from("./test_path_chain_2_temp");
std::fs::create_dir_all(&base1).unwrap();
std::fs::create_dir_all(&base2).unwrap();
let validator = PathValidator::new()
.add_allowed_root(&base1)
.add_allowed_root(&base2);
assert_eq!(validator.allowed_roots.len(), 2);
std::fs::remove_dir_all(&base1).ok();
std::fs::remove_dir_all(&base2).ok();
}
}