Skip to content

Commit a7d96b5

Browse files
committed
fix: enhance Windows path normalization to preserve UNC prefix and improve fallback behavior for non-existent paths
1 parent 3ce773c commit a7d96b5

File tree

1 file changed

+62
-11
lines changed

1 file changed

+62
-11
lines changed

crates/pet-fs/src/path.rs

Lines changed: 62 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ use std::{
99
// Normalizes the case of a path on Windows without resolving junctions/symlinks.
1010
// Uses GetLongPathNameW which normalizes case but preserves junction paths.
1111
// For unix, this is a noop.
12+
// Note: On Windows, case normalization only works for existing paths. For non-existent
13+
// paths, the function falls back to the absolute path without case normalization.
1214
// See: https://github.com/microsoft/python-environment-tools/issues/186
1315
pub fn norm_case<P: AsRef<Path>>(path: P) -> PathBuf {
1416
// On unix do not use canonicalize, results in weird issues with homebrew paths
@@ -29,21 +31,31 @@ pub fn norm_case<P: AsRef<Path>>(path: P) -> PathBuf {
2931
path.as_ref().to_path_buf()
3032
};
3133

32-
// Use GetLongPathNameW to normalize case without resolving junctions
33-
normalize_case_windows(&absolute_path).unwrap_or_else(|| path.as_ref().to_path_buf())
34+
// Use GetLongPathNameW to normalize case without resolving junctions.
35+
// If normalization fails, fall back to the computed absolute path to keep behavior consistent.
36+
normalize_case_windows(&absolute_path).unwrap_or(absolute_path)
3437
}
3538
}
3639

3740
/// Windows-specific path case normalization using GetLongPathNameW.
3841
/// This normalizes the case of path components but does NOT resolve junctions or symlinks.
42+
/// Note: GetLongPathNameW requires the path to exist on the filesystem to normalize it.
43+
/// For non-existent paths, it will fail and this function returns None.
44+
/// Also note: Converting paths to strings via to_string_lossy() may lose information
45+
/// for paths with invalid UTF-8 sequences (replaced with U+FFFD), though Windows paths
46+
/// are typically valid Unicode.
3947
#[cfg(windows)]
4048
fn normalize_case_windows(path: &Path) -> Option<PathBuf> {
4149
use std::ffi::OsString;
4250
use std::os::windows::ffi::{OsStrExt, OsStringExt};
4351
use windows_sys::Win32::Storage::FileSystem::GetLongPathNameW;
4452

53+
// Check if original path has UNC prefix before normalization
54+
let original_path_str = path.to_string_lossy();
55+
let original_has_unc = original_path_str.starts_with(r"\\?\");
56+
4557
// Normalize forward slashes to backslashes (canonicalize did this)
46-
let path_str = path.to_string_lossy().replace('/', "\\");
58+
let path_str = original_path_str.replace('/', "\\");
4759
let normalized_path = PathBuf::from(&path_str);
4860

4961
// Convert path to wide string (UTF-16) with null terminator
@@ -57,7 +69,7 @@ fn normalize_case_windows(path: &Path) -> Option<PathBuf> {
5769
let required_len = unsafe { GetLongPathNameW(wide_path.as_ptr(), std::ptr::null_mut(), 0) };
5870

5971
if required_len == 0 {
60-
// GetLongPathNameW failed, return None
72+
// GetLongPathNameW failed (path likely doesn't exist), return None
6173
return None;
6274
}
6375

@@ -80,15 +92,22 @@ fn normalize_case_windows(path: &Path) -> Option<PathBuf> {
8092

8193
// Remove UNC prefix if original path didn't have it
8294
// GetLongPathNameW may add \\?\ prefix in some cases
83-
let original_has_unc = path_str.starts_with(r"\\?\");
8495
if result_str.starts_with(r"\\?\") && !original_has_unc {
8596
result_str = result_str.trim_start_matches(r"\\?\").to_string();
8697
}
8798

88-
// Strip trailing path separators to match canonicalize behavior
89-
// (but keep the root like "C:\")
90-
while result_str.len() > 3 && (result_str.ends_with('\\') || result_str.ends_with('/')) {
91-
result_str.pop();
99+
// Strip trailing path separators to match canonicalize behavior,
100+
// but avoid stripping them from root paths (drive roots, UNC roots, network paths).
101+
// We use Path::parent() to detect root paths robustly.
102+
let mut current_path = PathBuf::from(&result_str);
103+
while current_path.parent().is_some() {
104+
let s = current_path.to_string_lossy();
105+
if s.ends_with('\\') || s.ends_with('/') {
106+
result_str.pop();
107+
current_path = PathBuf::from(&result_str);
108+
} else {
109+
break;
110+
}
92111
}
93112

94113
Some(PathBuf::from(result_str))
@@ -168,13 +187,32 @@ mod tests {
168187
use super::*;
169188

170189
#[test]
171-
fn test_norm_case_returns_path_for_nonexistent() {
172-
// norm_case should return the original path if it doesn't exist
190+
#[cfg(unix)]
191+
fn test_norm_case_returns_path_for_nonexistent_unix() {
192+
// On Unix, norm_case returns the path unchanged (noop)
173193
let nonexistent = PathBuf::from("/this/path/does/not/exist/anywhere");
174194
let result = norm_case(&nonexistent);
175195
assert_eq!(result, nonexistent);
176196
}
177197

198+
#[test]
199+
#[cfg(windows)]
200+
fn test_norm_case_returns_absolute_for_nonexistent_windows() {
201+
// On Windows, norm_case returns an absolute path even for non-existent paths
202+
// (falls back to absolute_path when GetLongPathNameW fails)
203+
let nonexistent = PathBuf::from("C:\\this\\path\\does\\not\\exist\\anywhere");
204+
let result = norm_case(&nonexistent);
205+
assert!(result.is_absolute(), "Result should be absolute path");
206+
// The path should be preserved (just made absolute if it wasn't)
207+
assert!(
208+
result
209+
.to_string_lossy()
210+
.to_lowercase()
211+
.contains("does\\not\\exist"),
212+
"Path components should be preserved"
213+
);
214+
}
215+
178216
#[test]
179217
fn test_norm_case_existing_path() {
180218
// norm_case should work on existing paths
@@ -339,4 +377,17 @@ mod tests {
339377
result
340378
);
341379
}
380+
381+
#[test]
382+
#[cfg(windows)]
383+
fn test_norm_case_windows_preserves_unc_prefix() {
384+
// If the original path has a UNC prefix, it should be preserved
385+
let unc_path = PathBuf::from(r"\\?\C:\Windows");
386+
let result = norm_case(&unc_path);
387+
assert!(
388+
result.to_string_lossy().starts_with(r"\\?\"),
389+
"Should preserve UNC prefix when present in original, got: {:?}",
390+
result
391+
);
392+
}
342393
}

0 commit comments

Comments
 (0)