Files
RustPython/crates/vm/src/getpath.rs
T
youknowdot f39da82d9d Prefer resolved executable symlinks in POSIX virtual environments (#9058)
Resolve executable symlinks for virtual-environment paths while retaining macOS framework launcher behavior. Include directory-canonicalization and launcher regressions, using core for UTF-8 checks.

Assisted-by: OpenAI Codex:model identifier unavailable
2026-10-12 02:07:08 +09:00

498 lines
16 KiB
Rust

//! Path configuration for RustPython (ref: Modules/getpath.py)
//!
//! This module implements Python path calculation logic following getpath.py.
//! It uses landmark-based search to locate prefix, exec_prefix, and stdlib directories.
//!
//! The main entry point is `init_path_config()` which computes Paths from Settings.
use crate::vm::{Paths, Settings};
use std::env;
use std::path::{Path, PathBuf};
// Platform-specific landmarks (ref: getpath.py PLATFORM CONSTANTS)
#[cfg(not(windows))]
mod platform {
use crate::version;
pub(super) const BUILDDIR_TXT: &str = "pybuilddir.txt";
pub(super) const BUILD_LANDMARK: &str = "Modules/Setup.local";
pub(super) const VENV_LANDMARK: &str = "pyvenv.cfg";
pub(super) const BUILDSTDLIB_LANDMARK: &str = "Lib/os.py";
pub(super) fn stdlib_subdir() -> String {
format!("lib/python{}.{}", version::MAJOR, version::MINOR)
}
pub(super) fn stdlib_landmarks() -> [String; 2] {
let subdir = stdlib_subdir();
[format!("{subdir}/os.py"), format!("{subdir}/os.pyc")]
}
pub(super) fn platstdlib_landmark() -> String {
format!(
"lib/python{}.{}/lib-dynload",
version::MAJOR,
version::MINOR
)
}
pub(super) fn zip_landmark() -> String {
format!("lib/python{}{}.zip", version::MAJOR, version::MINOR)
}
}
#[cfg(windows)]
mod platform {
use crate::version;
pub(super) const BUILDDIR_TXT: &str = "pybuilddir.txt";
pub(super) const BUILD_LANDMARK: &str = "Modules\\Setup.local";
pub(super) const VENV_LANDMARK: &str = "pyvenv.cfg";
pub(super) const BUILDSTDLIB_LANDMARK: &str = "Lib\\os.py";
pub(super) const STDLIB_SUBDIR: &str = "Lib";
pub(super) fn stdlib_landmarks() -> [String; 2] {
["Lib\\os.py".into(), "Lib\\os.pyc".into()]
}
pub(super) fn platstdlib_landmark() -> String {
"DLLs".into()
}
pub(super) fn zip_landmark() -> String {
format!("python{}{}.zip", version::MAJOR, version::MINOR)
}
}
// Helper functions (ref: getpath.py HELPER FUNCTIONS)
/// Search upward from a directory for landmark files/directories
/// Returns the directory where a landmark was found
fn search_up<P, F>(start: P, landmarks: &[&str], test: F) -> Option<PathBuf>
where
P: AsRef<Path>,
F: Fn(&Path) -> bool,
{
let mut current = start.as_ref().to_path_buf();
loop {
for landmark in landmarks {
let path = current.join(landmark);
if test(&path) {
return Some(current);
}
}
if !current.pop() {
return None;
}
}
}
/// Search upward for a file landmark
fn search_up_file<P: AsRef<Path>>(start: P, landmarks: &[&str]) -> Option<PathBuf> {
search_up(start, landmarks, |p| p.is_file())
}
/// Search upward for a directory landmark
#[cfg(not(windows))]
fn search_up_dir<P: AsRef<Path>>(start: P, landmarks: &[&str]) -> Option<PathBuf> {
search_up(start, landmarks, |p| p.is_dir())
}
// Path computation functions
/// Compute path configuration from Settings
///
/// This function should be called before interpreter initialization.
/// It returns a Paths struct with all computed path values.
pub fn init_path_config(settings: &Settings) -> Paths {
let mut paths = Paths::default();
// Step 0: Get executable path
let executable = get_executable_path();
let real_executable = executable
.as_ref()
.map(|p| p.to_string_lossy().into_owned())
.unwrap_or_default();
// Step 1: Check for PYTHONEXECUTABLE / __PYVENV_LAUNCHER__
// When set, these are used as sys.executable and when searching for venvs.
// The argv0 path is kept as the base executable for prefix calculation.
let exe_dir = if let Some(override_exe) = env_executable_override() {
paths.executable.clone_from(&override_exe);
paths.base_executable = real_executable;
PathBuf::from(&override_exe).parent().map(PathBuf::from)
} else {
paths.executable = real_executable;
executable
.as_ref()
.and_then(|p| p.parent().map(PathBuf::from))
};
// Step 2: Check for venv (pyvenv.cfg) and get 'home'
let (venv_prefix, home_dir) = detect_venv(exe_dir.as_ref());
let search_dir = home_dir.clone().or(exe_dir);
// Step 3: Check for build directory
let build_prefix = detect_build_directory(search_dir.as_ref());
// Step 4: Calculate prefix via landmark search
// When in venv, search_dir is home_dir, so this gives us the base Python's prefix
let calculated_prefix = calculate_prefix(search_dir.as_ref(), build_prefix.as_ref());
// Step 5: Set prefix and base_prefix
if venv_prefix.is_some() {
// In venv: prefix = venv directory, base_prefix = original Python's prefix
paths.prefix = venv_prefix.as_ref().map_or_else(
|| calculated_prefix.clone(),
|p| p.to_string_lossy().into_owned(),
);
paths.base_prefix = calculated_prefix;
} else {
// Not in venv: prefix == base_prefix
paths.prefix.clone_from(&calculated_prefix);
paths.base_prefix = calculated_prefix;
}
// Step 6: Calculate exec_prefix
paths.exec_prefix = if venv_prefix.is_some() {
// In venv: exec_prefix = prefix (venv directory)
paths.prefix.clone()
} else {
calculate_exec_prefix(search_dir.as_ref(), paths.prefix.as_ref())
};
paths.base_exec_prefix.clone_from(&paths.base_prefix);
// Step 7: Calculate base_executable (if not already set by an env override)
if paths.base_executable.is_empty() {
paths.base_executable = calculate_base_executable(executable.as_ref(), home_dir.as_ref());
}
// Step 8: Build module_search_paths
paths.module_search_paths =
build_module_search_paths(settings, &paths.prefix, &paths.exec_prefix);
// Step 9: Calculate stdlib_dir
paths.stdlib_dir = calculate_stdlib_dir(&paths.prefix);
paths
}
/// Get default prefix value used when landmark search fails.
///
/// A compile-time `RUSTPYTHON_PREFIX` always wins. Otherwise POSIX uses the
/// conventional install prefix, while Windows has no meaningful compile-time
/// prefix and falls back to the executable's directory (ref: getpath.py).
///
/// A bare drive root must never be returned on Windows: pip walks up from
/// `<prefix>/Lib/site-packages` and would otherwise probe the drive root for
/// writability, which fails for standard users (see issue #8246).
fn default_prefix(exe_dir: Option<&PathBuf>) -> String {
if let Some(prefix) = std::option_env!("RUSTPYTHON_PREFIX") {
return prefix.to_owned();
}
if cfg!(windows) {
if let Some(dir) = exe_dir {
return dir.to_string_lossy().into_owned();
}
// Executable directory is unknown; use a valid absolute root as a last
// resort rather than a drive-relative bare "C:".
"C:\\".to_owned()
} else {
"/usr/local".to_owned()
}
}
/// Detect virtual environment by looking for pyvenv.cfg
/// Returns (venv_prefix, home_dir from pyvenv.cfg)
fn detect_venv(exe_dir: Option<&PathBuf>) -> (Option<PathBuf>, Option<PathBuf>) {
// Try exe_dir/../pyvenv.cfg first (standard venv layout: venv/bin/python)
if let Some(dir) = exe_dir
&& let Some(venv_dir) = dir.parent()
{
let cfg = venv_dir.join(platform::VENV_LANDMARK);
if cfg.exists()
&& let Some(home) = parse_pyvenv_home(&cfg)
{
return (Some(venv_dir.to_path_buf()), Some(PathBuf::from(home)));
}
}
// Try exe_dir/pyvenv.cfg (alternative layout)
if let Some(dir) = exe_dir {
let cfg = dir.join(platform::VENV_LANDMARK);
if cfg.exists()
&& let Some(home) = parse_pyvenv_home(&cfg)
{
return (Some(dir.clone()), Some(PathBuf::from(home)));
}
}
(None, None)
}
/// Detect if running from a build directory
fn detect_build_directory(exe_dir: Option<&PathBuf>) -> Option<PathBuf> {
let dir = exe_dir?;
// Check for pybuilddir.txt (indicates build directory)
if dir.join(platform::BUILDDIR_TXT).exists() {
return Some(dir.clone());
}
// Check for Modules/Setup.local (build landmark)
if dir.join(platform::BUILD_LANDMARK).exists() {
return Some(dir.clone());
}
// Search up for Lib/os.py (build stdlib landmark)
search_up_file(dir, &[platform::BUILDSTDLIB_LANDMARK])
}
/// Calculate prefix by searching for landmarks
fn calculate_prefix(exe_dir: Option<&PathBuf>, build_prefix: Option<&PathBuf>) -> String {
// 1. If build directory detected, use it
if let Some(bp) = build_prefix {
return bp.to_string_lossy().into_owned();
}
if let Some(dir) = exe_dir {
// 2. Search for ZIP landmark
let zip = platform::zip_landmark();
if let Some(prefix) = search_up_file(dir, &[&zip]) {
return prefix.to_string_lossy().into_owned();
}
// 3. Search for stdlib landmarks (os.py)
let landmarks = platform::stdlib_landmarks();
let refs: Vec<&str> = landmarks.iter().map(|s| s.as_str()).collect();
if let Some(prefix) = search_up_file(dir, &refs) {
return prefix.to_string_lossy().into_owned();
}
}
// 4. Fallback to default
default_prefix(exe_dir)
}
/// Calculate exec_prefix
fn calculate_exec_prefix(exe_dir: Option<&PathBuf>, prefix: &str) -> String {
#[cfg(windows)]
{
// Windows: exec_prefix == prefix
let _ = exe_dir; // silence unused warning
prefix.to_owned()
}
#[cfg(not(windows))]
{
// POSIX: search for lib-dynload directory
if let Some(dir) = exe_dir {
let landmark = platform::platstdlib_landmark();
if let Some(exec_prefix) = search_up_dir(dir, &[&landmark]) {
return exec_prefix.to_string_lossy().into_owned();
}
}
// Fallback: same as prefix
prefix.to_owned()
}
}
/// Resolve final-component links without resolving directory aliases (ref: getpath.c).
#[cfg(unix)]
fn resolve_executable_symlinks(executable: &Path) -> Option<PathBuf> {
use std::os::unix::ffi::OsStrExt;
let mut path = executable.to_path_buf();
// CPython rejects 40 successful hops, matching the Linux 4.2 kernel limit.
for _ in 0..40 {
let Ok(target) = std::fs::read_link(&path) else {
return Some(path);
};
if target.is_absolute() {
path = target;
continue;
}
// Match getpath.c's raw dirname join, then normalize lexically without
// resolving directory symlinks or losing a double-leading-slash root.
let bytes = path.as_os_str().as_bytes();
let end = bytes.iter().rposition(|&b| b == b'/');
let dirname = &bytes[..end.unwrap_or(bytes.len())];
let mut joined = PathBuf::from(std::ffi::OsStr::from_bytes(dirname));
// Filesystem text uses UTF-8/surrogateescape; count characters, not bytes.
let multiple_chars =
core::str::from_utf8(dirname).map_or(dirname.len() > 1, |s| s.chars().nth(1).is_some());
if multiple_chars && !dirname.ends_with(b"/") {
joined.as_mut_os_string().push("/");
}
joined.as_mut_os_string().push(target.as_os_str());
let bytes = joined.as_os_str().as_bytes();
let double_root = bytes.starts_with(b"//") && !bytes.starts_with(b"///");
path.clear();
for component in joined.components() {
if component == std::path::Component::ParentDir
&& !path.as_os_str().is_empty()
&& !path.ends_with("..")
{
path.pop();
} else if component != std::path::Component::CurDir {
path.push(component.as_os_str());
}
}
if double_root {
path = Path::new("//").join(path.strip_prefix("/").ok()?);
}
}
None
}
/// Calculate base_executable
fn calculate_base_executable(executable: Option<&PathBuf>, home_dir: Option<&PathBuf>) -> String {
// If in venv and we have home, prefer the actual target of a symlink.
if let (Some(exe), Some(home)) = (executable, home_dir)
&& let Some(exe_name) = exe.file_name()
{
#[cfg(unix)]
if let Some(resolved) = resolve_executable_symlinks(exe)
&& !resolved.as_os_str().is_empty()
&& resolved.as_os_str() != exe.as_os_str()
{
return resolved.to_string_lossy().into_owned();
}
let base = home.join(exe_name);
return base.to_string_lossy().into_owned();
}
// Otherwise, base_executable == executable
executable
.map(|p| p.to_string_lossy().into_owned())
.unwrap_or_default()
}
/// Calculate stdlib_dir (sys._stdlib_dir)
/// Returns None if the stdlib directory doesn't exist
fn calculate_stdlib_dir(prefix: &str) -> Option<String> {
let stdlib_dir = Path::new(prefix).join(cfg_select! {
windows => platform::STDLIB_SUBDIR,
_ => platform::stdlib_subdir(),
});
if stdlib_dir.is_dir() {
Some(stdlib_dir.to_string_lossy().into_owned())
} else {
None
}
}
/// Build the complete module_search_paths (sys.path)
fn build_module_search_paths(settings: &Settings, prefix: &str, exec_prefix: &str) -> Vec<String> {
let mut paths = Vec::new();
// 1. PYTHONPATH/RUSTPYTHONPATH from settings
paths.extend(settings.path_list.iter().cloned());
// 2. ZIP file path
let zip_path = PathBuf::from(prefix).join(platform::zip_landmark());
paths.push(zip_path.to_string_lossy().into_owned());
// 3. stdlib and platstdlib directories
#[cfg(not(windows))]
{
// POSIX: stdlib first, then lib-dynload
let stdlib_dir = PathBuf::from(prefix).join(platform::stdlib_subdir());
paths.push(stdlib_dir.to_string_lossy().into_owned());
let platstdlib = PathBuf::from(exec_prefix).join(platform::platstdlib_landmark());
paths.push(platstdlib.to_string_lossy().into_owned());
}
#[cfg(windows)]
{
// Windows: DLLs first, then Lib
let platstdlib = PathBuf::from(exec_prefix).join(platform::platstdlib_landmark());
paths.push(platstdlib.to_string_lossy().into_owned());
let stdlib_dir = PathBuf::from(prefix).join(platform::STDLIB_SUBDIR);
paths.push(stdlib_dir.to_string_lossy().into_owned());
}
paths
}
/// `PYTHONEXECUTABLE` takes precedence over `__PYVENV_LAUNCHER__`.
/// An empty value is ignored.
fn env_executable_override() -> Option<String> {
for name in ["PYTHONEXECUTABLE", "__PYVENV_LAUNCHER__"] {
if let Ok(value) = crate::host_env::os::var(name)
&& !value.is_empty()
{
return Some(value);
}
}
None
}
/// Get the current executable path
fn get_executable_path() -> Option<PathBuf> {
#[cfg(not(target_arch = "wasm32"))]
{
let exec_arg = env::args_os().next()?;
crate::host_env::fs::which(exec_arg)
}
#[cfg(target_arch = "wasm32")]
{
let exec_arg = env::args().next()?;
Some(PathBuf::from(exec_arg))
}
}
/// Parse pyvenv.cfg and extract the 'home' key value
fn parse_pyvenv_home(pyvenv_cfg: &Path) -> Option<String> {
#[cfg(any(not(target_arch = "wasm32"), target_os = "wasi"))]
let content = crate::host_env::fs::read_to_string(pyvenv_cfg).ok()?;
#[cfg(all(target_arch = "wasm32", not(target_os = "wasi")))]
let content = std::fs::read_to_string(pyvenv_cfg).ok()?;
for line in content.lines() {
if let Some((key, value)) = line.split_once('=')
&& key.trim().to_lowercase() == "home"
{
return Some(value.trim().to_string());
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn init_path_config_basic() {
let settings = Settings::default();
let paths = init_path_config(&settings);
// Just verify it doesn't panic and returns valid paths
assert!(!paths.prefix.is_empty());
}
#[test]
fn search_up() {
// Test with a path that doesn't have any landmarks
let result = search_up_file(
crate::host_env::os::temp_dir(),
&["nonexistent_landmark_xyz"],
);
assert!(result.is_none());
}
#[test]
fn default_prefix_basic() {
let prefix = default_prefix(None);
assert!(!prefix.is_empty());
}
}