use std::collections::HashSet;
use std::path::{Component, Path, PathBuf};
#[derive(Debug, Clone, PartialEq)]
pub enum PathValidationError {
OutsideRoot { path: PathBuf, root: PathBuf },
AbsolutePathNotAllowed(PathBuf),
ParentTraversalNotAllowed(PathBuf),
ExtensionNotAllowed { path: PathBuf, extension: String },
InvalidPath(PathBuf),
}
impl std::fmt::Display for PathValidationError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
PathValidationError::OutsideRoot { path, root } => {
write!(
f,
"Path '{}' is outside allowed root '{}'",
path.display(),
root.display()
)
}
PathValidationError::AbsolutePathNotAllowed(path) => {
write!(f, "Absolute path '{}' not allowed", path.display())
}
PathValidationError::ParentTraversalNotAllowed(path) => {
write!(
f,
"Parent traversal '..' not allowed in path '{}'",
path.display()
)
}
PathValidationError::ExtensionNotAllowed { path, extension } => {
write!(
f,
"File extension '{}' not allowed for path '{}'",
extension,
path.display()
)
}
PathValidationError::InvalidPath(path) => {
write!(f, "Invalid path '{}'", path.display())
}
}
}
}
impl std::error::Error for PathValidationError {}
#[derive(Debug, Clone)]
pub struct PathRestriction {
pub root_dir: PathBuf,
pub allow_absolute: bool,
pub allow_parent_traversal: bool,
pub allowed_extensions: Option<HashSet<String>>,
}
impl Default for PathRestriction {
fn default() -> Self {
Self {
root_dir: PathBuf::from("."),
allow_absolute: false,
allow_parent_traversal: false,
allowed_extensions: None,
}
}
}
#[derive(Clone)]
pub struct PathValidator {
restriction: PathRestriction,
}
impl PathValidator {
pub fn new(restriction: PathRestriction) -> Self {
Self { restriction }
}
pub fn with_root_dir(root_dir: PathBuf) -> Self {
Self::new(PathRestriction {
root_dir,
allow_absolute: false,
allow_parent_traversal: false,
allowed_extensions: None,
})
}
pub fn validate_and_normalize(&self, path: &Path) -> Result<PathBuf, PathValidationError> {
if path.is_absolute() && !self.restriction.allow_absolute {
return Err(PathValidationError::AbsolutePathNotAllowed(
path.to_path_buf(),
));
}
if !self.restriction.allow_parent_traversal {
let path_str = path.to_string_lossy();
if path_str.contains("..") {
return Err(PathValidationError::ParentTraversalNotAllowed(
path.to_path_buf(),
));
}
}
let normalized = self.canonicalize_safe(path)?;
if let Ok(root) = self.restriction.root_dir.canonicalize()
&& !normalized.starts_with(&root)
{
return Err(PathValidationError::OutsideRoot {
path: normalized.clone(),
root,
});
}
if let Some(allowed) = &self.restriction.allowed_extensions
&& let Some(ext) = normalized.extension()
{
let ext_str = ext.to_string_lossy().to_lowercase();
if !allowed.contains(&ext_str) {
return Err(PathValidationError::ExtensionNotAllowed {
path: normalized.clone(),
extension: ext_str,
});
}
}
Ok(normalized)
}
fn canonicalize_safe(&self, path: &Path) -> Result<PathBuf, PathValidationError> {
let full_path = if path.is_absolute() {
path.to_path_buf()
} else {
self.restriction.root_dir.join(path)
};
match full_path.canonicalize() {
Ok(canon) => Ok(canon),
Err(_) => {
let mut result = PathBuf::new();
for component in full_path.components() {
match component {
Component::Prefix(_) | Component::RootDir | Component::Normal(_) => {
result.push(component);
}
Component::CurDir => {
}
Component::ParentDir => {
if !self.restriction.allow_parent_traversal {
return Err(PathValidationError::ParentTraversalNotAllowed(
full_path,
));
}
if !result.pop() {
return Err(PathValidationError::InvalidPath(full_path));
}
}
}
}
Ok(result)
}
}
}
pub fn is_valid(&self, path: &Path) -> bool {
self.validate_and_normalize(path).is_ok()
}
pub fn restriction(&self) -> &PathRestriction {
&self.restriction
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_path_validator_blocks_parent_traversal() {
let restriction = PathRestriction {
root_dir: PathBuf::from("/safe"),
allow_absolute: false,
allow_parent_traversal: false,
allowed_extensions: None,
};
let validator = PathValidator::new(restriction);
assert!(
validator
.validate_and_normalize(Path::new("../etc/passwd"))
.is_err()
);
assert!(
validator
.validate_and_normalize(Path::new("safe/../../etc/passwd"))
.is_err()
);
}
#[test]
fn test_path_validator_blocks_absolute_paths() {
let restriction = PathRestriction {
root_dir: PathBuf::from("/safe"),
allow_absolute: false,
allow_parent_traversal: false,
allowed_extensions: None,
};
let validator = PathValidator::new(restriction);
assert!(
validator
.validate_and_normalize(Path::new("/etc/passwd"))
.is_err()
);
}
#[test]
fn test_path_validator_extension_whitelist() {
let mut allowed = HashSet::new();
allowed.insert("aether".to_string());
allowed.insert("txt".to_string());
let restriction = PathRestriction {
root_dir: PathBuf::from("/safe"),
allow_absolute: false,
allow_parent_traversal: false,
allowed_extensions: Some(allowed),
};
let _validator = PathValidator::new(restriction);
use std::fs;
let temp_dir = std::env::temp_dir();
let test_file = temp_dir.join("test.aether");
fs::write(&test_file, "test").unwrap();
}
#[test]
fn test_path_validator_with_root_dir() {
let validator = PathValidator::with_root_dir(PathBuf::from("/tmp/test"));
let result = validator.validate_and_normalize(Path::new("subdir/file.txt"));
assert!(result.is_ok() || result.is_err()); }
#[test]
fn test_path_error_display() {
let err = PathValidationError::OutsideRoot {
path: PathBuf::from("/etc/passwd"),
root: PathBuf::from("/safe"),
};
assert!(err.to_string().contains("outside allowed root"));
let err = PathValidationError::AbsolutePathNotAllowed(PathBuf::from("/etc/passwd"));
assert!(err.to_string().contains("not allowed"));
let err = PathValidationError::ParentTraversalNotAllowed(PathBuf::from("../file"));
assert!(err.to_string().contains("Parent traversal"));
}
}