From 915a6836eb71e5d1c345457c76c0d7b2be1f72f7 Mon Sep 17 00:00:00 2001 From: dot Date: Sat, 10 Oct 2026 20:02:21 +0900 Subject: [PATCH] Prefer resolved executable symlinks in POSIX virtual environments 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 --- crates/vm/src/getpath.rs | 59 ++++++++++++- extra_tests/snippets/stdlib_sys.py | 132 +++++++++++++++++++++++++++++ 2 files changed, 190 insertions(+), 1 deletion(-) diff --git a/crates/vm/src/getpath.rs b/crates/vm/src/getpath.rs index eef1417ab14..f869922f984 100644 --- a/crates/vm/src/getpath.rs +++ b/crates/vm/src/getpath.rs @@ -299,12 +299,69 @@ fn calculate_exec_prefix(exe_dir: Option<&PathBuf>, prefix: &str) -> String { } } +/// Resolve final-component links without resolving directory aliases (ref: getpath.c). +#[cfg(unix)] +fn resolve_executable_symlinks(executable: &Path) -> Option { + 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, construct base_executable from home + // 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(); } diff --git a/extra_tests/snippets/stdlib_sys.py b/extra_tests/snippets/stdlib_sys.py index 185e89f8b19..c785a092efc 100644 --- a/extra_tests/snippets/stdlib_sys.py +++ b/extra_tests/snippets/stdlib_sys.py @@ -1,7 +1,10 @@ import os +import shutil import subprocess import sys +import tempfile import warnings +from pathlib import Path from testutils import assert_raises @@ -192,3 +195,132 @@ def test_getframemodulename(): del os.environ["PYTHONBREAKPOINT"] else: os.environ["PYTHONBREAKPOINT"] = saved_breakpoint_env + + +# A venv's symlink target takes precedence over home/. +if os.name == "posix" and sys.platform not in ("wasi", "emscripten"): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory).resolve() + interpreter = Path(sys.executable).resolve() + # A framework build can also run its embedded app directly. Match the + # installed launcher's file identity before applying launcher semantics. + framework_base = ( + Path(sys.base_prefix) + / "bin" + / f"python{sys.version_info.major}.{sys.version_info.minor}" + ) + framework_launcher = ( + sys.platform == "darwin" + and bool(getattr(sys, "_framework", "")) + and framework_base.is_file() + and interpreter.samefile(framework_base) + ) + home = root / "home" + home.mkdir() + venv_env = dict(os.environ) + for name in ("PYTHONHOME", "PYTHONEXECUTABLE", "__PYVENV_LAUNCHER__"): + venv_env.pop(name, None) + # Keep startup imports available even with the synthetic home directory. + venv_env["PYTHONPATH"] = os.pathsep.join(sys.path) + path_code = ( + "import sys; print(sys.executable); " + "print(sys._base_executable); print(sys.prefix)" + ) + + def executable_paths(executable, *, cwd=None, env=venv_env): + result = subprocess.run( + [str(executable), "-B", "-S", "-c", path_code], + cwd=cwd, + env=env, + capture_output=True, + text=True, + timeout=10, + ) + assert result.returncode == 0, result.stderr + return result.stdout.splitlines() + + for kind in ("absolute", "relative", "chain"): + bin_dir = root / kind / "bin" + bin_dir.mkdir(parents=True) + # Exercise both parent and adjacent pyvenv.cfg discovery. + prefix = bin_dir if kind == "chain" else bin_dir.parent + (prefix / "pyvenv.cfg").write_text(f"home = {home}\n") + executable = bin_dir / kind + # This regular file must never be preferred to a symlink's target. + (home / kind).touch() + if kind == "absolute": + executable.symlink_to(interpreter) + elif kind == "relative": + executable.symlink_to(os.path.relpath(interpreter, bin_dir)) + else: + (bin_dir / "target").symlink_to(interpreter) + executable.symlink_to("target") + invocation = f"./bin/{kind}" if kind == "relative" else executable + assert executable_paths(invocation, cwd=bin_dir.parent) == [ + str(executable), + str(framework_base if framework_launcher else interpreter), + str(prefix), + ] + + # Framework launchers preset their base; ordinary binaries keep argv[0]. + plain = root / "plain" / "python" + plain.parent.mkdir() + plain.symlink_to(interpreter) + assert executable_paths(plain)[:2] == [ + str(plain), + str(framework_base if framework_launcher else plain), + ] + + # Without a launcher preset, copies fall back to home/. + copied = root / "copied" / "python" + copied.parent.mkdir() + shutil.copy2(interpreter, copied) + (home / copied.name).touch() + (copied.parent / "pyvenv.cfg").write_text(f"home = {home}\n") + assert executable_paths(copied) == [ + str(copied), + str(framework_base if framework_launcher else home / copied.name), + str(copied.parent), + ] + + # A directory alias does not turn a copied executable into a symlink. + directory_alias = root / "directory-alias" + directory_alias.symlink_to(copied.parent, target_is_directory=True) + aliased_copy = directory_alias / copied.name + # The framework launcher canonicalizes its directory before dispatch. + assert executable_paths(aliased_copy) == [ + str(copied if framework_launcher else aliased_copy), + str(framework_base if framework_launcher else home / copied.name), + str(copied.parent if framework_launcher else directory_alias), + ] + + # Without a launcher preset, final-link resolution preserves target aliases. + target_link = root / "linked" / "python" + target_link.parent.mkdir() + (target_link.parent / "pyvenv.cfg").write_text(f"home = {home}\n") + target_link.symlink_to("../directory-alias/python") + assert executable_paths(target_link) == [ + str(target_link), + str(framework_base if framework_launcher else aliased_copy), + str(target_link.parent), + ] + + # macOS framework builds use a framework-derived base for overrides. + if sys.platform == "linux": + # Relative targets also preserve a double-leading-slash directory. + double_target = root / "double-target" + double_target.symlink_to("directory-alias/python") + target_link.unlink() + target_link.symlink_to("/" + str(double_target)) + assert executable_paths(target_link) == [ + str(target_link), + "/" + str(aliased_copy), + str(target_link.parent), + ] + + override_env = dict(venv_env, __PYVENV_LAUNCHER__=str(executable)) + assert executable_paths(plain, env=override_env) == [ + str(executable), + str(plain), + str(prefix), + ]