@@ -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
1315pub 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) ]
4048fn 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