diff --git a/Cargo.lock b/Cargo.lock index f1200f1e..294f5f84 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -470,6 +470,7 @@ version = "0.1.0" dependencies = [ "log", "msvc_spectre_libs", + "windows-sys 0.59.0", ] [[package]] diff --git a/crates/pet-fs/Cargo.toml b/crates/pet-fs/Cargo.toml index 1774d51a..ff0439cd 100644 --- a/crates/pet-fs/Cargo.toml +++ b/crates/pet-fs/Cargo.toml @@ -6,6 +6,10 @@ license = "MIT" [target.'cfg(target_os = "windows")'.dependencies] msvc_spectre_libs = { version = "0.1.1", features = ["error"] } +windows-sys = { version = "0.59", features = [ + "Win32_Foundation", + "Win32_Storage_FileSystem", +] } [dependencies] log = "0.4.21" diff --git a/crates/pet-fs/src/path.rs b/crates/pet-fs/src/path.rs index 39a2e823..8343df50 100644 --- a/crates/pet-fs/src/path.rs +++ b/crates/pet-fs/src/path.rs @@ -6,8 +6,8 @@ use std::{ path::{Path, PathBuf}, }; -// Similar to fs::canonicalize, but ignores UNC paths and returns the path as is (for windows). -// Usefulfor windows to ensure we have the paths in the right casing. +// Similar to fs::canonicalize, but does not resolve junctions/symlinks on Windows. +// Useful for Windows to ensure we have the paths in the right casing. // For unix, this is a noop. pub fn norm_case>(path: P) -> PathBuf { // On unix do not use canonicalize, results in weird issues with homebrew paths @@ -18,29 +18,151 @@ pub fn norm_case>(path: P) -> PathBuf { return path.as_ref().to_path_buf(); #[cfg(windows)] - use std::fs; + { + // Use GetLongPathNameW to normalize case without resolving junctions/symlinks + // This preserves user-provided paths when they go through junctions + // (e.g., Windows Store Python, user junctions from C: to S: drive) + get_long_path_name(path.as_ref()).unwrap_or_else(|| path.as_ref().to_path_buf()) + } +} - #[cfg(windows)] - if let Ok(resolved) = fs::canonicalize(&path) { - if cfg!(unix) { - return resolved; - } - // Windows specific handling, https://github.com/rust-lang/rust/issues/42869 - let has_unc_prefix = path.as_ref().to_string_lossy().starts_with(r"\\?\"); - if resolved.to_string_lossy().starts_with(r"\\?\") && !has_unc_prefix { - // If the resolved path has a UNC prefix, but the original path did not, - // we need to remove the UNC prefix. - PathBuf::from(resolved.to_string_lossy().trim_start_matches(r"\\?\")) - } else { - resolved +/// Uses Windows GetLongPathNameW API to normalize path casing +/// without resolving symlinks or junctions. +#[cfg(windows)] +fn get_long_path_name(path: &Path) -> Option { + use std::ffi::OsString; + use std::os::windows::ffi::{OsStrExt, OsStringExt}; + use windows_sys::Win32::Storage::FileSystem::GetLongPathNameW; + + // Convert path to wide string (null-terminated) + let wide_path: Vec = path + .as_os_str() + .encode_wide() + .chain(std::iter::once(0)) + .collect(); + + // First call to get required buffer size + let required_len = unsafe { GetLongPathNameW(wide_path.as_ptr(), std::ptr::null_mut(), 0) }; + if required_len == 0 { + return None; + } + + // Allocate buffer and get the long path name + let mut buffer: Vec = vec![0; required_len as usize]; + let result = unsafe { GetLongPathNameW(wide_path.as_ptr(), buffer.as_mut_ptr(), required_len) }; + + if result == 0 || result > required_len { + return None; + } + + // Trim the null terminator and convert back to PathBuf + buffer.truncate(result as usize); + Some(PathBuf::from(OsString::from_wide(&buffer))) +} + +/// Checks if the given path is a Windows junction (mount point). +/// Junctions are directory reparse points with IO_REPARSE_TAG_MOUNT_POINT. +/// Returns false on non-Windows platforms or if the path is a regular symlink. +#[cfg(windows)] +pub fn is_junction>(path: P) -> bool { + use std::fs::OpenOptions; + use std::os::windows::fs::OpenOptionsExt; + use std::os::windows::io::AsRawHandle; + use windows_sys::Win32::Foundation::INVALID_HANDLE_VALUE; + use windows_sys::Win32::Storage::FileSystem::{ + FileAttributeTagInfo, GetFileInformationByHandleEx, FILE_ATTRIBUTE_REPARSE_POINT, + FILE_FLAG_BACKUP_SEMANTICS, FILE_FLAG_OPEN_REPARSE_POINT, + }; + + const IO_REPARSE_TAG_MOUNT_POINT: u32 = 0xA0000003; + + #[repr(C)] + struct FILE_ATTRIBUTE_TAG_INFO { + file_attributes: u32, + reparse_tag: u32, + } + + // Check if it's a reparse point first using metadata + let metadata = match std::fs::symlink_metadata(&path) { + Ok(m) => m, + Err(_) => return false, + }; + + // Use file_attributes to check for reparse point + use std::os::windows::fs::MetadataExt; + let attrs = metadata.file_attributes(); + if attrs & FILE_ATTRIBUTE_REPARSE_POINT == 0 { + return false; + } + + // Open the file/directory to get the reparse tag + let file = match OpenOptions::new() + .read(true) + .custom_flags(FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT) + .open(&path) + { + Ok(f) => f, + Err(_) => return false, + }; + + let handle = file.as_raw_handle(); + if handle as isize == INVALID_HANDLE_VALUE as isize { + return false; + } + + let mut tag_info = FILE_ATTRIBUTE_TAG_INFO { + file_attributes: 0, + reparse_tag: 0, + }; + + let success = unsafe { + GetFileInformationByHandleEx( + handle as *mut _, + FileAttributeTagInfo, + &mut tag_info as *mut _ as *mut _, + std::mem::size_of::() as u32, + ) + }; + + if success == 0 { + return false; + } + + // IO_REPARSE_TAG_MOUNT_POINT indicates a junction + tag_info.reparse_tag == IO_REPARSE_TAG_MOUNT_POINT +} + +#[cfg(not(windows))] +pub fn is_junction>(_path: P) -> bool { + // Junctions only exist on Windows + false +} + +/// Checks if any component of the given path traverses through a junction. +/// This is useful for determining if a path was accessed via a junction. +#[cfg(windows)] +pub fn path_contains_junction>(path: P) -> bool { + let path = path.as_ref(); + let mut current = PathBuf::new(); + + for component in path.components() { + current.push(component); + if current.exists() && is_junction(¤t) { + return true; } - } else { - path.as_ref().to_path_buf() } + false +} + +#[cfg(not(windows))] +pub fn path_contains_junction>(_path: P) -> bool { + false } // Resolves symlinks to the real file. // If the real file == exe, then it is not a symlink. +// Note: Windows junctions are NOT resolved - only true symlinks are resolved. +// This preserves user-provided paths that traverse through junctions. pub fn resolve_symlink>(exe: &T) -> Option { let name = exe.as_ref().file_name()?.to_string_lossy(); // In bin directory of homebrew, we have files like python-build, python-config, python3-config @@ -58,6 +180,22 @@ pub fn resolve_symlink>(exe: &T) -> Option { if metadata.is_file() || !metadata.file_type().is_symlink() { return None; } + + // On Windows, check if this is a junction - we don't want to resolve junctions + // as they may point to system-only locations (e.g., Windows Store Python) + // or the user may have set up junctions intentionally to map drives. + #[cfg(windows)] + if is_junction(exe) { + return None; + } + + // Also check if any parent directory is a junction - if so, don't resolve + // as the user's path should be preserved. + #[cfg(windows)] + if path_contains_junction(exe) { + return None; + } + if let Ok(readlink) = std::fs::canonicalize(exe) { if readlink == exe.as_ref().to_path_buf() { None @@ -107,3 +245,101 @@ fn get_user_home() -> Option { Err(_) => None, } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_norm_case_returns_path_unchanged_on_nonexistent() { + // For non-existent paths, norm_case should return the original path + let path = PathBuf::from("/nonexistent/path/to/python"); + let result = norm_case(&path); + assert_eq!(result, path); + } + + #[test] + fn test_is_junction_returns_false_for_regular_file() { + // Create a temp file and verify it's not detected as a junction + let temp_dir = std::env::temp_dir(); + let test_file = temp_dir.join("test_junction_check.txt"); + std::fs::write(&test_file, "test").ok(); + + assert!(!is_junction(&test_file)); + + // Cleanup + std::fs::remove_file(&test_file).ok(); + } + + #[test] + fn test_is_junction_returns_false_for_regular_directory() { + // Regular directories should not be detected as junctions + let temp_dir = std::env::temp_dir(); + assert!(!is_junction(&temp_dir)); + } + + #[test] + fn test_is_junction_returns_false_for_nonexistent_path() { + let path = PathBuf::from("/nonexistent/path"); + assert!(!is_junction(&path)); + } + + #[test] + fn test_path_contains_junction_returns_false_for_regular_path() { + // Regular paths should not be detected as containing junctions + let temp_dir = std::env::temp_dir(); + assert!(!path_contains_junction(&temp_dir)); + } + + #[test] + fn test_path_contains_junction_returns_false_for_nonexistent_path() { + let path = PathBuf::from("/nonexistent/path/to/file"); + assert!(!path_contains_junction(&path)); + } + + #[test] + fn test_resolve_symlink_returns_none_for_regular_file() { + // Create a temp file named python_test to pass the name filter + let temp_dir = std::env::temp_dir(); + let test_file = temp_dir.join("python_test"); + std::fs::write(&test_file, "test").ok(); + + // Regular files should not be resolved as symlinks + assert!(resolve_symlink(&test_file).is_none()); + + // Cleanup + std::fs::remove_file(&test_file).ok(); + } + + #[test] + fn test_resolve_symlink_skips_config_files() { + let path = PathBuf::from("/usr/bin/python-config"); + assert!(resolve_symlink(&path).is_none()); + + let path2 = PathBuf::from("/usr/bin/python-build"); + assert!(resolve_symlink(&path2).is_none()); + } + + #[test] + fn test_resolve_symlink_skips_non_python_files() { + let path = PathBuf::from("/usr/bin/ruby"); + assert!(resolve_symlink(&path).is_none()); + } + + #[cfg(unix)] + #[test] + fn test_norm_case_is_noop_on_unix() { + // On Unix, norm_case should return the path unchanged + let path = PathBuf::from("/usr/bin/python3"); + let result = norm_case(&path); + assert_eq!(result, path); + } + + #[cfg(unix)] + #[test] + fn test_is_junction_always_false_on_unix() { + // Junctions don't exist on Unix + let path = PathBuf::from("/usr/bin"); + assert!(!is_junction(&path)); + } +} diff --git a/crates/pet-python-utils/src/env.rs b/crates/pet-python-utils/src/env.rs index a23ec520..5f54afaf 100644 --- a/crates/pet-python-utils/src/env.rs +++ b/crates/pet-python-utils/src/env.rs @@ -3,6 +3,7 @@ use log::{error, trace}; use pet_core::{arch::Architecture, env::PythonEnv, python_environment::PythonEnvironment}; +use pet_fs::path::path_contains_junction; use serde::{Deserialize, Serialize}; use std::{ path::{Path, PathBuf}, @@ -89,9 +90,13 @@ impl ResolvedPythonEnv { fn get_interpreter_details(executable: &Path) -> Option { // Spawn the python exe and get the version, sys.prefix and sys.executable. - let executable = executable.to_str()?; + let executable_str = executable.to_str()?; let start = SystemTime::now(); - trace!("Executing Python: {} -c {}", executable, PYTHON_INFO_CMD); + trace!( + "Executing Python: {} -c {}", + executable_str, + PYTHON_INFO_CMD + ); let result = new_silent_command(executable) .args(["-c", PYTHON_INFO_CMD]) .output(); @@ -100,20 +105,38 @@ fn get_interpreter_details(executable: &Path) -> Option { let output = String::from_utf8(output.stdout).unwrap().trim().to_string(); trace!( "Executed Python {:?} in {:?} & produced an output {:?}", - executable, + executable_str, start.elapsed(), output ); if let Some((_, output)) = output.split_once(PYTHON_INFO_JSON_SEPARATOR) { if let Ok(info) = serde_json::from_str::(output) { + // Python's sys.executable may resolve junctions to their physical location. + // If the user provided a path that goes through a junction, we should + // preserve their original path instead of using the resolved path. + // This is important for: + // 1. Windows Store Python (junctions to WindowsApps) + // 2. User-created junctions (e.g., C: -> S: drive mappings) + let final_executable = if path_contains_junction(executable) { + // User's path contains a junction - preserve their path + trace!( + "Preserving user-provided path {:?} (contains junction), Python reported {:?}", + executable, + info.executable + ); + executable.to_path_buf() + } else { + PathBuf::from(info.executable.clone()) + }; + let mut symlinks = vec![ - PathBuf::from(executable), + executable.to_path_buf(), PathBuf::from(info.executable.clone()), ]; symlinks.sort(); symlinks.dedup(); Some(ResolvedPythonEnv { - executable: PathBuf::from(info.executable.clone()), + executable: final_executable, prefix: PathBuf::from(info.sys_prefix), version: info.version.trim().to_string(), is64_bit: info.is64_bit, @@ -122,14 +145,14 @@ fn get_interpreter_details(executable: &Path) -> Option { } else { error!( "Python Execution for {:?} produced an output {:?} that could not be parsed as JSON", - executable, output, + executable_str, output, ); None } } else { error!( "Python Execution for {:?} produced an output {:?} without a separator", - executable, output, + executable_str, output, ); None } @@ -137,7 +160,7 @@ fn get_interpreter_details(executable: &Path) -> Option { Err(err) => { error!( "Failed to execute Python to resolve info {:?}: {}", - executable, err + executable_str, err ); None }