use crate::error::InklogError;
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(),
};
#[cfg_attr(not(windows), allow(unused_mut))]
let mut inside_base = canonical_path.starts_with(&canonical_base);
if !inside_base {
#[cfg(windows)]
if let Some(base_plain) = canonical_base
.to_string_lossy()
.strip_prefix(r"\\?\")
.map(std::path::PathBuf::from)
{
inside_base = canonical_path.starts_with(&base_plain);
}
}
if !inside_base {
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 {
match self.sanitize(path) {
Ok(sanitized) => ValidationResult::sanitized(sanitized),
Err(e) => ValidationResult::invalid(&e.to_string()),
}
} else {
result
}
}
pub fn sanitize(&self, path: &Path) -> Result<PathBuf, InklogError> {
let mut components: Vec<std::path::Component<'_>> = Vec::new();
for component in path.components() {
match component {
std::path::Component::ParentDir => {
if components.pop().is_none() {
return Err(InklogError::ConfigError(crate::i18n::tr(
"validation-path_traversal",
)));
}
}
std::path::Component::CurDir => {}
_ => components.push(component),
}
}
Ok(components.iter().collect())
}
}
impl Default for PathValidator {
fn default() -> Self {
Self::new()
}
}
#[cfg(unix)]
pub fn open_validated_file(path: &Path) -> std::io::Result<std::fs::File> {
use nix::fcntl::OFlag;
use nix::sys::stat::Mode;
let fd = nix::fcntl::open(
path,
OFlag::O_RDONLY | OFlag::O_NOFOLLOW | OFlag::O_CLOEXEC,
Mode::empty(),
)
.map_err(std::io::Error::from)?;
Ok(std::fs::File::from(fd))
}
#[cfg(not(unix))]
pub fn open_validated_file(path: &Path) -> std::io::Result<std::fs::File> {
std::fs::File::open(path)
}
#[cfg(unix)]
pub fn create_validated_file(path: &Path) -> std::io::Result<std::fs::File> {
use nix::fcntl::OFlag;
use nix::sys::stat::Mode;
let fd = nix::fcntl::open(
path,
OFlag::O_WRONLY | OFlag::O_CREAT | OFlag::O_TRUNC | OFlag::O_NOFOLLOW | OFlag::O_CLOEXEC,
Mode::from_bits_truncate(0o600),
)
.map_err(std::io::Error::from)?;
Ok(std::fs::File::from(fd))
}
#[cfg(not(unix))]
pub fn create_validated_file(path: &Path) -> std::io::Result<std::fs::File> {
std::fs::File::create(path)
}
#[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")).unwrap();
assert_eq!(norm(&sanitized), "bar");
let sanitized = validator.sanitize(Path::new("foo/./bar")).unwrap();
assert_eq!(norm(&sanitized), "foo/bar");
let sanitized = validator.sanitize(Path::new("foo/../bar/../baz")).unwrap();
assert_eq!(norm(&sanitized), "baz");
}
#[test]
fn test_sanitize_rejects_pure_traversal() {
let validator = PathValidator::new();
assert!(validator.sanitize(Path::new("../../etc/passwd")).is_err());
assert!(validator.sanitize(Path::new("..")).is_err());
assert!(validator.sanitize(Path::new("../foo")).is_err());
assert!(validator.sanitize(Path::new("../..")).is_err());
}
#[test]
fn test_sanitize_allows_internal_traversal() {
let validator = PathValidator::new();
let sanitized = validator.sanitize(Path::new("foo/..")).unwrap();
assert!(sanitized.as_os_str().is_empty());
}
#[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")).unwrap();
assert_eq!(sanitized.to_string_lossy(), "foo");
}
#[test]
fn test_sanitize_empty_path() {
let validator = PathValidator::new();
let sanitized = validator.sanitize(Path::new("")).unwrap();
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");
}
#[test]
#[cfg(unix)]
fn test_open_validated_file_rejects_symlink() {
let dir = tempfile::TempDir::new().unwrap();
let real = dir.path().join("real.log");
std::fs::write(&real, b"data").unwrap();
let link = dir.path().join("link.log");
std::os::unix::fs::symlink(&real, &link).unwrap();
assert!(
super::open_validated_file(&link).is_err(),
"symlinked leaf must be rejected by O_NOFOLLOW"
);
let f = super::open_validated_file(&real).expect("regular file should open");
drop(f);
}
#[test]
#[cfg(unix)]
fn test_create_validated_file_rejects_symlink_and_preserves_target() {
use std::io::Write;
let dir = tempfile::TempDir::new().unwrap();
let victim = dir.path().join("victim.log");
std::fs::write(&victim, b"do not touch").unwrap();
let out_link = dir.path().join("out.log");
std::os::unix::fs::symlink(&victim, &out_link).unwrap();
assert!(
super::create_validated_file(&out_link).is_err(),
"symlinked output must be rejected by O_NOFOLLOW"
);
assert_eq!(std::fs::read(&victim).unwrap(), b"do not touch");
let out = dir.path().join("out2.log");
super::create_validated_file(&out)
.unwrap()
.write_all(b"x")
.unwrap();
assert_eq!(std::fs::read(&out).unwrap(), b"x");
}
}