diff --git a/docs/doctoring/trusted-uv-lock-materialization.md b/docs/doctoring/trusted-uv-lock-materialization.md new file mode 100644 index 000000000..79e9d98c8 --- /dev/null +++ b/docs/doctoring/trusted-uv-lock-materialization.md @@ -0,0 +1,146 @@ +# Trusted `uv.lock` materialization: evidence and design record + +## Decision + +Central coverage automation may translate a tracked `uv.lock` from the exact +validated pull-request base revision into a pip-compatible, hash-pinned +requirements closure. The translation must not depend on a mutable runner tool, +repository-head dependency metadata, ambient runner configuration, or network +access during export. + +The implementation therefore: + +1. inventories regular blobs from the validated 40-character base commit before + deciding whether a `uv.lock` has a sibling `pyproject.toml`; +2. reads an inventoried lock and project file only through `git show` at that + same immutable revision; an absent sibling is an explicit orphan, while a + read failure for an inventoried blob is fatal and cannot be misclassified as + absence; +3. installs one process-wide urllib opener with an empty proxy map and a redirect + handler that rejects every redirect before urllib creates a target request; +4. downloads one fixed official Astral `uv` archive from a literal HTTPS URL and + accepts a response only when its parsed origin remains HTTPS, + `releases.astral.sh`, and the absent or explicit default port 443; malformed + or nondefault ports fail closed; +5. verifies the bounded archive with a pinned SHA-256 digest before extraction; +6. accepts only the expected regular-file tar member within explicit size bounds; +7. writes the executable with mode `0755` and verifies that it reports the exact + pinned `uv` version; +8. executes `uv export` with `--frozen`, `--offline`, `--no-cache`, + `--no-progress`, `--color never`, `--no-emit-project`, and `--no-editable` in + an isolated temporary project; +9. supplies a minimal environment with isolated home, temporary, cache, and + configuration directories, disables dotenv loading and managed Python + downloads, and does not inherit arbitrary runner variables; +10. keeps project metadata discovery enabled because the reconstructed + `pyproject.toml` is an authoritative input; `--no-config` is deliberately not + used because uv documents that it disables `pyproject.toml` discovery; +11. rejects every nonempty export unless every logical line is an exact normalized + package `==` pin followed only by complete SHA-256 hashes; and +12. exposes only generated requirements files and a source manifest to the later + networkless coverage environment. + +## Standards and current-tool rationale + +The approved SLSA specification is version 1.2. Its provenance model treats +verifiable origin and production history as software-supply-chain evidence, and +its source track distinguishes trusted robots whose identity and codebase cannot +be unilaterally influenced. Binding reads to an immutable Git revision, pinning +the exporter artifact by digest, isolating ambient configuration, and rejecting +malformed exporter output follow that trust-minimization direction without +claiming a SLSA conformance level. + +Astral documents `uv export` as the supported conversion path from `uv.lock` to a +pip-compatible requirements format. Hashes are emitted by default. `--frozen` +prevents lock mutation, `--offline` prevents network access, and `--no-cache` +uses an ephemeral cache. Project and editable entries are omitted because the +coverage sandbox loads repository source directly and needs only the +third-party dependency closure. + +The global `--no-config` option is not appropriate here. uv documents that it +prevents discovery of both `pyproject.toml` and `uv.toml`. The materializer +instead isolates `HOME`, `XDG_CONFIG_HOME`, `XDG_CACHE_HOME`, and `TMPDIR`, +sets `UV_NO_ENV_FILE=1` and `UV_PYTHON_DOWNLOADS=never`, and passes only a fixed +`PATH`. This preserves the exact reconstructed project metadata while excluding +user-level and runner-level configuration state. + +Generic requirements discovery continues to accept a global +`--require-hashes` directive because pip performs a later closure preflight. +Trusted `uv export` output uses a stricter rule: every logical line must begin +with a normalized package name and exact `==` pin, and every following hash must +be a complete `sha256` digest. Option lines, direct or local references, other +algorithms, truncated digests, and global directives are rejected even when they +contain a `--hash=` substring. + +The download request uses neither ambient proxy configuration nor automatic +redirect following. Any HTTP redirect is rejected before a request to the target +location can be created. The parsed response origin is still checked as defense +in depth: a nondefault or malformed port is a distinct authority and cannot be +accepted merely because the scheme and hostname match. The archive bytes must +then match the pinned digest. The redirect and origin boundaries prevent +unintended network side effects; the digest pin separately establishes +executable payload identity. + +## Modular and workspace boundary + +Nested standalone services are supported: a repository may contain several +independent directories, each with its own sibling `pyproject.toml` and +`uv.lock`; each pair is read and exported independently from the immutable base +revision. This fits the organization’s standalone-product plus reusable-module +MSA contract without copying central review logic into product repositories. + +A true uv workspace can require member `pyproject.toml` files in addition to the +root lock and root project metadata. The current materializer does not +reconstruct arbitrary workspace members. Such an export therefore fails closed +instead of silently producing incomplete dependency evidence. Workspace-member +reconstruction is tracked separately in `.github#750`; that change must enumerate +member metadata from the same immutable base tree and prove `--all-packages` and +local-package omission semantics before it is enabled. + +## Verification contract + +Regression coverage must prove: + +- base-revision-only reads and rejection of unsafe revision/path shapes; +- an absent sibling project is skipped, but an inventoried project blob that + cannot be read propagates a fatal error before uv starts; +- the download opener is cached, disables ambient proxies, and rejects redirects + before following them; +- fixed HTTPS scheme and hostname validation, acceptance only of an absent or + explicit port 443, rejection of malformed and nondefault ports, bounded reads, + archive digest, member type, member size, executable size, executable mode, + and exact version; +- frozen, offline, cacheless, noninteractive exporter arguments; +- isolated environment directories and exclusion of arbitrary ambient variables; +- continued project metadata discovery with no `--no-config` regression; +- timeout, process, parse, and exporter failures fail closed; +- orphan locks and empty third-party closures remain nonfatal and explicit; +- every nonempty line is a normalized exact package pin with one or more complete + SHA-256 hashes; and +- `pyproject.toml` enables branch measurement and the changed production module + retains 100% statement and branch coverage plus 100% production docstrings. + +## References + +Astral Software, Inc. (n.d.). *Exporting a lockfile*. uv documentation. Retrieved +August 4, 2026, from https://docs.astral.sh/uv/concepts/projects/export/ + +Astral Software, Inc. (n.d.). *Locking and syncing*. uv documentation. Retrieved +August 4, 2026, from https://docs.astral.sh/uv/concepts/projects/sync/ + +Astral Software, Inc. (n.d.). *The uv command-line interface*. uv documentation. +Retrieved August 4, 2026, from https://docs.astral.sh/uv/reference/cli/ + +Berners-Lee, T., Fielding, R., & Masinter, L. (2005). *Uniform Resource Identifier +(URI): Generic syntax* (STD 66; RFC 3986). Internet Engineering Task Force. +https://doi.org/10.17487/RFC3986 + +Supply-chain Levels for Software Artifacts. (2025). *SLSA specification +(version 1.2)*. https://slsa.dev/spec/v1.2/ + +Supply-chain Levels for Software Artifacts. (2025). *Provenance (version 1.2)*. +https://slsa.dev/spec/v1.2/provenance + +Supply-chain Levels for Software Artifacts. (2025). *Source: Requirements for +producing source (version 1.2)*. +https://slsa.dev/spec/v1.2/source-requirements diff --git a/pyproject.toml b/pyproject.toml index ee2585bf0..f837b4354 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,6 +15,7 @@ dev = [ pythonpath = ["."] [tool.coverage.run] +branch = true source = ["scripts/ci"] omit = ["tests/*"] diff --git a/scripts/ci/materialize_base_python_requirements.py b/scripts/ci/materialize_base_python_requirements.py old mode 100644 new mode 100755 index 8158372df..53b4788dd --- a/scripts/ci/materialize_base_python_requirements.py +++ b/scripts/ci/materialize_base_python_requirements.py @@ -4,18 +4,73 @@ from __future__ import annotations import argparse +import atexit import fnmatch +import functools +import hashlib +import io import json +import os import pathlib import re import shutil import subprocess import sys +import tarfile import tempfile +import urllib.parse +import urllib.request +from typing import Any SHA_RE = re.compile(r"^[0-9a-fA-F]{40}$") +UV_EXACT_REQUIREMENT_RE = re.compile( + r"[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?" + r"(?:\[[A-Za-z0-9._-]+(?:,[A-Za-z0-9._-]+)*\])?" + r"==[^\s;]+(?:\s*;\s*\S(?:.*\S)?)?" +) +UV_SHA256_HASH_RE = re.compile(r"--hash=sha256:[0-9a-fA-F]{64}") UV_EXPORT_TIMEOUT_SECONDS = 120 +TRUSTED_UV_VERSION = "0.12.1" +TRUSTED_UV_ARCHIVE_URL = ( + "https://releases.astral.sh/github/uv/releases/download/0.12.1/" + "uv-x86_64-unknown-linux-gnu.tar.gz" +) +TRUSTED_UV_ARCHIVE_SHA256 = ( + "90b2f223fb69d19db49e117da601f64978593417988530aa733d456141b4bcbb" +) +TRUSTED_UV_ARCHIVE_MEMBER = "uv-x86_64-unknown-linux-gnu/uv" +TRUSTED_UV_DOWNLOAD_TIMEOUT_SECONDS = 120 +TRUSTED_UV_DOWNLOAD_MAX_BYTES = 64 * 1024 * 1024 +TRUSTED_UV_BINARY_MAX_BYTES = 64 * 1024 * 1024 +TRUSTED_UV_VERSION_TIMEOUT_SECONDS = 10 + + +class _RejectTrustedUvRedirects(urllib.request.HTTPRedirectHandler): + """Reject every redirect before urllib issues a request to its target.""" + + def redirect_request( + self, + request: urllib.request.Request, + response: Any, + code: int, + message: str, + headers: Any, + new_url: str, + ) -> None: + """Fail closed for all redirect status codes and target locations.""" + del request, response, code, message, headers, new_url + raise RuntimeError("trusted uv archive redirects are forbidden") + + +@functools.cache +def _install_trusted_uv_url_opener() -> None: + """Install one process-wide no-proxy, no-redirect opener for the fixed URL.""" + opener = urllib.request.build_opener( + urllib.request.ProxyHandler({}), + _RejectTrustedUvRedirects(), + ) + urllib.request.install_opener(opener) def _is_candidate_lock_name(name: str) -> bool: @@ -66,6 +121,30 @@ def _is_hash_pinned(content: bytes) -> bool: ) +def _is_fully_hash_pinned_requirement(line: str) -> bool: + """Return whether one uv-export line is an exact package pin with SHA-256 hashes.""" + fields = re.split(r"\s+(?=--hash=)", line) + if len(fields) < 2: + return False + requirement, *hashes = fields + if UV_EXACT_REQUIREMENT_RE.fullmatch(requirement) is None: + return False + return all(UV_SHA256_HASH_RE.fullmatch(hash_value) for hash_value in hashes) + + +def _is_fully_hash_pinned_export(content: bytes) -> bool: + """Return whether every emitted uv requirement is exactly SHA-256 pinned. + + The fixed exporter invocation does not request index, find-links, binary, or + global hash directives. Every non-comment logical line must therefore be one + normalized package ``==`` pin with at least one complete SHA-256 hash. Option + lines, local/direct references, other algorithms, and truncated hashes are + rejected even when they contain a ``--hash=`` substring. + """ + lines = _requirement_lines(content) + return bool(lines) and all(_is_fully_hash_pinned_requirement(line) for line in lines) + + def _git(repo_root: pathlib.Path, *args: str) -> bytes: """Run one read-only git command in the materialized repository.""" completed = subprocess.run( @@ -80,6 +159,130 @@ def _git(repo_root: pathlib.Path, *args: str) -> bytes: return completed.stdout +def _download_trusted_uv_archive() -> bytes: + """Download the fixed uv release archive through one HTTPS trust boundary.""" + _install_trusted_uv_url_opener() + try: + # Keep the audited URL literal at the network sink so static analysis can + # prove that neither user data nor repository content selects a scheme, + # host, path, query, fragment, method, or request header. + with urllib.request.urlopen( # nosec B310 -- literal HTTPS URL plus SHA pin + "https://releases.astral.sh/github/uv/releases/download/0.12.1/" + "uv-x86_64-unknown-linux-gnu.tar.gz", + timeout=TRUSTED_UV_DOWNLOAD_TIMEOUT_SECONDS, + ) as response: + final_url = urllib.parse.urlparse(response.geturl()) + try: + final_port = final_url.port + except ValueError as exc: + raise RuntimeError( + "trusted uv archive redirected outside the fixed " + "releases.astral.sh HTTPS origin" + ) from exc + if ( + (final_url.scheme, final_url.hostname) + != ("https", "releases.astral.sh") + or final_port not in (None, 443) + ): + raise RuntimeError( + "trusted uv archive redirected outside the fixed " + "releases.astral.sh HTTPS origin" + ) + payload = response.read(TRUSTED_UV_DOWNLOAD_MAX_BYTES + 1) + except OSError as exc: + raise RuntimeError( + f"trusted uv archive download failed: {type(exc).__name__}" + ) from exc + + if len(payload) > TRUSTED_UV_DOWNLOAD_MAX_BYTES: + raise RuntimeError("trusted uv archive exceeded the bounded download size") + return payload + + +def _verified_uv_binary(archive_payload: bytes) -> bytes: + """Return the bounded uv executable after archive and member verification.""" + digest = hashlib.sha256(archive_payload).hexdigest() + if digest != TRUSTED_UV_ARCHIVE_SHA256: + raise RuntimeError("trusted uv archive checksum verification failed") + + try: + with tarfile.open(fileobj=io.BytesIO(archive_payload), mode="r:gz") as bundle: + try: + member = bundle.getmember(TRUSTED_UV_ARCHIVE_MEMBER) + except KeyError as exc: + raise RuntimeError("trusted uv archive omitted the uv executable") from exc + if not member.isfile(): + raise RuntimeError("trusted uv archive member is not a regular file") + if member.size > TRUSTED_UV_BINARY_MAX_BYTES: + raise RuntimeError("trusted uv executable exceeded the bounded size") + extracted = bundle.extractfile(member) + if extracted is None: # pragma: no cover - guarded by member.isfile() + raise AssertionError("regular tar members must be extractable") + binary = extracted.read(TRUSTED_UV_BINARY_MAX_BYTES + 1) + except tarfile.TarError as exc: + raise RuntimeError("trusted uv archive could not be parsed") from exc + + if len(binary) != member.size: + raise RuntimeError("trusted uv executable size did not match its archive metadata") + return binary + + +@functools.cache +def _install_trusted_uv() -> str: + """Install and verify the pinned uv exporter once for this process.""" + tool_dir = pathlib.Path(tempfile.mkdtemp(prefix="opencode-trusted-uv-")) + tool_dir.mkdir(mode=0o700, parents=True, exist_ok=True) + uv_path = tool_dir / "uv" + try: + uv_path.write_bytes(_verified_uv_binary(_download_trusted_uv_archive())) + uv_path.chmod(0o755) + try: + completed = subprocess.run( + [str(uv_path), "--version"], + check=False, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + timeout=TRUSTED_UV_VERSION_TIMEOUT_SECONDS, + ) + except (OSError, subprocess.TimeoutExpired) as exc: + raise RuntimeError( + f"trusted uv executable verification failed: {type(exc).__name__}" + ) from exc + observed = completed.stdout.decode("utf-8", errors="replace").strip() + if completed.returncode != 0 or observed != f"uv {TRUSTED_UV_VERSION}": + raise RuntimeError( + "trusted uv executable reported an unexpected version or exit status" + ) + except Exception: + shutil.rmtree(tool_dir, ignore_errors=True) + raise + + atexit.register(shutil.rmtree, tool_dir, ignore_errors=True) + return str(uv_path) + + +def _trusted_uv_export_environment(work_dir: pathlib.Path) -> dict[str, str]: + """Create the minimal deterministic environment allowed to influence uv export.""" + directories = { + "HOME": work_dir / ".uv-home", + "TMPDIR": work_dir / ".uv-tmp", + "XDG_CACHE_HOME": work_dir / ".uv-cache", + "XDG_CONFIG_HOME": work_dir / ".uv-config", + } + for directory in directories.values(): + directory.mkdir(mode=0o700, parents=True, exist_ok=True) + return { + "HOME": str(directories["HOME"]), + "NO_COLOR": "1", + "PATH": os.defpath, + "TMPDIR": str(directories["TMPDIR"]), + "UV_NO_ENV_FILE": "1", + "UV_PYTHON_DOWNLOADS": "never", + "XDG_CACHE_HOME": str(directories["XDG_CACHE_HOME"]), + "XDG_CONFIG_HOME": str(directories["XDG_CONFIG_HOME"]), + } + + def _run_uv_export( work_dir: pathlib.Path, uv_path: str, @@ -88,11 +291,11 @@ def _run_uv_export( ) -> subprocess.CompletedProcess[bytes]: """Run ``uv export`` for a reconstructed base project and return the result. - ``--frozen`` forbids lock mutation and ``--offline`` forbids network access, - so the export is a pure function of the already-trusted base ``uv.lock`` and - ``pyproject.toml``; ``--no-emit-project``/``--no-editable`` drop the project - itself (installed via ``PYTHONPATH`` in the sandbox) and keep only its - hash-pinned dependency closure. + ``--frozen`` forbids lock mutation and ``--offline`` forbids network access. + A minimal environment and ephemeral cache/config/home directories prevent + runner-level configuration, dotenv files, Python downloads, or persistent + cache state from selecting export behavior. Project metadata discovery stays + enabled so the reconstructed ``pyproject.toml`` remains authoritative. """ return subprocess.run( [ @@ -100,6 +303,10 @@ def _run_uv_export( "export", "--frozen", "--offline", + "--no-cache", + "--no-progress", + "--color", + "never", "--no-emit-project", "--no-editable", "--format", @@ -110,61 +317,73 @@ def _run_uv_export( stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=timeout, + env=_trusted_uv_export_environment(work_dir), ) -def _export_uv_lock( - repo_root: pathlib.Path, base_sha: str, lock_path: str -) -> bytes | None: - """Export a base ``uv.lock`` to a hash-pinned requirements closure, or ``None``. - - ``uv.lock`` is not a pip-installable format, so a uv-managed repository - materializes no dependencies and its offline coverage run fails at import. - When ``uv`` is available, reconstruct the exact base ``uv.lock`` and its - sibling ``pyproject.toml`` in an isolated temporary directory and run - ``uv export --frozen`` to produce a fully hash-pinned closure the trusted - installer can consume like any other lock. Both inputs are read only from - the validated base commit, so no PR-mutable content reaches ``uv``. Return - ``None`` — degrading to the prior no-uv behavior — when ``uv`` is absent, - the sibling ``pyproject.toml`` is missing at the base commit, the export - fails, or its output is not fully hash-pinned, so this can never break an - otherwise-working build. - """ - uv_path = shutil.which("uv") - if uv_path is None: - return None +def _uv_pyproject_path(lock_path: str) -> str: + """Return the sibling project metadata path for one safe tracked uv lock.""" project_dir = pathlib.PurePosixPath(lock_path).parent - pyproject_path = ( + return ( "pyproject.toml" if str(project_dir) == "." else f"{project_dir}/pyproject.toml" ) - try: - lock_content = _git(repo_root, "show", f"{base_sha}:{lock_path}") - pyproject_content = _git(repo_root, "show", f"{base_sha}:{pyproject_path}") - except RuntimeError: - return None + + +def _export_uv_lock( + repo_root: pathlib.Path, base_sha: str, lock_path: str +) -> bytes | None: + """Export one tracked base ``uv.lock`` into a trusted hash-pinned closure. + + The caller proves that the sibling ``pyproject.toml`` is a regular blob in + the same exact base tree before invoking this function. Any later Git read + failure is therefore an integrity or availability failure, not evidence of + an orphan lock, and propagates fail-closed. A successful comment-only export + represents a valid project with no third-party dependency closure. + """ + pyproject_path = _uv_pyproject_path(lock_path) + lock_content = _git(repo_root, "show", f"{base_sha}:{lock_path}") + pyproject_content = _git(repo_root, "show", f"{base_sha}:{pyproject_path}") + uv_path = _install_trusted_uv() + with tempfile.TemporaryDirectory() as work_dir: work_path = pathlib.Path(work_dir) (work_path / "uv.lock").write_bytes(lock_content) (work_path / "pyproject.toml").write_bytes(pyproject_content) try: completed = _run_uv_export(work_path, uv_path) - except (OSError, subprocess.TimeoutExpired): - return None + except (OSError, subprocess.TimeoutExpired) as exc: + raise RuntimeError( + f"could not run trusted uv export for tracked base lock {lock_path}: " + f"{type(exc).__name__}" + ) from exc + if completed.returncode != 0: - return None - exported = completed.stdout - return exported if _is_hash_pinned(exported) else None + stderr = completed.stderr.decode("utf-8", errors="replace") + normalized_stderr = " ".join(stderr.split()) + detail = ( + normalized_stderr[:500] + if normalized_stderr + else f"exit status {completed.returncode}" + ) + raise RuntimeError( + f"uv export failed for tracked base lock {lock_path}: {detail}" + ) + exported = completed.stdout + if not _requirement_lines(exported): + return None + if not _is_fully_hash_pinned_export(exported): + raise RuntimeError( + f"uv export for tracked base lock {lock_path} was not fully hash-pinned" + ) + return exported -def base_hash_locks(repo_root: pathlib.Path, base_sha: str) -> list[tuple[str, bytes]]: - """Return regular hash-lock blobs from the exact validated base commit.""" - if not SHA_RE.fullmatch(base_sha): - raise ValueError("base SHA must be exactly 40 hexadecimal characters") - locks: list[tuple[str, bytes]] = [] - entries = _git(repo_root, "ls-tree", "-r", "-z", "--full-tree", base_sha) +def _regular_base_blob_paths(entries: bytes) -> list[tuple[str, pathlib.PurePosixPath]]: + """Parse exact-tree output into safe regular blob paths in repository order.""" + regular_blobs: list[tuple[str, pathlib.PurePosixPath]] = [] for raw_entry in entries.split(b"\0"): if not raw_entry: continue @@ -186,11 +405,27 @@ def base_hash_locks(repo_root: pathlib.Path, base_sha: str) -> list[tuple[str, b or ".." in candidate.parts ): continue + regular_blobs.append((path, candidate)) + return regular_blobs + + +def base_hash_locks(repo_root: pathlib.Path, base_sha: str) -> list[tuple[str, bytes]]: + """Return regular hash-lock blobs from the exact validated base commit.""" + if not SHA_RE.fullmatch(base_sha): + raise ValueError("base SHA must be exactly 40 hexadecimal characters") + + entries = _git(repo_root, "ls-tree", "-r", "-z", "--full-tree", base_sha) + regular_blobs = _regular_base_blob_paths(entries) + regular_paths = {path for path, _candidate in regular_blobs} + locks: list[tuple[str, bytes]] = [] + for path, candidate in regular_blobs: if _is_candidate_lock_name(candidate.name): content = _git(repo_root, "show", f"{base_sha}:{path}") if _is_hash_pinned(content): locks.append((path, content)) elif candidate.name == "uv.lock": + if _uv_pyproject_path(path) not in regular_paths: + continue exported = _export_uv_lock(repo_root, base_sha, path) if exported is not None: locks.append((path, exported)) diff --git a/tests/test_materialize_base_python_requirements.py b/tests/test_materialize_base_python_requirements.py index 41b86b261..fd2b68f1a 100644 --- a/tests/test_materialize_base_python_requirements.py +++ b/tests/test_materialize_base_python_requirements.py @@ -1,8 +1,11 @@ from __future__ import annotations +import hashlib +import io import runpy import subprocess import sys +import tarfile from pathlib import Path import pytest @@ -378,7 +381,7 @@ def test_uv_lock_is_exported_to_a_hash_pinned_lock( ) -> None: """A base uv.lock is exported via uv into a materialized hash-pinned closure.""" repo, base_sha = _uv_repo(tmp_path, with_pyproject=True) - monkeypatch.setattr(materializer.shutil, "which", lambda _name: "/usr/bin/uv") + monkeypatch.setattr(materializer, "_install_trusted_uv", lambda: "/usr/bin/uv") hashed = b"demo-dep==1 --hash=sha256:" + b"a" * 64 + b"\n" monkeypatch.setattr( materializer, @@ -393,14 +396,19 @@ def test_uv_lock_is_exported_to_a_hash_pinned_lock( assert (output / "requirements-000.txt").read_bytes() == hashed -def test_uv_lock_skipped_when_uv_is_unavailable( +def test_uv_lock_fails_closed_when_trusted_uv_bootstrap_fails( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: - """Without the uv exporter, a uv.lock-only repo materializes nothing (no regression).""" + """A tracked project uv.lock cannot silently lose its dependency evidence.""" repo, base_sha = _uv_repo(tmp_path, with_pyproject=True) - monkeypatch.setattr(materializer.shutil, "which", lambda _name: None) - assert materializer.materialize(repo, base_sha, tmp_path / "output") == [] + def fail_install() -> str: + raise RuntimeError("trusted uv bootstrap failed") + + monkeypatch.setattr(materializer, "_install_trusted_uv", fail_install) + + with pytest.raises(RuntimeError, match="trusted uv bootstrap failed"): + materializer.materialize(repo, base_sha, tmp_path / "output") def test_uv_lock_skipped_when_pyproject_is_absent( @@ -408,41 +416,327 @@ def test_uv_lock_skipped_when_pyproject_is_absent( ) -> None: """A uv.lock without a sibling pyproject.toml at base (in a subdir) cannot be exported.""" repo, base_sha = _uv_repo(tmp_path, with_pyproject=False, lock_dir="service") - monkeypatch.setattr(materializer.shutil, "which", lambda _name: "/usr/bin/uv") + def unexpected_install() -> str: + raise AssertionError("orphan uv.lock must not bootstrap uv") + + monkeypatch.setattr(materializer, "_install_trusted_uv", unexpected_install) assert materializer.materialize(repo, base_sha, tmp_path / "output") == [] -def test_uv_lock_skipped_when_export_fails( +def test_uv_lock_fails_closed_when_export_fails( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: - """A non-zero uv export (e.g. a stale lock) is skipped, never materialized.""" + """A stale or otherwise unexportable tracked uv.lock blocks evidence creation.""" repo, base_sha = _uv_repo(tmp_path, with_pyproject=True) - monkeypatch.setattr(materializer.shutil, "which", lambda _name: "/usr/bin/uv") + monkeypatch.setattr(materializer, "_install_trusted_uv", lambda: "/usr/bin/uv") monkeypatch.setattr( materializer, "_run_uv_export", - lambda _work, _uv_path: _export(1, b""), + lambda _work, _uv_path: subprocess.CompletedProcess( + ["uv", "export"], 1, b"", b"lock is stale\n" + ), ) - assert materializer.materialize(repo, base_sha, tmp_path / "output") == [] + with pytest.raises(RuntimeError, match="uv export failed.*lock is stale"): + materializer.materialize(repo, base_sha, tmp_path / "output") -def test_uv_lock_skipped_when_export_is_not_hash_pinned( +def test_uv_lock_fails_closed_when_export_is_not_hash_pinned( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: - """A uv export that somehow lacks hashes is rejected by the hash-pin guard.""" + """A nonempty uv export without hashes is rejected instead of ignored.""" repo, base_sha = _uv_repo(tmp_path, with_pyproject=True) - monkeypatch.setattr(materializer.shutil, "which", lambda _name: "/usr/bin/uv") + monkeypatch.setattr(materializer, "_install_trusted_uv", lambda: "/usr/bin/uv") monkeypatch.setattr( materializer, "_run_uv_export", lambda _work, _uv_path: _export(0, b"unpinned==1\n"), ) + with pytest.raises(RuntimeError, match="not fully hash-pinned"): + materializer.materialize(repo, base_sha, tmp_path / "output") + + +def test_uv_lock_with_empty_dependency_closure_materializes_nothing( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """A successful comment-only uv export represents a valid empty closure.""" + repo, base_sha = _uv_repo(tmp_path, with_pyproject=True) + monkeypatch.setattr(materializer, "_install_trusted_uv", lambda: "/usr/bin/uv") + monkeypatch.setattr( + materializer, + "_run_uv_export", + lambda _work, _uv_path: _export(0, b"# no third-party dependencies\n"), + ) + assert materializer.materialize(repo, base_sha, tmp_path / "output") == [] +class _FakeResponse: + """Minimal context-managed HTTP response used by trusted uv download tests.""" + + def __init__(self, payload: bytes, final_url: str) -> None: + """Store deterministic response bytes and the observed final URL.""" + self._payload = payload + self._final_url = final_url + + def __enter__(self) -> "_FakeResponse": + """Return this response from a context manager.""" + return self + + def __exit__(self, *_args: object) -> None: + """Close the fake response without suppressing exceptions.""" + + def geturl(self) -> str: + """Return the final URL after redirects.""" + return self._final_url + + def read(self, size: int) -> bytes: + """Return at most ``size`` bytes like an HTTP response.""" + return self._payload[:size] + + +def _trusted_uv_archive( + binary: bytes = b"verified-uv", + *, + member_name: str = materializer.TRUSTED_UV_ARCHIVE_MEMBER, + regular: bool = True, +) -> bytes: + """Build a deterministic uv tar archive for supply-chain boundary tests.""" + payload = io.BytesIO() + with tarfile.open(fileobj=payload, mode="w:gz") as bundle: + member = tarfile.TarInfo(member_name) + if regular: + member.size = len(binary) + bundle.addfile(member, io.BytesIO(binary)) + else: + member.type = tarfile.DIRTYPE + bundle.addfile(member) + return payload.getvalue() + + +def test_download_trusted_uv_archive_accepts_fixed_https_origin( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The downloader returns bounded bytes from the fixed Astral HTTPS origin.""" + payload = b"archive" + response = _FakeResponse(payload, materializer.TRUSTED_UV_ARCHIVE_URL) + monkeypatch.setattr(materializer.urllib.request, "urlopen", lambda *_a, **_k: response) + + assert materializer._download_trusted_uv_archive() == payload + + +def test_download_trusted_uv_archive_rejects_unsafe_redirect( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A redirect away from the fixed HTTPS release host fails closed.""" + response = _FakeResponse(b"archive", "https://example.invalid/uv.tar.gz") + monkeypatch.setattr(materializer.urllib.request, "urlopen", lambda *_a, **_k: response) + + with pytest.raises(RuntimeError, match="redirected outside"): + materializer._download_trusted_uv_archive() + + +def test_download_trusted_uv_archive_rejects_network_and_size_failures( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Network errors and oversized archives cannot enter the trusted tool path.""" + monkeypatch.setattr( + materializer.urllib.request, + "urlopen", + lambda *_a, **_k: (_ for _ in ()).throw(OSError("offline")), + ) + with pytest.raises(RuntimeError, match="download failed"): + materializer._download_trusted_uv_archive() + + response = _FakeResponse(b"12345", materializer.TRUSTED_UV_ARCHIVE_URL) + monkeypatch.setattr(materializer.urllib.request, "urlopen", lambda *_a, **_k: response) + monkeypatch.setattr(materializer, "TRUSTED_UV_DOWNLOAD_MAX_BYTES", 4) + with pytest.raises(RuntimeError, match="bounded download size"): + materializer._download_trusted_uv_archive() + + +def test_verified_uv_binary_accepts_exact_archive( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """An exact-hash archive yields only its bounded regular uv member.""" + archive = _trusted_uv_archive() + monkeypatch.setattr( + materializer, "TRUSTED_UV_ARCHIVE_SHA256", hashlib.sha256(archive).hexdigest() + ) + + assert materializer._verified_uv_binary(archive) == b"verified-uv" + + +@pytest.mark.parametrize( + ("archive", "error"), + [ + (b"not-a-tar", "checksum verification failed"), + (_trusted_uv_archive(member_name="wrong/uv"), "omitted the uv executable"), + (_trusted_uv_archive(regular=False), "not a regular file"), + ], +) +def test_verified_uv_binary_rejects_invalid_archives( + archive: bytes, error: str, monkeypatch: pytest.MonkeyPatch +) -> None: + """Checksum, membership, and file-type violations fail closed.""" + if error != "checksum verification failed": + monkeypatch.setattr( + materializer, + "TRUSTED_UV_ARCHIVE_SHA256", + hashlib.sha256(archive).hexdigest(), + ) + with pytest.raises(RuntimeError, match=error): + materializer._verified_uv_binary(archive) + + +def test_verified_uv_binary_rejects_parse_and_size_failures( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Corrupt tar data and oversized executable metadata cannot be installed.""" + corrupt = b"not-a-tar" + monkeypatch.setattr( + materializer, "TRUSTED_UV_ARCHIVE_SHA256", hashlib.sha256(corrupt).hexdigest() + ) + with pytest.raises(RuntimeError, match="could not be parsed"): + materializer._verified_uv_binary(corrupt) + + archive = _trusted_uv_archive(binary=b"large") + monkeypatch.setattr( + materializer, "TRUSTED_UV_ARCHIVE_SHA256", hashlib.sha256(archive).hexdigest() + ) + monkeypatch.setattr(materializer, "TRUSTED_UV_BINARY_MAX_BYTES", 4) + with pytest.raises(RuntimeError, match="bounded size"): + materializer._verified_uv_binary(archive) + + +def test_verified_uv_binary_rejects_truncated_member( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A truncated regular member cannot satisfy the archive size receipt.""" + archive = b"archive" + monkeypatch.setattr( + materializer, "TRUSTED_UV_ARCHIVE_SHA256", hashlib.sha256(archive).hexdigest() + ) + + class _Member: + """Represent one regular member with a longer declared size.""" + + size = 2 + + @staticmethod + def isfile() -> bool: + """Return that this synthetic member is regular.""" + return True + + class _Bundle: + """Return a deliberately truncated member stream.""" + + def __enter__(self) -> "_Bundle": + """Enter the synthetic archive context.""" + return self + + def __exit__(self, *_args: object) -> None: + """Leave the synthetic archive context.""" + + @staticmethod + def getmember(_name: str) -> _Member: + """Return the synthetic regular member.""" + return _Member() + + @staticmethod + def extractfile(_member: _Member) -> io.BytesIO: + """Return fewer bytes than the member metadata declares.""" + return io.BytesIO(b"x") + + monkeypatch.setattr(materializer.tarfile, "open", lambda *_a, **_k: _Bundle()) + + with pytest.raises(RuntimeError, match="size did not match"): + materializer._verified_uv_binary(archive) + + +def test_install_trusted_uv_verifies_version_and_caches_path( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """The installer writes one executable, verifies its version, and caches it.""" + materializer._install_trusted_uv.cache_clear() + monkeypatch.setattr(materializer.tempfile, "mkdtemp", lambda **_k: str(tmp_path / "uv")) + monkeypatch.setattr(materializer, "_download_trusted_uv_archive", lambda: b"archive") + monkeypatch.setattr(materializer, "_verified_uv_binary", lambda _payload: b"binary") + registered: list[tuple[object, ...]] = [] + monkeypatch.setattr(materializer.atexit, "register", lambda *args, **_kwargs: registered.append(args)) + calls = 0 + + def verify(*_args: object, **_kwargs: object) -> subprocess.CompletedProcess[bytes]: + nonlocal calls + calls += 1 + return subprocess.CompletedProcess([], 0, b"uv 0.12.1\n", b"") + + monkeypatch.setattr(materializer.subprocess, "run", verify) + + first = materializer._install_trusted_uv() + second = materializer._install_trusted_uv() + + assert first == second == str(tmp_path / "uv" / "uv") + assert Path(first).read_bytes() == b"binary" + assert Path(first).stat().st_mode & 0o111 + assert calls == 1 + assert registered + materializer._install_trusted_uv.cache_clear() + + +@pytest.mark.parametrize( + "failure", + [ + FileNotFoundError("missing binary"), + subprocess.TimeoutExpired(["uv", "--version"], timeout=10), + ], +) +def test_install_trusted_uv_rejects_version_process_failures( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + failure: OSError | subprocess.TimeoutExpired, +) -> None: + """A missing or hung downloaded executable is removed and rejected.""" + materializer._install_trusted_uv.cache_clear() + tool_dir = tmp_path / "uv" + monkeypatch.setattr(materializer.tempfile, "mkdtemp", lambda **_k: str(tool_dir)) + monkeypatch.setattr(materializer, "_download_trusted_uv_archive", lambda: b"archive") + monkeypatch.setattr(materializer, "_verified_uv_binary", lambda _payload: b"binary") + + def fail(*_args: object, **_kwargs: object) -> None: + raise failure + + monkeypatch.setattr(materializer.subprocess, "run", fail) + with pytest.raises(RuntimeError, match="executable verification failed"): + materializer._install_trusted_uv() + assert not tool_dir.exists() + materializer._install_trusted_uv.cache_clear() + + +def test_install_trusted_uv_rejects_wrong_version_or_exit_status( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Unexpected version output or a nonzero status cannot satisfy the pin.""" + for completed in ( + subprocess.CompletedProcess([], 0, b"uv 0.12.0\n", b""), + subprocess.CompletedProcess([], 1, b"uv 0.12.1\n", b"failed"), + ): + materializer._install_trusted_uv.cache_clear() + tool_dir = tmp_path / f"uv-{completed.returncode}-{len(completed.stdout)}" + monkeypatch.setattr( + materializer.tempfile, "mkdtemp", lambda **_k: str(tool_dir) + ) + monkeypatch.setattr(materializer, "_download_trusted_uv_archive", lambda: b"archive") + monkeypatch.setattr(materializer, "_verified_uv_binary", lambda _payload: b"binary") + monkeypatch.setattr(materializer.subprocess, "run", lambda *_a, **_k: completed) + with pytest.raises(RuntimeError, match="unexpected version or exit status"): + materializer._install_trusted_uv() + assert not tool_dir.exists() + materializer._install_trusted_uv.cache_clear() + + def test_run_uv_export_invokes_uv_with_frozen_offline_flags( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: @@ -475,18 +769,19 @@ def fake_run(argv: list[str], **kwargs: object) -> subprocess.CompletedProcess[b subprocess.TimeoutExpired(["/usr/bin/uv", "export"], timeout=120), ], ) -def test_uv_export_process_failures_fall_back_to_no_lock( +def test_uv_export_process_failures_fail_closed( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, export_error: OSError | subprocess.TimeoutExpired, ) -> None: - """A missing or hung uv process preserves the documented best-effort fallback.""" + """A missing or hung trusted uv process cannot silently drop dependencies.""" repo, base_sha = _uv_repo(tmp_path, with_pyproject=True) - monkeypatch.setattr(materializer.shutil, "which", lambda _name: "/usr/bin/uv") + monkeypatch.setattr(materializer, "_install_trusted_uv", lambda: "/usr/bin/uv") def fail_export(_work: Path, _uv_path: str) -> None: raise export_error monkeypatch.setattr(materializer, "_run_uv_export", fail_export) - assert materializer.materialize(repo, base_sha, tmp_path / "output") == [] + with pytest.raises(RuntimeError, match="could not run trusted uv export"): + materializer.materialize(repo, base_sha, tmp_path / "output") diff --git a/tests/test_materialize_uv_export_hash_contract.py b/tests/test_materialize_uv_export_hash_contract.py new file mode 100644 index 000000000..9d88b4870 --- /dev/null +++ b/tests/test_materialize_uv_export_hash_contract.py @@ -0,0 +1,38 @@ +"""Fail-closed hash validation for trusted ``uv export`` output.""" + +from __future__ import annotations + +import subprocess +from pathlib import Path + +import pytest + +from scripts.ci import materialize_base_python_requirements as materializer + + +def test_uv_export_requires_a_hash_on_each_requirement( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A global require-hashes directive cannot replace per-requirement hashes.""" + + def fake_git(_repo_root: Path, *args: str) -> bytes: + assert args[0] == "show" + return b"version = 1\n" if args[1].endswith(":uv.lock") else b"[project]\n" + + malformed_export = b"--require-hashes\ndemo==1\n" + monkeypatch.setattr(materializer, "_git", fake_git) + monkeypatch.setattr(materializer, "_install_trusted_uv", lambda: "/trusted/uv") + monkeypatch.setattr( + materializer, + "_run_uv_export", + lambda _work_dir, _uv_path: subprocess.CompletedProcess( + ["uv", "export"], + 0, + malformed_export, + b"", + ), + ) + + with pytest.raises(RuntimeError, match="not fully hash-pinned"): + materializer._export_uv_lock(tmp_path, "a" * 40, "uv.lock") diff --git a/tests/test_trusted_uv_download_contract.py b/tests/test_trusted_uv_download_contract.py new file mode 100644 index 000000000..f227ed93c --- /dev/null +++ b/tests/test_trusted_uv_download_contract.py @@ -0,0 +1,79 @@ +"""Static security contract for the pinned trusted-uv network boundary.""" + +from __future__ import annotations + +import ast +from pathlib import Path + + +_REPO_ROOT = Path(__file__).resolve().parents[1] +_MATERIALIZER = _REPO_ROOT / "scripts" / "ci" / "materialize_base_python_requirements.py" +_EXPECTED_URL = ( + "https://releases.astral.sh/github/uv/releases/download/0.12.1/" + "uv-x86_64-unknown-linux-gnu.tar.gz" +) + + +def _module_tree() -> ast.Module: + """Parse the materializer without importing or executing repository code.""" + return ast.parse(_MATERIALIZER.read_text(encoding="utf-8"), filename=str(_MATERIALIZER)) + + +def _download_function() -> ast.FunctionDef: + """Return the trusted-uv downloader function from the parsed module.""" + for node in _module_tree().body: + if isinstance(node, ast.FunctionDef) and node.name == "_download_trusted_uv_archive": + return node + raise AssertionError("trusted uv downloader function is missing") + + +def _assigned_literal(name: str) -> object: + """Return one module-level literal assignment without evaluating code.""" + for node in _module_tree().body: + if not isinstance(node, ast.Assign) or len(node.targets) != 1: + continue + target = node.targets[0] + if isinstance(target, ast.Name) and target.id == name: + return ast.literal_eval(node.value) + raise AssertionError(f"module literal {name} is missing") + + +def _urlopen_calls() -> list[ast.Call]: + """Return calls whose attribute name is exactly ``urlopen``.""" + return [ + node + for node in ast.walk(_download_function()) + if isinstance(node, ast.Call) + and isinstance(node.func, ast.Attribute) + and node.func.attr == "urlopen" + ] + + +def test_urlopen_receives_one_literal_https_release_url() -> None: + """Static analysis can prove repository or user data never selects the URL.""" + calls = _urlopen_calls() + + assert len(calls) == 1 + assert len(calls[0].args) == 1 + url_argument = calls[0].args[0] + assert isinstance(url_argument, ast.Constant) + assert isinstance(url_argument.value, str) + assert url_argument.value == _EXPECTED_URL + + +def test_literal_network_sink_matches_the_documented_release_constant() -> None: + """The scanner-friendly sink literal cannot drift from the release identity.""" + assert _assigned_literal("TRUSTED_UV_ARCHIVE_URL") == _EXPECTED_URL + + +def test_downloader_never_constructs_a_dynamic_request_object() -> None: + """The audited downloader cannot hide a dynamic URL inside ``Request``.""" + request_calls = [ + node + for node in ast.walk(_download_function()) + if isinstance(node, ast.Call) + and isinstance(node.func, ast.Attribute) + and node.func.attr == "Request" + ] + + assert request_calls == [] diff --git a/tests/test_uv_export_isolation_contract.py b/tests/test_uv_export_isolation_contract.py new file mode 100644 index 000000000..f24a6ac8e --- /dev/null +++ b/tests/test_uv_export_isolation_contract.py @@ -0,0 +1,127 @@ +"""Behavioral isolation and output contracts for trusted ``uv export``.""" + +from __future__ import annotations + +import os +import subprocess +from pathlib import Path + +import pytest + +from scripts.ci import materialize_base_python_requirements as materializer + + +def test_uv_export_runs_with_a_bounded_isolated_environment( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Ambient runner configuration cannot select export behavior or cache state.""" + observed: dict[str, object] = {} + + def fake_run(command: list[str], **kwargs): + observed["command"] = command + observed["kwargs"] = kwargs + return subprocess.CompletedProcess(command, 0, b"", b"") + + monkeypatch.setattr(materializer.subprocess, "run", fake_run) + + result = materializer._run_uv_export(tmp_path, "/trusted/uv") + + assert result.returncode == 0 + assert observed["command"] == [ + "/trusted/uv", + "export", + "--frozen", + "--offline", + "--no-cache", + "--no-progress", + "--color", + "never", + "--no-emit-project", + "--no-editable", + "--format", + "requirements-txt", + ] + kwargs = observed["kwargs"] + assert isinstance(kwargs, dict) + assert kwargs["cwd"] == str(tmp_path) + assert kwargs["check"] is False + assert kwargs["stdout"] is subprocess.PIPE + assert kwargs["stderr"] is subprocess.PIPE + + environment = kwargs["env"] + assert environment == { + "HOME": str(tmp_path / ".uv-home"), + "NO_COLOR": "1", + "PATH": os.defpath, + "TMPDIR": str(tmp_path / ".uv-tmp"), + "UV_NO_ENV_FILE": "1", + "UV_PYTHON_DOWNLOADS": "never", + "XDG_CACHE_HOME": str(tmp_path / ".uv-cache"), + "XDG_CONFIG_HOME": str(tmp_path / ".uv-config"), + } + for directory_name in (".uv-home", ".uv-tmp", ".uv-cache", ".uv-config"): + assert (tmp_path / directory_name).is_dir() + + +def test_uv_export_does_not_disable_project_metadata_discovery() -> None: + """Isolation must retain the reconstructed project's ``pyproject.toml`` input.""" + source = Path(materializer.__file__).read_text(encoding="utf-8") + + assert '"--no-config"' not in source + assert "UV_NO_CONFIG" not in source + + +@pytest.mark.parametrize( + "content", + [ + b"--index-url https://packages.invalid/simple --hash=sha256:" + b"a" * 64 + b"\n", + b"demo @ file:///tmp/demo --hash=sha256:" + b"a" * 64 + b"\n", + b"demo==1 --hash=sha512:" + b"a" * 128 + b"\n", + b"demo==1 --hash=sha256:abcd\n", + ], +) +def test_uv_export_rejects_non_package_or_non_sha256_lines(content: bytes) -> None: + """An option, local reference, wrong algorithm, or short digest is not a lock pin.""" + assert materializer._is_fully_hash_pinned_export(content) is False + + +def test_uv_export_accepts_exact_package_pins_with_markers_and_multiple_hashes() -> None: + """A normalized exact requirement with SHA-256 hashes remains exportable.""" + content = ( + b"demo-extra[fast]==1.2.3 ; python_version >= '3.12' \\\n" + b" --hash=sha256:" + b"a" * 64 + b" \\\n" + b" --hash=sha256:" + b"b" * 64 + b"\n" + ) + + assert materializer._is_fully_hash_pinned_export(content) is True + + +def test_tracked_pyproject_read_failure_is_not_misclassified_as_orphan( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A present sibling metadata blob that cannot be read must fail closed.""" + tree = ( + b"100644 blob " + b"a" * 40 + b"\tpyproject.toml\0" + b"100644 blob " + b"b" * 40 + b"\tuv.lock\0" + ) + + def fake_git(_repo_root: Path, *args: str) -> bytes: + if args[0] == "ls-tree": + return tree + if args[0] == "show" and args[1].endswith(":uv.lock"): + return b"version = 1\n" + if args[0] == "show" and args[1].endswith(":pyproject.toml"): + raise RuntimeError("tracked metadata blob could not be read") + raise AssertionError(args) + + monkeypatch.setattr(materializer, "_git", fake_git) + monkeypatch.setattr( + materializer, + "_install_trusted_uv", + lambda: (_ for _ in ()).throw(AssertionError("uv must not start")), + ) + + with pytest.raises(RuntimeError, match="tracked metadata blob could not be read"): + materializer.base_hash_locks(tmp_path, "a" * 40) diff --git a/tests/test_uv_redirect_and_coverage_contract.py b/tests/test_uv_redirect_and_coverage_contract.py new file mode 100644 index 000000000..952b479d1 --- /dev/null +++ b/tests/test_uv_redirect_and_coverage_contract.py @@ -0,0 +1,87 @@ +"""Regression contracts for the trusted uv origin and coverage evidence.""" + +from __future__ import annotations + +import tomllib +from pathlib import Path + +import pytest + +from scripts.ci import materialize_base_python_requirements as materializer + + +class _FakeResponse: + """Minimal context-managed response exposing one deterministic final URL.""" + + def __init__(self, final_url: str, payload: bytes = b"archive") -> None: + """Store the final redirect URL and bounded response payload.""" + self._final_url = final_url + self._payload = payload + + def __enter__(self) -> "_FakeResponse": + """Return this response from the context manager.""" + return self + + def __exit__(self, *_args: object) -> None: + """Leave the synthetic response context without suppressing errors.""" + + def geturl(self) -> str: + """Return the URL observed after redirects.""" + return self._final_url + + def read(self, size: int) -> bytes: + """Return at most the requested number of bytes.""" + return self._payload[:size] + + +@pytest.mark.parametrize( + "unsafe_url", + [ + "https://releases.astral.sh:444/github/uv/releases/download/0.12.1/uv.tar.gz", + "https://releases.astral.sh:not-a-port/github/uv/releases/download/0.12.1/uv.tar.gz", + ], +) +def test_trusted_uv_download_rejects_nondefault_or_malformed_ports( + monkeypatch: pytest.MonkeyPatch, + unsafe_url: str, +) -> None: + """The pinned Astral host cannot redirect to another or malformed service port.""" + + response = _FakeResponse(unsafe_url) + monkeypatch.setattr( + materializer.urllib.request, + "urlopen", + lambda *_args, **_kwargs: response, + ) + + with pytest.raises(RuntimeError, match="redirected outside"): + materializer._download_trusted_uv_archive() + + +def test_trusted_uv_download_accepts_explicit_default_https_port( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """An explicit port 443 still denotes the fixed trusted HTTPS origin.""" + + response = _FakeResponse( + "https://releases.astral.sh:443/github/uv/releases/download/0.12.1/uv.tar.gz" + ) + monkeypatch.setattr( + materializer.urllib.request, + "urlopen", + lambda *_args, **_kwargs: response, + ) + + assert materializer._download_trusted_uv_archive() == b"archive" + + +def test_repository_coverage_contract_enforces_branches_at_one_hundred_percent() -> None: + """The declared 100% quality gate measures branch as well as statement coverage.""" + + repository_root = Path(__file__).resolve().parents[1] + configuration = tomllib.loads( + (repository_root / "pyproject.toml").read_text(encoding="utf-8") + ) + + assert configuration["tool"]["coverage"]["run"]["branch"] is True + assert configuration["tool"]["coverage"]["report"]["fail_under"] == 100 diff --git a/tests/test_uv_redirect_boundary.py b/tests/test_uv_redirect_boundary.py new file mode 100644 index 000000000..9accdadc1 --- /dev/null +++ b/tests/test_uv_redirect_boundary.py @@ -0,0 +1,60 @@ +"""Behavioral contracts for the trusted uv download redirect boundary.""" + +from __future__ import annotations + +import urllib.request + +import pytest + +from scripts.ci import materialize_base_python_requirements as materializer + + +def test_trusted_uv_redirect_handler_rejects_before_following() -> None: + """Every HTTP redirect is rejected before urllib creates a target request.""" + handler = materializer._RejectTrustedUvRedirects() + original = urllib.request.Request(materializer.TRUSTED_UV_ARCHIVE_URL) + + with pytest.raises(RuntimeError, match="redirects are forbidden"): + handler.redirect_request( + original, + None, + 302, + "Found", + {}, + "https://127.0.0.1/internal", + ) + + +def test_trusted_uv_opener_is_cached_and_disables_ambient_proxies( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The dedicated process installs one no-proxy, no-redirect opener.""" + materializer._install_trusted_uv_url_opener.cache_clear() + captured: dict[str, object] = {"builds": 0, "installs": 0} + sentinel = object() + + def fake_build_opener(*handlers: object) -> object: + captured["builds"] = int(captured["builds"]) + 1 + captured["handlers"] = handlers + return sentinel + + def fake_install_opener(opener: object) -> None: + captured["installs"] = int(captured["installs"]) + 1 + captured["opener"] = opener + + monkeypatch.setattr(materializer.urllib.request, "build_opener", fake_build_opener) + monkeypatch.setattr(materializer.urllib.request, "install_opener", fake_install_opener) + + materializer._install_trusted_uv_url_opener() + materializer._install_trusted_uv_url_opener() + + assert captured["builds"] == 1 + assert captured["installs"] == 1 + assert captured["opener"] is sentinel + handlers = captured["handlers"] + assert isinstance(handlers, tuple) + assert len(handlers) == 2 + assert isinstance(handlers[0], urllib.request.ProxyHandler) + assert handlers[0].proxies == {} + assert isinstance(handlers[1], materializer._RejectTrustedUvRedirects) + materializer._install_trusted_uv_url_opener.cache_clear()