Skip to main content

path_rs/
normalize.rs

1//! Lexical path normalization and filesystem canonicalization.
2//!
3//! # Important distinction
4//!
5//! - [`normalize`] is **lexical**: no filesystem access, no symlink resolution.
6//! - [`canonicalize_existing`] accesses the filesystem, requires existence, and resolves symlinks.
7
8use crate::error::PathError;
9use crate::internal::components::is_drive_relative_path;
10use crate::internal::validation::reject_nul_path;
11use std::path::{Component, Path, PathBuf};
12
13/// Lexically normalize a path without accessing the filesystem.
14///
15/// Collapses `.`, resolves `..` where possible, and removes empty segments
16/// produced by repeated separators. Leading `..` components on relative paths
17/// are preserved. Absolute paths that would escape the root via `..` stop at
18/// the root (Unix `/` or Windows drive/UNC root).
19///
20/// # Filesystem access
21///
22/// **No.** This never calls `std::fs::canonicalize` and does not follow symlinks.
23///
24/// # Drive-relative paths
25///
26/// Windows drive-relative paths (`C:foo`, `C:`) are rejected.
27///
28/// # Examples
29///
30/// ```
31/// use path_rs::normalize;
32/// use std::path::PathBuf;
33///
34/// let p = normalize("foo/./bar/../baz").unwrap();
35/// assert_eq!(p, PathBuf::from("foo/baz"));
36/// ```
37pub fn normalize(path: impl AsRef<Path>) -> Result<PathBuf, PathError> {
38    let path = path.as_ref();
39    reject_nul_path(path)?;
40
41    if path.as_os_str().is_empty() {
42        return Err(PathError::EmptyInput);
43    }
44
45    if is_drive_relative_path(path) {
46        return Err(PathError::drive_relative(path));
47    }
48
49    let mut out = PathBuf::new();
50    let mut absolute = false;
51
52    for component in path.components() {
53        match component {
54            Component::Prefix(prefix) => {
55                out.push(prefix.as_os_str());
56            }
57            Component::RootDir => {
58                out.push(component.as_os_str());
59                absolute = true;
60            }
61            Component::CurDir => {
62                // Skip `.`
63            }
64            Component::ParentDir => {
65                match out.components().next_back() {
66                    None => {
67                        if !absolute {
68                            out.push("..");
69                        }
70                    }
71                    Some(Component::ParentDir) => {
72                        // Relative path already stacked with `..`
73                        out.push("..");
74                    }
75                    Some(Component::RootDir) | Some(Component::Prefix(_)) => {
76                        // Cannot escape absolute root / prefix.
77                    }
78                    Some(Component::Normal(_)) => {
79                        out.pop();
80                    }
81                    Some(Component::CurDir) => {
82                        out.pop();
83                        out.push("..");
84                    }
85                }
86            }
87            Component::Normal(c) => {
88                out.push(c);
89            }
90        }
91    }
92
93    // Preserve a bare root / prefix-only path.
94    if out.as_os_str().is_empty() {
95        if absolute {
96            // Should not happen if RootDir was pushed.
97            out.push(Component::RootDir.as_os_str());
98        } else {
99            out.push(".");
100        }
101    }
102
103    Ok(out)
104}
105
106/// Canonicalize an existing path using the filesystem.
107///
108/// This resolves symlinks and requires every component to exist (platform rules apply).
109/// On Windows, the result may be a verbatim `\\?\` path; [`crate::platform::simplify_for_display`]
110/// can be used for user-facing output.
111///
112/// # Filesystem access
113///
114/// **Yes.** Requires the path to exist. Resolves symlinks.
115///
116/// # Examples
117///
118/// ```no_run
119/// use path_rs::canonicalize_existing;
120///
121/// let p = canonicalize_existing(".").unwrap();
122/// assert!(p.is_absolute());
123/// ```
124pub fn canonicalize_existing(path: impl AsRef<Path>) -> Result<PathBuf, PathError> {
125    let path = path.as_ref();
126    reject_nul_path(path)?;
127
128    if is_drive_relative_path(path) {
129        return Err(PathError::drive_relative(path));
130    }
131
132    std::fs::canonicalize(path).map_err(|e| PathError::filesystem(path, e))
133}
134
135#[cfg(test)]
136mod tests {
137    use super::*;
138
139    #[test]
140    fn collapses_dot_and_parent() {
141        assert_eq!(
142            normalize("foo/./bar/../baz").unwrap(),
143            PathBuf::from("foo/baz")
144        );
145        assert_eq!(normalize("./foo").unwrap(), PathBuf::from("foo"));
146        assert_eq!(normalize("foo//bar").unwrap(), PathBuf::from("foo/bar"));
147    }
148
149    #[test]
150    fn preserves_leading_parent_on_relative() {
151        assert_eq!(normalize("../foo").unwrap(), PathBuf::from("../foo"));
152        assert_eq!(normalize("foo/../../bar").unwrap(), PathBuf::from("../bar"));
153    }
154
155    #[test]
156    fn empty_rejected() {
157        assert!(matches!(normalize(""), Err(PathError::EmptyInput)));
158    }
159}