use anyhow::Result;
use camino::Utf8Path;
use std::path::{Path, PathBuf};
#[derive(Debug, thiserror::Error)]
pub enum PathValidationError {
#[error("cannot canonicalize path: {0}")]
CannotCanonicalize(String),
#[error("path escapes project root: {0} (root: {1})")]
OutsideRoot(String, String),
#[error("path contains suspicious traversal patterns: {0}")]
SuspiciousTraversal(String),
#[error("symlink escapes project root: {0} -> {1}")]
SymlinkEscape(String, String),
}
pub fn canonicalize_path(path: &Path) -> Result<PathBuf, PathValidationError> {
std::fs::canonicalize(path)
.map_err(|_| PathValidationError::CannotCanonicalize(path.to_string_lossy().to_string()))
}
pub fn normalize_path(path: &Path) -> Result<String> {
if let Ok(canonical) = std::fs::canonicalize(path) {
return Ok(canonical.to_string_lossy().to_string());
}
let path_str = path.to_string_lossy().to_string();
let normalized = if path_str.starts_with("./") {
path_str[2..].to_string()
} else {
path_str
};
Ok(normalized)
}
pub fn validate_path_within_root(path: &Path, root: &Path) -> Result<PathBuf, PathValidationError> {
let path_str = path.to_string_lossy();
if has_suspicious_traversal(&path_str) {
return Err(PathValidationError::SuspiciousTraversal(
path_str.to_string(),
));
}
let canonical_path = canonicalize_path(path)?;
let canonical_root = canonicalize_path(root)
.map_err(|_| PathValidationError::CannotCanonicalize(root.to_string_lossy().to_string()))?;
if !canonical_path.starts_with(&canonical_root) {
return Err(PathValidationError::OutsideRoot(
canonical_path.to_string_lossy().to_string(),
canonical_root.to_string_lossy().to_string(),
));
}
Ok(canonical_path)
}
pub fn has_suspicious_traversal(path: &str) -> bool {
let path_normalized = path.replace('\\', "/");
let parent_count = path_normalized.matches("../").count();
if parent_count >= 3 {
return true;
}
if path_normalized.starts_with("../") && !path_normalized.starts_with("../../") {
let depth = path_normalized.matches('/').count();
if depth <= 2 {
return true;
}
}
let path_win = path.replace('/', "\\");
if path_win.starts_with("..\\") && !path_win.starts_with("..\\..\\") {
let depth = path_win.matches('\\').count();
if depth <= 2 {
return true;
}
}
let parts: Vec<&str> = path_normalized.split('/').collect();
for (i, part) in parts.iter().enumerate() {
if *part == "." && i < parts.len() - 1 {
if parts[i + 1..].contains(&"..") {
return true;
}
}
}
let parts_win: Vec<&str> = path_win.split('\\').collect();
for (i, part) in parts_win.iter().enumerate() {
if *part == "." && i < parts_win.len() - 1 && parts_win[i + 1..].contains(&"..") {
return true;
}
}
false
}
pub fn is_safe_symlink(symlink_path: &Path, root: &Path) -> Result<bool, PathValidationError> {
let target = std::fs::read_link(symlink_path).map_err(|_| {
PathValidationError::CannotCanonicalize(symlink_path.to_string_lossy().to_string())
})?;
if target.is_absolute() {
match validate_path_within_root(&target, root) {
Ok(_) => return Ok(true),
Err(PathValidationError::OutsideRoot(_, _)) => {
return Err(PathValidationError::SymlinkEscape(
symlink_path.to_string_lossy().to_string(),
target.to_string_lossy().to_string(),
))
}
Err(e) => return Err(e),
}
}
let parent = symlink_path.parent().unwrap_or(symlink_path);
let resolved = parent.join(&target);
match validate_path_within_root(&resolved, root) {
Ok(_) => Ok(true),
Err(PathValidationError::OutsideRoot(_, _)) => Err(PathValidationError::SymlinkEscape(
symlink_path.to_string_lossy().to_string(),
target.to_string_lossy().to_string(),
)),
Err(e) => Err(e),
}
}
pub fn validate_utf8_path(
utf8_path: &Utf8Path,
root: &Path,
) -> Result<PathBuf, PathValidationError> {
let path = Path::new(utf8_path.as_str());
validate_path_within_root(path, root)
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
#[test]
fn test_normalize_path_relative_prefix() {
let temp_dir = TempDir::new().unwrap();
let nonexist = temp_dir.path().join("./src/lib.rs");
let result = normalize_path(&nonexist).unwrap();
assert!(result.contains("src/lib.rs"));
assert!(!result.starts_with("./"));
let nonexist2 = temp_dir.path().join("src/main.rs");
let result = normalize_path(&nonexist2).unwrap();
assert!(result.contains("src/main.rs"));
let test_file = temp_dir.path().join("test.rs");
std::fs::write(&test_file, b"fn test() {}").unwrap();
let result = normalize_path(&test_file).unwrap();
assert!(result.contains("test.rs"));
let relative_to_temp = temp_dir.path().join("./test.rs");
let result = normalize_path(&relative_to_temp).unwrap();
assert!(result.contains("test.rs"));
assert!(!result.starts_with("./"));
}
#[test]
fn test_normalize_path_absolute() {
let temp_dir = TempDir::new().unwrap();
let test_file = temp_dir.path().join("absolute.rs");
std::fs::write(&test_file, b"fn abs() {}").unwrap();
let result = normalize_path(&test_file).unwrap();
assert!(result.contains("absolute.rs"));
assert!(!result.starts_with("./"));
}
#[test]
fn test_normalize_path_non_existing() {
let result = normalize_path(Path::new("./does/not/exist.rs")).unwrap();
assert_eq!(result, "does/not/exist.rs");
let result = normalize_path(Path::new("nonexistent/path.rs")).unwrap();
assert_eq!(result, "nonexistent/path.rs");
}
#[test]
fn test_normalize_path_redundant_dots() {
let temp_dir = TempDir::new().unwrap();
let subdir = temp_dir.path().join("a/b");
std::fs::create_dir_all(&subdir).unwrap();
let test_file = subdir.join("test.rs");
std::fs::write(&test_file, b"fn test() {}").unwrap();
let result = normalize_path(&test_file).unwrap();
assert!(result.contains("test.rs"));
assert!(!result.contains(".."));
}
#[test]
fn test_has_suspicious_traversal_parent_patterns() {
assert!(has_suspicious_traversal("../../../etc/passwd"));
assert!(has_suspicious_traversal(
"..\\\\..\\\\..\\\\windows\\\\system32"
));
assert!(has_suspicious_traversal("../config"));
assert!(has_suspicious_traversal("..\\config"));
}
#[test]
fn test_has_suspicious_traversal_mixed_patterns() {
assert!(has_suspicious_traversal("./subdir/../../etc"));
assert!(has_suspicious_traversal(".\\subdir\\..\\..\\etc"));
}
#[test]
fn test_has_suspicious_traversal_normal_paths() {
assert!(!has_suspicious_traversal("src/main.rs"));
assert!(!has_suspicious_traversal("./src/lib.rs"));
assert!(!has_suspicious_traversal("../parent/src/lib.rs")); assert!(!has_suspicious_traversal("../../normal")); }
#[test]
fn test_validate_path_within_root_valid() {
let temp_dir = TempDir::new().unwrap();
let root = temp_dir.path();
let file_path = root.join("test.rs");
fs::write(&file_path, b"fn test() {}").unwrap();
let result = validate_path_within_root(&file_path, root);
assert!(result.is_ok());
assert!(result.unwrap().starts_with(root));
}
#[test]
fn test_validate_path_within_root_traversal_rejected() {
let temp_dir = TempDir::new().unwrap();
let root = temp_dir.path();
let outside = root.join("../../../etc/passwd");
let result = validate_path_within_root(&outside, root);
assert!(result.is_err());
assert!(matches!(
result.unwrap_err(),
PathValidationError::SuspiciousTraversal(_)
));
}
#[test]
fn test_validate_path_within_root_absolute_outside() {
let temp_dir = TempDir::new().unwrap();
let root = temp_dir.path();
let outside = Path::new("/etc/passwd");
let result = validate_path_within_root(outside, root);
assert!(result.is_err());
match result.unwrap_err() {
PathValidationError::SuspiciousTraversal(_) => {}
PathValidationError::OutsideRoot(_, _) => {}
_ => panic!("Expected traversal or outside error"),
}
}
#[test]
fn test_is_safe_symlink_inside_root() {
let temp_dir = TempDir::new().unwrap();
let root = temp_dir.path();
let target = root.join("target.rs");
fs::write(&target, b"fn target() {}").unwrap();
let symlink = root.join("link.rs");
#[cfg(unix)]
std::os::unix::fs::symlink(&target, &symlink).unwrap();
#[cfg(windows)]
std::os::windows::fs::symlink_file(&target, &symlink).unwrap();
#[cfg(any(unix, windows))]
{
let result = is_safe_symlink(&symlink, root);
assert!(result.is_ok());
assert!(result.unwrap());
}
}
#[test]
fn test_is_safe_symlink_outside_root() {
let temp_dir = TempDir::new().unwrap();
let root = temp_dir.path();
let outside_dir = TempDir::new().unwrap();
let target = outside_dir.path().join("outside.rs");
fs::write(&target, b"fn outside() {}").unwrap();
let symlink = root.join("link.rs");
#[cfg(unix)]
std::os::unix::fs::symlink(&target, &symlink).unwrap();
#[cfg(windows)]
std::os::windows::fs::symlink_file(&target, &symlink).unwrap();
#[cfg(any(unix, windows))]
{
let result = is_safe_symlink(&symlink, root);
assert!(result.is_err());
match result.unwrap_err() {
PathValidationError::SymlinkEscape(_, _) => {}
PathValidationError::CannotCanonicalize(_) => {
}
other => panic!(
"Expected SymlinkEscape or CannotCanonicalize, got: {:?}",
other
),
}
}
}
#[test]
fn test_cross_platform_path_handling() {
let temp_dir = TempDir::new().unwrap();
let root = temp_dir.path();
let subdir = root.join("src");
fs::create_dir(&subdir).unwrap();
let file_path = subdir.join("main.rs");
fs::write(&file_path, b"fn main() {}").unwrap();
let path_str = file_path.to_string_lossy().replace('\\', "/");
let result = validate_path_within_root(Path::new(&path_str), root);
assert!(result.is_ok());
if cfg!(windows) {
let path_str_win = file_path.to_string_lossy().replace('/', "\\");
let result_win = validate_path_within_root(Path::new(&path_str_win), root);
assert!(result_win.is_ok());
}
}
}