diff --git a/scripts/core_probe.py b/scripts/core_probe.py index 70b6b3052..1cf84cf54 100644 --- a/scripts/core_probe.py +++ b/scripts/core_probe.py @@ -766,27 +766,53 @@ def _bwrap_base(bwrap: str, network: bool) -> list[str]: return argv +def _python_prefix(path: Path) -> bool: + """A Python installation or a venv, and nothing wider: it holds + lib/pythonX.Y or a pyvenv.cfg, and it is neither / nor the home folder + nor a folder above it.""" + home = Path.home().resolve() + resolved = path.resolve() + if resolved == Path("/") or resolved == home or resolved in home.parents: + return False + version = f"python{sys.version_info.major}.{sys.version_info.minor}" + return (path / "pyvenv.cfg").is_file() or (path / "lib" / version).is_dir() + + def interpreter_paths() -> list[Path]: - """The trees the worker's Python lives in, when the sandbox's system - binds do not hold them: CI's setup-python under /opt/hostedtoolcache, a - venv, pyenv or uv. Each link on the way to the interpreter is a path too: - a venv's python points at uv's unversioned name, itself a link to the - versioned tree. Without them the worker could not start in the sandbox, - and every run crashed.""" + """What the worker's Python needs inside the sandbox when the system + binds do not hold it: CI's setup-python under /opt/hostedtoolcache, a + venv, pyenv or uv. Without it the worker could not start, and every run + crashed. + + Only Python installation prefixes are bound whole (sys.prefix, + sys.base_prefix and the prefix of each link's target, each proven by + lib/pythonX.Y or pyvenv.cfg). Every link on the way to the interpreter + is bound as itself, a single file, so ~/.local/bin/python3 never exposes + ~/.local.""" system = ("/usr/", "/bin/", "/lib") found: list[Path] = [] - candidates = [Path(sys.prefix), Path(sys.base_prefix)] + + def add(path: Path) -> None: + path = Path(os.path.abspath(path)) + # Inside a tree already bound, a path is there already: bwrap cannot + # mount over a link in a read-only tree, and the run would not start. + if any(path == bound or bound in path.parents for bound in found): + return + if not str(path).startswith(system) and path.exists(): + found.append(path) + + for prefix in (Path(sys.prefix), Path(sys.base_prefix)): + if _python_prefix(prefix): + add(prefix) hop = Path(sys.executable) for _ in range(16): - candidates.append(hop.parent.parent) + if _python_prefix(hop.parent.parent): + add(hop.parent.parent) if not hop.is_symlink(): break + add(hop) target = Path(os.readlink(hop)) hop = target if target.is_absolute() else hop.parent / target - for path in candidates: - path = Path(os.path.abspath(path)) - if not str(path).startswith(system) and path not in found and path.exists(): - found.append(path) return found diff --git a/tests/test_core_probe.py b/tests/test_core_probe.py index f9b3babb1..12795cb5e 100644 --- a/tests/test_core_probe.py +++ b/tests/test_core_probe.py @@ -672,10 +672,13 @@ class TheInterpreterIsInTheSandbox(unittest.TestCase): real = root / "uv" / "cpython-3.13.12" / "bin" real.mkdir(parents=True) (real / "python3.13").write_text("") + version = f"python{sys.version_info.major}.{sys.version_info.minor}" + (root / "uv" / "cpython-3.13.12" / "lib" / version).mkdir(parents=True) alias = root / "uv" / "cpython-3.13" alias.symlink_to(root / "uv" / "cpython-3.13.12") venv = root / "venv" / "bin" venv.mkdir(parents=True) + (root / "venv" / "pyvenv.cfg").write_text("home = x\n") (venv / "python").symlink_to(alias / "bin" / "python3.13") with mock.patch.object(core_probe.sys, "executable", str(venv / "python")), \ mock.patch.object(core_probe.sys, "prefix", str(root / "venv")), \ @@ -684,6 +687,36 @@ class TheInterpreterIsInTheSandbox(unittest.TestCase): self.assertIn(root / "venv", paths) self.assertIn(alias, paths) self.assertIn(root / "uv" / "cpython-3.13.12", paths) + # The venv's own link sits in the venv, bound already. + self.assertNotIn(venv / "python", paths) + + def test_a_link_in_a_shared_folder_binds_only_itself(self): + """~/.local/bin/python3 must not expose ~/.local: only the link file + and the proven Python prefix behind it are bound.""" + from unittest import mock # noqa: PLC0415 + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + prefix = root / "share" / "cpython" + (prefix / "bin").mkdir(parents=True) + (prefix / "bin" / "python3").write_text("") + version = f"python{sys.version_info.major}.{sys.version_info.minor}" + (prefix / "lib" / version).mkdir(parents=True) + local = root / "dotlocal" + (local / "bin").mkdir(parents=True) + (local / "secrets.txt").write_text("x") + (local / "bin" / "python3").symlink_to(prefix / "bin" / "python3") + with mock.patch.object(core_probe.sys, "executable", str(local / "bin" / "python3")), \ + mock.patch.object(core_probe.sys, "prefix", str(prefix)), \ + mock.patch.object(core_probe.sys, "base_prefix", str(prefix)): + paths = core_probe.interpreter_paths() + self.assertNotIn(local, paths) + self.assertIn(local / "bin" / "python3", paths) + self.assertIn(prefix, paths) + + def test_home_and_root_are_never_a_prefix(self): + self.assertFalse(core_probe._python_prefix(Path("/"))) + self.assertFalse(core_probe._python_prefix(Path.home())) def test_a_link_is_mounted_at_its_own_path(self): with tempfile.TemporaryDirectory() as tmp: