mirror of
https://github.com/RustPython/RustPython.git
synced 2026-10-28 18:59:04 +08:00
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:
1 parent
924ef2f434
commit
f39da82d9d
2 files changed
+190
-1
No files matched your search
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
]
|
||||
Reference in new issue
Block a user