Skip to content

Commit 9570adc

Browse files
committed
fix: avoid resolving windows junctions
1 parent 05f4529 commit 9570adc

File tree

4 files changed

+290
-26
lines changed

4 files changed

+290
-26
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: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,10 @@ 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 = [
10+
"Win32_Foundation",
11+
"Win32_Storage_FileSystem",
12+
] }
913

1014
[dependencies]
1115
log = "0.4.21"

crates/pet-fs/src/path.rs

Lines changed: 254 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -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.
1212
pub 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.
44166
pub 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

Comments
 (0)