Skip to main content

aft/
windows_path.rs

1//! Windows extended-length path normalization.
2//!
3//! `std::fs::canonicalize` returns paths in the extended-length namespace on
4//! Windows. Only DOS-drive and UNC names have a safe non-verbatim spelling;
5//! other namespaces (for example `\\?\Volume{GUID}\`) must retain their prefix.
6
7use std::path::{Path, PathBuf};
8
9/// Normalize a Windows path for comparisons and Win32 APIs.
10///
11/// Valid DOS and UNC extended-length paths lose their verbatim prefix. Other
12/// namespaces remain untouched because they cannot be represented safely
13/// without that prefix. Separators and drive letter casing are also normalized.
14pub fn normalize_windows_path(path: &Path) -> PathBuf {
15    let raw = path.to_string_lossy().replace('/', "\\");
16    let mut normalized = non_verbatim_path_text(&raw).unwrap_or(raw);
17    if normalized.as_bytes().get(1) == Some(&b':') {
18        let drive = normalized.as_bytes()[0];
19        if drive.is_ascii_lowercase() {
20            normalized.replace_range(0..1, &(drive as char).to_ascii_uppercase().to_string());
21        }
22    }
23    PathBuf::from(normalized)
24}
25
26/// String form of the strict verbatim-prefix conversion, for APIs that require a command line
27/// or URI rather than a [`Path`].
28pub fn non_verbatim_path_text(path: &str) -> Option<String> {
29    for prefix in [r"\\?\UNC\", r"\\??\UNC\", r"\??\UNC\"] {
30        if let Some(tail) = strip_ascii_prefix(path, prefix) {
31            if is_safe_unc_tail(tail) {
32                return Some(format!(r"\\{tail}"));
33            }
34            return None;
35        }
36    }
37
38    for prefix in [r"\\?\", r"\\??\", r"\??\"] {
39        if let Some(tail) = strip_ascii_prefix(path, prefix) {
40            let bytes = tail.as_bytes();
41            if bytes.len() >= 3
42                && bytes[0].is_ascii_alphabetic()
43                && bytes[1] == b':'
44                && matches!(bytes[2], b'\\' | b'/')
45                && !has_dot_component(tail)
46            {
47                return Some(tail.to_string());
48            }
49            return None;
50        }
51    }
52
53    None
54}
55
56fn is_safe_unc_tail(tail: &str) -> bool {
57    let mut components = tail.split(['\\', '/']);
58    components.next().is_some_and(|server| !server.is_empty())
59        && components.next().is_some_and(|share| !share.is_empty())
60        && !has_dot_component(tail)
61}
62
63fn has_dot_component(path: &str) -> bool {
64    path.split(['\\', '/'])
65        .any(|component| matches!(component, "." | ".."))
66}
67
68fn strip_ascii_prefix<'a>(value: &'a str, prefix: &str) -> Option<&'a str> {
69    let head = value.get(..prefix.len())?;
70    if head.eq_ignore_ascii_case(prefix) {
71        value.get(prefix.len()..)
72    } else {
73        None
74    }
75}
76
77#[cfg(test)]
78mod tests {
79    use super::{non_verbatim_path_text, normalize_windows_path};
80    use std::path::{Path, PathBuf};
81
82    #[test]
83    fn converts_only_valid_dos_and_unc_verbatim_paths() {
84        assert_eq!(
85            non_verbatim_path_text(r"\\?\C:\cache\server.cmd"),
86            Some(r"C:\cache\server.cmd".to_string())
87        );
88        assert_eq!(
89            non_verbatim_path_text(r"\\?\unc\host\share\server.cmd"),
90            Some(r"\\host\share\server.cmd".to_string())
91        );
92        assert_eq!(
93            normalize_windows_path(Path::new(r"\\??\d:\repo")),
94            PathBuf::from(r"D:\repo")
95        );
96    }
97
98    #[test]
99    fn preserves_unsupported_or_malformed_verbatim_namespaces() {
100        for path in [
101            r"\\?\Volume{1234}\server.cmd",
102            r"\\?\UNC\host",
103            r"\\?\UNC\\host\share",
104            r"\\??\UNC\\host\share",
105            r"\\?\UNC\host\share\..\file",
106            r"\\?\C:\repo\..\other",
107            r"\\?\C:relative",
108            r"\\?\relative",
109            r"C:\ordinary\path",
110        ] {
111            assert_eq!(non_verbatim_path_text(path), None, "{path}");
112        }
113    }
114}