From b15a9955b5db233f7cb8ec9ba63525b826742340 Mon Sep 17 00:00:00 2001 From: Jeong YunWon Date: Wed, 19 Aug 2026 10:22:33 +0900 Subject: [PATCH 1/4] importing: return a device or literal path unchanged; cite the EQFULL measurement MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `_nt_rnormpath` returns a path opening with `\\.\` or `\\?\` as it was given (rpath.py:106-111) — the first names a device, the second is passed to the OS literally, so a `.` or `..` inside one is an ordinary name and collapsing it would reach a different object. The walk was collapsing them. `Component::Prefix` already tells the four spellings apart, so the arm keys on `Verbatim`, `VerbatimUNC`, `VerbatimDisk` and `DeviceNS` rather than matching the separators by hand. Unix has no such component; the twenty-five spellings still agree with `posixpath.normpath` exactly. The `EQFULL` comment named no artefact for the name `interp_errno.py`'s mac list omits. It now records the measurement — 106 under 3.14.6 on darwin — and points at `errno_platform_names.py`, which asserts the whole darwin block under `gate=1`, so the reference lane re-measures it every run rather than trusting a number written down once. Assisted-by: Claude --- pyre/pyre-interpreter/src/importing.rs | 18 +++++++++++++++++- pyre/pyre-interpreter/src/module/errno/mod.rs | 13 ++++++++----- 2 files changed, 25 insertions(+), 6 deletions(-) diff --git a/pyre/pyre-interpreter/src/importing.rs b/pyre/pyre-interpreter/src/importing.rs index 930aa7e1270..8817d2199ca 100644 --- a/pyre/pyre-interpreter/src/importing.rs +++ b/pyre/pyre-interpreter/src/importing.rs @@ -27,7 +27,7 @@ use std::path::Path; not(feature = "sandbox"), not(target_arch = "wasm32") ))] -use std::path::Component; +use std::path::{Component, Prefix}; use crate::PyExecutionContext; use crate::{CodeObject, Mode, PyFrame, compile_source_with_filename}; @@ -2101,6 +2101,22 @@ fn absolute_from(path: PathBuf, cwd: &Path) -> PathBuf { not(target_arch = "wasm32") ))] fn normalize_lexically(path: &Path) -> PathBuf { + // `\\.\` device names and `\\?\` literal paths are handed to the OS as + // spelled and are returned unchanged (rpath.py:106-111): a `.` or `..` + // inside one is an ordinary name, so collapsing it would rewrite which + // object the path reaches. No such component exists on unix, where the + // arm is unreachable. + if let Some(Component::Prefix(prefix)) = path.components().next() + && matches!( + prefix.kind(), + Prefix::Verbatim(_) + | Prefix::VerbatimUNC(..) + | Prefix::VerbatimDisk(_) + | Prefix::DeviceNS(_) + ) + { + return path.to_path_buf(); + } let mut out = PathBuf::new(); for component in path.components() { match component { diff --git a/pyre/pyre-interpreter/src/module/errno/mod.rs b/pyre/pyre-interpreter/src/module/errno/mod.rs index 653d5e7dba0..92a0cb00289 100644 --- a/pyre/pyre-interpreter/src/module/errno/mod.rs +++ b/pyre/pyre-interpreter/src/module/errno/mod.rs @@ -164,11 +164,14 @@ crate::py_module! { store(name, *value as i64); } } - // `interp_errno.py`'s "MacOSX specific errnos" block, plus the - // `EQFULL` the 3.14 surface adds. `DefinedConstantInteger` - // drops each of these on a platform whose `errno.h` lacks it; - // the equivalent here is the target gate, since `libc` declares - // them for apple targets only. + // `interp_errno.py`'s "MacOSX specific errnos" block, plus + // `EQFULL`, which that list omits: measured under 3.14.6 on + // darwin, `errno.EQFULL` is 106, and + // `extra_tests/snippets/errno_platform_names.py` asserts the whole + // block so the reference lane re-measures it on every run. + // `DefinedConstantInteger` drops each of these on a platform whose + // `errno.h` lacks it; the equivalent here is the target gate, + // since `libc` declares them for apple targets only. #[cfg(target_vendor = "apple")] { let apple_entries: &[(&str, i32)] = &[ From 701df8e7cc30f6c674c30664f3458b32e6e14173 Mon Sep 17 00:00:00 2001 From: Jeong YunWon Date: Wed, 19 Aug 2026 15:29:54 +0900 Subject: [PATCH 2/4] extra_tests: gate pip end to end, resolved entirely from the checkout `pyre/extra_tests/pip/run.py` drives a release binary through `-m venv`, `ensurepip`, a wheel install over a running pip, a PEP 517 build whose backend needs nothing installed, a source distribution the runtime writes and pip then builds under real isolation, the console script and metadata that install produced, and an uninstall. Twelve checks, ~26s per backend on darwin-arm64; both backends pass, and pass again with every proxy pointed at a dead port. Everything it resolves is a wheel already tracked here: `lib-python/3/ensurepip/_bundled/pip-*.whl` and `lib-python/3/test/wheeldata/setuptools-*.whl`. The build environment pip constructs inherits the outer `--no-index --find-links`, so the isolated build resolves its backend locally as well, and the nested install's own line is what the check reads as evidence isolation ran. Neither version is written down; both come from the wheel filenames, so a stdlib sync that bumps either needs no edit here. One check asserts the posture the rest depend on, by requiring a plain `pip download` to fail. The reference interpreter is not in the gating set: pip succeeding under CPython says nothing about pyre. It runs the same sequence only once something has already failed, to separate a runtime defect from a rotted fixture. The step rides `cargo-test-linux`'s Linux copy, where the release binary and the interpreter to drive it are already assembled for the CPython suite, and runs ahead of the suite under the same `!cancelled() && runner.os == 'Linux'` guard that copy's other steps carry. Not covered, and stated in the runner: no index, no TLS, no dependency resolution, no C-extension wheels, and no non-Linux leg. `--with-network` adds the index leg for a manual run and is not what CI invokes. Assisted-by: Claude --- .github/workflows/pyre-ci.yml | 15 + pyre/extra_tests/README.md | 38 +- .../pip/fixtures/stpkg/pyproject.toml | 13 + pyre/extra_tests/pip/fixtures/stpkg/stpkg.py | 6 + .../pip/fixtures/tinypkg/pyproject.toml | 13 + .../pip/fixtures/tinypkg/tinybackend.py | 68 ++ .../pip/fixtures/tinypkg/tinypkg.py | 2 + pyre/extra_tests/pip/mksdist.py | 35 + pyre/extra_tests/pip/run.py | 704 ++++++++++++++++++ 9 files changed, 892 insertions(+), 2 deletions(-) create mode 100644 pyre/extra_tests/pip/fixtures/stpkg/pyproject.toml create mode 100644 pyre/extra_tests/pip/fixtures/stpkg/stpkg.py create mode 100644 pyre/extra_tests/pip/fixtures/tinypkg/pyproject.toml create mode 100644 pyre/extra_tests/pip/fixtures/tinypkg/tinybackend.py create mode 100644 pyre/extra_tests/pip/fixtures/tinypkg/tinypkg.py create mode 100644 pyre/extra_tests/pip/mksdist.py create mode 100644 pyre/extra_tests/pip/run.py diff --git a/.github/workflows/pyre-ci.yml b/.github/workflows/pyre-ci.yml index c31de38e45e..51282bd9559 100644 --- a/.github/workflows/pyre-ci.yml +++ b/.github/workflows/pyre-ci.yml @@ -439,6 +439,21 @@ jobs: if: ${{ !cancelled() && runner.os == 'Linux' }} shell: bash run: cargo build --release -p pyrex --bin pyre-dynasm --no-default-features --features dynasm + - name: Run pyre/extra_tests/pip (hermetic pip end-to-end) + # Drives the binary built above through venv, ensurepip, a wheel + # install, a PEP 517 build under real isolation, and an uninstall. + # Everything it resolves is a wheel already in the checkout, so it never + # reaches an index — and one of its checks asserts that, by requiring a + # plain `pip download` to fail. + # + # It rides this job's Linux copy for the same reason the suite below + # does: the release binary and the interpreter to drive it are already + # here. Ahead of the suite so its verdict lands early rather than after + # the suite's wall time, and `--dynasm-only` because the restored target + # directory can hold a `pyre-cranelift` this job never built. + if: ${{ !cancelled() && runner.os == 'Linux' }} + shell: bash + run: ${{ steps.cpython.outputs.python-path }} pyre/extra_tests/pip/run.py --dynasm-only - name: Run CPython suite (gate regressions, JIT on) # This is the only place CI runs the suite — `pyre/check.py` keeps the # stage behind `--cpython-suite`, which no job passes, so its wall time is diff --git a/pyre/extra_tests/README.md b/pyre/extra_tests/README.md index 33bacca498d..fcbae2c9699 100644 --- a/pyre/extra_tests/README.md +++ b/pyre/extra_tests/README.md @@ -44,14 +44,48 @@ works. `testutils.py` is the helper module shipped with the snippets rejects missing header fields. Each script cites the upstream file:line it guards; passing requires `exit 0` AND the final stdout line being `OK`. Runner: `pyre/extra_tests/parity_tests/run.py`. +- `pip/` — one stateful end-to-end sequence rather than a corpus: a release + binary is driven through `-m venv`, `ensurepip`, a wheel install, a PEP 517 + build under real isolation, the console script and metadata that install + produced, and an uninstall. Everything it resolves is a wheel the checkout + already carries (`lib-python/3/ensurepip/_bundled`, `lib-python/3/test/wheeldata`), + so it never reaches an index — and one of its checks asserts that by + requiring a plain `pip download` to fail. It sits apart from `snippets/` + because it is stateful, because it needs a per-check timeout an order of + magnitude larger, and because the reference interpreter is not a comparand + here: pip succeeding under CPython says nothing about pyre, so CPython is + used only as a control, and only after something has already failed, to + separate a runtime defect from a rotted fixture. Runner: + `pyre/extra_tests/pip/run.py`. - `upstream/` — no tests of its own: a runner plus a driver for the vendored PyPy tree at the repository **root** `extra_tests/`. Those files stay where upstream put them and run in place, so anything they already cover does not get rewritten under `parity_tests/`. Runner: `pyre/extra_tests/upstream/run.py`. -All three runners share the same backend discovery (cpython + -pyre-dynasm + pyre-cranelift) and exit code semantics. +The runners share the same backend discovery (pyre-dynasm + pyre-cranelift, +plus cpython where a reference comparison is the point) and exit code +semantics. + +## Running the pip gate + +```sh +python3 pyre/extra_tests/pip/run.py # every backend present +python3 pyre/extra_tests/pip/run.py --dynasm-only # what CI runs +python3 pyre/extra_tests/pip/run.py --keep # keep the working tree +python3 pyre/extra_tests/pip/run.py --with-network # also resolve from a real index +``` + +Each backend gets its own temporary tree, kept and named on failure. The +fixtures are copied into it before anything is built, because installing from +a source directory writes build artefacts beside it. `--with-network` adds +the one thing the gate cannot assert offline — that an index answers over TLS +— and is never what CI runs, so a package server being down cannot turn a +merge red. + +No version is written down: the pip and setuptools versions come from the +filenames of the wheels in the checkout, so a stdlib sync that bumps either +needs no edit here. ## The vendored root `extra_tests/` diff --git a/pyre/extra_tests/pip/fixtures/stpkg/pyproject.toml b/pyre/extra_tests/pip/fixtures/stpkg/pyproject.toml new file mode 100644 index 00000000000..5bd94164fcd --- /dev/null +++ b/pyre/extra_tests/pip/fixtures/stpkg/pyproject.toml @@ -0,0 +1,13 @@ +[build-system] +requires = ["setuptools>=40.8.0"] +build-backend = "setuptools.build_meta" + +[project] +name = "stpkg" +version = "0.2.0" + +[project.scripts] +stpkg-hi = "stpkg:main" + +[tool.setuptools] +py-modules = ["stpkg"] diff --git a/pyre/extra_tests/pip/fixtures/stpkg/stpkg.py b/pyre/extra_tests/pip/fixtures/stpkg/stpkg.py new file mode 100644 index 00000000000..b10fdad3eaa --- /dev/null +++ b/pyre/extra_tests/pip/fixtures/stpkg/stpkg.py @@ -0,0 +1,6 @@ +def hi(): + return "hi from stpkg" + + +def main(): + print(hi()) diff --git a/pyre/extra_tests/pip/fixtures/tinypkg/pyproject.toml b/pyre/extra_tests/pip/fixtures/tinypkg/pyproject.toml new file mode 100644 index 00000000000..059eb8961bf --- /dev/null +++ b/pyre/extra_tests/pip/fixtures/tinypkg/pyproject.toml @@ -0,0 +1,13 @@ +# `build-backend` is spelled out even though `tinybackend` is the only backend +# here: a `[build-system]` table without it means the legacy setuptools backend +# and `requires = ["setuptools>=40.8.0"]`, which would turn this fixture -- +# whose whole point is a PEP 517 build that needs nothing installed -- into a +# second copy of the `stpkg` one. +[build-system] +requires = [] +build-backend = "tinybackend" +backend-path = ["."] + +[project] +name = "tinypkg" +version = "0.1.0" diff --git a/pyre/extra_tests/pip/fixtures/tinypkg/tinybackend.py b/pyre/extra_tests/pip/fixtures/tinypkg/tinybackend.py new file mode 100644 index 00000000000..7ecdb1da66c --- /dev/null +++ b/pyre/extra_tests/pip/fixtures/tinypkg/tinybackend.py @@ -0,0 +1,68 @@ +"""A PEP 517 backend with no build dependencies, written on the stdlib alone. + +Installing this fixture exercises the hook protocol itself -- the isolated +environment, `get_requires_for_build_wheel`, `build_wheel`, and the unpacking +of what it returns -- with nothing to resolve and nothing to download. When it +passes and the `stpkg` fixture beside it does not, the defect is in what the +build environment installs rather than in the protocol. +""" + +import base64 +import hashlib +import os +import zipfile + +NAME = "tinypkg" +VERSION = "0.1.0" +DIST = f"{NAME}-{VERSION}" +METADATA = f"Metadata-Version: 2.1\nName: {NAME}\nVersion: {VERSION}\n" +WHEEL = ( + "Wheel-Version: 1.0\n" + "Generator: tinybackend\n" + "Root-Is-Purelib: true\n" + "Tag: py3-none-any\n" +) + + +def get_requires_for_build_wheel(config_settings=None): + return [] + + +def prepare_metadata_for_build_wheel(metadata_directory, config_settings=None): + info = os.path.join(metadata_directory, f"{DIST}.dist-info") + os.makedirs(info, exist_ok=True) + with open(os.path.join(info, "METADATA"), "w", encoding="utf-8") as out: + out.write(METADATA) + with open(os.path.join(info, "WHEEL"), "w", encoding="utf-8") as out: + out.write(WHEEL) + return f"{DIST}.dist-info" + + +def _record_line(name, payload): + digest = base64.urlsafe_b64encode(hashlib.sha256(payload).digest()) + return f"{name},sha256={digest.rstrip(b'=').decode()},{len(payload)}\n" + + +def build_wheel(wheel_directory, config_settings=None, metadata_directory=None): + filename = f"{DIST}-py3-none-any.whl" + with open(os.path.join(os.path.dirname(__file__), "tinypkg.py"), "rb") as source: + module = source.read() + info = f"{DIST}.dist-info" + entries = [ + ("tinypkg.py", module), + (f"{info}/METADATA", METADATA.encode()), + (f"{info}/WHEEL", WHEEL.encode()), + ] + # `RECORD` names itself with an empty hash, which is the one entry whose + # digest cannot be taken before the file exists. + record = "".join(_record_line(name, data) for name, data in entries) + record += f"{info}/RECORD,,\n" + with zipfile.ZipFile(os.path.join(wheel_directory, filename), "w") as wheel: + for name, data in entries: + wheel.writestr(name, data) + wheel.writestr(f"{info}/RECORD", record) + return filename + + +def build_sdist(sdist_directory, config_settings=None): + raise NotImplementedError("tinypkg is installed from its directory") diff --git a/pyre/extra_tests/pip/fixtures/tinypkg/tinypkg.py b/pyre/extra_tests/pip/fixtures/tinypkg/tinypkg.py new file mode 100644 index 00000000000..c369746002a --- /dev/null +++ b/pyre/extra_tests/pip/fixtures/tinypkg/tinypkg.py @@ -0,0 +1,2 @@ +def hello(): + return "hello from tinypkg" diff --git a/pyre/extra_tests/pip/mksdist.py b/pyre/extra_tests/pip/mksdist.py new file mode 100644 index 00000000000..a3fc8c6fd68 --- /dev/null +++ b/pyre/extra_tests/pip/mksdist.py @@ -0,0 +1,35 @@ +"""Write the `stpkg` source distribution. + +Run by the interpreter under test rather than by the driver, so the archive is +one the runtime produced: it goes through `tarfile` over `gzip` over `zlib`, +and the driver reads the result back with its own `tarfile` before pip is +allowed near it. A tarball that only the writer can open would otherwise +surface as an unrelated failure inside the build. +""" + +import io +import os +import sys +import tarfile +import time + +DIST = "stpkg-0.2.0" +PKG_INFO = b"Metadata-Version: 2.1\nName: stpkg\nVersion: 0.2.0\n" + + +def main(source, destination): + parent = os.path.dirname(destination) + if parent: + os.makedirs(parent, exist_ok=True) + with tarfile.open(destination, "w:gz") as archive: + for name in ("pyproject.toml", "stpkg.py"): + archive.add(os.path.join(source, name), arcname=f"{DIST}/{name}") + info = tarfile.TarInfo(f"{DIST}/PKG-INFO") + info.size = len(PKG_INFO) + info.mtime = int(time.time()) + archive.addfile(info, io.BytesIO(PKG_INFO)) + print(destination) + + +if __name__ == "__main__": + main(sys.argv[1], sys.argv[2]) diff --git a/pyre/extra_tests/pip/run.py b/pyre/extra_tests/pip/run.py new file mode 100644 index 00000000000..935a6a4584c --- /dev/null +++ b/pyre/extra_tests/pip/run.py @@ -0,0 +1,704 @@ +#!/usr/bin/env python3 +"""End-to-end gate for the interpreter's own pip, resolved entirely offline. + +Drives a release pyre binary through the sequence a user performs when they +install something: + + -m venv -> ensurepip -> pip install (wheel) + -> pip install (PEP 517 build, isolated) + -> console script -> metadata -> uninstall + +Every artefact comes from the checkout: the pip wheel `ensurepip` bundles, and +the setuptools wheel under `lib-python/3/test/wheeldata` that the suite's own +venv helpers glob for. Nothing is resolved from an index, so the gate cannot +fail because a package server is slow, and it cannot pass because a stale +wheel was lying in a cache. One check spends a second proving exactly that, +before the first check that resolves anything: a plain `pip download` of a +name only an index can answer has to fail. + +This lives beside `snippets/` and `parity_tests/` rather than in them because +it is one long stateful sequence -- the build in a later check consumes the +archive an earlier one wrote -- and because it needs a per-check timeout an +order of magnitude above what a snippet gets. + +The reference CPython is not in the gating set: pip succeeding there says +nothing about pyre. It is used only as a control, and only after something +has already failed, to separate "pyre broke" from "the fixture rotted". + +Usage: + python3 pyre/extra_tests/pip/run.py [--dynasm-only|--cranelift-only] + [--with-network] [--keep] + [--no-cpython-control] + [--timeout SECONDS] + +Exit code is 0 iff every (backend, check) pair passed. +""" + +from __future__ import annotations + +import argparse +import os +import re +import shutil +import subprocess +import sys +import tarfile +import tempfile +from pathlib import Path +from typing import Callable, NamedTuple + +HERE = Path(__file__).resolve().parent +ROOT = HERE.parent.parent.parent +TARGET_RELEASE = ROOT / "target" / "release" +FIXTURES = HERE / "fixtures" +MKSDIST = HERE / "mksdist.py" + +# The two wheel directories the checkout already carries. `_bundled` is what +# `ensurepip` installs into a fresh venv; `wheeldata` is what the suite's own +# `setup_venv_with_pip_setuptools` helper globs, and it is the whole reason a +# build with real isolation can be gated without an index. +BUNDLED = ROOT / "lib-python" / "3" / "ensurepip" / "_bundled" +WHEELDATA = ROOT / "lib-python" / "3" / "test" / "wheeldata" + +EXE = ".exe" if sys.platform == "win32" else "" +SCRIPTS = "Scripts" if sys.platform == "win32" else "bin" + +# Per check, not per run. The slowest check builds a wheel in an isolated +# environment it has to populate first, which measures in single-digit seconds +# on an unloaded machine; the margin is for a shared runner, and the timeout +# report names the check and echoes what the child had written so far, so a +# hit reads as a diagnosis rather than as one word. +TIMEOUT = 300 + +# The reference the control leg has to be, for the same reason the parity +# runner pins it: an older interpreter disagrees about things that are not +# what this gate measures. +CPYTHON_TARGET = (3, 14) + +SDIST = "stpkg-0.2.0.tar.gz" +SDIST_MEMBERS = { + "stpkg-0.2.0/pyproject.toml", + "stpkg-0.2.0/stpkg.py", + "stpkg-0.2.0/PKG-INFO", +} + + +class Failed(Exception): + """A check that did not hold, and the evidence for saying so.""" + + def __init__(self, reason: str, evidence: str = "") -> None: + super().__init__(reason) + self.reason = reason + self.evidence = evidence + + +class Failure(NamedTuple): + """One (check, backend) pair that did not pass.""" + + check: str + backend: str + reason: str + evidence: str + + +class Result(NamedTuple): + """What a spawned command did.""" + + argv: list[str] + returncode: int + stdout: str + stderr: str + + @property + def output(self) -> str: + return self.stdout + self.stderr + + def describe(self) -> str: + spelled = " ".join(self.argv) + return f"$ {spelled}\n{self.output}" + + +def _sole_wheel(directory: Path, stem: str) -> tuple[str, str]: + """The one `stem-*.whl` in `directory`, as its name and its version. + + Read rather than written down, so a stdlib sync that bumps either bundled + wheel needs no edit here: a version literal in this file would turn that + sync red for a reason that has nothing to do with the runtime. + """ + found = sorted(directory.glob(f"{stem}-*.whl")) + if len(found) != 1: + names = ", ".join(path.name for path in found) or "" + raise SystemExit(f"expected exactly one {stem} wheel in {directory}, found: {names}") + name, version, *_ = found[0].name.split("-") + return name, version + + +class Context: + """One backend's run: where it works, and what it has established so far.""" + + def __init__( + self, backend: str, interpreter: str, root: Path, network: bool, timeout: int + ) -> None: + self.backend = backend + self.interpreter = interpreter + self.root = root + self.network = network + self.timeout = timeout + self.tmp = root / "tmp" + self.src = root / "src" + self.venv = root / "venv" + self.dist = root / "dist" + # What the venv's own pip answers for its version, set by the import + # check and compared against everywhere else. + self.pipver = "" + + @property + def python(self) -> Path: + return self.venv / SCRIPTS / f"python{EXE}" + + def script(self, name: str) -> Path: + return self.venv / SCRIPTS / f"{name}{EXE}" + + def env(self) -> dict[str, str]: + """The child environment, pinned so no check can reach an index. + + Three of these are belt and braces over the `--no-index` every install + already carries: a developer's or runner's `pip.conf` can re-add an + index and a find-links, and an inherited cache can answer a resolve + that should have failed. The dead index URL turns any path that + survives all of that into an immediate error instead of a timeout, and + the first check asserts that it does. + """ + env = dict(os.environ) + env.update( + { + "PIP_NO_INPUT": "1", + "PIP_NO_CACHE_DIR": "1", + "PIP_DISABLE_PIP_VERSION_CHECK": "1", + "TMPDIR": str(self.tmp), + "TEMP": str(self.tmp), + "TMP": str(self.tmp), + } + ) + if not self.network: + env.update( + { + "PIP_CONFIG_FILE": os.devnull, + "PIP_INDEX_URL": "http://127.0.0.1:1/simple", + "PIP_RETRIES": "0", + "PIP_TIMEOUT": "5", + } + ) + for name in ("PYTHONPATH", "PYTHONHOME", "VIRTUAL_ENV"): + env.pop(name, None) + return env + + +def _spawn(ctx: Context, argv: list[str], cwd: Path | None = None) -> Result: + """Run a command in the pinned environment and decode what it said.""" + spelled = [str(part) for part in argv] + try: + proc = subprocess.run( + spelled, + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + timeout=ctx.timeout, + cwd=None if cwd is None else str(cwd), + env=ctx.env(), + ) + except subprocess.TimeoutExpired as expired: + # The timeout path raises with whatever chunks it had joined, which is + # bytes on POSIX however the call was configured; printing those would + # put the one thing worth reading inside a `b'...'` repr. + partial = expired.stderr or "" + if isinstance(partial, bytes): + partial = partial.decode("utf-8", "replace") + raise Failed(f"timed out after {ctx.timeout}s", f"$ {' '.join(spelled)}\n{partial}") + return Result(spelled, proc.returncode, proc.stdout, proc.stderr) + + +def _ok(result: Result) -> Result: + if result.returncode != 0: + raise Failed(f"exited {result.returncode}", result.describe()) + return result + + +def _contains(result: Result, needle: str) -> None: + if needle not in result.output: + raise Failed(f"output does not contain {needle!r}", result.describe()) + + +def _lines(result: Result) -> list[str]: + return [line for line in result.stdout.splitlines() if line.strip()] + + +def _under(child: Path, parent: Path) -> bool: + try: + child.resolve().relative_to(parent.resolve()) + except ValueError: + return False + return True + + +# --- the checks, in the order a user meets them ------------------------------- + + +def check_hermetic_guard(ctx: Context) -> None: + """A name only an index can answer must not resolve. + + This is the gate's self-test. Every later check passes `--no-index` and + names a directory in the checkout, so all of them would keep passing if + the environment silently grew a working index -- and then the gate would + be measuring a package server. Here the guard is inverted: a plain + `pip download` with no local link directory has to fail, and it has to + fail for the stated reason rather than by crashing. + """ + result = _spawn( + ctx, + [ctx.python, "-P", "-m", "pip", "download", "--no-deps", "--dest", ctx.root / "dl", "six"], + cwd=ctx.root, + ) + if result.returncode == 0: + raise Failed("an index answered, so the run is not hermetic", result.describe()) + if "No matching distribution found" not in result.output: + raise Failed("failed, but not by finding no distribution", result.describe()) + + +def check_venv(ctx: Context) -> None: + """`-m venv` builds a usable environment that points back at its base.""" + _ok(_spawn(ctx, [ctx.interpreter, "-m", "venv", ctx.venv], cwd=ctx.root)) + if not ctx.python.exists(): + raise Failed(f"no interpreter at {ctx.python}") + config = ctx.venv / "pyvenv.cfg" + if not config.exists(): + raise Failed(f"no pyvenv.cfg at {config}") + settings = {} + for line in config.read_text(encoding="utf-8").splitlines(): + key, sep, value = line.partition("=") + if sep: + settings[key.strip()] = value.strip() + if "home" not in settings: + raise Failed("pyvenv.cfg has no home key", config.read_text(encoding="utf-8")) + recorded = settings.get("executable") + if recorded and not os.path.samefile(recorded, ctx.interpreter): + raise Failed( + f"pyvenv.cfg executable is {recorded!r}, not {ctx.interpreter!r}", + config.read_text(encoding="utf-8"), + ) + + +def check_pip_import(ctx: Context) -> None: + """The venv's pip imports, from the venv, and agrees with ensurepip.""" + probe = ( + "import pip, ensurepip\n" + "print(pip.__version__)\n" + "print(pip.__file__)\n" + "print(ensurepip.version())\n" + ) + result = _ok(_spawn(ctx, [ctx.python, "-P", "-c", probe], cwd=ctx.root)) + reported = _lines(result) + if len(reported) != 3: + raise Failed("expected three lines", result.describe()) + version, location, bundled = reported + if version != bundled: + raise Failed(f"pip reports {version}, ensurepip bundles {bundled}", result.describe()) + if not _under(Path(location), ctx.venv): + raise Failed(f"pip came from {location}, not from the venv", result.describe()) + ctx.pipver = version + + +def check_pip_cli(ctx: Context) -> None: + """The console entry point runs and names the same pip the import did.""" + result = _ok(_spawn(ctx, [ctx.python, "-P", "-m", "pip", "--version"], cwd=ctx.root)) + if not re.match(rf"^pip {re.escape(ctx.pipver)} from .*\(python 3\.\d+\)$", result.stdout.strip()): + raise Failed(f"unexpected version line for pip {ctx.pipver}", result.describe()) + + +def check_offline_wheel_install(ctx: Context) -> None: + """A real wheel installs from a local directory, over a running copy. + + Reinstalling pip on top of itself is the unglamorous half of an install: + the existing distribution's RECORD has to be read and its files removed + while the tool doing the removing is the one being replaced, and the + console script has to come back working. The wheel is the one the + checkout ships, so this is a couple of thousand members rather than a toy. + """ + name, version = _sole_wheel(BUNDLED, "pip") + result = _ok( + _spawn( + ctx, + [ + ctx.python, "-P", "-m", "pip", "install", + "--no-index", "--find-links", BUNDLED, "--force-reinstall", "pip", + ], + cwd=ctx.root, + ) + ) + _contains(result, f"Successfully installed {name}-{version}") + after = _ok(_spawn(ctx, [ctx.python, "-P", "-m", "pip", "--version"], cwd=ctx.root)) + if not after.stdout.strip().startswith(f"pip {version} "): + raise Failed("the reinstalled pip does not report itself", after.describe()) + + +def check_pep517_without_build_deps(ctx: Context) -> None: + """A PEP 517 build whose backend needs nothing installed. + + Separates the hook protocol from what populates the build environment: if + this passes and the setuptools build below does not, the isolation + machinery works and the thing it failed to install is the subject. + """ + result = _ok( + _spawn( + ctx, + [ctx.python, "-P", "-m", "pip", "install", "--no-index", ctx.src / "tinypkg"], + cwd=ctx.root, + ) + ) + _contains(result, "Successfully built tinypkg") + _contains(result, "Successfully installed tinypkg-0.1.0") + # From the run root and with `-P`, so nothing but the install can answer + # the import -- the fixture directory holds a `tinypkg.py` that would + # satisfy it just as well. + imported = _ok( + _spawn(ctx, [ctx.python, "-P", "-c", "import tinypkg; print(tinypkg.hello())"], cwd=ctx.root) + ) + if imported.stdout.strip() != "hello from tinypkg": + raise Failed("the installed module did not answer", imported.describe()) + + +def check_sdist_build(ctx: Context) -> None: + """The runtime writes a source distribution the driver can read back.""" + archive = ctx.dist / SDIST + _ok(_spawn(ctx, [ctx.python, "-P", MKSDIST, ctx.src / "stpkg", archive], cwd=ctx.root)) + if not archive.exists(): + raise Failed(f"no archive at {archive}") + with tarfile.open(archive) as opened: + members = set(opened.getnames()) + missing = SDIST_MEMBERS - members + if missing: + raise Failed(f"archive is missing {sorted(missing)}", f"members: {sorted(members)}") + + +def check_sdist_to_wheel_isolated(ctx: Context) -> None: + """A source distribution builds through a real isolated environment. + + The build backend is resolved and installed by a nested pip into a + throwaway prefix -- the outer `--no-index --find-links` reach it, which is + what makes an isolated build possible with no index at all. `-v` is what + surfaces the nested install's own line, and that line is the evidence the + isolation ran rather than being skipped. + """ + name, version = _sole_wheel(WHEELDATA, "setuptools") + result = _ok( + _spawn( + ctx, + [ + ctx.python, "-P", "-m", "pip", "install", "-v", + "--no-index", "--find-links", WHEELDATA, ctx.dist / SDIST, + ], + cwd=ctx.root, + ) + ) + _contains(result, "Installing build dependencies") + _contains(result, f"Successfully installed {name}-{version}") + _contains(result, "Created wheel for stpkg") + _contains(result, "Successfully built stpkg") + _contains(result, "Successfully installed stpkg-0.2.0") + + +def check_console_script(ctx: Context) -> None: + """The generated console script runs on its own and points into the venv.""" + script = ctx.script("stpkg-hi") + if not script.exists(): + raise Failed(f"no console script at {script}") + result = _ok(_spawn(ctx, [script], cwd=ctx.root)) + if result.stdout.strip() != "hi from stpkg": + raise Failed("the console script did not answer", result.describe()) + if sys.platform != "win32": + # Windows gets an executable shim instead, whose target is not + # readable as text. + first = script.read_text(encoding="utf-8", errors="replace").splitlines()[0] + if not first.startswith("#!") or not os.path.samefile(first[2:].strip(), ctx.python): + raise Failed(f"console script shebang is {first!r}", first) + + +def check_entry_point_metadata(ctx: Context) -> None: + """The installed distribution's metadata reads back through the stdlib.""" + probe = ( + "import importlib.metadata as m\n" + "d = m.distribution('stpkg')\n" + "print(d.version)\n" + "print(sorted((e.name, e.value) for e in d.entry_points))\n" + ) + result = _ok(_spawn(ctx, [ctx.python, "-P", "-c", probe], cwd=ctx.root)) + reported = _lines(result) + expected = ["0.2.0", "[('stpkg-hi', 'stpkg:main')]"] + if reported != expected: + raise Failed(f"metadata reads {reported}, expected {expected}", result.describe()) + + +def check_pip_list(ctx: Context) -> None: + """Everything installed so far is what pip reports as installed.""" + result = _ok(_spawn(ctx, [ctx.python, "-P", "-m", "pip", "list", "--format=freeze"], cwd=ctx.root)) + listed = set(_lines(result)) + _, pipver = _sole_wheel(BUNDLED, "pip") + expected = {f"pip=={pipver}", "stpkg==0.2.0", "tinypkg==0.1.0"} + missing = expected - listed + if missing: + raise Failed(f"pip list is missing {sorted(missing)}", result.describe()) + + +def check_uninstall(ctx: Context) -> None: + """Uninstalling removes the modules and the scripts that came with them.""" + result = _ok( + _spawn(ctx, [ctx.python, "-P", "-m", "pip", "uninstall", "-y", "stpkg", "tinypkg"], cwd=ctx.root) + ) + _contains(result, "Successfully uninstalled stpkg-0.2.0") + _contains(result, "Successfully uninstalled tinypkg-0.1.0") + gone = _spawn(ctx, [ctx.python, "-P", "-c", "import stpkg"], cwd=ctx.root) + if gone.returncode == 0: + raise Failed("the module still imports after being uninstalled", gone.describe()) + if "ModuleNotFoundError" not in gone.output: + raise Failed("import failed for some other reason", gone.describe()) + if ctx.script("stpkg-hi").exists(): + raise Failed(f"{ctx.script('stpkg-hi')} outlived its distribution") + + +def check_network_download(ctx: Context) -> None: + """An index answers over TLS. Only under `--with-network`.""" + result = _ok( + _spawn( + ctx, + [ctx.python, "-P", "-m", "pip", "download", "--no-deps", "--dest", ctx.root / "net", "six"], + cwd=ctx.root, + ) + ) + _contains(result, "Saved") + + +HERMETIC: list[tuple[str, Callable[[Context], None]]] = [ + ("venv", check_venv), + ("pip-import", check_pip_import), + ("pip-cli", check_pip_cli), + # Standing between the checks that only need the venv and the first one + # that resolves anything. + ("hermetic-guard", check_hermetic_guard), + ("offline-wheel-install", check_offline_wheel_install), + ("pep517-no-build-deps", check_pep517_without_build_deps), + ("sdist-build", check_sdist_build), + ("sdist-to-wheel-isolated", check_sdist_to_wheel_isolated), + ("console-script", check_console_script), + ("entry-point-metadata", check_entry_point_metadata), + ("pip-list", check_pip_list), + ("uninstall", check_uninstall), +] + +NETWORKED: list[tuple[str, Callable[[Context], None]]] = [ + ("network-download", check_network_download), +] + + +def _checks(network: bool) -> list[tuple[str, Callable[[Context], None]]]: + # The guard asserts there is no index, so it is the one check a networked + # run has to drop rather than reorder. + if not network: + return HERMETIC + return [pair for pair in HERMETIC if pair[0] != "hermetic-guard"] + NETWORKED + + +def _prepare(root: Path) -> None: + """Lay out one run's working tree. + + The fixtures are copied because installing from a source directory writes + build artefacts beside it, and the originals are tracked files. + """ + (root / "tmp").mkdir(parents=True, exist_ok=True) + (root / "dist").mkdir(parents=True, exist_ok=True) + shutil.copytree(FIXTURES, root / "src") + + +def _sequence( + backend: str, interpreter: str, root: Path, network: bool, timeout: int, verbose: bool +) -> list[Failure]: + """Run every check for one interpreter, stopping at the first failure. + + Stopping is not a policy choice: the checks share one venv and each builds + on the last, so a later check after a failure would report the earlier + defect a second time under a name that does not describe it. + """ + _prepare(root) + ctx = Context(backend, interpreter, root, network, timeout) + for name, check in _checks(network): + try: + check(ctx) + except Failed as failed: + print(f" {name:<26s} {backend}=FAIL ({failed.reason})") + return [Failure(name, backend, failed.reason, failed.evidence)] + if verbose: + print(f" {name:<26s} {backend}=OK") + return [] + + +def _probe(command: str) -> tuple[tuple[int, int], str] | None: + """What an interpreter reports as its version and its own path.""" + probe = "import sys; print(sys.version_info[0], sys.version_info[1]); print(sys.executable)" + try: + proc = subprocess.run([command, "-c", probe], capture_output=True, text=True, timeout=60) + except (OSError, subprocess.SubprocessError): + return None + if proc.returncode != 0: + return None + reported = (proc.stdout or "").splitlines() + if len(reported) < 2: + return None + try: + major, minor = reported[0].split() + except ValueError: + return None + return (int(major), int(minor)), reported[1].strip() or command + + +def _cpython() -> str | None: + """The reference interpreter, if one of the right version is around. + + Unlike the parity runner this does not stop when there is none: the + reference is a control here, not a comparand, and a run that found a real + failure should still report it. + """ + named = os.environ.get("PYRE_CHECK_PYTHON3") + for candidate in [named] if named else ["python3.14", "python3", "python"]: + if named is None and shutil.which(candidate) is None: + continue + probed = _probe(candidate) + if probed is not None and probed[0] == CPYTHON_TARGET: + return probed[1] + return None + + +def _control(failure: Failure, network: bool, timeout: int, keep: bool) -> str: + """Whether the reference interpreter fails the same check. + + Answers the question a red gate raises first: did the runtime break, or + did the fixture rot? It costs nothing on a green run because it is only + reached once something has already failed. + """ + reference = _cpython() + if reference is None: + return "fixture control: no CPython %d.%d found, so not run" % CPYTHON_TARGET + root = Path(tempfile.mkdtemp(prefix="pyre-pip-control-")) + try: + failures = _sequence("cpython", reference, root, network, timeout, verbose=False) + finally: + if not keep: + shutil.rmtree(root, ignore_errors=True) + if not failures: + return f"fixture control: cpython passed every check, so {failure.check} is a pyre defect" + if failures[0].check == failure.check: + return ( + f"fixture control: cpython fails {failure.check} too " + f"({failures[0].reason}) -- the fixture or the bundled wheels rotted" + ) + return ( + f"fixture control: cpython got no further than {failures[0].check} " + f"({failures[0].reason}), so this run proves nothing either way" + ) + + +def _report(failures: list[Failure], control: str) -> None: + print("=" * 72) + print(f"{len(failures)} failure(s)") + print(control) + for failure in failures: + print() + print(f" {failure.check} [{failure.backend}]: {failure.reason}") + for line in failure.evidence.strip().splitlines(): + print(f" {line}") + print("=" * 72) + + +def _annotate(failures: list[Failure], control: str) -> None: + """One GitHub Actions error annotation per failure.""" + path = HERE.relative_to(ROOT).joinpath("run.py").as_posix() + for failure in failures: + message = f"{failure.backend}: {failure.check}: {failure.reason} | {control}" + escaped = message.replace("%", "%25").replace("\r", "%0D").replace("\n", "%0A") + print(f"::error file={path},title=pip::{escaped}") + + +def _backends(only_dynasm: bool, only_cranelift: bool) -> list[tuple[str, str]]: + """The release binaries to drive. + + Named individually rather than by globbing `pyre*`: `target/release/pyre` + is whatever the last build wrote there, and a sandbox build of that name + has no filesystem and no stdlib to find. + """ + backends = [] + dynasm = TARGET_RELEASE / f"pyre-dynasm{EXE}" + cranelift = TARGET_RELEASE / f"pyre-cranelift{EXE}" + if not only_cranelift and dynasm.exists(): + backends.append(("dynasm", str(dynasm))) + if not only_dynasm and cranelift.exists(): + backends.append(("cranelift", str(cranelift))) + return backends + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--dynasm-only", action="store_true") + parser.add_argument("--cranelift-only", action="store_true") + parser.add_argument( + "--with-network", + action="store_true", + help="also resolve from a real index; never used by the merge gate", + ) + parser.add_argument("--keep", action="store_true", help="keep the working tree and print its path") + parser.add_argument("--no-cpython-control", action="store_true") + parser.add_argument("--timeout", type=int, default=TIMEOUT, help="seconds per check") + args = parser.parse_args() + + sys.stdout.reconfigure(encoding="utf-8", errors="replace", line_buffering=True) + + for directory in (BUNDLED, WHEELDATA): + if not directory.is_dir(): + print(f"missing wheel directory: {directory}", file=sys.stderr) + return 1 + + backends = _backends(args.dynasm_only, args.cranelift_only) + if not backends: + print(f"no pyre release binary under {TARGET_RELEASE}", file=sys.stderr) + return 1 + + checks = _checks(args.with_network) + print(f"backends: {[name for name, _ in backends]}") + print(f"checks: {len(checks)}{' (with network)' if args.with_network else ' (hermetic)'}") + print() + + failures: list[Failure] = [] + for backend, interpreter in backends: + root = Path(tempfile.mkdtemp(prefix=f"pyre-pip-{backend}-")) + print(f"{backend}: {interpreter}") + found = _sequence(backend, interpreter, root, args.with_network, args.timeout, verbose=True) + failures.extend(found) + if found or args.keep: + print(f" working tree kept at {root}") + else: + shutil.rmtree(root, ignore_errors=True) + print() + + if not failures: + print("pip end-to-end passes on every backend") + return 0 + + control = "fixture control: skipped" + if not args.no_cpython_control: + control = _control(failures[0], args.with_network, args.timeout, args.keep) + _report(failures, control) + if os.environ.get("GITHUB_ACTIONS") == "true": + _annotate(failures, control) + return 1 + + +if __name__ == "__main__": + sys.exit(main()) From 78632e92808ce7b9796b3ef360ab73ed25e1625c Mon Sep 17 00:00:00 2001 From: Jeong YunWon Date: Wed, 19 Aug 2026 17:00:19 +0900 Subject: [PATCH 3/4] extra_tests(pip): disown every inherited PIP_ setting `--no-index` closes the index and leaves link directories open, and pip takes a long option's value from the matching `PIP_*` name, so a caller's `PIP_FIND_LINKS` reached the resolver: with a directory holding a `six` wheel exported that way, `hermetic-guard` reported `Successfully downloaded six`. A wheelhouse carrying setuptools but not `six` passes that guard and then answers the isolated build, which is the case the guard cannot see. The whole `PIP_*` namespace is dropped rather than an enumeration of the names with an obvious reach, and `PIP_CONFIG_FILE` moves to the unconditional block: a configuration file is another such source, and the networked leg wants the default index rather than whichever mirror the host is pointed at. Re-verified with `PIP_FIND_LINKS` and `PIP_EXTRA_INDEX_URL` set to hostile values: 12/12 unchanged. Separately, `uninstall` reported two distributions removed and probed one. Both are probed now. Assisted-by: Claude --- pyre/extra_tests/pip/run.py | 43 ++++++++++++++++++++++++------------- 1 file changed, 28 insertions(+), 15 deletions(-) diff --git a/pyre/extra_tests/pip/run.py b/pyre/extra_tests/pip/run.py index 935a6a4584c..cb7032bd9ed 100644 --- a/pyre/extra_tests/pip/run.py +++ b/pyre/extra_tests/pip/run.py @@ -160,18 +160,29 @@ def script(self, name: str) -> Path: return self.venv / SCRIPTS / f"{name}{EXE}" def env(self) -> dict[str, str]: - """The child environment, pinned so no check can reach an index. - - Three of these are belt and braces over the `--no-index` every install - already carries: a developer's or runner's `pip.conf` can re-add an - index and a find-links, and an inherited cache can answer a resolve - that should have failed. The dead index URL turns any path that - survives all of that into an immediate error instead of a timeout, and - the first check asserts that it does. + """The child environment, pinned so no check inherits a wheel source. + + Every `PIP_*` the caller had is dropped, not just the ones with an + obvious reach: pip takes a long option's value from the matching + `PIP_*` name, and `--find-links` is one of them. `--no-index` closes + the index and leaves link directories open, so a developer or a runner + with a configured wheelhouse would resolve the isolated build's + backend from outside the checkout and still see a green gate. The + `pip download` guard cannot catch that on its own -- it asks for a + name a wheelhouse has no reason to carry. + + A configuration file is another such source and is disowned in both + modes: the networked leg wants the default index rather than whichever + mirror the host is pointed at. What is left is the dead index URL, + which turns any resolve that still gets out into an immediate error + rather than a timeout -- and the guard asserts it does. """ - env = dict(os.environ) + env = { + name: value for name, value in os.environ.items() if not name.startswith("PIP_") + } env.update( { + "PIP_CONFIG_FILE": os.devnull, "PIP_NO_INPUT": "1", "PIP_NO_CACHE_DIR": "1", "PIP_DISABLE_PIP_VERSION_CHECK": "1", @@ -183,7 +194,6 @@ def env(self) -> dict[str, str]: if not self.network: env.update( { - "PIP_CONFIG_FILE": os.devnull, "PIP_INDEX_URL": "http://127.0.0.1:1/simple", "PIP_RETRIES": "0", "PIP_TIMEOUT": "5", @@ -457,11 +467,14 @@ def check_uninstall(ctx: Context) -> None: ) _contains(result, "Successfully uninstalled stpkg-0.2.0") _contains(result, "Successfully uninstalled tinypkg-0.1.0") - gone = _spawn(ctx, [ctx.python, "-P", "-c", "import stpkg"], cwd=ctx.root) - if gone.returncode == 0: - raise Failed("the module still imports after being uninstalled", gone.describe()) - if "ModuleNotFoundError" not in gone.output: - raise Failed("import failed for some other reason", gone.describe()) + # Both, separately: one command reported two uninstalls, and a module left + # behind by either of them is the thing worth catching. + for module in ("stpkg", "tinypkg"): + gone = _spawn(ctx, [ctx.python, "-P", "-c", f"import {module}"], cwd=ctx.root) + if gone.returncode == 0: + raise Failed(f"{module} still imports after being uninstalled", gone.describe()) + if "ModuleNotFoundError" not in gone.output: + raise Failed(f"the {module} import failed for some other reason", gone.describe()) if ctx.script("stpkg-hi").exists(): raise Failed(f"{ctx.script('stpkg-hi')} outlived its distribution") From 901e280603d657f3f3135cf66be5c1c62dbfb7bf Mon Sep 17 00:00:00 2001 From: Jeong YunWon Date: Wed, 19 Aug 2026 17:00:19 +0900 Subject: [PATCH 4/4] importing: exempt only a literally spelled device or literal path `_nt_rnormpath` tests for the `\\.\` and `\\?\` prefixes before it rewrites `/` into `\`, so a slash-spelled device path is not exempt and is normalized like any other; `ntpath.normpath("//./device/../name")` answers `\\.\device\name` under 3.14.6. The exemption here matched on `Prefix::DeviceNS` and the verbatim kinds, which `Path::components` also parses out of `//./name`, so those came back with their `..` intact. The test now reads the spelling, and the arm is `#[cfg(windows)]`: `_posix_rnormpath` has no counterpart to it, and on unix a backslash is an ordinary filename character, so the parsed-prefix test had been doing the platform gating implicitly. A `#[cfg(windows)]` unit test covers the three literal forms and the slash-spelled one. It asserts that the `..` was resolved rather than the separators the result comes back with: this walk never rewrites `/` into `\` for any path, so pinning that would assert a parity it does not claim. The `EQFULL` comment said the reference lane re-measures the value. It does not: `errno_platform_names.py` asserts presence and that every exported code reaches `errorcode`, and there is no value to measure here because the table reads `host_errno::EQFULL`. Line-numbered citations in the same function replaced with the arms they name. Assisted-by: Claude --- pyre/pyre-interpreter/src/importing.rs | 78 +++++++++++++++---- pyre/pyre-interpreter/src/module/errno/mod.rs | 10 ++- 2 files changed, 68 insertions(+), 20 deletions(-) diff --git a/pyre/pyre-interpreter/src/importing.rs b/pyre/pyre-interpreter/src/importing.rs index 8817d2199ca..aff719ea05c 100644 --- a/pyre/pyre-interpreter/src/importing.rs +++ b/pyre/pyre-interpreter/src/importing.rs @@ -27,7 +27,7 @@ use std::path::Path; not(feature = "sandbox"), not(target_arch = "wasm32") ))] -use std::path::{Component, Prefix}; +use std::path::Component; use crate::PyExecutionContext; use crate::{CodeObject, Mode, PyFrame, compile_source_with_filename}; @@ -2102,20 +2102,23 @@ fn absolute_from(path: PathBuf, cwd: &Path) -> PathBuf { ))] fn normalize_lexically(path: &Path) -> PathBuf { // `\\.\` device names and `\\?\` literal paths are handed to the OS as - // spelled and are returned unchanged (rpath.py:106-111): a `.` or `..` + // spelled and are returned unchanged (`_nt_rnormpath`): a `.` or `..` // inside one is an ordinary name, so collapsing it would rewrite which - // object the path reaches. No such component exists on unix, where the - // arm is unreachable. - if let Some(Component::Prefix(prefix)) = path.components().next() - && matches!( - prefix.kind(), - Prefix::Verbatim(_) - | Prefix::VerbatimUNC(..) - | Prefix::VerbatimDisk(_) - | Prefix::DeviceNS(_) - ) + // object the path reaches. + // + // The exemption is read off the spelling rather than off the parsed + // prefix, and the difference is observable: `//./name` parses as the same + // device prefix, but `_nt_rnormpath` tests for the two literal prefixes + // *before* it rewrites `/` into `\`, so a slash-spelled device path is + // never exempt and goes on to be normalized like any other. The whole + // arm is Windows-only because `_posix_rnormpath` has no counterpart to + // it, and on unix a backslash is an ordinary filename character. + #[cfg(windows)] { - return path.to_path_buf(); + let spelled = path.as_os_str().as_encoded_bytes(); + if spelled.starts_with(br"\\.\") || spelled.starts_with(br"\\?\") { + return path.to_path_buf(); + } } let mut out = PathBuf::new(); for component in path.components() { @@ -2127,8 +2130,10 @@ fn normalize_lexically(path: &Path) -> PathBuf { out.pop(); } // `/..` is `/`: nothing sits above a root, so the component - // names nothing and is dropped rather than kept - // (`rpath.py:53-57`, and `:142` for the drive-rooted spelling). + // names nothing and is dropped rather than kept -- the + // `i == 0 and prefix.endswith(sep)` arm of `_posix_rnormpath`, + // and the same arm of `_nt_rnormpath` for the drive-rooted + // spelling. Some(Component::RootDir) => {} // A relative path may open with `..`, `../..` keeps both, and // a drive-relative `C:..` keeps its own: popping a prefix @@ -2146,7 +2151,8 @@ fn normalize_lexically(path: &Path) -> PathBuf { /// A path opening with exactly two slashes is reserved for the host to /// interpret, so `//host/bin` keeps both while `///x` collapses to one -/// (`rpath.py:43-47`). `Path::components` yields a single `RootDir` either +/// (the `initial_slashes == 2` arm of `_posix_rnormpath`). `Path::components` +/// yields a single `RootDir` either /// way, so the distinction has to be read off the original spelling and put /// back afterwards. #[cfg(all( @@ -5939,4 +5945,44 @@ mod tests { assert_eq!(rpython_str_find_char("plain", '.', 0), -1); assert_eq!(rpython_str_slice_prefix("pkg.child", 3), "pkg"); } + + #[cfg(all( + feature = "host_env", + not(feature = "sandbox"), + not(target_arch = "wasm32"), + windows + ))] + #[test] + fn only_a_literally_spelled_device_or_literal_path_skips_normalization() { + // Spelled with the two backslashes the exemption is written in, so a + // `..` inside names a file rather than a level to walk up. + for spelled in [ + r"\\.\device\..\name", + r"\\?\C:\dir\..\file", + r"\\?\UNC\host\share\..\x", + ] { + assert_eq!( + normalize_lexically(Path::new(spelled)), + PathBuf::from(spelled) + ); + } + + // The same device prefix spelled with slashes is not exempt: + // `_nt_rnormpath` matches the literal prefixes before it rewrites `/` + // into `\`, so this one is normalized. What is asserted is that the + // `..` was resolved, not the separators it comes back with -- this + // walk never rewrites `/` into `\`, for any path. + let normalized = normalize_lexically(Path::new("//./device/../name")); + assert!( + !normalized + .components() + .any(|part| part == Component::ParentDir), + "{normalized:?}" + ); + assert!(normalized.ends_with("name"), "{normalized:?}"); + assert!( + matches!(normalized.components().next(), Some(Component::Prefix(_))), + "{normalized:?}" + ); + } } diff --git a/pyre/pyre-interpreter/src/module/errno/mod.rs b/pyre/pyre-interpreter/src/module/errno/mod.rs index 92a0cb00289..d4d00706ddf 100644 --- a/pyre/pyre-interpreter/src/module/errno/mod.rs +++ b/pyre/pyre-interpreter/src/module/errno/mod.rs @@ -165,10 +165,12 @@ crate::py_module! { } } // `interp_errno.py`'s "MacOSX specific errnos" block, plus - // `EQFULL`, which that list omits: measured under 3.14.6 on - // darwin, `errno.EQFULL` is 106, and - // `extra_tests/snippets/errno_platform_names.py` asserts the whole - // block so the reference lane re-measures it on every run. + // `EQFULL`, which that list omits. Nothing here writes a number + // down -- `libc` supplies each one, and `errno.EQFULL` read 106 + // under 3.14.6 on darwin when that omission was checked. + // `extra_tests/snippets/errno_platform_names.py` asserts that + // every name in this block is exported and reaches `errorcode`, + // on the reference interpreter as well as on pyre. // `DefinedConstantInteger` drops each of these on a platform whose // `errno.h` lacks it; the equivalent here is the target gate, // since `libc` declares them for apple targets only.