use std::fs;
use std::path::{Component, Path, PathBuf};
#[derive(Debug, Clone)]
pub struct SecurityPolicy {
pub base_dir: Option<PathBuf>,
pub reject_parent_refs: bool,
}
impl Default for SecurityPolicy {
fn default() -> Self {
Self {
base_dir: None, reject_parent_refs: true, }
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum PathSecurityError {
ParentReferenceNotAllowed { component: String },
EscapesBaseDirectory { path: PathBuf, base: PathBuf },
IoError(String),
}
impl std::fmt::Display for PathSecurityError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
PathSecurityError::ParentReferenceNotAllowed { component } => {
write!(f, "Parent reference not allowed: {}", component)
}
PathSecurityError::EscapesBaseDirectory { path, base } => {
write!(
f,
"Path escapes base directory: {:?} (base: {:?})",
path, base
)
}
PathSecurityError::IoError(e) => {
write!(f, "IO error: {}", e)
}
}
}
}
impl std::error::Error for PathSecurityError {}
pub fn validate_path<P: AsRef<Path>>(
path: P,
policy: &SecurityPolicy,
) -> Result<PathBuf, PathSecurityError> {
let path = path.as_ref();
for component in path.components() {
if component == Component::ParentDir && policy.reject_parent_refs {
return Err(PathSecurityError::ParentReferenceNotAllowed {
component: "..".to_string(),
});
}
}
let base_dir = policy.base_dir.as_ref();
let canonical = if path.exists() {
fs::canonicalize(path).map_err(|e| PathSecurityError::IoError(e.to_string()))?
} else if let Some(base) = base_dir {
let resolved = base.join(path);
clean_path(&resolved)
} else {
clean_path(path)
};
if let Some(base) = base_dir {
let canonical_base = base.canonicalize().map_err(|e| {
PathSecurityError::IoError(format!("Failed to canonicalize base directory: {}", e))
})?;
if !canonical.starts_with(&canonical_base) {
return Err(PathSecurityError::EscapesBaseDirectory {
path: canonical,
base: canonical_base,
});
}
}
Ok(canonical)
}
fn clean_path(path: &Path) -> PathBuf {
let mut components: Vec<Component> = Vec::new();
for component in path.components() {
match component {
Component::CurDir => {
continue;
}
Component::ParentDir => {
if let Some(Component::Normal(_)) = components.last() {
components.pop();
} else if components.is_empty() {
components.push(component);
}
}
other => {
components.push(other);
}
}
}
components.iter().collect()
}
pub fn normalize_path_secure(path: &str, base_dir: Option<&Path>) -> Result<PathBuf, String> {
let policy = SecurityPolicy {
base_dir: base_dir.map(|p| p.to_path_buf()),
..SecurityPolicy::default()
};
validate_path(path, &policy).map_err(|e| e.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
#[test]
fn test_valid_path() {
let dir = TempDir::new().unwrap();
let file = dir.path().join("test.txt");
fs::write(&file, "test").unwrap();
let policy = SecurityPolicy::default();
let result = validate_path(&file, &policy);
assert!(result.is_ok());
}
#[test]
fn test_path_traversal_blocked() {
let policy = SecurityPolicy::default();
let malicious = "../../../etc/passwd";
let result = validate_path(malicious, &policy);
assert!(result.is_err());
assert!(matches!(
result.unwrap_err(),
PathSecurityError::ParentReferenceNotAllowed { .. }
));
}
#[test]
fn test_escapes_base_directory() {
let dir = TempDir::new().unwrap();
let outside = dir.path().parent().unwrap().join("outside.txt");
fs::write(&outside, "content").unwrap();
let policy = SecurityPolicy {
base_dir: Some(dir.path().to_path_buf()),
..SecurityPolicy::default()
};
let result = validate_path(&outside, &policy);
assert!(result.is_err());
assert!(matches!(
result.unwrap_err(),
PathSecurityError::EscapesBaseDirectory { .. }
));
}
#[test]
fn test_clean_path() {
let messy = PathBuf::from("/tmp/subdir/../file.txt");
let cleaned = clean_path(&messy);
assert_eq!(cleaned, PathBuf::from("/tmp/file.txt"));
}
#[test]
fn test_normalize_path_secure_rejects_traversal() {
let dir = TempDir::new().unwrap();
let result = normalize_path_secure("../../../etc/passwd", Some(dir.path()));
assert!(result.is_err());
}
#[test]
fn test_new_file_path_validation() {
let dir = TempDir::new().unwrap();
let new_file = dir.path().join("subdir").join("new.txt");
fs::create_dir_all(new_file.parent().unwrap()).unwrap();
let policy = SecurityPolicy::default();
let result = validate_path(&new_file, &policy);
assert!(result.is_ok());
}
#[test]
fn test_base_dir_nonexistent_file_inside() {
let dir = TempDir::new().unwrap();
let base = dir.path().to_path_buf();
let target = base.join("nonexistent.txt");
let policy = SecurityPolicy {
base_dir: Some(base),
reject_parent_refs: true,
};
let result = validate_path(&target, &policy);
assert!(result.is_ok());
}
#[test]
fn test_base_dir_nonexistent_file_escapes_via_parent_ref() {
let dir = TempDir::new().unwrap();
let base = dir.path().to_path_buf();
let target = base.join("..").join("outside.txt");
let policy = SecurityPolicy {
base_dir: Some(base),
reject_parent_refs: false,
};
let result = validate_path(&target, &policy);
assert!(result.is_err());
assert!(matches!(
result.unwrap_err(),
PathSecurityError::EscapesBaseDirectory { .. }
));
}
#[test]
fn test_base_dir_nonexistent_absolute_path_escapes() {
let dir = TempDir::new().unwrap();
let base = dir.path().to_path_buf();
#[cfg(unix)]
let target = PathBuf::from("/tmp/some_random_nonexistent_file.txt");
#[cfg(windows)]
let target = PathBuf::from("C:\\temp\\some_random_nonexistent_file.txt");
let policy = SecurityPolicy {
base_dir: Some(base),
reject_parent_refs: true,
};
let result = validate_path(&target, &policy);
assert!(result.is_err());
assert!(matches!(
result.unwrap_err(),
PathSecurityError::EscapesBaseDirectory { .. }
));
}
#[test]
fn test_table_driven_traversal() {
let policy = SecurityPolicy::default();
let cases = vec![
"../etc/passwd",
"foo/bar/../../../etc/passwd",
"foo/../bar",
"../",
"..",
"a/b/c/..",
];
for case in cases {
let result = validate_path(case, &policy);
assert!(result.is_err(), "Expected {} to fail", case);
assert!(
matches!(
result.unwrap_err(),
PathSecurityError::ParentReferenceNotAllowed { .. }
),
"Expected ParentReferenceNotAllowed for {}",
case
);
}
}
}