use std::collections::HashSet;
use std::path::{Component, PathBuf};
#[allow(dead_code)]
pub const ALLOWED_SECRET_BASE_DIRS: &[&str] = &["/run/secrets", "/var/secrets"];
#[allow(dead_code)]
const MAX_PATH_LENGTH: usize = 4096;
#[allow(dead_code)]
#[derive(Debug, Clone)]
pub struct PathValidator {
allowed_base_dirs: HashSet<PathBuf>,
max_path_length: usize,
}
impl PathValidator {
#[allow(dead_code)]
pub fn new() -> Self {
let allowed_base_dirs = ALLOWED_SECRET_BASE_DIRS.iter().map(PathBuf::from).collect();
Self {
allowed_base_dirs,
max_path_length: MAX_PATH_LENGTH,
}
}
#[allow(dead_code)]
pub fn with_allowed_dirs(allowed_dirs: Vec<PathBuf>) -> Self {
Self {
allowed_base_dirs: allowed_dirs.into_iter().collect(),
max_path_length: MAX_PATH_LENGTH,
}
}
#[allow(dead_code)]
pub fn validate_and_resolve(&self, file_path: &str) -> Result<PathBuf, PathValidationError> {
if file_path.len() > self.max_path_length {
return Err(PathValidationError::TooLong);
}
let path = PathBuf::from(file_path);
if path.is_absolute() {
return Err(PathValidationError::AbsolutePath);
}
for component in path.components() {
match component {
Component::ParentDir => {
return Err(PathValidationError::ParentDirectoryReference);
}
Component::Prefix(_) => {
return Err(PathValidationError::InvalidComponent);
}
Component::RootDir => {
return Err(PathValidationError::AbsolutePath);
}
Component::CurDir => continue,
Component::Normal(_) => continue,
}
}
if contains_encoded_traversal(file_path) {
return Err(PathValidationError::EncodedTraversal);
}
let current_dir =
std::env::current_dir().map_err(|_| PathValidationError::CurrentDirUnavailable)?;
let full_path = current_dir.join(&path);
let canonical_path = full_path
.canonicalize()
.map_err(|_| PathValidationError::NotFound)?;
let is_allowed = self
.allowed_base_dirs
.iter()
.any(|base| canonical_path.starts_with(base))
|| canonical_path.starts_with(¤t_dir);
if !is_allowed {
return Err(PathValidationError::OutsideAllowedDirectory);
}
Ok(canonical_path)
}
}
impl Default for PathValidator {
fn default() -> Self {
Self::new()
}
}
#[allow(dead_code)]
fn contains_encoded_traversal(s: &str) -> bool {
let lower = s.to_lowercase();
lower.contains("%2e")
|| lower.contains("%252e")
|| lower.contains("%5c")
|| lower.contains("%255c")
}
#[allow(dead_code)]
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PathValidationError {
TooLong,
AbsolutePath,
ParentDirectoryReference,
InvalidComponent,
EncodedTraversal,
NotFound,
CurrentDirUnavailable,
OutsideAllowedDirectory,
}
impl std::fmt::Display for PathValidationError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::TooLong => write!(
f,
"Path exceeds maximum length of {} bytes",
MAX_PATH_LENGTH
),
Self::AbsolutePath => write!(f, "Absolute paths are not allowed for security reasons"),
Self::ParentDirectoryReference => {
write!(f, "Parent directory references (..) are not allowed")
}
Self::InvalidComponent => write!(f, "Invalid path component detected"),
Self::EncodedTraversal => {
write!(f, "URL-encoded directory traversal pattern detected")
}
Self::NotFound => write!(f, "The specified path does not exist"),
Self::CurrentDirUnavailable => write!(f, "Cannot determine the current directory"),
Self::OutsideAllowedDirectory => {
write!(f, "Path is outside the allowed directories")
}
}
}
}
impl std::error::Error for PathValidationError {}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_rejects_absolute_path() {
let validator = PathValidator::new();
let result = validator.validate_and_resolve("/etc/passwd");
assert_eq!(result.err(), Some(PathValidationError::AbsolutePath));
}
#[test]
fn test_rejects_parent_directory() {
let validator = PathValidator::new();
let result = validator.validate_and_resolve("../../../etc/passwd");
assert_eq!(
result.err(),
Some(PathValidationError::ParentDirectoryReference)
);
}
#[test]
fn test_rejects_encoded_traversal() {
let validator = PathValidator::new();
let result = validator.validate_and_resolve("%2e%2e/etc/passwd");
assert_eq!(result.err(), Some(PathValidationError::EncodedTraversal));
let result = validator.validate_and_resolve("%252e%252e/etc/passwd");
assert_eq!(result.err(), Some(PathValidationError::EncodedTraversal));
}
#[test]
fn test_rejects_too_long_path() {
let validator = PathValidator::new();
let long_path = "a".repeat(5000);
let result = validator.validate_and_resolve(&long_path);
assert_eq!(result.err(), Some(PathValidationError::TooLong));
}
#[test]
fn test_path_validator_default() {
let validator = PathValidator::default();
assert!(!validator.allowed_base_dirs.is_empty());
assert_eq!(validator.max_path_length, MAX_PATH_LENGTH);
}
}