perl_path_normalize/
lib.rs1#![deny(unsafe_code)]
8#![warn(missing_docs)]
9
10use std::path::{Component, Path, PathBuf};
11
12#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
14pub enum NormalizePathError {
15 #[error("Path traversal attempt detected: {0}")]
17 PathTraversalAttempt(String),
18}
19
20pub fn normalize_path_within_workspace(
25 path: &Path,
26 workspace_root: &Path,
27) -> Result<PathBuf, NormalizePathError> {
28 let mut stack: Vec<Component<'_>> = workspace_root.components().collect();
29 let workspace_depth = stack.len();
30
31 for component in path.components() {
32 match component {
33 Component::ParentDir => {
34 if stack.len() <= workspace_depth {
35 return Err(NormalizePathError::PathTraversalAttempt(format!(
36 "Path attempts to escape workspace: {}",
37 path.display()
38 )));
39 }
40 stack.pop();
41 }
42 Component::Normal(name) => {
43 stack.push(Component::Normal(name));
44 }
45 Component::CurDir => {
46 }
48 Component::RootDir | Component::Prefix(_) => {
49 return Err(NormalizePathError::PathTraversalAttempt(format!(
50 "Invalid component in relative path: {}",
51 path.display()
52 )));
53 }
54 }
55 }
56
57 let mut normalized = PathBuf::new();
58 for component in stack {
59 normalized.push(component.as_os_str());
60 }
61
62 Ok(normalized)
63}
64
65#[cfg(test)]
66mod tests {
67 use super::{NormalizePathError, normalize_path_within_workspace};
68 use std::path::PathBuf;
69
70 type TestResult = Result<(), Box<dyn std::error::Error>>;
71
72 #[test]
73 fn normalizes_safe_relative_path() -> TestResult {
74 let temp_dir = tempfile::tempdir()?;
75 let workspace = temp_dir.path().canonicalize()?;
76
77 let normalized =
78 normalize_path_within_workspace(&PathBuf::from("src/main.pl"), &workspace)?;
79 assert!(normalized.starts_with(&workspace));
80 assert!(normalized.to_string_lossy().contains("src"));
81 assert!(normalized.to_string_lossy().contains("main.pl"));
82
83 Ok(())
84 }
85
86 #[test]
87 fn rejects_parent_directory_escape() -> TestResult {
88 let temp_dir = tempfile::tempdir()?;
89 let workspace = temp_dir.path().canonicalize()?;
90
91 let result =
92 normalize_path_within_workspace(&PathBuf::from("../../../etc/passwd"), &workspace);
93 assert!(matches!(result, Err(NormalizePathError::PathTraversalAttempt(_))));
94
95 Ok(())
96 }
97}