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
This commit is contained in:
youknowdot authored and GitHub committed 2026-10-12 02:07:08 +09:00
1 parent 924ef2f434
commit f39da82d9d
2 files changed
+190 -1

No files matched your search

+58 -1
View File
@@ -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<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, 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();
}
+132
View File
@@ -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 @@ finally:
del os.environ["PYTHONBREAKPOINT"]
else:
os.environ["PYTHONBREAKPOINT"] = saved_breakpoint_env
# A venv's symlink target takes precedence over home/<invoked name>.
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/<invoked name>.
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),
]