@@ -6,8 +6,8 @@ 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+ // Similar to fs::canonicalize, but does not resolve junctions/symlinks on Windows .
10+ // Useful for Windows to ensure we have the paths in the right casing.
1111// For unix, this is a noop.
1212pub fn norm_case < P : AsRef < Path > > ( path : P ) -> PathBuf {
1313 // On unix do not use canonicalize, results in weird issues with homebrew paths
@@ -18,29 +18,151 @@ pub fn norm_case<P: AsRef<Path>>(path: P) -> PathBuf {
1818 return path. as_ref ( ) . to_path_buf ( ) ;
1919
2020 #[ cfg( windows) ]
21- use std:: fs;
21+ {
22+ // Use GetLongPathNameW to normalize case without resolving junctions/symlinks
23+ // This preserves user-provided paths when they go through junctions
24+ // (e.g., Windows Store Python, user junctions from C: to S: drive)
25+ get_long_path_name ( path. as_ref ( ) ) . unwrap_or_else ( || path. as_ref ( ) . to_path_buf ( ) )
26+ }
27+ }
2228
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- } else {
35- resolved
29+ /// Uses Windows GetLongPathNameW API to normalize path casing
30+ /// without resolving symlinks or junctions.
31+ #[ cfg( windows) ]
32+ fn get_long_path_name ( path : & Path ) -> Option < PathBuf > {
33+ use std:: ffi:: OsString ;
34+ use std:: os:: windows:: ffi:: { OsStrExt , OsStringExt } ;
35+ use windows_sys:: Win32 :: Storage :: FileSystem :: GetLongPathNameW ;
36+
37+ // Convert path to wide string (null-terminated)
38+ let wide_path: Vec < u16 > = path
39+ . as_os_str ( )
40+ . encode_wide ( )
41+ . chain ( std:: iter:: once ( 0 ) )
42+ . collect ( ) ;
43+
44+ // First call to get required buffer size
45+ let required_len = unsafe { GetLongPathNameW ( wide_path. as_ptr ( ) , std:: ptr:: null_mut ( ) , 0 ) } ;
46+ if required_len == 0 {
47+ return None ;
48+ }
49+
50+ // Allocate buffer and get the long path name
51+ let mut buffer: Vec < u16 > = vec ! [ 0 ; required_len as usize ] ;
52+ let result = unsafe { GetLongPathNameW ( wide_path. as_ptr ( ) , buffer. as_mut_ptr ( ) , required_len) } ;
53+
54+ if result == 0 || result > required_len {
55+ return None ;
56+ }
57+
58+ // Trim the null terminator and convert back to PathBuf
59+ buffer. truncate ( result as usize ) ;
60+ Some ( PathBuf :: from ( OsString :: from_wide ( & buffer) ) )
61+ }
62+
63+ /// Checks if the given path is a Windows junction (mount point).
64+ /// Junctions are directory reparse points with IO_REPARSE_TAG_MOUNT_POINT.
65+ /// Returns false on non-Windows platforms or if the path is a regular symlink.
66+ #[ cfg( windows) ]
67+ pub fn is_junction < P : AsRef < Path > > ( path : P ) -> bool {
68+ use std:: fs:: OpenOptions ;
69+ use std:: os:: windows:: fs:: OpenOptionsExt ;
70+ use std:: os:: windows:: io:: AsRawHandle ;
71+ use windows_sys:: Win32 :: Foundation :: INVALID_HANDLE_VALUE ;
72+ use windows_sys:: Win32 :: Storage :: FileSystem :: {
73+ FileAttributeTagInfo , GetFileInformationByHandleEx , FILE_ATTRIBUTE_REPARSE_POINT ,
74+ FILE_FLAG_BACKUP_SEMANTICS , FILE_FLAG_OPEN_REPARSE_POINT ,
75+ } ;
76+
77+ const IO_REPARSE_TAG_MOUNT_POINT : u32 = 0xA0000003 ;
78+
79+ #[ repr( C ) ]
80+ struct FILE_ATTRIBUTE_TAG_INFO {
81+ file_attributes : u32 ,
82+ reparse_tag : u32 ,
83+ }
84+
85+ // Check if it's a reparse point first using metadata
86+ let metadata = match std:: fs:: symlink_metadata ( & path) {
87+ Ok ( m) => m,
88+ Err ( _) => return false ,
89+ } ;
90+
91+ // Use file_attributes to check for reparse point
92+ use std:: os:: windows:: fs:: MetadataExt ;
93+ let attrs = metadata. file_attributes ( ) ;
94+ if attrs & FILE_ATTRIBUTE_REPARSE_POINT == 0 {
95+ return false ;
96+ }
97+
98+ // Open the file/directory to get the reparse tag
99+ let file = match OpenOptions :: new ( )
100+ . read ( true )
101+ . custom_flags ( FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT )
102+ . open ( & path)
103+ {
104+ Ok ( f) => f,
105+ Err ( _) => return false ,
106+ } ;
107+
108+ let handle = file. as_raw_handle ( ) ;
109+ if handle as isize == INVALID_HANDLE_VALUE {
110+ return false ;
111+ }
112+
113+ let mut tag_info = FILE_ATTRIBUTE_TAG_INFO {
114+ file_attributes : 0 ,
115+ reparse_tag : 0 ,
116+ } ;
117+
118+ let success = unsafe {
119+ GetFileInformationByHandleEx (
120+ handle as * mut _ ,
121+ FileAttributeTagInfo ,
122+ & mut tag_info as * mut _ as * mut _ ,
123+ std:: mem:: size_of :: < FILE_ATTRIBUTE_TAG_INFO > ( ) as u32 ,
124+ )
125+ } ;
126+
127+ if success == 0 {
128+ return false ;
129+ }
130+
131+ // IO_REPARSE_TAG_MOUNT_POINT indicates a junction
132+ tag_info. reparse_tag == IO_REPARSE_TAG_MOUNT_POINT
133+ }
134+
135+ #[ cfg( not( windows) ) ]
136+ pub fn is_junction < P : AsRef < Path > > ( _path : P ) -> bool {
137+ // Junctions only exist on Windows
138+ false
139+ }
140+
141+ /// Checks if any component of the given path traverses through a junction.
142+ /// This is useful for determining if a path was accessed via a junction.
143+ #[ cfg( windows) ]
144+ pub fn path_contains_junction < P : AsRef < Path > > ( path : P ) -> bool {
145+ let path = path. as_ref ( ) ;
146+ let mut current = PathBuf :: new ( ) ;
147+
148+ for component in path. components ( ) {
149+ current. push ( component) ;
150+ if current. exists ( ) && is_junction ( & current) {
151+ return true ;
36152 }
37- } else {
38- path. as_ref ( ) . to_path_buf ( )
39153 }
154+ false
155+ }
156+
157+ #[ cfg( not( windows) ) ]
158+ pub fn path_contains_junction < P : AsRef < Path > > ( _path : P ) -> bool {
159+ false
40160}
41161
42162// Resolves symlinks to the real file.
43163// If the real file == exe, then it is not a symlink.
164+ // Note: Windows junctions are NOT resolved - only true symlinks are resolved.
165+ // This preserves user-provided paths that traverse through junctions.
44166pub fn resolve_symlink < T : AsRef < Path > > ( exe : & T ) -> Option < PathBuf > {
45167 let name = exe. as_ref ( ) . file_name ( ) ?. to_string_lossy ( ) ;
46168 // In bin directory of homebrew, we have files like python-build, python-config, python3-config
@@ -58,6 +180,22 @@ pub fn resolve_symlink<T: AsRef<Path>>(exe: &T) -> Option<PathBuf> {
58180 if metadata. is_file ( ) || !metadata. file_type ( ) . is_symlink ( ) {
59181 return None ;
60182 }
183+
184+ // On Windows, check if this is a junction - we don't want to resolve junctions
185+ // as they may point to system-only locations (e.g., Windows Store Python)
186+ // or the user may have set up junctions intentionally to map drives.
187+ #[ cfg( windows) ]
188+ if is_junction ( exe) {
189+ return None ;
190+ }
191+
192+ // Also check if any parent directory is a junction - if so, don't resolve
193+ // as the user's path should be preserved.
194+ #[ cfg( windows) ]
195+ if path_contains_junction ( exe) {
196+ return None ;
197+ }
198+
61199 if let Ok ( readlink) = std:: fs:: canonicalize ( exe) {
62200 if readlink == exe. as_ref ( ) . to_path_buf ( ) {
63201 None
@@ -107,3 +245,101 @@ fn get_user_home() -> Option<PathBuf> {
107245 Err ( _) => None ,
108246 }
109247}
248+
249+ #[ cfg( test) ]
250+ mod tests {
251+ use super :: * ;
252+
253+ #[ test]
254+ fn test_norm_case_returns_path_unchanged_on_nonexistent ( ) {
255+ // For non-existent paths, norm_case should return the original path
256+ let path = PathBuf :: from ( "/nonexistent/path/to/python" ) ;
257+ let result = norm_case ( & path) ;
258+ assert_eq ! ( result, path) ;
259+ }
260+
261+ #[ test]
262+ fn test_is_junction_returns_false_for_regular_file ( ) {
263+ // Create a temp file and verify it's not detected as a junction
264+ let temp_dir = std:: env:: temp_dir ( ) ;
265+ let test_file = temp_dir. join ( "test_junction_check.txt" ) ;
266+ std:: fs:: write ( & test_file, "test" ) . ok ( ) ;
267+
268+ assert ! ( !is_junction( & test_file) ) ;
269+
270+ // Cleanup
271+ std:: fs:: remove_file ( & test_file) . ok ( ) ;
272+ }
273+
274+ #[ test]
275+ fn test_is_junction_returns_false_for_regular_directory ( ) {
276+ // Regular directories should not be detected as junctions
277+ let temp_dir = std:: env:: temp_dir ( ) ;
278+ assert ! ( !is_junction( & temp_dir) ) ;
279+ }
280+
281+ #[ test]
282+ fn test_is_junction_returns_false_for_nonexistent_path ( ) {
283+ let path = PathBuf :: from ( "/nonexistent/path" ) ;
284+ assert ! ( !is_junction( & path) ) ;
285+ }
286+
287+ #[ test]
288+ fn test_path_contains_junction_returns_false_for_regular_path ( ) {
289+ // Regular paths should not be detected as containing junctions
290+ let temp_dir = std:: env:: temp_dir ( ) ;
291+ assert ! ( !path_contains_junction( & temp_dir) ) ;
292+ }
293+
294+ #[ test]
295+ fn test_path_contains_junction_returns_false_for_nonexistent_path ( ) {
296+ let path = PathBuf :: from ( "/nonexistent/path/to/file" ) ;
297+ assert ! ( !path_contains_junction( & path) ) ;
298+ }
299+
300+ #[ test]
301+ fn test_resolve_symlink_returns_none_for_regular_file ( ) {
302+ // Create a temp file named python_test to pass the name filter
303+ let temp_dir = std:: env:: temp_dir ( ) ;
304+ let test_file = temp_dir. join ( "python_test" ) ;
305+ std:: fs:: write ( & test_file, "test" ) . ok ( ) ;
306+
307+ // Regular files should not be resolved as symlinks
308+ assert ! ( resolve_symlink( & test_file) . is_none( ) ) ;
309+
310+ // Cleanup
311+ std:: fs:: remove_file ( & test_file) . ok ( ) ;
312+ }
313+
314+ #[ test]
315+ fn test_resolve_symlink_skips_config_files ( ) {
316+ let path = PathBuf :: from ( "/usr/bin/python-config" ) ;
317+ assert ! ( resolve_symlink( & path) . is_none( ) ) ;
318+
319+ let path2 = PathBuf :: from ( "/usr/bin/python-build" ) ;
320+ assert ! ( resolve_symlink( & path2) . is_none( ) ) ;
321+ }
322+
323+ #[ test]
324+ fn test_resolve_symlink_skips_non_python_files ( ) {
325+ let path = PathBuf :: from ( "/usr/bin/ruby" ) ;
326+ assert ! ( resolve_symlink( & path) . is_none( ) ) ;
327+ }
328+
329+ #[ cfg( unix) ]
330+ #[ test]
331+ fn test_norm_case_is_noop_on_unix ( ) {
332+ // On Unix, norm_case should return the path unchanged
333+ let path = PathBuf :: from ( "/usr/bin/python3" ) ;
334+ let result = norm_case ( & path) ;
335+ assert_eq ! ( result, path) ;
336+ }
337+
338+ #[ cfg( unix) ]
339+ #[ test]
340+ fn test_is_junction_always_false_on_unix ( ) {
341+ // Junctions don't exist on Unix
342+ let path = PathBuf :: from ( "/usr/bin" ) ;
343+ assert ! ( !is_junction( & path) ) ;
344+ }
345+ }
0 commit comments