Skip to main content

perl_path_normalize/
lib.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}