use crate::utils::error::{Error, Result};
use std::collections::HashSet;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PathValidationError {
PathTraversal { path: String },
AbsolutePath { path: String },
DepthExceeded {
path: String,
depth: usize,
max: usize,
},
NullByte { path: String },
InvalidExtension { path: String, expected: Vec<String> },
SymlinkEscape { link: String, target: String },
WorkspaceEscape { path: String, workspace: String },
EmptyPath,
InvalidUtf8 { path: String },
UnicodeNormalization { path: String, reason: String },
}
impl std::fmt::Display for PathValidationError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::PathTraversal { path } => {
write!(f, "Path traversal detected: {path} contains '..'")
}
Self::AbsolutePath { path } => {
write!(f, "Absolute path not allowed: {path}")
}
Self::DepthExceeded { path, depth, max } => {
write!(f, "Path depth {depth} exceeds maximum {max}: {path}")
}
Self::NullByte { path } => {
write!(f, "Path contains null byte: {path}")
}
Self::InvalidExtension { path, expected } => {
write!(
f,
"Invalid extension for {path}, expected one of: {}",
expected.join(", ")
)
}
Self::SymlinkEscape { link, target } => {
write!(f, "Symlink {link} points outside workspace: {target}")
}
Self::WorkspaceEscape { path, workspace } => {
write!(f, "Path {path} escapes workspace {workspace}")
}
Self::EmptyPath => {
write!(f, "Path cannot be empty")
}
Self::InvalidUtf8 { path } => {
write!(f, "Path contains invalid UTF-8: {path}")
}
Self::UnicodeNormalization { path, reason } => {
write!(f, "Unicode normalization issue in {path}: {reason}")
}
}
}
}
impl std::error::Error for PathValidationError {}
impl From<PathValidationError> for Error {
fn from(err: PathValidationError) -> Self {
Error::new(&err.to_string())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct SafePath {
inner: PathBuf,
absolute: PathBuf,
}
impl SafePath {
#[must_use]
pub fn as_path(&self) -> &Path {
&self.inner
}
#[must_use]
pub fn absolute(&self) -> &Path {
&self.absolute
}
#[must_use]
pub fn to_path_buf(&self) -> PathBuf {
self.inner.clone()
}
#[must_use]
pub fn extension(&self) -> Option<&str> {
self.inner.extension().and_then(|s| s.to_str())
}
#[must_use]
pub fn file_name(&self) -> Option<&str> {
self.inner.file_name().and_then(|s| s.to_str())
}
}
impl AsRef<Path> for SafePath {
fn as_ref(&self) -> &Path {
&self.inner
}
}
impl std::fmt::Display for SafePath {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.inner.display())
}
}
#[derive(Debug, Clone)]
pub struct PathValidator {
workspace_root: PathBuf,
max_depth: Option<usize>,
allowed_extensions: HashSet<String>,
allow_absolute: bool,
follow_symlinks: bool,
}
impl PathValidator {
#[must_use]
pub fn new(workspace_root: &Path) -> Self {
Self {
workspace_root: workspace_root.to_path_buf(),
max_depth: None,
allowed_extensions: HashSet::new(),
allow_absolute: false,
follow_symlinks: true,
}
}
#[must_use]
pub fn with_max_depth(mut self, max_depth: usize) -> Self {
self.max_depth = Some(max_depth);
self
}
#[must_use]
pub fn with_allowed_extensions(mut self, extensions: Vec<&str>) -> Self {
self.allowed_extensions = extensions.into_iter().map(|s| s.to_string()).collect();
self
}
#[must_use]
pub fn with_absolute_paths(mut self, allow: bool) -> Self {
self.allow_absolute = allow;
self
}
#[must_use]
pub fn with_follow_symlinks(mut self, follow: bool) -> Self {
self.follow_symlinks = follow;
self
}
pub fn validate(&self, path: impl AsRef<Path>) -> Result<SafePath> {
let path = path.as_ref();
if path.as_os_str().is_empty() {
return Err(PathValidationError::EmptyPath.into());
}
let path_str = path
.to_str()
.ok_or_else(|| PathValidationError::InvalidUtf8 {
path: path.display().to_string(),
})?;
if path_str.contains('\0') {
return Err(PathValidationError::NullByte {
path: path_str.to_string(),
}
.into());
}
self.check_path_traversal(path)?;
if path.is_absolute() && !self.allow_absolute {
return Err(PathValidationError::AbsolutePath {
path: path_str.to_string(),
}
.into());
}
if let Some(max_depth) = self.max_depth {
let depth = path.components().count();
if depth > max_depth {
return Err(PathValidationError::DepthExceeded {
path: path_str.to_string(),
depth,
max: max_depth,
}
.into());
}
}
if !self.allowed_extensions.is_empty() {
self.check_extension(path)?;
}
let absolute = self.resolve_safely(path)?;
self.check_workspace_bounds(&absolute)?;
if self.follow_symlinks {
self.check_symlink(&absolute)?;
}
Ok(SafePath {
inner: path.to_path_buf(),
absolute,
})
}
fn check_path_traversal(&self, path: &Path) -> Result<()> {
for component in path.components() {
if let std::path::Component::ParentDir = component {
return Err(PathValidationError::PathTraversal {
path: path.display().to_string(),
}
.into());
}
}
Ok(())
}
fn check_extension(&self, path: &Path) -> Result<()> {
let ext = path.extension().and_then(|s| s.to_str()).ok_or_else(|| {
PathValidationError::InvalidExtension {
path: path.display().to_string(),
expected: self.allowed_extensions.iter().cloned().collect(),
}
})?;
if !self.allowed_extensions.contains(ext) {
return Err(PathValidationError::InvalidExtension {
path: path.display().to_string(),
expected: self.allowed_extensions.iter().cloned().collect(),
}
.into());
}
Ok(())
}
fn resolve_safely(&self, path: &Path) -> Result<PathBuf> {
let base = if path.is_absolute() {
path.to_path_buf()
} else {
self.workspace_root.join(path)
};
if base.exists() {
base.canonicalize().map_err(|e| {
Error::new(&format!(
"Failed to canonicalize path {}: {}",
base.display(),
e
))
})
} else {
let mut absolute = self
.workspace_root
.canonicalize()
.unwrap_or_else(|_| self.workspace_root.clone());
for component in path.components() {
match component {
std::path::Component::Normal(c) => {
absolute.push(c);
}
std::path::Component::RootDir if self.allow_absolute => {
absolute = PathBuf::from("/");
}
std::path::Component::RootDir => {}
std::path::Component::ParentDir => {
return Err(PathValidationError::PathTraversal {
path: path.display().to_string(),
}
.into());
}
_ => {}
}
}
Ok(absolute)
}
}
fn check_workspace_bounds(&self, absolute: &Path) -> Result<()> {
let workspace_canonical = self.workspace_root.canonicalize().map_err(|e| {
Error::new(&format!(
"Failed to canonicalize workspace root {}: {}",
self.workspace_root.display(),
e
))
})?;
if !absolute.starts_with(&workspace_canonical) {
return Err(PathValidationError::WorkspaceEscape {
path: absolute.display().to_string(),
workspace: workspace_canonical.display().to_string(),
}
.into());
}
Ok(())
}
fn check_symlink(&self, path: &Path) -> Result<()> {
if path.is_symlink() {
let target = std::fs::read_link(path).map_err(|e| {
Error::new(&format!("Failed to read symlink {}: {}", path.display(), e))
})?;
let target_absolute = if target.is_absolute() {
target
} else {
path.parent()
.ok_or_else(|| Error::new("Symlink has no parent"))?
.join(target)
};
self.check_workspace_bounds(&target_absolute)?;
}
Ok(())
}
pub fn validate_relative(&self, path: impl AsRef<Path>) -> Result<SafePath> {
let path = path.as_ref();
if path.is_absolute() {
return Err(PathValidationError::AbsolutePath {
path: path.display().to_string(),
}
.into());
}
self.validate(path)
}
pub fn validate_batch(&self, paths: &[impl AsRef<Path>]) -> Result<Vec<SafePath>> {
paths.iter().map(|p| self.validate(p)).collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn test_valid_relative_path() {
let workspace = tempdir().expect("Failed to create temp dir");
let validator = PathValidator::new(workspace.path());
let result = validator.validate("templates/example.tera");
assert!(result.is_ok());
let safe_path = result.expect("Should validate");
assert_eq!(safe_path.extension(), Some("tera"));
}
#[test]
fn test_path_traversal_blocked() {
let workspace = tempdir().expect("Failed to create temp dir");
let validator = PathValidator::new(workspace.path());
let result = validator.validate("../../../etc/passwd");
assert!(result.is_err());
let err = result.unwrap_err();
assert!(err.to_string().contains("Path traversal"));
}
#[test]
fn test_null_byte_blocked() {
let workspace = tempdir().expect("Failed to create temp dir");
let validator = PathValidator::new(workspace.path());
let result = validator.validate("file\0.txt");
assert!(result.is_err());
let err = result.unwrap_err();
assert!(err.to_string().contains("null byte"));
}
#[test]
fn test_absolute_path_blocked_by_default() {
let workspace = tempdir().expect("Failed to create temp dir");
let validator = PathValidator::new(workspace.path());
let result = validator.validate("/etc/passwd");
assert!(result.is_err());
let err = result.unwrap_err();
assert!(err.to_string().contains("Absolute path"));
}
#[test]
fn test_absolute_path_allowed_when_configured() {
let workspace = tempdir().expect("Failed to create temp dir");
let validator = PathValidator::new(workspace.path()).with_absolute_paths(true);
let test_file = workspace.path().join("test.txt");
std::fs::write(&test_file, "test").expect("Failed to create test file");
let result = validator.validate(&test_file);
assert!(result.is_ok());
}
#[test]
fn test_depth_limit_enforced() {
let workspace = tempdir().expect("Failed to create temp dir");
let validator = PathValidator::new(workspace.path()).with_max_depth(3);
let deep_path = "a/b/c/d/e/f/g.txt";
let result = validator.validate(deep_path);
assert!(result.is_err());
let err = result.unwrap_err();
assert!(err.to_string().contains("depth"));
}
#[test]
fn test_extension_validation() {
let workspace = tempdir().expect("Failed to create temp dir");
let validator =
PathValidator::new(workspace.path()).with_allowed_extensions(vec!["tera", "tmpl"]);
let result_valid = validator.validate("template.tera");
assert!(result_valid.is_ok());
let result_invalid = validator.validate("script.sh");
assert!(result_invalid.is_err());
let err = result_invalid.unwrap_err();
assert!(err.to_string().contains("Invalid extension"));
}
#[test]
fn test_empty_path_blocked() {
let workspace = tempdir().expect("Failed to create temp dir");
let validator = PathValidator::new(workspace.path());
let result = validator.validate("");
assert!(result.is_err());
let err = result.unwrap_err();
assert!(err.to_string().contains("empty"));
}
#[test]
fn test_workspace_escape_blocked() {
let workspace = tempdir().expect("Failed to create temp dir");
let validator = PathValidator::new(workspace.path()).with_absolute_paths(true);
let result = validator.validate("/etc/passwd");
assert!(result.is_err());
let err = result.unwrap_err();
assert!(err.to_string().contains("workspace"));
}
#[test]
fn test_symlink_validation() {
let workspace = tempdir().expect("Failed to create temp dir");
let validator = PathValidator::new(workspace.path());
let target = workspace.path().join("target.txt");
std::fs::write(&target, "content").expect("Failed to create target file");
let link = workspace.path().join("link.txt");
#[cfg(unix)]
std::os::unix::fs::symlink(&target, &link).expect("Failed to create symlink");
#[cfg(unix)]
let result = validator.validate("link.txt");
#[cfg(unix)]
assert!(result.is_ok());
}
#[test]
fn test_batch_validation() {
let workspace = tempdir().expect("Failed to create temp dir");
let validator = PathValidator::new(workspace.path());
let paths = vec!["file1.txt", "file2.txt", "templates/example.tera"];
let result = validator.validate_batch(&paths);
assert!(result.is_ok());
let safe_paths = result.expect("Should validate all");
assert_eq!(safe_paths.len(), 3);
}
#[test]
fn test_safe_path_accessors() {
let workspace = tempdir().expect("Failed to create temp dir");
let validator = PathValidator::new(workspace.path());
let safe_path = validator
.validate("templates/example.tera")
.expect("Should validate");
assert_eq!(safe_path.extension(), Some("tera"));
assert_eq!(safe_path.file_name(), Some("example.tera"));
assert_eq!(safe_path.as_path(), Path::new("templates/example.tera"));
}
#[test]
fn test_unicode_path_handling() {
let workspace = tempdir().expect("Failed to create temp dir");
let validator = PathValidator::new(workspace.path());
let result = validator.validate("templates/例え.tera");
assert!(result.is_ok());
}
#[test]
fn test_validate_relative_rejects_absolute() {
let workspace = tempdir().expect("Failed to create temp dir");
let validator = PathValidator::new(workspace.path());
let result = validator.validate_relative("/etc/passwd");
assert!(result.is_err());
let err = result.unwrap_err();
assert!(err.to_string().contains("Absolute path"));
}
}