use std::path::{Path, PathBuf};
#[derive(Debug)]
pub enum PathSecurityError {
NotFound(PathBuf),
Traversal(PathBuf),
OutsideWorkspace(PathBuf),
}
impl std::fmt::Display for PathSecurityError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::NotFound(p) => write!(f, "Path not found: {}", p.display()),
Self::Traversal(p) => write!(f, "Path traversal detected: {}", p.display()),
Self::OutsideWorkspace(p) => write!(f, "Path outside workspace: {}", p.display()),
}
}
}
impl std::error::Error for PathSecurityError {}
pub struct PathGuard {
root: PathBuf,
}
impl PathGuard {
pub fn new(cwd: &Path) -> Self {
let root = cwd.canonicalize().unwrap_or_else(|_| cwd.to_path_buf());
Self { root }
}
pub fn validate(&self, path: &Path) -> Result<PathBuf, PathSecurityError> {
if path.components().any(|c| c.as_os_str() == "..") {
return Err(PathSecurityError::Traversal(path.to_path_buf()));
}
if path.exists() {
let canonical = path
.canonicalize()
.map_err(|_| PathSecurityError::NotFound(path.to_path_buf()))?;
if !canonical.starts_with(&self.root) {
return Err(PathSecurityError::OutsideWorkspace(canonical));
}
Ok(canonical)
} else {
Ok(path.to_path_buf())
}
}
pub fn validate_traversal(&self, path: &Path) -> Result<PathBuf, PathSecurityError> {
if path.components().any(|c| c.as_os_str() == "..") {
return Err(PathSecurityError::Traversal(path.to_path_buf()));
}
if path.exists() {
let canonical = path
.canonicalize()
.map_err(|_| PathSecurityError::NotFound(path.to_path_buf()))?;
Ok(canonical)
} else {
Ok(path.to_path_buf())
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
#[test]
fn reject_traversal() {
let tmp = tempfile::tempdir().unwrap();
let guard = PathGuard::new(tmp.path());
let result = guard.validate(Path::new("../../../etc/passwd"));
assert!(result.is_err());
}
#[test]
fn accept_valid_path() {
let tmp = tempfile::tempdir().unwrap();
let test_file = tmp.path().join("test.txt");
fs::write(&test_file, "hello").unwrap();
let guard = PathGuard::new(tmp.path());
let result = guard.validate(&test_file);
assert!(result.is_ok());
}
#[test]
fn reject_absolute_outside() {
let tmp = tempfile::tempdir().unwrap();
let guard = PathGuard::new(tmp.path());
let result = guard.validate(Path::new("/etc/passwd"));
assert!(result.is_err());
}
}