Skip to main content

perl_parser_core/syntax/
path_normalize.rs

1//! Secure workspace-relative path normalization.
2//!
3//! This crate performs component-based normalization for paths that may not yet
4//! exist on disk. It prevents parent-directory traversal beyond a canonical
5//! workspace root.
6
7#![deny(unsafe_code)]
8#![warn(missing_docs)]
9
10use std::path::{Component, Path, PathBuf};
11
12/// Errors produced while normalizing a relative path against a workspace root.
13#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
14pub enum NormalizePathError {
15    /// Path traversal or invalid component escaping workspace constraints.
16    #[error("Path traversal attempt detected: {0}")]
17    PathTraversalAttempt(String),
18}
19
20/// Normalize `path` beneath `workspace_root` while preventing parent traversal escapes.
21///
22/// This function is intended for paths that may not exist yet and therefore cannot
23/// be canonicalized directly.
24pub 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                // ignore
47            }
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
98    #[test]
99    fn rejects_absolute_paths() -> TestResult {
100        let temp_dir = tempfile::tempdir()?;
101        let workspace = temp_dir.path().canonicalize()?;
102
103        let absolute = workspace.join("lib").join("Foo.pm");
104        let result = normalize_path_within_workspace(&absolute, &workspace);
105        assert!(matches!(result, Err(NormalizePathError::PathTraversalAttempt(_))));
106
107        Ok(())
108    }
109
110    #[test]
111    fn normalizes_mixed_current_and_parent_components() -> TestResult {
112        let temp_dir = tempfile::tempdir()?;
113        let workspace = temp_dir.path().canonicalize()?;
114
115        let normalized =
116            normalize_path_within_workspace(&PathBuf::from("./lib/./../bin/tool.pl"), &workspace)?;
117        assert_eq!(normalized, workspace.join("bin").join("tool.pl"));
118
119        Ok(())
120    }
121}