Skip to content

Commit 9d5a308

Browse files
authored
fix Windows-specific path normalization using GetLongPathNameW (#277)
Fixes #186
1 parent 9dac444 commit 9d5a308

File tree

3 files changed

+303
-17
lines changed

3 files changed

+303
-17
lines changed

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/pet-fs/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ license = "MIT"
66

77
[target.'cfg(target_os = "windows")'.dependencies]
88
msvc_spectre_libs = { version = "0.1.1", features = ["error"] }
9+
windows-sys = { version = "0.59", features = ["Win32_Storage_FileSystem", "Win32_Foundation"] }
910

1011
[dependencies]
1112
log = "0.4.21"

crates/pet-fs/src/path.rs

Lines changed: 301 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,12 @@ use std::{
66
path::{Path, PathBuf},
77
};
88

9-
// Similar to fs::canonicalize, but ignores UNC paths and returns the path as is (for windows).
10-
// Usefulfor windows to ensure we have the paths in the right casing.
9+
// Normalizes the case of a path on Windows without resolving junctions/symlinks.
10+
// 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.
14+
// See: https://github.com/microsoft/python-environment-tools/issues/186
1215
pub fn norm_case<P: AsRef<Path>>(path: P) -> PathBuf {
1316
// On unix do not use canonicalize, results in weird issues with homebrew paths
1417
// Even readlink does the same thing
@@ -18,25 +21,96 @@ pub fn norm_case<P: AsRef<Path>>(path: P) -> PathBuf {
1821
return path.as_ref().to_path_buf();
1922

2023
#[cfg(windows)]
21-
use std::fs;
24+
{
25+
// First, convert to absolute path if relative, without resolving symlinks/junctions
26+
let absolute_path = if path.as_ref().is_absolute() {
27+
path.as_ref().to_path_buf()
28+
} else if let Ok(abs) = std::env::current_dir() {
29+
abs.join(path.as_ref())
30+
} else {
31+
path.as_ref().to_path_buf()
32+
};
2233

23-
#[cfg(windows)]
24-
if let Ok(resolved) = fs::canonicalize(&path) {
25-
if cfg!(unix) {
26-
return resolved;
27-
}
28-
// Windows specific handling, https://github.com/rust-lang/rust/issues/42869
29-
let has_unc_prefix = path.as_ref().to_string_lossy().starts_with(r"\\?\");
30-
if resolved.to_string_lossy().starts_with(r"\\?\") && !has_unc_prefix {
31-
// If the resolved path has a UNC prefix, but the original path did not,
32-
// we need to remove the UNC prefix.
33-
PathBuf::from(resolved.to_string_lossy().trim_start_matches(r"\\?\"))
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)
37+
}
38+
}
39+
40+
/// Windows-specific path case normalization using GetLongPathNameW.
41+
/// 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.
47+
#[cfg(windows)]
48+
fn normalize_case_windows(path: &Path) -> Option<PathBuf> {
49+
use std::ffi::OsString;
50+
use std::os::windows::ffi::{OsStrExt, OsStringExt};
51+
use windows_sys::Win32::Storage::FileSystem::GetLongPathNameW;
52+
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+
57+
// Normalize forward slashes to backslashes (canonicalize did this)
58+
let path_str = original_path_str.replace('/', "\\");
59+
let normalized_path = PathBuf::from(&path_str);
60+
61+
// Convert path to wide string (UTF-16) with null terminator
62+
let wide_path: Vec<u16> = normalized_path
63+
.as_os_str()
64+
.encode_wide()
65+
.chain(std::iter::once(0))
66+
.collect();
67+
68+
// First call to get required buffer size
69+
let required_len = unsafe { GetLongPathNameW(wide_path.as_ptr(), std::ptr::null_mut(), 0) };
70+
71+
if required_len == 0 {
72+
// GetLongPathNameW failed (path likely doesn't exist), return None
73+
return None;
74+
}
75+
76+
// Allocate buffer and get the normalized path
77+
let mut buffer: Vec<u16> = vec![0; required_len as usize];
78+
let actual_len =
79+
unsafe { GetLongPathNameW(wide_path.as_ptr(), buffer.as_mut_ptr(), required_len) };
80+
81+
if actual_len == 0 || actual_len > required_len {
82+
// Call failed or buffer too small
83+
return None;
84+
}
85+
86+
// Truncate buffer to actual length (excluding null terminator)
87+
buffer.truncate(actual_len as usize);
88+
89+
// Convert back to PathBuf
90+
let os_string = OsString::from_wide(&buffer);
91+
let mut result_str = os_string.to_string_lossy().to_string();
92+
93+
// Remove UNC prefix if original path didn't have it
94+
// GetLongPathNameW may add \\?\ prefix in some cases
95+
if result_str.starts_with(r"\\?\") && !original_has_unc {
96+
result_str = result_str.trim_start_matches(r"\\?\").to_string();
97+
}
98+
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);
34108
} else {
35-
resolved
109+
break;
36110
}
37-
} else {
38-
path.as_ref().to_path_buf()
39111
}
112+
113+
Some(PathBuf::from(result_str))
40114
}
41115

42116
// Resolves symlinks to the real file.
@@ -107,3 +181,213 @@ fn get_user_home() -> Option<PathBuf> {
107181
Err(_) => None,
108182
}
109183
}
184+
185+
#[cfg(test)]
186+
mod tests {
187+
use super::*;
188+
189+
#[test]
190+
#[cfg(unix)]
191+
fn test_norm_case_returns_path_for_nonexistent_unix() {
192+
// On Unix, norm_case returns the path unchanged (noop)
193+
let nonexistent = PathBuf::from("/this/path/does/not/exist/anywhere");
194+
let result = norm_case(&nonexistent);
195+
assert_eq!(result, nonexistent);
196+
}
197+
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+
216+
#[test]
217+
fn test_norm_case_existing_path() {
218+
// norm_case should work on existing paths
219+
let temp_dir = std::env::temp_dir();
220+
let result = norm_case(&temp_dir);
221+
// On unix, should return unchanged; on Windows, should normalize case
222+
assert!(result.exists());
223+
}
224+
225+
#[test]
226+
#[cfg(unix)]
227+
fn test_norm_case_unix_noop() {
228+
// On unix, norm_case should return the path unchanged
229+
let path = PathBuf::from("/Some/Path/With/Mixed/Case");
230+
let result = norm_case(&path);
231+
assert_eq!(result, path);
232+
}
233+
234+
#[test]
235+
#[cfg(windows)]
236+
fn test_norm_case_windows_case_normalization() {
237+
// On Windows, norm_case should normalize the case of existing paths
238+
// Use the Windows directory which always exists
239+
let path = PathBuf::from("c:\\windows\\system32");
240+
let result = norm_case(&path);
241+
// The result should have proper casing (C:\Windows\System32)
242+
assert!(result.to_string_lossy().contains("Windows"));
243+
assert!(result.to_string_lossy().contains("System32"));
244+
}
245+
246+
#[test]
247+
#[cfg(windows)]
248+
fn test_norm_case_windows_preserves_junction() {
249+
// This is the key test for issue #186:
250+
// norm_case should NOT resolve junctions to their target
251+
use std::fs;
252+
use std::process::Command;
253+
254+
let temp_dir = std::env::temp_dir();
255+
let target_dir = temp_dir.join("pet_test_junction_target");
256+
let junction_dir = temp_dir.join("pet_test_junction_link");
257+
258+
// Clean up any existing test directories
259+
let _ = fs::remove_dir_all(&target_dir);
260+
let _ = fs::remove_dir_all(&junction_dir);
261+
262+
// Create target directory
263+
fs::create_dir_all(&target_dir).expect("Failed to create target directory");
264+
265+
// Create a junction using mklink /J (requires no special privileges)
266+
let output = Command::new("cmd")
267+
.args([
268+
"/C",
269+
"mklink",
270+
"/J",
271+
&junction_dir.to_string_lossy(),
272+
&target_dir.to_string_lossy(),
273+
])
274+
.output()
275+
.expect("Failed to create junction");
276+
277+
if !output.status.success() {
278+
// Clean up and skip test if junction creation failed
279+
let _ = fs::remove_dir_all(&target_dir);
280+
eprintln!(
281+
"Skipping junction test - failed to create junction: {}",
282+
String::from_utf8_lossy(&output.stderr)
283+
);
284+
return;
285+
}
286+
287+
// Verify junction was created
288+
assert!(junction_dir.exists(), "Junction should exist");
289+
290+
// The key assertion: norm_case should return the junction path, NOT the target path
291+
let result = norm_case(&junction_dir);
292+
293+
// The result should still be the junction path, not resolved to target
294+
// Compare the path names (case-insensitive on Windows)
295+
assert!(
296+
result
297+
.to_string_lossy()
298+
.to_lowercase()
299+
.contains("pet_test_junction_link"),
300+
"norm_case should preserve junction path, got: {:?}",
301+
result
302+
);
303+
assert!(
304+
!result
305+
.to_string_lossy()
306+
.to_lowercase()
307+
.contains("pet_test_junction_target"),
308+
"norm_case should NOT resolve to target path, got: {:?}",
309+
result
310+
);
311+
312+
// Clean up
313+
// Remove junction first (using rmdir, not remove_dir_all, to not follow the junction)
314+
let _ = Command::new("cmd")
315+
.args(["/C", "rmdir", &junction_dir.to_string_lossy()])
316+
.output();
317+
let _ = fs::remove_dir_all(&target_dir);
318+
}
319+
320+
#[test]
321+
#[cfg(windows)]
322+
fn test_norm_case_windows_relative_path() {
323+
// Test that relative paths are converted to absolute
324+
let relative = PathBuf::from(".");
325+
let result = norm_case(&relative);
326+
assert!(result.is_absolute(), "Result should be absolute path");
327+
}
328+
329+
#[test]
330+
#[cfg(windows)]
331+
fn test_norm_case_windows_no_unc_prefix_added() {
332+
// Ensure we don't add UNC prefix to paths that didn't have it
333+
let path = PathBuf::from("C:\\Windows");
334+
let result = norm_case(&path);
335+
assert!(
336+
!result.to_string_lossy().starts_with(r"\\?\"),
337+
"Should not add UNC prefix"
338+
);
339+
}
340+
341+
#[test]
342+
#[cfg(windows)]
343+
fn test_norm_case_windows_strips_trailing_slash() {
344+
// norm_case should strip trailing slashes to match canonicalize behavior
345+
let path_with_slash = PathBuf::from("C:\\Windows\\");
346+
let result = norm_case(&path_with_slash);
347+
assert!(
348+
!result.to_string_lossy().ends_with('\\'),
349+
"Should strip trailing backslash, got: {:?}",
350+
result
351+
);
352+
353+
// But root paths like C:\ should keep their slash
354+
let root_path = PathBuf::from("C:\\");
355+
let root_result = norm_case(&root_path);
356+
assert!(
357+
root_result.to_string_lossy().ends_with('\\'),
358+
"Root path should keep trailing backslash, got: {:?}",
359+
root_result
360+
);
361+
}
362+
363+
#[test]
364+
#[cfg(windows)]
365+
fn test_norm_case_windows_normalizes_slashes() {
366+
// norm_case should convert forward slashes to backslashes (like canonicalize did)
367+
let path_with_forward_slashes = PathBuf::from("C:/Windows/System32");
368+
let result = norm_case(&path_with_forward_slashes);
369+
assert!(
370+
!result.to_string_lossy().contains('/'),
371+
"Should convert forward slashes to backslashes, got: {:?}",
372+
result
373+
);
374+
assert!(
375+
result.to_string_lossy().contains('\\'),
376+
"Should have backslashes, got: {:?}",
377+
result
378+
);
379+
}
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+
}
393+
}

0 commit comments

Comments
 (0)