Skip to content

Commit b69b434

Browse files
Add regression test for getsyspath() symlinked stdlib leak
1 parent d2b0234 commit b69b434

1 file changed

Lines changed: 82 additions & 0 deletions

File tree

mypy/test/test_pyinfo.py

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
from __future__ import annotations
2+
3+
import os
4+
import sys
5+
import sysconfig
6+
import tempfile
7+
import unittest
8+
9+
from mypy import pyinfo
10+
11+
12+
class GetSysPathSuite(unittest.TestCase):
13+
"""Regression tests for mypy.pyinfo.getsyspath()."""
14+
15+
@unittest.skipIf(sys.platform == "win32", "os.symlink requires elevated privileges on Windows")
16+
def test_excludes_stdlib_when_install_path_is_symlinked(self) -> None:
17+
"""Regression test for python/mypy#21474.
18+
19+
On Homebrew/pyenv/python-build-standalone, the Python install path is
20+
reached via a symlink (e.g. ``/opt/homebrew/opt/python@3.13`` ->
21+
``/opt/homebrew/Cellar/python@3.13/3.13.7``). ``sys.base_exec_prefix``
22+
and ``sysconfig.get_path("stdlib")`` retain the symlink form, while
23+
entries of ``sys.path`` arrive pre-resolved by Python. ``getsyspath()``
24+
normalised both sides with ``os.path.abspath`` (which does not resolve
25+
symlinks), so the stdlib entry was never excluded and leaked into
26+
``SearchPaths.package_path``.
27+
"""
28+
ver = f"python{sys.version_info.major}.{sys.version_info.minor}"
29+
30+
with tempfile.TemporaryDirectory() as tmp:
31+
real_prefix = os.path.join(tmp, "cellar")
32+
real_stdlib = os.path.join(real_prefix, "lib", ver)
33+
real_dynload = os.path.join(real_stdlib, "lib-dynload")
34+
os.makedirs(real_dynload)
35+
36+
symlink_prefix = os.path.join(tmp, "opt")
37+
os.symlink(real_prefix, symlink_prefix)
38+
symlink_stdlib = os.path.join(symlink_prefix, "lib", ver)
39+
40+
# Sanity check that the two paths really do differ textually but
41+
# resolve to the same directory — otherwise the bug isn't being
42+
# exercised at all.
43+
assert symlink_stdlib != real_stdlib
44+
assert os.path.samefile(symlink_stdlib, real_stdlib)
45+
46+
original_base_exec_prefix = sys.base_exec_prefix
47+
original_path = sys.path[:]
48+
original_get_path = sysconfig.get_path
49+
50+
def fake_get_path(name: str) -> str:
51+
assert name == "stdlib", f"unexpected get_path({name!r})"
52+
return symlink_stdlib
53+
54+
try:
55+
sys.base_exec_prefix = symlink_prefix
56+
sysconfig.get_path = fake_get_path # type: ignore[assignment]
57+
# First entry is dropped by getsyspath() unless safe_path is
58+
# on, so use a sentinel there.
59+
sys.path = ["<sentinel>", real_stdlib, real_dynload]
60+
61+
result = pyinfo.getsyspath()
62+
finally:
63+
sys.base_exec_prefix = original_base_exec_prefix
64+
sysconfig.get_path = original_get_path
65+
sys.path = original_path
66+
67+
leaked = [
68+
p
69+
for p in result
70+
if os.path.exists(p)
71+
and (os.path.samefile(p, real_stdlib) or os.path.samefile(p, real_dynload))
72+
]
73+
self.assertEqual(
74+
leaked,
75+
[],
76+
f"stdlib leaked into getsyspath() result: {leaked!r} "
77+
f"(full result: {result!r})",
78+
)
79+
80+
81+
if __name__ == "__main__":
82+
unittest.main()

0 commit comments

Comments
 (0)