use std::path::{Path, PathBuf};
use tracing::warn;
#[derive(Debug, Clone)]
pub struct PathValidatorConfig {
pub allow_absolute: bool,
pub base_dir: Option<PathBuf>,
pub allow_symlinks: bool,
pub deny_components: Vec<String>,
}
impl Default for PathValidatorConfig {
fn default() -> Self {
Self {
allow_absolute: true,
base_dir: None,
allow_symlinks: false,
deny_components: vec![
"..".to_string(),
".git".to_string(),
".ssh".to_string(),
".env".to_string(),
"etc".to_string(),
"passwd".to_string(),
"shadow".to_string(),
],
}
}
}
#[derive(Debug, Clone)]
pub struct ValidationResult {
pub valid: bool,
pub error: Option<String>,
pub sanitized_path: Option<PathBuf>,
}
impl ValidationResult {
pub fn valid() -> Self {
Self {
valid: true,
error: None,
sanitized_path: None,
}
}
pub fn invalid(message: &str) -> Self {
Self {
valid: false,
error: Some(message.to_string()),
sanitized_path: None,
}
}
pub fn sanitized(path: PathBuf) -> Self {
Self {
valid: true,
error: None,
sanitized_path: Some(path),
}
}
}
#[derive(Debug, Clone)]
pub struct PathValidator {
config: PathValidatorConfig,
}
impl PathValidator {
pub fn new() -> Self {
Self {
config: PathValidatorConfig::default(),
}
}
pub fn with_config(config: PathValidatorConfig) -> Self {
Self { config }
}
pub fn validate(&self, path: &Path) -> ValidationResult {
if path
.components()
.any(|c| c == std::path::Component::ParentDir)
{
warn!(
"{} ({} components)",
crate::i18n::tr("validation-path_traversal"),
path.components().count()
);
return ValidationResult::invalid(&crate::i18n::tr("validation-path_traversal"));
}
for component in path.components() {
if let std::path::Component::Normal(name) = component {
let name_str = name.to_string_lossy();
if self.config.deny_components.iter().any(|d| name_str == *d) {
warn!(
"{}: {}",
crate::i18n::tr("validation-dangerous_component"),
name_str
);
let mut args = fluent_bundle::FluentArgs::new();
args.set("component", name_str.to_string());
return ValidationResult::invalid(&crate::i18n::tr_args(
"validation-dangerous_component",
args,
));
}
}
}
if !self.config.allow_absolute && path.is_absolute() {
return ValidationResult::invalid(&crate::i18n::tr("validation-no_absolute"));
}
if !self.config.allow_symlinks
&& let Ok(metadata) = std::fs::symlink_metadata(path)
&& metadata.file_type().is_symlink()
{
return ValidationResult::invalid(&crate::i18n::tr("validation-no_symlinks"));
}
if let Some(ref base_dir) = self.config.base_dir {
let canonical_path = match path.canonicalize() {
Ok(p) => p,
Err(_) => path.to_path_buf(),
};
let canonical_base = match base_dir.canonicalize() {
Ok(p) => p,
Err(_) => base_dir.clone(),
};
if !canonical_path.starts_with(&canonical_base) {
#[cfg(windows)]
{
let base_plain = canonical_base
.to_string_lossy()
.strip_prefix(r"\\?\")
.map(std::path::PathBuf::from);
if let Some(base_plain) = base_plain {
if !canonical_path.starts_with(&base_plain) {
return ValidationResult::invalid(&crate::i18n::tr(
"validation-outside_base",
));
}
} else {
return ValidationResult::invalid(&crate::i18n::tr(
"validation-outside_base",
));
}
}
#[cfg(not(windows))]
{
return ValidationResult::invalid(&crate::i18n::tr("validation-outside_base"));
}
}
}
ValidationResult::valid()
}
pub fn validate_and_sanitize(&self, path: &Path) -> ValidationResult {
let result = self.validate(path);
if result.valid {
let sanitized = self.sanitize(path);
ValidationResult::sanitized(sanitized)
} else {
result
}
}
pub fn sanitize(&self, path: &Path) -> PathBuf {
let mut components = Vec::new();
for component in path.components() {
match component {
std::path::Component::ParentDir => {
components.pop();
}
std::path::Component::CurDir => {}
_ => components.push(component),
}
}
components.iter().collect()
}
}
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();
assert!(!validator.validate(Path::new("../etc/passwd")).valid);
assert!(!validator.validate(Path::new("foo/../bar")).valid);
assert!(!validator.validate(Path::new("foo/../../bar")).valid);
}
#[test]
fn test_dangerous_components() {
let validator = PathValidator::new();
assert!(!validator.validate(Path::new("/etc/passwd")).valid);
assert!(!validator.validate(Path::new("~/.ssh/id_rsa")).valid);
assert!(!validator.validate(Path::new("./.env")).valid);
}
#[test]
fn test_absolute_path_restriction() {
let config = PathValidatorConfig {
allow_absolute: false,
..Default::default()
};
let validator = PathValidator::with_config(config);
let abs = if cfg!(windows) {
Path::new("C:\\Windows\\system32")
} else {
Path::new("/absolute/path")
};
assert!(!validator.validate(abs).valid);
assert!(validator.validate(Path::new("relative/path")).valid);
}
#[test]
fn test_symlink_detection() {
let config = PathValidatorConfig {
allow_symlinks: false,
..Default::default()
};
let validator = PathValidator::with_config(config);
let result = validator.validate(Path::new("/nonexistent"));
assert!(result.valid);
}
#[test]
fn test_sanitize() {
let validator = PathValidator::new();
let norm = |p: &Path| p.to_string_lossy().replace('\\', "/").to_string();
let sanitized = validator.sanitize(Path::new("foo/../bar"));
assert_eq!(norm(&sanitized), "bar");
let sanitized = validator.sanitize(Path::new("foo/./bar"));
assert_eq!(norm(&sanitized), "foo/bar");
let sanitized = validator.sanitize(Path::new("foo/../bar/../baz"));
assert_eq!(norm(&sanitized), "baz");
}
#[test]
fn test_safe_paths() {
let validator = PathValidator::new();
assert!(validator.validate(Path::new("logs/app.log")).valid);
assert!(validator.validate(Path::new("/var/log/app.log")).valid);
}
#[test]
fn test_validation_result_valid() {
let result = ValidationResult::valid();
assert!(result.valid);
assert!(result.error.is_none());
assert!(result.sanitized_path.is_none());
}
#[test]
fn test_validation_result_invalid() {
let result = ValidationResult::invalid("test error message");
assert!(!result.valid);
assert_eq!(result.error.as_ref().unwrap(), "test error message");
assert!(result.sanitized_path.is_none());
}
#[test]
fn test_validation_result_sanitized() {
let path = PathBuf::from("/safe/path/file.log");
let result = ValidationResult::sanitized(path.clone());
assert!(result.valid);
assert!(result.error.is_none());
assert_eq!(result.sanitized_path.as_ref().unwrap(), &path);
}
#[test]
fn test_path_validator_default() {
let validator = PathValidator::default();
let result = validator.validate(Path::new("safe/path.log"));
assert!(result.valid);
}
#[test]
fn test_path_validator_with_config() {
let config = PathValidatorConfig {
allow_absolute: false,
base_dir: None,
allow_symlinks: true,
deny_components: vec![],
};
let validator = PathValidator::with_config(config);
assert!(validator.validate(Path::new("etc/passwd")).valid);
}
#[test]
fn test_validate_and_sanitize_valid() {
let validator = PathValidator::new();
let result = validator.validate_and_sanitize(Path::new("logs/app.log"));
assert!(result.valid);
assert!(result.sanitized_path.is_some());
let sanitized = result.sanitized_path.unwrap();
assert!(sanitized.to_string_lossy().contains("app.log"));
}
#[test]
fn test_validate_and_sanitize_invalid() {
let validator = PathValidator::new();
let result = validator.validate_and_sanitize(Path::new("../etc/passwd"));
assert!(!result.valid);
assert!(result.error.is_some());
assert!(result.sanitized_path.is_none());
}
#[test]
fn test_validate_and_sanitize_removes_parent_dir() {
let validator = PathValidator::new();
let result = validator.validate_and_sanitize(Path::new("foo/../bar"));
assert!(!result.valid);
}
#[test]
fn test_sanitize_with_curdir_only() {
let validator = PathValidator::new();
let sanitized = validator.sanitize(Path::new("././foo"));
assert_eq!(sanitized.to_string_lossy(), "foo");
}
#[test]
fn test_sanitize_empty_path() {
let validator = PathValidator::new();
let sanitized = validator.sanitize(Path::new(""));
assert_eq!(sanitized.to_string_lossy(), "");
}
#[test]
fn test_base_dir_validation_inside() {
let temp_dir = tempfile::tempdir().expect("failed to create temp dir");
let base_dir = temp_dir.path().to_path_buf();
let nested_dir = base_dir.join("logs");
std::fs::create_dir_all(&nested_dir).expect("failed to create nested dir");
let log_file = nested_dir.join("app.log");
std::fs::write(&log_file, "test").expect("failed to write file");
let config = PathValidatorConfig {
allow_absolute: true,
base_dir: Some(base_dir.clone()),
allow_symlinks: false,
deny_components: vec![],
};
let validator = PathValidator::with_config(config);
let result = validator.validate(&log_file);
assert!(result.valid);
}
#[test]
fn test_base_dir_validation_outside() {
let base_temp = tempfile::tempdir().expect("failed to create base temp dir");
let outside_temp = tempfile::tempdir().expect("failed to create outside temp dir");
let base_dir = base_temp.path().to_path_buf();
let outside_file = outside_temp.path().join("outside.log");
std::fs::write(&outside_file, "test").expect("failed to write file");
let config = PathValidatorConfig {
allow_absolute: true,
base_dir: Some(base_dir.clone()),
allow_symlinks: false,
deny_components: vec![],
};
let validator = PathValidator::with_config(config);
let result = validator.validate(&outside_file);
assert!(!result.valid);
assert!(result.error.as_ref().unwrap().contains("base directory"));
}
#[cfg(unix)]
#[test]
fn test_symlink_detection_with_real_symlink() {
let temp_dir = tempfile::tempdir().expect("failed to create temp dir");
let target_file = temp_dir.path().join("target.log");
std::fs::write(&target_file, "test").expect("failed to write target");
let symlink_path = temp_dir.path().join("link.log");
std::os::unix::fs::symlink(&target_file, &symlink_path).expect("failed to create symlink");
let config = PathValidatorConfig {
allow_absolute: true,
base_dir: None,
allow_symlinks: false,
deny_components: vec![],
};
let validator = PathValidator::with_config(config);
let result = validator.validate(&symlink_path);
assert!(!result.valid, "Symlink should be detected and rejected");
assert!(
result
.error
.as_ref()
.is_some_and(|m| m.contains("Symlinks are not allowed"))
);
}
#[cfg(unix)]
#[test]
fn test_symlink_allowed() {
let temp_dir = tempfile::tempdir().expect("failed to create temp dir");
let target_file = temp_dir.path().join("target.log");
std::fs::write(&target_file, "test").expect("failed to write target");
let symlink_path = temp_dir.path().join("link.log");
std::os::unix::fs::symlink(&target_file, &symlink_path).expect("failed to create symlink");
let config = PathValidatorConfig {
allow_absolute: true,
base_dir: None,
allow_symlinks: true,
deny_components: vec![],
};
let validator = PathValidator::with_config(config);
let result = validator.validate(&symlink_path);
assert!(result.valid);
}
#[test]
fn test_absolute_path_allowed_by_default() {
let validator = PathValidator::new();
let result = validator.validate(Path::new("/var/log/app.log"));
assert!(result.valid);
}
#[test]
fn test_dangerous_component_passwd() {
let validator = PathValidator::new();
let result = validator.validate(Path::new("/some/passwd/file"));
assert!(!result.valid);
assert!(
result
.error
.as_ref()
.unwrap()
.contains("Dangerous path component")
);
}
#[test]
fn test_dangerous_component_shadow() {
let validator = PathValidator::new();
let result = validator.validate(Path::new("/etc/shadow"));
assert!(!result.valid);
assert!(
result
.error
.as_ref()
.unwrap()
.contains("Dangerous path component")
);
}
#[test]
fn test_dangerous_component_git() {
let validator = PathValidator::new();
let result = validator.validate(Path::new("project/.git/config"));
assert!(!result.valid);
assert!(
result
.error
.as_ref()
.unwrap()
.contains("Dangerous path component")
);
}
#[test]
fn test_base_dir_validation_path_canonicalize_fails() {
let temp_dir = tempfile::tempdir().expect("failed to create temp dir");
let base_dir = temp_dir.path().to_path_buf();
let nonexistent_path = base_dir.join("nonexistent_subdir").join("app.log");
let config = PathValidatorConfig {
allow_absolute: true,
base_dir: Some(base_dir.clone()),
allow_symlinks: false,
deny_components: vec![],
};
let validator = PathValidator::with_config(config);
let result = validator.validate(&nonexistent_path);
assert!(
result.valid,
"path under base_dir should be valid even if canonicalize fails, got: {:?}",
result.error
);
}
#[test]
fn test_base_dir_validation_base_dir_canonicalize_fails() {
let temp_dir = tempfile::tempdir().expect("failed to create temp dir");
let nonexistent_base = temp_dir.path().join("nonexistent_base_dir");
let relative_path = Path::new("logs/app.log");
let config = PathValidatorConfig {
allow_absolute: true,
base_dir: Some(nonexistent_base.clone()),
allow_symlinks: false,
deny_components: vec![],
};
let validator = PathValidator::with_config(config);
let result = validator.validate(relative_path);
assert!(!result.valid);
assert!(
result
.error
.as_ref()
.is_some_and(|m| m.contains("base directory")),
"expected base directory error, got: {:?}",
result.error
);
}
#[test]
fn test_validate_accepts_filenames_with_double_dots() {
let validator = super::PathValidator::new();
let result = validator.validate(Path::new("foo..bar"));
assert!(
result.valid,
"foo..bar should be accepted, got error: {:?}",
result.error
);
let result = validator.validate(Path::new("../etc/passwd"));
assert!(!result.valid, "../etc/passwd should be rejected");
}
}