Skip to content

Commit f39da82

Browse files
authored
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
1 parent 924ef2f commit f39da82

2 files changed

Lines changed: 190 additions & 1 deletion

File tree

‎crates/vm/src/getpath.rs‎

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -299,12 +299,69 @@ fn calculate_exec_prefix(exe_dir: Option<&PathBuf>, prefix: &str) -> String {
299299
}
300300
}
301301

302+
/// Resolve final-component links without resolving directory aliases (ref: getpath.c).
303+
#[cfg(unix)]
304+
fn resolve_executable_symlinks(executable: &Path) -> Option<PathBuf> {
305+
use std::os::unix::ffi::OsStrExt;
306+
307+
let mut path = executable.to_path_buf();
308+
// CPython rejects 40 successful hops, matching the Linux 4.2 kernel limit.
309+
for _ in 0..40 {
310+
let Ok(target) = std::fs::read_link(&path) else {
311+
return Some(path);
312+
};
313+
if target.is_absolute() {
314+
path = target;
315+
continue;
316+
}
317+
318+
// Match getpath.c's raw dirname join, then normalize lexically without
319+
// resolving directory symlinks or losing a double-leading-slash root.
320+
let bytes = path.as_os_str().as_bytes();
321+
let end = bytes.iter().rposition(|&b| b == b'/');
322+
let dirname = &bytes[..end.unwrap_or(bytes.len())];
323+
let mut joined = PathBuf::from(std::ffi::OsStr::from_bytes(dirname));
324+
// Filesystem text uses UTF-8/surrogateescape; count characters, not bytes.
325+
let multiple_chars =
326+
core::str::from_utf8(dirname).map_or(dirname.len() > 1, |s| s.chars().nth(1).is_some());
327+
if multiple_chars && !dirname.ends_with(b"/") {
328+
joined.as_mut_os_string().push("/");
329+
}
330+
joined.as_mut_os_string().push(target.as_os_str());
331+
let bytes = joined.as_os_str().as_bytes();
332+
let double_root = bytes.starts_with(b"//") && !bytes.starts_with(b"///");
333+
path.clear();
334+
for component in joined.components() {
335+
if component == std::path::Component::ParentDir
336+
&& !path.as_os_str().is_empty()
337+
&& !path.ends_with("..")
338+
{
339+
path.pop();
340+
} else if component != std::path::Component::CurDir {
341+
path.push(component.as_os_str());
342+
}
343+
}
344+
if double_root {
345+
path = Path::new("//").join(path.strip_prefix("/").ok()?);
346+
}
347+
}
348+
None
349+
}
350+
302351
/// Calculate base_executable
303352
fn calculate_base_executable(executable: Option<&PathBuf>, home_dir: Option<&PathBuf>) -> String {
304-
// If in venv and we have home, construct base_executable from home
353+
// If in venv and we have home, prefer the actual target of a symlink.
305354
if let (Some(exe), Some(home)) = (executable, home_dir)
306355
&& let Some(exe_name) = exe.file_name()
307356
{
357+
#[cfg(unix)]
358+
if let Some(resolved) = resolve_executable_symlinks(exe)
359+
&& !resolved.as_os_str().is_empty()
360+
&& resolved.as_os_str() != exe.as_os_str()
361+
{
362+
return resolved.to_string_lossy().into_owned();
363+
}
364+
308365
let base = home.join(exe_name);
309366
return base.to_string_lossy().into_owned();
310367
}

‎extra_tests/snippets/stdlib_sys.py‎

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
import os
2+
import shutil
23
import subprocess
34
import sys
5+
import tempfile
46
import warnings
7+
from pathlib import Path
58

69
from testutils import assert_raises
710

@@ -192,3 +195,132 @@ def test_getframemodulename():
192195
del os.environ["PYTHONBREAKPOINT"]
193196
else:
194197
os.environ["PYTHONBREAKPOINT"] = saved_breakpoint_env
198+
199+
200+
# A venv's symlink target takes precedence over home/<invoked name>.
201+
if os.name == "posix" and sys.platform not in ("wasi", "emscripten"):
202+
with tempfile.TemporaryDirectory() as directory:
203+
root = Path(directory).resolve()
204+
interpreter = Path(sys.executable).resolve()
205+
# A framework build can also run its embedded app directly. Match the
206+
# installed launcher's file identity before applying launcher semantics.
207+
framework_base = (
208+
Path(sys.base_prefix)
209+
/ "bin"
210+
/ f"python{sys.version_info.major}.{sys.version_info.minor}"
211+
)
212+
framework_launcher = (
213+
sys.platform == "darwin"
214+
and bool(getattr(sys, "_framework", ""))
215+
and framework_base.is_file()
216+
and interpreter.samefile(framework_base)
217+
)
218+
home = root / "home"
219+
home.mkdir()
220+
venv_env = dict(os.environ)
221+
for name in ("PYTHONHOME", "PYTHONEXECUTABLE", "__PYVENV_LAUNCHER__"):
222+
venv_env.pop(name, None)
223+
# Keep startup imports available even with the synthetic home directory.
224+
venv_env["PYTHONPATH"] = os.pathsep.join(sys.path)
225+
path_code = (
226+
"import sys; print(sys.executable); "
227+
"print(sys._base_executable); print(sys.prefix)"
228+
)
229+
230+
def executable_paths(executable, *, cwd=None, env=venv_env):
231+
result = subprocess.run(
232+
[str(executable), "-B", "-S", "-c", path_code],
233+
cwd=cwd,
234+
env=env,
235+
capture_output=True,
236+
text=True,
237+
timeout=10,
238+
)
239+
assert result.returncode == 0, result.stderr
240+
return result.stdout.splitlines()
241+
242+
for kind in ("absolute", "relative", "chain"):
243+
bin_dir = root / kind / "bin"
244+
bin_dir.mkdir(parents=True)
245+
# Exercise both parent and adjacent pyvenv.cfg discovery.
246+
prefix = bin_dir if kind == "chain" else bin_dir.parent
247+
(prefix / "pyvenv.cfg").write_text(f"home = {home}\n")
248+
executable = bin_dir / kind
249+
# This regular file must never be preferred to a symlink's target.
250+
(home / kind).touch()
251+
if kind == "absolute":
252+
executable.symlink_to(interpreter)
253+
elif kind == "relative":
254+
executable.symlink_to(os.path.relpath(interpreter, bin_dir))
255+
else:
256+
(bin_dir / "target").symlink_to(interpreter)
257+
executable.symlink_to("target")
258+
invocation = f"./bin/{kind}" if kind == "relative" else executable
259+
assert executable_paths(invocation, cwd=bin_dir.parent) == [
260+
str(executable),
261+
str(framework_base if framework_launcher else interpreter),
262+
str(prefix),
263+
]
264+
265+
# Framework launchers preset their base; ordinary binaries keep argv[0].
266+
plain = root / "plain" / "python"
267+
plain.parent.mkdir()
268+
plain.symlink_to(interpreter)
269+
assert executable_paths(plain)[:2] == [
270+
str(plain),
271+
str(framework_base if framework_launcher else plain),
272+
]
273+
274+
# Without a launcher preset, copies fall back to home/<invoked name>.
275+
copied = root / "copied" / "python"
276+
copied.parent.mkdir()
277+
shutil.copy2(interpreter, copied)
278+
(home / copied.name).touch()
279+
(copied.parent / "pyvenv.cfg").write_text(f"home = {home}\n")
280+
assert executable_paths(copied) == [
281+
str(copied),
282+
str(framework_base if framework_launcher else home / copied.name),
283+
str(copied.parent),
284+
]
285+
286+
# A directory alias does not turn a copied executable into a symlink.
287+
directory_alias = root / "directory-alias"
288+
directory_alias.symlink_to(copied.parent, target_is_directory=True)
289+
aliased_copy = directory_alias / copied.name
290+
# The framework launcher canonicalizes its directory before dispatch.
291+
assert executable_paths(aliased_copy) == [
292+
str(copied if framework_launcher else aliased_copy),
293+
str(framework_base if framework_launcher else home / copied.name),
294+
str(copied.parent if framework_launcher else directory_alias),
295+
]
296+
297+
# Without a launcher preset, final-link resolution preserves target aliases.
298+
target_link = root / "linked" / "python"
299+
target_link.parent.mkdir()
300+
(target_link.parent / "pyvenv.cfg").write_text(f"home = {home}\n")
301+
target_link.symlink_to("../directory-alias/python")
302+
assert executable_paths(target_link) == [
303+
str(target_link),
304+
str(framework_base if framework_launcher else aliased_copy),
305+
str(target_link.parent),
306+
]
307+
308+
# macOS framework builds use a framework-derived base for overrides.
309+
if sys.platform == "linux":
310+
# Relative targets also preserve a double-leading-slash directory.
311+
double_target = root / "double-target"
312+
double_target.symlink_to("directory-alias/python")
313+
target_link.unlink()
314+
target_link.symlink_to("/" + str(double_target))
315+
assert executable_paths(target_link) == [
316+
str(target_link),
317+
"/" + str(aliased_copy),
318+
str(target_link.parent),
319+
]
320+
321+
override_env = dict(venv_env, __PYVENV_LAUNCHER__=str(executable))
322+
assert executable_paths(plain, env=override_env) == [
323+
str(executable),
324+
str(plain),
325+
str(prefix),
326+
]

0 commit comments

Comments
 (0)