@@ -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
1215pub 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