pathx/utils/
normalize.rs

1use std::path::{Component, Path, PathBuf};
2
3/// Error type returned when path normalization fails.
4#[derive(Debug, Clone)]
5pub struct NormalizeError {
6    pub message: &'static str,
7}
8
9impl std::fmt::Display for NormalizeError {
10    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
11        write!(f, "Path normalization failed: {}", self.message)
12    }
13}
14
15impl std::error::Error for NormalizeError {}
16
17/// Extension trait to add `normalize` to [`Path`].
18pub trait Normalize {
19    /// Normalize a path lexically, resolving `.` and `..` without accessing the filesystem.
20    ///
21    /// # Errors
22    ///
23    /// Returns [`NormalizeError`] if the path attempts to traverse above its root.
24    fn normalize(&self) -> Result<PathBuf, NormalizeError>;
25}
26
27impl Normalize for Path {
28    fn normalize(&self) -> Result<PathBuf, NormalizeError> {
29        let mut lexical = PathBuf::new();
30        let mut iter = self.components().peekable();
31
32        let root_len = match iter.peek() {
33            Some(Component::ParentDir) => {
34                return Err(NormalizeError {
35                    message: "cannot start with ParentDir",
36                });
37            }
38            Some(Component::Prefix(prefix)) => {
39                lexical.push(prefix.as_os_str());
40                iter.next();
41                if let Some(Component::RootDir) = iter.peek() {
42                    lexical.push(Component::RootDir);
43                    iter.next();
44                }
45                lexical.as_os_str().len()
46            }
47            Some(Component::RootDir) | Some(Component::CurDir) => {
48                lexical.push(iter.next().unwrap());
49                lexical.as_os_str().len()
50            }
51            None => return Ok(PathBuf::new()),
52            Some(Component::Normal(_)) => 0,
53        };
54
55        for component in iter {
56            match component {
57                Component::RootDir => unreachable!(),
58                Component::Prefix(_) => {
59                    return Err(NormalizeError {
60                        message: "unexpected prefix component",
61                    });
62                }
63                Component::CurDir => continue,
64                Component::ParentDir => {
65                    if lexical.as_os_str().len() == root_len {
66                        return Err(NormalizeError {
67                            message: "attempted to traverse above root",
68                        });
69                    } else {
70                        lexical.pop();
71                    }
72                }
73                Component::Normal(path) => lexical.push(path),
74            }
75        }
76
77        Ok(lexical)
78    }
79}
80
81/// Normalize a path lexically, resolving `.` and `..` without accessing the filesystem.
82///
83/// # Errors
84///
85/// Returns [`NormalizeError`] if the path attempts to traverse above its root.
86pub fn normalize(path: &Path) -> Result<PathBuf, NormalizeError> {
87    Normalize::normalize(path)
88}
89
90/// A convenience function that returns the normalized path or the original path if normalization fails.
91pub fn normalize_lossy(path: &Path) -> PathBuf {
92    path.normalize().unwrap_or_else(|_| path.to_path_buf())
93}
94
95#[cfg(test)]
96mod tests {
97    use super::*;
98
99    #[test]
100    fn test_normalize_basic() {
101        let path = Path::new("foo/./bar/../baz");
102        let normalized = path.normalize().unwrap();
103        assert_eq!(normalized, PathBuf::from("foo/baz"));
104    }
105
106    #[test]
107    fn test_normalize_rooted() {
108        let path = Path::new("/foo/../bar");
109        let normalized = path.normalize().unwrap();
110        assert_eq!(normalized, PathBuf::from("/bar"));
111    }
112
113    #[test]
114    fn test_normalize_error() {
115        let path = Path::new("../foo");
116        let err = path.normalize().unwrap_err();
117        assert_eq!(err.message, "cannot start with ParentDir");
118    }
119
120    #[test]
121    fn test_normalize_lossy() {
122        let path = Path::new("../foo");
123        let normalized = normalize_lossy(path);
124        assert_eq!(normalized, PathBuf::from("../foo"));
125    }
126}