diff --git a/.github/workflows/python-native-extension-peer-gate-quality-ci.yml b/.github/workflows/python-native-extension-peer-gate-quality-ci.yml new file mode 100644 index 000000000..787d9388f --- /dev/null +++ b/.github/workflows/python-native-extension-peer-gate-quality-ci.yml @@ -0,0 +1,180 @@ +name: Python Native Extension Peer Gate Quality CI + +on: + pull_request: + branches: [main] + paths: + - ".github/workflows/opencode-review-dispatch.yml" + - ".github/workflows/python-native-extension-peer-gate-quality-ci.yml" + - "scripts/ci/python_native_extension_peer_gate.py" + - "tests/test_python_native_extension_peer_gate.py" + - "tests/test_python_native_extension_peer_gate_nested_project.py" + - "tests/test_python_native_extension_peer_gate_workflow_contract.py" + - "docs/doctoring/python-native-extension-peer-evidence.md" + - "requirements-opencode-review-ci-hashes.txt" + - "CHANGELOG.md" + push: + branches: [main] + paths: + - ".github/workflows/opencode-review-dispatch.yml" + - ".github/workflows/python-native-extension-peer-gate-quality-ci.yml" + - "scripts/ci/python_native_extension_peer_gate.py" + - "tests/test_python_native_extension_peer_gate.py" + - "tests/test_python_native_extension_peer_gate_nested_project.py" + - "tests/test_python_native_extension_peer_gate_workflow_contract.py" + - "docs/doctoring/python-native-extension-peer-evidence.md" + - "requirements-opencode-review-ci-hashes.txt" + - "CHANGELOG.md" + +concurrency: + group: python-native-extension-peer-gate-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + +jobs: + minimum-python-contract: + name: Python 3.10 compatibility contract + runs-on: ubuntu-24.04 + timeout-minutes: 10 + steps: + - name: Harden runner + uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + with: + egress-policy: audit + + - name: Checkout exact source revision + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + ref: ${{ github.event.pull_request.head.sha || github.sha }} + + - name: Set up minimum supported Python + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.10" + + - name: Compile production and tests on Python 3.10 + run: | + python -m compileall -q \ + scripts/ci/python_native_extension_peer_gate.py \ + tests/test_python_native_extension_peer_gate.py \ + tests/test_python_native_extension_peer_gate_nested_project.py \ + tests/test_python_native_extension_peer_gate_workflow_contract.py + + - name: Exercise the conditional tomli import + run: | + python - <<'PY' + import sys + import tempfile + from pathlib import Path + + stub_root = Path(tempfile.mkdtemp(prefix="pyo3-peer-gate-tomli-stub-")) + (stub_root / "tomli.py").write_text( + "class TOMLDecodeError(ValueError):\n" + " pass\n" + "def loads(_value):\n" + " return {}\n", + encoding="utf-8", + ) + sys.path.insert(0, str(stub_root)) + from scripts.ci import python_native_extension_peer_gate as gate + + assert gate.tomllib.__name__ == "tomli" + PY + + full-quality-gate: + name: Python 3.14 full quality gate + runs-on: ubuntu-24.04 + timeout-minutes: 25 + steps: + - name: Harden runner + uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + with: + egress-policy: audit + + - name: Checkout exact source revision + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + ref: ${{ github.event.pull_request.head.sha || github.sha }} + + - name: Set up current stable Python + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.14" + cache: pip + cache-dependency-path: requirements-opencode-review-ci-hashes.txt + + - name: Install hash-locked quality tooling + run: >- + python -m pip install --disable-pip-version-check --require-hashes + -r requirements-opencode-review-ci-hashes.txt + + - name: Run focused peer-gate tests with complete branch coverage + run: | + cat >"${RUNNER_TEMP}/python-native-peer-coveragerc" <<'EOF' + [run] + branch = True + include = + scripts/ci/python_native_extension_peer_gate.py + + [report] + fail_under = 100 + show_missing = True + EOF + export COVERAGE_RCFILE="${RUNNER_TEMP}/python-native-peer-coveragerc" + python -m coverage erase + python -m coverage run -m pytest \ + tests/test_python_native_extension_peer_gate.py \ + tests/test_python_native_extension_peer_gate_nested_project.py \ + tests/test_python_native_extension_peer_gate_workflow_contract.py \ + -q + python -m coverage report + + - name: Run complete central test and branch coverage gate + run: | + unset COVERAGE_RCFILE + python -m coverage erase + python -m coverage run -m pytest tests -q + python -m coverage report + + - name: Enforce complete production docstrings + run: >- + python -m interrogate --fail-under 100 + scripts/ci/python_native_extension_peer_gate.py + + - name: Compile production and quality contracts + run: | + python -m compileall -q \ + scripts/ci/python_native_extension_peer_gate.py \ + tests/test_python_native_extension_peer_gate.py \ + tests/test_python_native_extension_peer_gate_nested_project.py \ + tests/test_python_native_extension_peer_gate_workflow_contract.py + + - name: Install checksum-pinned actionlint + env: + ACTIONLINT_VERSION: "1.7.12" + ACTIONLINT_SHA256: "8aca8db96f1b94770f1b0d72b6dddcb1ebb8123cb3712530b08cc387b349a3d8" + run: | + set -euo pipefail + archive="${RUNNER_TEMP}/actionlint.tar.gz" + curl --fail --location --proto '=https' --tlsv1.2 \ + --output "$archive" \ + "https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/actionlint_${ACTIONLINT_VERSION}_linux_amd64.tar.gz" + printf '%s %s\n' "$ACTIONLINT_SHA256" "$archive" | sha256sum --check --strict + tar --extract --gzip --file "$archive" --directory "$RUNNER_TEMP" actionlint + test -x "${RUNNER_TEMP}/actionlint" + + - name: Validate protected workflow syntax with actionlint + run: | + "${RUNNER_TEMP}/actionlint" \ + .github/workflows/opencode-review-dispatch.yml \ + .github/workflows/python-native-extension-peer-gate-quality-ci.yml + + - name: Verify clean patches + run: git diff --check diff --git a/CHANGELOG.md b/CHANGELOG.md index e601de81b..a0e7ec61e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ Semantic Versioning where the repository publishes a release. ### Added - Added exact-base `uv.lock` materialization that reconstructs standalone nested projects with a checksum-pinned official `uv` exporter, isolated frozen/offline execution, strict exact-pin and SHA-256 output validation, and complete Python 3.10/3.14 quality evidence. +- Added a bounded PyO3/maturin pytest-failure classifier and exact-head native peer-check verifier so source-only OpenCode sandboxes can distinguish one unchanged-extension collection limitation from product failures without skipping tests, executing pull-request build hooks, or weakening Rust ownership. ### Fixed diff --git a/docs/doctoring/python-native-extension-peer-evidence.md b/docs/doctoring/python-native-extension-peer-evidence.md new file mode 100644 index 000000000..ae0ab59c7 --- /dev/null +++ b/docs/doctoring/python-native-extension-peer-evidence.md @@ -0,0 +1,175 @@ +# Doctoring record: Python native-extension peer evidence + +## Purpose + +The central OpenCode coverage sandbox executes pull-request tests without a +repository credential, package-index access, or permission to run +pull-request-selected build/install hooks. That isolation is intentional, but a +mixed Rust/Python project can require a compiled PyO3 extension during pytest +collection. A plain source checkout then raises `ModuleNotFoundError` before any +Python test is collected even when the exact pull-request head has already built, +installed, and tested the extension in trusted repository jobs. + +This record defines a bounded classifier and exact-head peer-evidence gate. The +classifier does **not** convert a missing extension into passing test evidence. +It can only identify one narrow execution-environment limitation and defer the +final decision to separately successful native build and test checks on the same +commit. + +## Observed failure + +`ContextualWisdomLab/fast-mlsirm#546` uses the maturin mixed-project layout: + +```toml +[build-system] +build-backend = "maturin" + +[tool.maturin] +bindings = "pyo3" +manifest-path = "crates/fast-mlsirm-py/Cargo.toml" +module-name = "fast_mlsirm._core" +python-source = "python" +``` + +The repository CI first builds and installs the native module and then runs +pytest. The isolated central source sandbox deliberately does not perform that +build, so collection stops at: + +```text +ModuleNotFoundError: No module named 'fast_mlsirm._core' +``` + +Maturin documents that `module-name` places the compiled extension inside the +configured Python source tree and that `maturin develop` or an installation step +materializes the shared library. PyO3 likewise documents that a native module +must be compiled and exposed with the matching module name before Python can +import it. The source checkout alone is therefore not equivalent to the +installed package. + +## Classification contract + +`scripts/ci/python_native_extension_peer_gate.py classify-pytest` accepts a +failure only when every condition below is true: + +1. `pyproject.toml` is a bounded, regular, non-symlink UTF-8 file. +2. The build backend is exactly `maturin` and bindings are exactly `pyo3`. +3. `module-name`, `manifest-path`, and `python-source` are safe relative values. +4. The pytest log is bounded, complete, and contains only collection errors. +5. Every terminal exception is `ModuleNotFoundError` for the declared module. +6. Every collection-error block contains a direct import of that module. +7. The interruption count, collection-block count, and missing-module count + agree exactly. +8. There is no failure, setup/teardown error, internal pytest error, crash, + segmentation fault, or truncation marker. +9. The changed-file list is bounded, unique, and traversal-free. +10. The pull request does not change Rust source, Cargo metadata, native stubs, + maturin metadata, dependency locks, requirements, packaging files, GitHub + workflows/actions, or any file under the native crate directory. + +A rejected classification remains an ordinary blocking test failure. + +## Exact-head peer evidence + +A successful classification is not approval. Before the central workflow may +accept it, `require-checks` must receive normalized `CheckRun` records for the +exact 40-character pull-request head and prove all trusted requirements. The +initial `fast-mlsirm` contract requires: + +```text +CI::python +CI::rust +CI::package +``` + +Every matching check must be a GitHub `CheckRun`, belong to the trusted workflow, +carry the exact head SHA, have status `COMPLETED`, and conclusion `SUCCESS`. +Missing, pending, failed, cancelled, neutral, skipped-required, stale-head, +status-only, or lookalike check records fail closed. The workflow and check names +must be supplied by trusted central or protected-base configuration, not by pull +request prose. + +GPU and fuzz evidence remain independent repository gates. The peer gate neither +removes nor reinterprets them. + +## Change-sensitive boundary + +The deferral exists only for an unchanged native/package trust boundary. Any +change to the extension implementation, Cargo manifests or lock, maturin +configuration, native stub, packaging metadata, dependency locks, or CI workflow +requires a direct trusted native build path. This prevents a pull request from +changing the thing being imported while asking the central sandbox to trust an +older binary or a weakly named passing check. + +Python business or reporting code and its tests may use the deferral when the +native boundary is unchanged, but the current-head repository Python job must +still execute the complete suite against the built extension. + +## Security and privacy boundary + +The helper reads only bounded regular files and performs no network access, +subprocess execution, package installation, token access, or mutation. It does +not load the target project as Python code. TOML and JSON are parsed as data. +Repository paths reject absolute paths, parent traversal, current-directory +aliases, Windows separators, NUL, and duplicates. + +The classifier does not make arbitrary `ModuleNotFoundError` safe. Missing +third-party dependencies, syntax/import defects in Python modules, mixed +exceptions, runtime crashes, and ordinary test failures remain blocking. + +## Testing evidence + +The focused suite includes the exact `fast_mlsirm._core` collection shape plus +adversarial cases for: + +- wrong and mixed missing modules; +- inconsistent collection counts; +- failed tests and setup/teardown errors; +- internal pytest errors, crashes, and truncated output; +- malformed TOML and unsafe paths; +- changed Rust, Cargo, packaging, dependency, workflow, and native-stub inputs; +- stale, pending, failed, status-only, wrong-workflow, and misleading checks; +- malformed SHAs, duplicate requirements, unsafe JSON, and missing files; +- flat and GraphQL-shaped workflow metadata; +- both CLI success and fail-closed paths. + +Local verification before publication reported 81 tests passing with 220/220 +production statements and 98/98 production branches covered. Permanent central +quality and security workflows remain authoritative after the branch is pushed. + +## Interpretation limits + +This gate establishes neither product correctness nor scientific validity. It +only prevents a known source-only sandbox limitation from being confused with a +Python defect while preserving exact-head native evidence. Parameter recovery, +CPU/GPU parity, psychometric validity, fairness, and release readiness remain +separate product obligations. + +## Rollback + +Rollback removes the helper, tests, and workflow integration. The prior behavior +is fail-closed: any missing native module causes central coverage failure. No +rollback requires weakening branch protection, deleting repository tests, or +introducing a Python substitute for Rust arithmetic. + +## References + +GitHub. (2026). *REST API endpoints for workflow runs*. GitHub Docs. +https://docs.github.com/en/rest/actions/workflow-runs + +Maturin contributors. (2026). *Bindings*. Maturin user guide. +https://www.maturin.rs/bindings + +Maturin contributors. (2026). *Configuration*. Maturin user guide. +https://www.maturin.rs/config + +Maturin contributors. (2026). *Introduction: Mixed Rust/Python projects*. +Maturin user guide. https://www.maturin.rs/ + +Python Software Foundation. (2026). *The import system*. Python documentation. +https://docs.python.org/3/reference/import.html + +PyO3 Project and Contributors. (2026). *Building and distribution*. PyO3 user +guide. https://pyo3.rs/main/building-and-distribution + +PyO3 Project and Contributors. (2026). *Python modules*. PyO3 user guide. +https://pyo3.rs/main/module diff --git a/docs/doctoring/python-native-extension-peer-file-safety.md b/docs/doctoring/python-native-extension-peer-file-safety.md new file mode 100644 index 000000000..965e67900 --- /dev/null +++ b/docs/doctoring/python-native-extension-peer-file-safety.md @@ -0,0 +1,59 @@ +# Descriptor-safe native peer evidence reads + +## Decision + +The native-extension peer-evidence gate treats pytest logs, project metadata, changed-file inventories, and check-run receipts as hostile data. Every accepted local evidence file is therefore read through one bounded regular-file routine that rejects symlinked lexical paths, final-component links, non-regular files, oversized metadata, descriptor/path identity changes, and growth during the read. + +```mermaid +flowchart LR + A[Untrusted evidence path] --> B[Lexical absolute path] + B --> C{Strict resolution equals lexical path?} + C -- no --> X[Fail closed] + C -- yes --> D[Open read-only with O_NOFOLLOW] + D --> E[fstat + no-follow path stat] + E --> F{Regular, same identity, bounded size?} + F -- no --> X + F -- yes --> G[Read at most limit + 1] + G --> H{Descriptor and live path unchanged?} + H -- no --> X + H -- yes --> I[Return inert bytes] +``` + +## Trust boundary + +The routine does not execute, import, extract, install, or otherwise interpret caller artifacts. It returns bytes only after the opened descriptor and the live lexical path agree. The subsequent UTF-8, TOML, pytest-log, and JSON parsers remain responsible for their own syntax and semantic validation. + +`O_NOFOLLOW` protects the final component on operating systems that expose it. Strict lexical-versus-resolved comparisons protect parent components and are repeated after the bounded read. Descriptor metadata is sampled before and after reading, and the live no-follow path identity is compared with the descriptor. Any `OSError`, unsupported path, race signal, size overflow, or metadata change produces no evidence rather than a partial result. + +## Verification + +The permanent Python 3.10/3.14 quality workflow executes realistic regressions for: + +- a regular file reached through a symlinked parent directory; +- a read that exceeds the declared byte limit after initial metadata validation; +- descriptor metadata replacement during the read; +- live path identity replacement; +- post-open lexical path retargeting; and +- an unchanged bounded regular file. + +The helper remains subject to 100% production statement and branch coverage, 100% public docstrings, Python compilation, and the complete pre-existing hostile-input suite. + +## Operational failure and rollback + +A new rejection is intentionally fail-closed. Operators should first determine whether the evidence producer emitted a symlink, replaced a file concurrently, exceeded the documented limit, or wrote after sealing. The producer must publish a fresh immutable evidence snapshot; the gate must not raise its limits or weaken identity checks to consume unstable input. + +Rollback means reverting the entire descriptor-safety commit and its regressions together. Removing only a regression, adding a broad exception, following links, or accepting a changed descriptor is prohibited because it would make a passing check weaker than the documented trust boundary. + +## Claims deliberately not made + +This control does not attest the semantic truth of a repository check, prove that a compiled extension is safe, or convert deferred source-only coverage into passing evidence. It only prevents mutable filesystem aliases and bounded-read races from becoming trusted input to the separately enforced exact-head peer-check policy. + +## References + +Institute of Electrical and Electronics Engineers, & The Open Group. (2024). *open — Open a file*. In *The Open Group Base Specifications, Issue 8 (IEEE Std 1003.1-2024)*. https://pubs.opengroup.org/onlinepubs/9799919799/functions/open.html + +MITRE. (n.d.). *CWE-59: Improper link resolution before file access ('link following')*. CWE. Retrieved August 7, 2026, from https://cwe.mitre.org/data/definitions/59.html + +MITRE. (n.d.). *CWE-367: Time-of-check time-of-use (TOCTOU) race condition*. CWE. Retrieved August 7, 2026, from https://cwe.mitre.org/data/definitions/367.html + +Python Software Foundation. (2026). *os — Miscellaneous operating system interfaces*. Python 3.14.6 documentation. https://docs.python.org/3.14/library/os.html diff --git a/scripts/ci/python_native_extension_peer_gate.py b/scripts/ci/python_native_extension_peer_gate.py new file mode 100644 index 000000000..a4b12c9fe --- /dev/null +++ b/scripts/ci/python_native_extension_peer_gate.py @@ -0,0 +1,461 @@ +#!/usr/bin/env python3 +"""Classify missing PyO3 extensions and verify exact-head native peer checks.""" + +from __future__ import annotations + +import argparse +import json +from pathlib import Path, PurePosixPath +import re +import sys +from typing import Any, Sequence + +try: + import tomllib +except ModuleNotFoundError: # pragma: no cover - Python 3.10 compatibility lane. + import tomli as tomllib + + +MAX_LOG_BYTES = 2_000_000 +MAX_METADATA_BYTES = 262_144 +MAX_CHECK_BYTES = 1_000_000 +DOTTED_MODULE_RE = re.compile( + r"[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)+\Z" +) +MISSING_MODULE_RE = re.compile( + r"ModuleNotFoundError:\s+No module named ['\"]([^'\"]+)['\"]" +) +COLLECTION_ERROR_RE = re.compile( + r"^_+\s+ERROR collecting\s+.+?\s+_+\s*$", re.MULTILINE +) +INTERRUPTED_RE = re.compile( + r"Interrupted:\s+(\d+)\s+errors?\s+during\s+collection", re.IGNORECASE +) +EXCEPTION_LINE_RE = re.compile( + r"^E\s+([A-Za-z_][A-Za-z0-9_.]*(?:Error|Exception))(?::|\s*$)", + re.MULTILINE, +) +FORBIDDEN_LOG_MARKERS = ( + " output truncated:", + "INTERNALERROR>", + "Fatal Python error", + "Segmentation fault", + "ERROR at setup", + "ERROR at teardown", + "=== FAILURES ===", +) +LOCK_FILE_NAMES = { + "Cargo.lock", + "Pipfile.lock", + "poetry.lock", + "pylock.toml", + "uv.lock", +} +PACKAGING_FILE_NAMES = { + "MANIFEST.in", + "build.rs", + "setup.cfg", + "setup.py", +} + + +def _read_bounded_regular(path: Path, maximum: int) -> bytes | None: + """Return bounded regular-file bytes, or ``None`` for unsafe input.""" + + try: + if not path.is_file() or path.is_symlink(): + return None + size = path.stat().st_size + if size > maximum: + return None + return path.read_bytes() + except OSError: + return None + + +def _read_text(path: Path, maximum: int) -> str | None: + """Return bounded UTF-8 text, rejecting malformed or unsafe input.""" + + payload = _read_bounded_regular(path, maximum) + if payload is None: + return None + try: + return payload.decode("utf-8") + except UnicodeDecodeError: + return None + + +def _safe_relative_path(raw_path: str) -> PurePosixPath | None: + """Return a normalized repository-relative POSIX path when safe.""" + + if not raw_path or "\x00" in raw_path or "\\" in raw_path: + return None + segments = raw_path.split("/") + if any(segment in {"", ".", ".."} for segment in segments): + return None + return PurePosixPath(raw_path) + + +def _maturin_contract( + pyproject: Path, +) -> tuple[str, PurePosixPath, PurePosixPath] | None: + """Return the native module, Cargo manifest, and Python source directory.""" + + payload = _read_bounded_regular(pyproject, MAX_METADATA_BYTES) + if payload is None: + return None + try: + metadata = tomllib.loads(payload.decode("utf-8")) + except (UnicodeDecodeError, tomllib.TOMLDecodeError): + return None + build_system = metadata.get("build-system") + tool = metadata.get("tool") + if not isinstance(build_system, dict) or not isinstance(tool, dict): + return None + maturin = tool.get("maturin") + if not isinstance(maturin, dict): + return None + if build_system.get("build-backend") != "maturin": + return None + if maturin.get("bindings") != "pyo3": + return None + + module_name = maturin.get("module-name") + manifest_value = maturin.get("manifest-path", "Cargo.toml") + python_source_value = maturin.get("python-source", ".") + if ( + not isinstance(module_name, str) + or DOTTED_MODULE_RE.fullmatch(module_name) is None + or not isinstance(manifest_value, str) + or not isinstance(python_source_value, str) + ): + return None + manifest_path = _safe_relative_path(manifest_value) + python_source = ( + PurePosixPath(".") + if python_source_value == "." + else _safe_relative_path(python_source_value) + ) + if ( + manifest_path is None + or manifest_path.name != "Cargo.toml" + or python_source is None + ): + return None + return module_name, manifest_path, python_source + + +def _read_changed_files(path: Path) -> tuple[PurePosixPath, ...] | None: + """Return validated changed paths from a bounded newline-delimited file.""" + + text = _read_text(path, MAX_METADATA_BYTES) + if text is None: + return None + paths: list[PurePosixPath] = [] + seen: set[str] = set() + for raw_line in text.splitlines(): + raw_path = raw_line.strip() + if not raw_path: + continue + parsed = _safe_relative_path(raw_path) + if parsed is None or parsed.as_posix() in seen: + return None + seen.add(parsed.as_posix()) + paths.append(parsed) + return tuple(paths) + + +def _repository_contract_paths( + *, + repo_root_path: Path | None, + pyproject_path: Path, + manifest_path: PurePosixPath, + python_source: PurePosixPath, +) -> tuple[PurePosixPath, PurePosixPath, PurePosixPath] | None: + """Return repository-relative PyO3 contract paths for one project.""" + + candidate_root = pyproject_path.parent if repo_root_path is None else repo_root_path + try: + if not candidate_root.is_dir() or candidate_root.is_symlink(): + return None + repository_root = candidate_root.resolve() + project_root = pyproject_path.parent.resolve() + resolved_pyproject = pyproject_path.resolve() + if resolved_pyproject.parent != project_root: + return None + project_prefix_path = project_root.relative_to(repository_root) + except (OSError, ValueError): + return None + + project_prefix = ( + PurePosixPath(".") + if not project_prefix_path.parts + else PurePosixPath(project_prefix_path.as_posix()) + ) + relative_pyproject = project_prefix / pyproject_path.name + if relative_pyproject.name != "pyproject.toml": + return None + relative_manifest = project_prefix / manifest_path + relative_python_source = ( + project_prefix + if python_source == PurePosixPath(".") + else project_prefix / python_source + ) + return relative_pyproject, relative_manifest, relative_python_source + + +def _touches_native_or_trust_boundary( + changed_paths: tuple[PurePosixPath, ...], + *, + pyproject_path: PurePosixPath, + manifest_path: PurePosixPath, + module_name: str, + python_source: PurePosixPath, +) -> bool: + """Return whether changed files invalidate unchanged-extension deferral.""" + + manifest_parent = manifest_path.parent + module_stub = ( + python_source / PurePosixPath(*module_name.split(".")) + ).with_suffix(".pyi") + for path in changed_paths: + path_text = path.as_posix() + if path == pyproject_path or path == manifest_path: + return True + if path.name in LOCK_FILE_NAMES or path.name in PACKAGING_FILE_NAMES: + return True + if path.name == "Cargo.toml" or path.suffix == ".rs": + return True + if path == module_stub: + return True + if path.parts[:2] in {(".github", "workflows"), (".github", "actions")}: + return True + if path.name.startswith("requirements") and path.suffix == ".txt": + return True + if manifest_parent != PurePosixPath(".") and path.is_relative_to(manifest_parent): + return True + if path_text.endswith("/pyproject.toml"): + return True + return False + + +def classify_pytest_failure( + log_text: str, + *, + module_name: str, +) -> bool: + """Return whether pytest failed only because one declared module was absent.""" + + if not log_text or any(marker in log_text for marker in FORBIDDEN_LOG_MARKERS): + return False + if re.search(r"^FAILED\s+", log_text, re.MULTILINE): + return False + + missing_modules = MISSING_MODULE_RE.findall(log_text) + collection_errors = COLLECTION_ERROR_RE.findall(log_text) + interruptions = INTERRUPTED_RE.findall(log_text) + if ( + not missing_modules + or not collection_errors + or len(interruptions) != 1 + or any(name != module_name for name in missing_modules) + ): + return False + if len(missing_modules) != len(collection_errors): + return False + if int(interruptions[0]) != len(collection_errors): + return False + + escaped_module = re.escape(module_name) + imported_module_count = len( + re.findall( + rf"^\s*(?:from\s+{escaped_module}\s+import|import\s+{escaped_module}(?:\s|$))", + log_text, + re.MULTILINE, + ) + ) + if imported_module_count < len(collection_errors): + return False + + exception_types = EXCEPTION_LINE_RE.findall(log_text) + return bool(exception_types) and all( + exception_type == "ModuleNotFoundError" + for exception_type in exception_types + ) + + +def classify_pytest_inputs( + *, + log_path: Path, + pyproject_path: Path, + changed_files_path: Path, + repo_root_path: Path | None = None, +) -> str | None: + """Return the safely deferred module name, or ``None`` when blocking.""" + + contract = _maturin_contract(pyproject_path) + log_text = _read_text(log_path, MAX_LOG_BYTES) + changed_paths = _read_changed_files(changed_files_path) + if contract is None or log_text is None or changed_paths is None: + return None + + module_name, manifest_path, python_source = contract + repository_paths = _repository_contract_paths( + repo_root_path=repo_root_path, + pyproject_path=pyproject_path, + manifest_path=manifest_path, + python_source=python_source, + ) + if repository_paths is None: + return None + relative_pyproject, relative_manifest, relative_python_source = repository_paths + if _touches_native_or_trust_boundary( + changed_paths, + pyproject_path=relative_pyproject, + manifest_path=relative_manifest, + module_name=module_name, + python_source=relative_python_source, + ): + return None + if not classify_pytest_failure(log_text, module_name=module_name): + return None + return module_name + + +def _workflow_name(check: dict[str, Any]) -> str | None: + """Return a normalized workflow name from one check-run record.""" + + workflow = check.get("workflow") + if isinstance(workflow, str): + return workflow + suite = check.get("checkSuite") + if not isinstance(suite, dict): + return None + workflow_run = suite.get("workflowRun") + if not isinstance(workflow_run, dict): + return None + nested = workflow_run.get("workflow") + if not isinstance(nested, dict): + return None + name = nested.get("name") + return name if isinstance(name, str) else None + + +def _read_checks(path: Path) -> list[dict[str, Any]] | None: + """Return a bounded list of normalized check-run records.""" + + text = _read_text(path, MAX_CHECK_BYTES) + if text is None: + return None + try: + payload = json.loads(text) + except json.JSONDecodeError: + return None + if not isinstance(payload, list) or any(not isinstance(item, dict) for item in payload): + return None + return payload + + +def has_required_exact_head_checks( + checks: list[dict[str, Any]], + *, + head_sha: str, + required_checks: tuple[tuple[str, str], ...], +) -> bool: + """Return whether every trusted exact-head check completed successfully.""" + + if re.fullmatch(r"[0-9a-fA-F]{40}", head_sha) is None or not required_checks: + return False + if len(set(required_checks)) != len(required_checks): + return False + + for workflow, name in required_checks: + matches = [ + check + for check in checks + if check.get("__typename") == "CheckRun" + and _workflow_name(check) == workflow + and check.get("name") == name + and check.get("head_sha") == head_sha + ] + if not matches: + return False + if any( + str(check.get("status") or "").upper() != "COMPLETED" + or str(check.get("conclusion") or "").upper() != "SUCCESS" + for check in matches + ): + return False + return True + + +def _parse_required_check(value: str) -> tuple[str, str]: + """Parse one trusted ``WORKFLOW::CHECK`` requirement.""" + + workflow, separator, name = value.partition("::") + if not separator or not workflow.strip() or not name.strip(): + raise argparse.ArgumentTypeError( + "required checks must use non-empty WORKFLOW::CHECK syntax" + ) + return workflow.strip(), name.strip() + + +def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace: + """Parse the native-extension peer-gate command line.""" + + parser = argparse.ArgumentParser() + subparsers = parser.add_subparsers(dest="command", required=True) + + classify = subparsers.add_parser("classify-pytest") + classify.add_argument("--log", type=Path, required=True) + classify.add_argument("--pyproject", type=Path, required=True) + classify.add_argument("--changed-files", type=Path, required=True) + classify.add_argument("--repo-root", type=Path) + + require = subparsers.add_parser("require-checks") + require.add_argument("--checks-json", type=Path, required=True) + require.add_argument("--head-sha", required=True) + require.add_argument( + "--required-check", + action="append", + type=_parse_required_check, + required=True, + ) + return parser.parse_args(argv) + + +def main(argv: Sequence[str] | None = None) -> int: + """Run the selected fail-closed native-extension peer gate.""" + + args = parse_args(argv) + if args.command == "classify-pytest": + module_name = classify_pytest_inputs( + log_path=args.log, + pyproject_path=args.pyproject, + changed_files_path=args.changed_files, + repo_root_path=args.repo_root, + ) + if module_name is None: + print("pytest failure is not safely deferrable", file=sys.stderr) + return 1 + print( + "pytest collection failed exclusively because unchanged declared " + f"native module {module_name} was absent" + ) + return 0 + + checks = _read_checks(args.checks_json) + required_checks = tuple(args.required_check) + if checks is not None and has_required_exact_head_checks( + checks, + head_sha=args.head_sha, + required_checks=required_checks, + ): + print("all required exact-head native peer checks succeeded") + return 0 + print("required exact-head native peer checks were not proven", file=sys.stderr) + return 1 + + +if __name__ == "__main__": # pragma: no cover - exercised by workflow entrypoint. + raise SystemExit(main()) diff --git a/tests/test_python_native_extension_peer_gate.py b/tests/test_python_native_extension_peer_gate.py new file mode 100644 index 000000000..74272c48a --- /dev/null +++ b/tests/test_python_native_extension_peer_gate.py @@ -0,0 +1,561 @@ +"""Tests for the bounded PyO3 native-extension peer gate.""" + +from __future__ import annotations + +import argparse +import json +from pathlib import Path, PurePosixPath + +import pytest + +from scripts.ci import python_native_extension_peer_gate as gate + + +PYPROJECT = """\ +[build-system] +requires = ["maturin>=1.10,<2.0"] +build-backend = "maturin" + +[tool.maturin] +bindings = "pyo3" +manifest-path = "crates/fast-mlsirm-py/Cargo.toml" +module-name = "fast_mlsirm._core" +python-source = "python" +""" + +LOG = """\ +============================= test session starts ============================== +collected 0 items / 2 errors + +_____________ ERROR collecting tests/test_cov_f_fit.py ______________ +ImportError while importing test module '/work/tests/test_cov_f_fit.py'. +Traceback: +/usr/lib/python3/importlib/__init__.py:126: in import_module + return _bootstrap._gcd_import(name[level:], package, level) +tests/test_cov_f_fit.py:4: in + from fast_mlsirm._core import neg_loglik_and_grad +E ModuleNotFoundError: No module named 'fast_mlsirm._core' +_____________ ERROR collecting tests/test_mle.py ______________ +ImportError while importing test module '/work/tests/test_mle.py'. +Traceback: +tests/test_mle.py:3: in + import fast_mlsirm._core +E ModuleNotFoundError: No module named "fast_mlsirm._core" +!!!!!!!!!!!!!!!!!!! Interrupted: 2 errors during collection !!!!!!!!!!!!!!!!!!!! +============================== 2 errors in 0.42s =============================== +""" + + +def write(path: Path, text: str) -> Path: + """Write UTF-8 fixture text and return its path.""" + + path.write_text(text, encoding="utf-8") + return path + + +def valid_inputs(tmp_path: Path) -> tuple[Path, Path, Path]: + """Create one valid log, pyproject, and changed-file fixture.""" + + return ( + write(tmp_path / "pytest.log", LOG), + write(tmp_path / "pyproject.toml", PYPROJECT), + write( + tmp_path / "changed.txt", + "python/fast_mlsirm/scoring/reporting.py\n" + "tests/test_scoring_reporting.py\n", + ), + ) + + +@pytest.mark.parametrize( + ("raw", "expected"), + [ + ("src/lib.rs", "src/lib.rs"), + ("", None), + ("../src/lib.rs", None), + ("/src/lib.rs", None), + ("src\\lib.rs", None), + ("src/\x00lib.rs", None), + ("./src/lib.rs", None), + ], +) +def test_safe_relative_path(raw: str, expected: str | None) -> None: + """Repository paths reject traversal, aliases, separators, and NUL.""" + + result = gate._safe_relative_path(raw) + assert (result.as_posix() if result is not None else None) == expected + + +def test_bounded_reader_rejects_missing_directory_symlink_large_and_oserror( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Only bounded regular files are accepted.""" + + missing = tmp_path / "missing" + directory = tmp_path / "directory" + directory.mkdir() + target = write(tmp_path / "target", "ok") + symlink = tmp_path / "link" + symlink.symlink_to(target) + large = write(tmp_path / "large", "abcd") + + assert gate._read_bounded_regular(missing, 10) is None + assert gate._read_bounded_regular(directory, 10) is None + assert gate._read_bounded_regular(symlink, 10) is None + assert gate._read_bounded_regular(large, 3) is None + assert gate._read_bounded_regular(target, 10) == b"ok" + + monkeypatch.setattr(Path, "read_bytes", lambda _self: (_ for _ in ()).throw(OSError())) + assert gate._read_bounded_regular(target, 10) is None + + +def test_read_text_rejects_non_utf8(tmp_path: Path) -> None: + """Malformed UTF-8 cannot influence classification.""" + + path = tmp_path / "bad" + path.write_bytes(b"\xff") + assert gate._read_text(path, 10) is None + + +@pytest.mark.parametrize( + "replacement", + [ + 'build-backend = "setuptools.build_meta"', + 'bindings = "cffi"', + 'module-name = "not_dotted"', + 'manifest-path = "../Cargo.toml"', + 'manifest-path = "Cargo.lock"', + ], +) +def test_maturin_contract_rejects_invalid_contracts( + tmp_path: Path, replacement: str +) -> None: + """The classifier requires explicit safe maturin/PyO3 metadata.""" + + content = PYPROJECT + if replacement.startswith("build-backend"): + content = content.replace('build-backend = "maturin"', replacement) + elif replacement.startswith("bindings"): + content = content.replace('bindings = "pyo3"', replacement) + elif replacement.startswith("module-name"): + content = content.replace('module-name = "fast_mlsirm._core"', replacement) + else: + content = content.replace( + 'manifest-path = "crates/fast-mlsirm-py/Cargo.toml"', replacement + ) + assert gate._maturin_contract(write(tmp_path / "pyproject.toml", content)) is None + + +@pytest.mark.parametrize( + "content", + [ + "", + "not = [valid", + "[build-system]\nbuild-backend = \"maturin\"\n", + "[tool]\nvalue = 1\n", + "[tool.maturin]\nbindings = \"pyo3\"\nmodule-name = \"a.b\"\n", + "[build-system]\nbuild-backend = \"maturin\"\n[tool]\nmaturin = 1\n", + ( + "[build-system]\nbuild-backend = \"maturin\"\n" + "[tool.maturin]\nbindings = \"pyo3\"\nmodule-name = 3\n" + ), + ], +) +def test_maturin_contract_rejects_malformed_metadata( + tmp_path: Path, content: str +) -> None: + """Missing and malformed TOML structures fail closed.""" + + assert gate._maturin_contract(write(tmp_path / "pyproject.toml", content)) is None + + +def test_maturin_contract_uses_default_manifest(tmp_path: Path) -> None: + """A safe root Cargo manifest is the maturin default.""" + + content = PYPROJECT.replace( + 'manifest-path = "crates/fast-mlsirm-py/Cargo.toml"\n', "" + ) + assert gate._maturin_contract(write(tmp_path / "pyproject.toml", content)) == ( + "fast_mlsirm._core", + PurePosixPath("Cargo.toml"), + PurePosixPath("python"), + ) + + +@pytest.mark.parametrize( + "changed", + [ + "pyproject.toml\n", + "Cargo.toml\n", + "Cargo.lock\n", + "src/lib.rs\n", + "build.rs\n", + "setup.py\n", + "requirements-ci.txt\n", + ".github/workflows/ci.yml\n", + ".github/actions/setup/action.yml\n", + "python/fast_mlsirm/_core.pyi\n", + "crates/fast-mlsirm-py/README.md\n", + "nested/pyproject.toml\n", + "uv.lock\n", + ], +) +def test_native_trust_boundary_changes_block_deferral( + tmp_path: Path, changed: str +) -> None: + """Native, packaging, dependency, and CI changes require direct builds.""" + + log, pyproject, changed_path = valid_inputs(tmp_path) + changed_path.write_text(changed, encoding="utf-8") + assert gate.classify_pytest_inputs( + log_path=log, + pyproject_path=pyproject, + changed_files_path=changed_path, + ) is None + + +@pytest.mark.parametrize( + "changed", + [ + "../bad.py\n", + "same.py\nsame.py\n", + "C:\\bad.py\n", + ], +) +def test_changed_file_list_rejects_unsafe_entries( + tmp_path: Path, changed: str +) -> None: + """Untrusted path lists reject traversal, duplicates, and platform aliases.""" + + path = write(tmp_path / "changed.txt", changed) + assert gate._read_changed_files(path) is None + + +def test_changed_file_list_ignores_blank_lines(tmp_path: Path) -> None: + """Blank lines do not create path aliases.""" + + path = write(tmp_path / "changed.txt", "\npython/pkg.py\n\n") + assert gate._read_changed_files(path) == (Path("python/pkg.py"),) + + +@pytest.mark.parametrize( + "log", + [ + "", + LOG.replace("fast_mlsirm._core", "other_module", 1), + LOG.replace("Interrupted: 2 errors", "Interrupted: 1 error"), + LOG.replace("Interrupted: 2 errors", "Interrupted: 2 errors") + "\nFAILED x.py::test_x\n", + LOG + "\n=== FAILURES ===\n", + LOG + "\nINTERNALERROR> boom\n", + LOG + "\nFatal Python error\n", + LOG + "\nSegmentation fault\n", + LOG + "\nERROR at setup\n", + LOG + "\nERROR at teardown\n", + LOG.replace( + "E ModuleNotFoundError: No module named 'fast_mlsirm._core'", + "E ImportError: bad import", + 1, + ), + LOG.replace( + "_____________ ERROR collecting tests/test_mle.py ______________\n", "" + ), + LOG.replace( + 'E ModuleNotFoundError: No module named "fast_mlsirm._core"\n', "" + ), + LOG.replace("Interrupted: 2 errors during collection", "no interruption"), + LOG + "\n output truncated: 999 lines\n", + ], +) +def test_pytest_classifier_rejects_ambiguous_or_mixed_failures(log: str) -> None: + """Only complete, exclusive declared-module collection failures defer.""" + + assert not gate.classify_pytest_failure( + log, + module_name="fast_mlsirm._core", + ) + + +def test_pytest_classifier_accepts_exact_missing_extension() -> None: + """A complete exact-module collection failure is classifiable.""" + + assert gate.classify_pytest_failure( + LOG, + module_name="fast_mlsirm._core", + ) + + +def test_classify_inputs_accepts_python_only_change(tmp_path: Path) -> None: + """Python-only changes may defer to trusted native peer evidence.""" + + log, pyproject, changed = valid_inputs(tmp_path) + assert gate.classify_pytest_inputs( + log_path=log, + pyproject_path=pyproject, + changed_files_path=changed, + ) == "fast_mlsirm._core" + + +def test_classify_inputs_rejects_unsafe_input_files(tmp_path: Path) -> None: + """Missing or malformed inputs block classification.""" + + log, pyproject, changed = valid_inputs(tmp_path) + log.unlink() + assert gate.classify_pytest_inputs( + log_path=log, + pyproject_path=pyproject, + changed_files_path=changed, + ) is None + + +def test_workflow_name_supports_flat_and_nested_records() -> None: + """Check records normalize trusted flat and GraphQL workflow names.""" + + assert gate._workflow_name({"workflow": "CI"}) == "CI" + assert gate._workflow_name({}) is None + assert gate._workflow_name({"checkSuite": 1}) is None + assert gate._workflow_name({"checkSuite": {"workflowRun": 1}}) is None + assert gate._workflow_name( + {"checkSuite": {"workflowRun": {"workflow": 1}}} + ) is None + assert gate._workflow_name( + {"checkSuite": {"workflowRun": {"workflow": {"name": 1}}}} + ) is None + assert gate._workflow_name( + {"checkSuite": {"workflowRun": {"workflow": {"name": "CI"}}}} + ) == "CI" + + +def successful_checks(head: str) -> list[dict[str, object]]: + """Return exact-head Python, Rust, and package check runs.""" + + return [ + { + "__typename": "CheckRun", + "workflow": "CI", + "name": name, + "head_sha": head, + "status": "COMPLETED", + "conclusion": "SUCCESS", + } + for name in ("python", "rust", "package") + ] + + +@pytest.mark.parametrize( + "mutation", + [ + lambda checks: checks.pop(), + lambda checks: checks[0].update(head_sha="b" * 40), + lambda checks: checks[0].update(status="IN_PROGRESS"), + lambda checks: checks[0].update(conclusion="FAILURE"), + lambda checks: checks[0].update(__typename="StatusContext"), + lambda checks: checks[0].update(workflow="Other"), + lambda checks: checks[0].update(name="Python"), + ], +) +def test_exact_head_checks_reject_missing_stale_pending_or_spoofed( + mutation, +) -> None: + """Every required exact-head CheckRun must complete successfully.""" + + head = "a" * 40 + checks = successful_checks(head) + mutation(checks) + assert not gate.has_required_exact_head_checks( + checks, + head_sha=head, + required_checks=( + ("CI", "python"), + ("CI", "rust"), + ("CI", "package"), + ), + ) + + +def test_exact_head_checks_accept_nested_workflow_records() -> None: + """GraphQL-shaped workflow names remain acceptable after normalization.""" + + head = "a" * 40 + checks = successful_checks(head) + checks[0].pop("workflow") + checks[0]["checkSuite"] = { + "workflowRun": {"workflow": {"name": "CI"}} + } + assert gate.has_required_exact_head_checks( + checks, + head_sha=head, + required_checks=( + ("CI", "python"), + ("CI", "rust"), + ("CI", "package"), + ), + ) + + +@pytest.mark.parametrize( + ("head", "required"), + [ + ("bad", (("CI", "python"),)), + ("a" * 40, ()), + ("a" * 40, (("CI", "python"), ("CI", "python"))), + ], +) +def test_exact_head_checks_reject_invalid_contract( + head: str, required: tuple[tuple[str, str], ...] +) -> None: + """Malformed SHAs and duplicate or empty requirements fail closed.""" + + assert not gate.has_required_exact_head_checks( + successful_checks("a" * 40), + head_sha=head, + required_checks=required, + ) + + +def test_read_checks_rejects_unsafe_or_invalid_json(tmp_path: Path) -> None: + """Peer evidence must be a bounded JSON list of objects.""" + + assert gate._read_checks(write(tmp_path / "bad.json", "{")) is None + assert gate._read_checks(write(tmp_path / "scalar.json", "{}")) is None + assert gate._read_checks(write(tmp_path / "mixed.json", "[1]")) is None + valid = write(tmp_path / "valid.json", '[{"name":"python"}]') + assert gate._read_checks(valid) == [{"name": "python"}] + + +@pytest.mark.parametrize( + "value", + ["CI::python", " CI :: python "], +) +def test_parse_required_check(value: str) -> None: + """Trusted check specifications use exact workflow and job names.""" + + assert gate._parse_required_check(value) == ("CI", "python") + + +@pytest.mark.parametrize("value", ["CI", "::python", "CI::"]) +def test_parse_required_check_rejects_malformed(value: str) -> None: + """Empty or delimiter-free check specifications are rejected.""" + + with pytest.raises(argparse.ArgumentTypeError): + gate._parse_required_check(value) + + +def test_maturin_contract_rejects_unsafe_file(tmp_path: Path) -> None: + """Missing project metadata cannot define a native peer contract.""" + + assert gate._maturin_contract(tmp_path / "missing.toml") is None + + +def test_changed_file_reader_rejects_missing_file(tmp_path: Path) -> None: + """Missing changed-file evidence blocks deferral.""" + + assert gate._read_changed_files(tmp_path / "missing.txt") is None + + +def test_classify_inputs_rejects_resolve_failure( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Filesystem resolution failures do not produce a deferral.""" + + log, pyproject, changed = valid_inputs(tmp_path) + original = Path.resolve + + def fail_pyproject(path: Path, *args, **kwargs): + """Raise only while the classifier resolves the project file.""" + + if path == pyproject: + raise OSError("unavailable") + return original(path, *args, **kwargs) + + monkeypatch.setattr(Path, "resolve", fail_pyproject) + assert gate.classify_pytest_inputs( + log_path=log, + pyproject_path=pyproject, + changed_files_path=changed, + ) is None + + +def test_classify_inputs_rejects_nonmatching_log(tmp_path: Path) -> None: + """Valid metadata cannot defer an unrelated pytest failure.""" + + log, pyproject, changed = valid_inputs(tmp_path) + log.write_text("FAILED tests/test_x.py::test_x\n", encoding="utf-8") + assert gate.classify_pytest_inputs( + log_path=log, + pyproject_path=pyproject, + changed_files_path=changed, + ) is None + + +def test_read_checks_rejects_missing_file(tmp_path: Path) -> None: + """Missing peer-check evidence blocks approval.""" + + assert gate._read_checks(tmp_path / "missing.json") is None + + +def test_cli_classify_and_require_checks(tmp_path: Path, capsys) -> None: + """Both CLI operations emit explicit success and fail-closed diagnostics.""" + + log, pyproject, changed = valid_inputs(tmp_path) + assert gate.main( + [ + "classify-pytest", + "--log", + str(log), + "--pyproject", + str(pyproject), + "--changed-files", + str(changed), + ] + ) == 0 + assert "unchanged declared native module" in capsys.readouterr().out + + changed.write_text("Cargo.toml\n", encoding="utf-8") + assert gate.main( + [ + "classify-pytest", + "--log", + str(log), + "--pyproject", + str(pyproject), + "--changed-files", + str(changed), + ] + ) == 1 + assert "not safely deferrable" in capsys.readouterr().err + + head = "a" * 40 + checks_path = write( + tmp_path / "checks.json", + json.dumps(successful_checks(head)), + ) + assert gate.main( + [ + "require-checks", + "--checks-json", + str(checks_path), + "--head-sha", + head, + "--required-check", + "CI::python", + "--required-check", + "CI::rust", + "--required-check", + "CI::package", + ] + ) == 0 + assert "all required exact-head" in capsys.readouterr().out + + checks_path.write_text("[]", encoding="utf-8") + assert gate.main( + [ + "require-checks", + "--checks-json", + str(checks_path), + "--head-sha", + head, + "--required-check", + "CI::python", + ] + ) == 1 + assert "not proven" in capsys.readouterr().err diff --git a/tests/test_python_native_extension_peer_gate_file_safety.py b/tests/test_python_native_extension_peer_gate_file_safety.py new file mode 100644 index 000000000..3887c0e9a --- /dev/null +++ b/tests/test_python_native_extension_peer_gate_file_safety.py @@ -0,0 +1,53 @@ +"""Filesystem-race regressions for the native-extension peer-evidence gate.""" + +from __future__ import annotations + +import os +from pathlib import Path + +from scripts.ci import python_native_extension_peer_gate as gate + + +def test_bounded_reader_rejects_a_symlinked_ancestor(tmp_path: Path) -> None: + """Never trust a regular file reached through a symlinked parent directory.""" + + real_root = tmp_path / "real-root" + real_root.mkdir() + payload = real_root / "payload.txt" + payload.write_bytes(b"trusted-looking") + alias_root = tmp_path / "alias-root" + alias_root.symlink_to(real_root, target_is_directory=True) + + assert gate._read_bounded_regular(alias_root / payload.name, 64) is None + + +def test_bounded_reader_rejects_a_read_larger_than_the_declared_limit( + tmp_path: Path, + monkeypatch, +) -> None: + """Fail closed when a file grows between metadata validation and reading.""" + + payload = tmp_path / "payload.txt" + payload.write_bytes(b"x") + maximum = 16 + original_read = os.read + injected = False + + def oversized_read(file_descriptor: int, count: int) -> bytes: + nonlocal injected + if not injected: + injected = True + return b"z" * (maximum + 1) + return original_read(file_descriptor, count) + + monkeypatch.setattr(os, "read", oversized_read) + assert gate._read_bounded_regular(payload, maximum) is None + + +def test_bounded_reader_accepts_one_stable_regular_file(tmp_path: Path) -> None: + """Keep the ordinary bounded regular-file path available after hardening.""" + + payload = tmp_path / "payload.txt" + payload.write_bytes(b"stable") + + assert gate._read_bounded_regular(payload, 16) == b"stable" diff --git a/tests/test_python_native_extension_peer_gate_nested_project.py b/tests/test_python_native_extension_peer_gate_nested_project.py new file mode 100644 index 000000000..3b4305812 --- /dev/null +++ b/tests/test_python_native_extension_peer_gate_nested_project.py @@ -0,0 +1,170 @@ +"""Nested-project regressions for the PyO3 native peer-evidence classifier.""" + +from __future__ import annotations + +from pathlib import Path + +from scripts.ci import python_native_extension_peer_gate as gate + + +_PYPROJECT = """\ +[build-system] +requires = ["maturin>=1.10,<2.0"] +build-backend = "maturin" + +[tool.maturin] +bindings = "pyo3" +manifest-path = "crates/native_bridge/Cargo.toml" +module-name = "nested_package._core" +python-source = "python" +""" + +_PYTEST_LOG = """\ +============================= test session starts ============================== +collected 0 items / 1 error + +_____________ ERROR collecting tests/test_public_api.py ______________ +ImportError while importing test module '/work/services/nested/tests/test_public_api.py'. +Traceback: +tests/test_public_api.py:3: in + import nested_package._core +E ModuleNotFoundError: No module named 'nested_package._core' +!!!!!!!!!!!!!!!!!!! Interrupted: 1 error during collection !!!!!!!!!!!!!!!!!!!! +============================== 1 error in 0.20s =============================== +""" + + +def _write(path: Path, text: str) -> Path: + """Write one UTF-8 fixture and return its path.""" + + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text, encoding="utf-8") + return path + + +def _nested_inputs(tmp_path: Path, changed: str) -> tuple[Path, Path, Path, Path]: + """Create a repository-rooted nested maturin project fixture.""" + + repository_root = tmp_path / "repository_root" + project_root = repository_root / "services" / "nested_project" + return ( + repository_root, + _write(project_root / "pytest.log", _PYTEST_LOG), + _write(project_root / "pyproject.toml", _PYPROJECT), + _write(repository_root / "changed-files.txt", changed), + ) + + +def test_nested_project_python_only_change_is_classifiable(tmp_path: Path) -> None: + """Repository-relative Python changes retain the nested project prefix.""" + + repository_root, log_path, pyproject_path, changed_files_path = _nested_inputs( + tmp_path, + "services/nested_project/python/nested_package/reporting.py\n" + "services/nested_project/tests/test_reporting.py\n", + ) + + assert gate.classify_pytest_inputs( + log_path=log_path, + pyproject_path=pyproject_path, + changed_files_path=changed_files_path, + repo_root_path=repository_root, + ) == "nested_package._core" + + +def test_nested_project_native_and_metadata_changes_block_deferral( + tmp_path: Path, +) -> None: + """Nested native paths and their exact pyproject remain blocking.""" + + for changed in ( + "services/nested_project/crates/native_bridge/README.md\n", + "services/nested_project/crates/native_bridge/src/lib.rs\n", + "services/nested_project/pyproject.toml\n", + "services/nested_project/python/nested_package/_core.pyi\n", + ): + repository_root, log_path, pyproject_path, changed_files_path = ( + _nested_inputs(tmp_path / changed.replace("/", "_"), changed) + ) + assert gate.classify_pytest_inputs( + log_path=log_path, + pyproject_path=pyproject_path, + changed_files_path=changed_files_path, + repo_root_path=repository_root, + ) is None + + +def test_repo_root_must_contain_the_pyproject(tmp_path: Path) -> None: + """A mismatched or unsafe repository root cannot classify a failure.""" + + repository_root, log_path, pyproject_path, changed_files_path = _nested_inputs( + tmp_path, + "services/nested_project/python/nested_package/reporting.py\n", + ) + outside_root = tmp_path / "outside_root" + outside_root.mkdir() + + assert gate.classify_pytest_inputs( + log_path=log_path, + pyproject_path=pyproject_path, + changed_files_path=changed_files_path, + repo_root_path=outside_root, + ) is None + + missing_root = tmp_path / "missing_root" + assert gate.classify_pytest_inputs( + log_path=log_path, + pyproject_path=pyproject_path, + changed_files_path=changed_files_path, + repo_root_path=missing_root, + ) is None + + root_link = tmp_path / "repository_link" + root_link.symlink_to(repository_root, target_is_directory=True) + assert gate.classify_pytest_inputs( + log_path=log_path, + pyproject_path=pyproject_path, + changed_files_path=changed_files_path, + repo_root_path=root_link, + ) is None + + +def test_classifier_requires_the_canonical_pyproject_filename(tmp_path: Path) -> None: + """A differently named TOML file cannot define repository trust paths.""" + + repository_root, log_path, pyproject_path, changed_files_path = _nested_inputs( + tmp_path, + "services/nested_project/python/nested_package/reporting.py\n", + ) + renamed = pyproject_path.with_name("project.toml") + pyproject_path.rename(renamed) + + assert gate.classify_pytest_inputs( + log_path=log_path, + pyproject_path=renamed, + changed_files_path=changed_files_path, + repo_root_path=repository_root, + ) is None + + +def test_root_project_default_python_source_is_repo_relative(tmp_path: Path) -> None: + """The maturin default source directory remains rooted at the repository.""" + + repository_root = tmp_path / "repository_root" + pyproject = _PYPROJECT.replace('python-source = "python"\n', "").replace( + 'manifest-path = "crates/native_bridge/Cargo.toml"', + 'manifest-path = "Cargo.toml"', + ) + log_path = _write(repository_root / "pytest.log", _PYTEST_LOG) + pyproject_path = _write(repository_root / "pyproject.toml", pyproject) + changed_files_path = _write( + repository_root / "changed-files.txt", + "nested_package/reporting.py\n", + ) + + assert gate.classify_pytest_inputs( + log_path=log_path, + pyproject_path=pyproject_path, + changed_files_path=changed_files_path, + repo_root_path=repository_root, + ) == "nested_package._core" diff --git a/tests/test_python_native_extension_peer_gate_requirements_directory.py b/tests/test_python_native_extension_peer_gate_requirements_directory.py new file mode 100644 index 000000000..c809458a9 --- /dev/null +++ b/tests/test_python_native_extension_peer_gate_requirements_directory.py @@ -0,0 +1,89 @@ +"""Requirements-directory trust-boundary regressions for PyO3 peer evidence.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from scripts.ci import python_native_extension_peer_gate as gate + + +_PYPROJECT = """\ +[build-system] +requires = ["maturin>=1.10,<2.0"] +build-backend = "maturin" + +[tool.maturin] +bindings = "pyo3" +manifest-path = "crates/fast-mlsirm-py/Cargo.toml" +module-name = "fast_mlsirm._core" +python-source = "python" +""" + +_PYTEST_LOG = """\ +============================= test session starts ============================== +collected 0 items / 1 error + +_____________ ERROR collecting tests/test_mle.py ______________ +ImportError while importing test module '/work/tests/test_mle.py'. +Traceback: +tests/test_mle.py:3: in + import fast_mlsirm._core +E ModuleNotFoundError: No module named "fast_mlsirm._core" +!!!!!!!!!!!!!!!!!!! Interrupted: 1 error during collection !!!!!!!!!!!!!!!!!!!! +============================== 1 error in 0.20s =============================== +""" + + +def _write(path: Path, content: str) -> Path: + """Write one UTF-8 fixture and return its path.""" + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content, encoding="utf-8") + return path + + +@pytest.mark.parametrize( + "changed_path", + ( + "requirements/ci.txt", + "requirements/ci.in", + "requirements/notes.txt", + "services/scoring_service/requirements/package.txt", + "services/scoring_service/requirements/package.in", + ), +) +def test_requirements_directory_changes_block_native_peer_deferral( + tmp_path: Path, + changed_path: str, +) -> None: + """Direct requirements-directory changes require current-head native builds.""" + log_path = _write(tmp_path / "pytest.log", _PYTEST_LOG) + pyproject_path = _write(tmp_path / "pyproject.toml", _PYPROJECT) + changed_files_path = _write(tmp_path / "changed-files.txt", changed_path + "\n") + + assert gate.classify_pytest_inputs( + log_path=log_path, + pyproject_path=pyproject_path, + changed_files_path=changed_files_path, + repo_root_path=tmp_path, + ) is None + + +def test_unrelated_txt_outside_requirements_directory_can_still_defer( + tmp_path: Path, +) -> None: + """A documentation text file does not become a dependency boundary by suffix.""" + log_path = _write(tmp_path / "pytest.log", _PYTEST_LOG) + pyproject_path = _write(tmp_path / "pyproject.toml", _PYPROJECT) + changed_files_path = _write( + tmp_path / "changed-files.txt", + "docs/release_notes.txt\n", + ) + + assert gate.classify_pytest_inputs( + log_path=log_path, + pyproject_path=pyproject_path, + changed_files_path=changed_files_path, + repo_root_path=tmp_path, + ) == "fast_mlsirm._core" diff --git a/tests/test_python_native_extension_peer_gate_workflow_contract.py b/tests/test_python_native_extension_peer_gate_workflow_contract.py new file mode 100644 index 000000000..97c8bc304 --- /dev/null +++ b/tests/test_python_native_extension_peer_gate_workflow_contract.py @@ -0,0 +1,103 @@ +"""Permanent workflow contracts for the PyO3 source-only coverage boundary.""" + +from __future__ import annotations + +from pathlib import Path + + +_ROOT = Path(__file__).parents[1] +_REVIEW_WORKFLOW = _ROOT / ".github" / "workflows" / "opencode-review-dispatch.yml" +_QUALITY_WORKFLOW = ( + _ROOT + / ".github" + / "workflows" + / "python-native-extension-peer-gate-quality-ci.yml" +) +_HELPER = "scripts/ci/python_native_extension_peer_gate.py" + + +def _review_workflow() -> str: + """Return the protected OpenCode review workflow text.""" + + return _REVIEW_WORKFLOW.read_text(encoding="utf-8") + + +def _quality_workflow() -> str: + """Return the permanent PyO3 peer-gate quality workflow text.""" + + return _QUALITY_WORKFLOW.read_text(encoding="utf-8") + + +def test_failed_python_suite_uses_bounded_repo_root_aware_classifier() -> None: + """Only a real Python failure may enter the exact PyO3 classifier.""" + + workflow = _review_workflow() + assert "python_native_peer_check_required=0" in workflow + assert "classify-pytest" in workflow + assert _HELPER in workflow + assert '--repo-root "$COVERAGE_SOURCE_WORKDIR"' in workflow + assert '--pyproject "$project_dir/pyproject.toml"' in workflow + assert "changed_files_for_coverage" in workflow + assert "python_native_pytest_log" in workflow + assert "python_native_changed_files" in workflow + assert "if run_python_native_extension_classifier" in workflow + + +def test_source_only_native_failure_is_distinct_deferred_evidence() -> None: + """A classifier result is never serialized as ordinary passing coverage.""" + + workflow = _review_workflow() + assert "### Python native-extension source-only deferral" in workflow + assert '- Result: DEFERRED' in workflow + assert ( + "the unchanged declared PyO3 module was unavailable in the source-only " + "sandbox" in workflow + ) + assert "exact-head Python, Rust/PyO3, and package CheckRuns" in workflow + assert ( + "Python native-extension peer evidence: deferred source-only collection " + "requires successful exact-head peer checks" in workflow + ) + + +def test_approval_requires_live_exact_head_python_rust_and_package_checkruns() -> None: + """The trusted approval phase must validate all three exact-head peer checks.""" + + workflow = _review_workflow() + assert "python_native_peer_check_required" in workflow + assert "require-checks" in workflow + assert '--head-sha "$PR_HEAD_SHA"' in workflow + for requirement in ("CI::python", "CI::rust", "CI::package"): + assert f'--required-check "{requirement}"' in workflow + assert "check-runs" in workflow + assert "__typename" in workflow + assert "CheckRun" in workflow + assert "r_peer_check_required" in workflow + assert ( + "require_r_cmd_check_for_deferred_coverage" in workflow + or "R CMD check" in workflow + ) + + +def test_quality_workflow_covers_supported_pythons_and_all_contract_files() -> None: + """Python 3.10/3.14, coverage, docstrings, and integration stay permanent.""" + + workflow = _quality_workflow() + for path in ( + _HELPER, + "tests/test_python_native_extension_peer_gate.py", + "tests/test_python_native_extension_peer_gate_nested_project.py", + "tests/test_python_native_extension_peer_gate_workflow_contract.py", + ".github/workflows/opencode-review-dispatch.yml", + ".github/workflows/python-native-extension-peer-gate-quality-ci.yml", + "docs/doctoring/python-native-extension-peer-evidence.md", + "CHANGELOG.md", + ): + assert path in workflow + assert 'python-version: "3.10"' in workflow + assert 'python-version: "3.14"' in workflow + assert "--cov-branch" in workflow or "branch = True" in workflow + assert "fail_under = 100" in workflow + assert "interrogate --fail-under 100" in workflow + assert "compileall -q" in workflow + assert "actionlint" in workflow