fix(macos): scrub gate env, refuse EACCES, normalize marker paths
Review fixes from kokhlo's live-hardware review: - The boot-gate probe now runs with PYTHONHOME / PYTHONPATH / PYTHONSTARTUP / __PYVENV_LAUNCHER__ scrubbed: an inherited PYTHONHOME=<venv> boots a staged copy that would otherwise die with "No module named 'encodings'", papering over the exact prefix failure the gate exists to catch. - OSError is split by errno: ENOENT/ENOEXEC (fixtures, foreign-arch images) still skip; EACCES after our own chmod now refuses the install instead of silently accepting a broken copy. - Marker writes and both marker comparisons go through os.path.realpath, so the managed-runtime layout (cpython-3.11-macos-* symlinked to cpython-3.11.15-macos-*) no longer reports stale on a fresh install. Tests: +3 (env-scrub spy, EACCES refusal, symlinked-home state). 100 passed in the module + doctor neighborhoods.
This commit is contained in:
@@ -38,6 +38,7 @@ uv-managed. Best-effort: never raises to callers.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import errno
|
||||
import logging
|
||||
import os
|
||||
import platform
|
||||
@@ -61,6 +62,13 @@ class _BootGateFailed(Exception):
|
||||
"""Staged copy refused to boot; the live venv must stay untouched."""
|
||||
|
||||
|
||||
def _marker_value(source_file: Path) -> str:
|
||||
"""Canonical marker value: fully resolved so symlinked spellings of the
|
||||
same store binary (``cpython-3.11-macos-*`` → ``cpython-3.11.15-macos-*``)
|
||||
compare equal."""
|
||||
return os.path.realpath(str(source_file))
|
||||
|
||||
|
||||
def is_macos() -> bool:
|
||||
return platform.system() == "Darwin"
|
||||
|
||||
@@ -231,19 +239,34 @@ def _materialize_aliases(
|
||||
def _passes_boot_gate(staged: Path, venv_dir: Path) -> bool:
|
||||
"""Launch *staged* and demand encodings + the venv prefix.
|
||||
|
||||
``OSError`` (fixture / wrong arch / exec-format) is treated as a skip —
|
||||
we cannot verify, and we also cannot brick a host that cannot run the
|
||||
binary. A real crash (dyld, encodings, wrong prefix) refuses the install.
|
||||
The probe runs with ``PYTHONHOME``/``PYTHONPATH`` scrubbed: an inherited
|
||||
``PYTHONHOME`` papers over exactly the prefix-resolution failure the gate
|
||||
exists to catch. ``OSError`` is split by errno — ``ENOENT``/``ENOEXEC``
|
||||
(fixture binaries, foreign-arch images) means the binary cannot run here
|
||||
at all, so the symlinked venv was equally dead and installing cannot make
|
||||
things worse: skip. Anything else (notably ``EACCES`` after our own
|
||||
chmod) is a broken install about to go live: refuse.
|
||||
"""
|
||||
env = {
|
||||
k: v
|
||||
for k, v in os.environ.items()
|
||||
if k not in ("PYTHONHOME", "PYTHONPATH", "PYTHONSTARTUP",
|
||||
"__PYVENV_LAUNCHER__")
|
||||
}
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
[str(staged), "-c", "import encodings, sys; print(sys.prefix)"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
env=env,
|
||||
)
|
||||
except OSError:
|
||||
return True
|
||||
except OSError as exc:
|
||||
if exc.errno in (errno.ENOENT, errno.ENOEXEC):
|
||||
logger.debug("boot gate skipped: cannot execute %s (%s)", staged, exc)
|
||||
return True
|
||||
logger.warning("boot gate: staged copy not executable: %s", exc)
|
||||
return False
|
||||
except subprocess.TimeoutExpired:
|
||||
return False
|
||||
if proc.returncode != 0:
|
||||
@@ -282,7 +305,7 @@ def _install_anchor(venv_dir: Path, source_file: Path) -> None:
|
||||
f"staged copy at {tmp_path} failed encodings/prefix probe"
|
||||
)
|
||||
os.replace(tmp_path, venv_py)
|
||||
_anchor_marker(venv_bin).write_text(str(source_file), encoding="utf-8")
|
||||
_anchor_marker(venv_bin).write_text(_marker_value(source_file), encoding="utf-8")
|
||||
_materialize_aliases(venv_bin, venv_py, refresh=True)
|
||||
except Exception:
|
||||
try:
|
||||
@@ -317,8 +340,8 @@ def ensure_tcc_anchor(project_root: Path | None = None) -> Path | None:
|
||||
if not venv_py.is_symlink():
|
||||
marker = _anchor_marker(venv_py.parent)
|
||||
try:
|
||||
if marker.is_file() and marker.read_text(encoding="utf-8").strip() == str(
|
||||
source_file
|
||||
if marker.is_file() and marker.read_text(encoding="utf-8").strip() == (
|
||||
_marker_value(source_file)
|
||||
):
|
||||
_provision_libpython(venv_dir, source_file, refresh=False)
|
||||
if _passes_boot_gate(venv_py, venv_dir):
|
||||
@@ -361,7 +384,7 @@ def tcc_anchor_state(project_root: Path | None = None) -> tuple[str, str]:
|
||||
if not venv_py.is_symlink():
|
||||
marker = _anchor_marker(venv_py.parent)
|
||||
source_file = _interpreter_file(source)
|
||||
expected = str(source_file) if source_file is not None else source
|
||||
expected = _marker_value(source_file) if source_file is not None else source
|
||||
try:
|
||||
if marker.is_file() and marker.read_text(encoding="utf-8").strip() == expected:
|
||||
return "active", str(venv_py)
|
||||
|
||||
@@ -12,6 +12,7 @@ the existing macOS CI job, not against one-byte fixtures.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import errno
|
||||
import os
|
||||
import platform
|
||||
import shutil
|
||||
@@ -332,15 +333,47 @@ class TestBootGate:
|
||||
assert not tcc._passes_boot_gate(tmp_path / "staged", tmp_path / "venv")
|
||||
|
||||
def test_skips_unexecutable_staging(self, tmp_path, monkeypatch):
|
||||
# OSError (fixture binaries, exec-format on a foreign arch) means the
|
||||
# binary cannot run here at all — the symlinked venv was equally dead,
|
||||
# so installing cannot make things worse. Skip, don't refuse.
|
||||
# ENOENT/ENOEXEC (fixture bad-shebang binaries, wrong-arch images)
|
||||
# mean the binary cannot run here at all — the symlinked venv was
|
||||
# equally dead, so installing cannot make things worse.
|
||||
def noexec(*a, **k):
|
||||
raise OSError("cannot execute")
|
||||
raise OSError(errno.ENOENT, "No such file or directory")
|
||||
|
||||
monkeypatch.setattr(tcc.subprocess, "run", noexec)
|
||||
assert tcc._passes_boot_gate(tmp_path / "staged", tmp_path / "venv")
|
||||
|
||||
def test_refuses_eacces_after_chmod(self, tmp_path, monkeypatch):
|
||||
# EACCES after copy2+chmod is a broken install about to go live —
|
||||
# refuse, never skip (review: kokhlo on #95605).
|
||||
def denied(*a, **k):
|
||||
raise PermissionError(errno.EACCES, "Permission denied")
|
||||
|
||||
monkeypatch.setattr(tcc.subprocess, "run", denied)
|
||||
assert not tcc._passes_boot_gate(tmp_path / "staged", tmp_path / "venv")
|
||||
|
||||
def test_scrubs_pythonhome_pythonpath(self, tmp_path, monkeypatch):
|
||||
# A staged copy that dies with `No module named 'encodings'` boots
|
||||
# fine under PYTHONHOME=<venv> — the inherited var papers over the
|
||||
# exact failure the gate exists to catch (review: kokhlo on #95605,
|
||||
# verified live). The probe must run with a scrubbed environment.
|
||||
captured = {}
|
||||
|
||||
def spy(*a, **k):
|
||||
captured.update(k)
|
||||
return self._proc(a[0], 0, f"{tmp_path / 'venv'}\n", "")
|
||||
|
||||
monkeypatch.setattr(tcc.subprocess, "run", spy)
|
||||
monkeypatch.setenv("PYTHONHOME", "/poison")
|
||||
monkeypatch.setenv("PYTHONPATH", "/poison")
|
||||
venv = tmp_path / "venv"
|
||||
venv.mkdir(exist_ok=True)
|
||||
|
||||
assert tcc._passes_boot_gate(tmp_path / "staged", venv)
|
||||
env = captured.get("env")
|
||||
assert env is not None, "gate must pass an explicit scrubbed env"
|
||||
assert "PYTHONHOME" not in env
|
||||
assert "PYTHONPATH" not in env
|
||||
|
||||
def test_accepts_matching_prefix(self, tmp_path, monkeypatch):
|
||||
venv = tmp_path / "venv"
|
||||
venv.mkdir()
|
||||
@@ -352,6 +385,29 @@ class TestBootGate:
|
||||
|
||||
|
||||
class TestTccAnchorState:
|
||||
def test_state_active_through_unpatched_home_symlink(self, tmp_path, monkeypatch):
|
||||
# The managed-runtime layout symlinks cpython-3.11-macos-* →
|
||||
# cpython-3.11.15-macos-*, so pyvenv.cfg home and the marker record
|
||||
# different spellings of the same binary. State must resolve both
|
||||
# sides before comparing, or a fresh install reports stale
|
||||
# (review: kokhlo on #95605, hit on a live venv).
|
||||
_darwin(monkeypatch)
|
||||
patched = _build_store(tmp_path, version="3.11.15")
|
||||
versionless = patched.parent.parent / "cpython-3.11-macos-aarch64-none"
|
||||
os.symlink(patched.parent, versionless)
|
||||
home = versionless / "bin"
|
||||
|
||||
root = tmp_path / "checkout"
|
||||
venv_bin = root / ".venv" / "bin"
|
||||
venv_bin.mkdir(parents=True)
|
||||
(root / ".venv" / "pyvenv.cfg").write_text(f"home = {home}\n")
|
||||
os.symlink(home / "python3.11", venv_bin / "python")
|
||||
|
||||
tcc.ensure_tcc_anchor(root)
|
||||
|
||||
status, _ = tcc.tcc_anchor_state(root)
|
||||
assert status == "active"
|
||||
|
||||
def test_state_missing_then_active(self, tmp_path, monkeypatch):
|
||||
_darwin(monkeypatch)
|
||||
store_bin = _build_store(tmp_path)
|
||||
|
||||
Reference in New Issue
Block a user