From b7fcfa2c0946fc291deb079c784ab0c800b0e371 Mon Sep 17 00:00:00 2001 From: Patrick Ryan Date: Sat, 3 Oct 2026 17:17:49 -0500 Subject: [PATCH] builder: fail-closed sccache gate with mutation tests Run assert-sccache-build.sh in the sccache install layer (install flags from cargo's own record, pinned crates, no network stack, dist gating, source patterns, no sccache-dist), set SCCACHE_MAX_FRAME_LENGTH, record cargo-auditable version and assertion script hash in builder-inputs, rebuild on script change, add OpenVEX statements, the post-build audit harness, and pytest suites that prove every assertion rejects its violation. --- .github/scripts/assert-image.sh | 26 ++ .github/scripts/listen-check.sh | 11 + .github/workflows/build-environment-image.yml | 6 +- .gitignore | 2 + environments/alpine-3.24-musl/Containerfile | 5 +- .../alpine-3.24-musl/assert-sccache-build.sh | 157 +++++++ .../alpine-3.24-musl/vex/builder.openvex.json | 43 ++ tests/conftest.py | 24 ++ tests/test_assert_image.py | 104 +++++ tests/test_assert_sccache_build.py | 385 ++++++++++++++++++ 10 files changed, 761 insertions(+), 2 deletions(-) create mode 100755 .github/scripts/assert-image.sh create mode 100755 .github/scripts/listen-check.sh create mode 100644 .gitignore create mode 100755 environments/alpine-3.24-musl/assert-sccache-build.sh create mode 100644 environments/alpine-3.24-musl/vex/builder.openvex.json create mode 100644 tests/conftest.py create mode 100644 tests/test_assert_image.py create mode 100644 tests/test_assert_sccache_build.py diff --git a/.github/scripts/assert-image.sh b/.github/scripts/assert-image.sh new file mode 100755 index 0000000..20448ca --- /dev/null +++ b/.github/scripts/assert-image.sh @@ -0,0 +1,26 @@ +#!/bin/sh +# usage: assert-image.sh +set -u +HERE=$(cd "$(dirname "$0")" && pwd) +IMG="$1"; SBOM="$2"; fail=0 +check() { d=$1; shift; if "$@"; then echo "PASS $d"; else echo "FAIL $d"; fail=1; fi; } +count_purl() { jq --arg p "$1" '[.components[]|select((.purl//"")|startswith($p))]|length' "$SBOM"; } + +have_sccache() { podman run --rm --entrypoint sh "$IMG" -c 'test -x /opt/cargo/bin/sccache'; } +no_dist_bin() { ! podman run --rm --entrypoint sh "$IMG" -c 'test -e /opt/cargo/bin/sccache-dist'; } +sbom_has_cargo(){ [ "$(count_purl pkg:cargo/)" -ge 100 ]; } +no_netstack() { for c in opendal reqwest rustls openssl hyper-rustls; do [ "$(count_purl "pkg:cargo/$c")" = 0 ] || return 1; done; } +pinned() { [ "$(count_purl "pkg:cargo/$1")" -ge 1 ]; } +frame_env() { podman image inspect "$IMG" --format '{{range .Config.Env}}{{println .}}{{end}}' | grep -q '^SCCACHE_MAX_FRAME_LENGTH='; } +listen_ok() { podman run --rm -v "$HERE/listen-check.sh:/listen-check.sh:ro" --entrypoint sh "$IMG" /listen-check.sh; } + +check "control: sccache binary exists in image" have_sccache +check "no sccache-dist binary" no_dist_bin +check "control: SBOM has >=100 cargo components" sbom_has_cargo +check "no network-stack crates in SBOM" no_netstack +check "pinned tar@0.4.40 present" pinned tar@0.4.40 +check "pinned bytes@1.10.1 present" pinned bytes@1.10.1 +check "pinned rand@0.8.5 present" pinned rand@0.8.5 +check "SCCACHE_MAX_FRAME_LENGTH set in image" frame_env +check "server seen on loopback; no other listeners" listen_ok +exit $fail diff --git a/.github/scripts/listen-check.sh b/.github/scripts/listen-check.sh new file mode 100755 index 0000000..563c418 --- /dev/null +++ b/.github/scripts/listen-check.sh @@ -0,0 +1,11 @@ +#!/bin/sh +# runs inside the image: start sccache on a known port, list TCP listeners +export SCCACHE_SERVER_PORT=4777 SCCACHE_DIR=/tmp/c +sccache --start-server >/dev/null 2>&1 +sleep 1 +awk '$4=="0A" {print $2}' /proc/net/tcp /proc/net/tcp6 2>/dev/null > /tmp/listeners +sccache --stop-server >/dev/null 2>&1 +grep -q '^0100007F:12A9$' /tmp/listeners || { echo "control failed: no loopback listener on 4777 seen"; exit 2; } +if grep -v -E '^(0100007F:|00000000000000000000000001000000:)' /tmp/listeners | grep -q .; then + echo "non-loopback listener(s):"; grep -v -E '^(0100007F:|00000000000000000000000001000000:)' /tmp/listeners; exit 1 +fi diff --git a/.github/workflows/build-environment-image.yml b/.github/workflows/build-environment-image.yml index 475081f..1112a4e 100644 --- a/.github/workflows/build-environment-image.yml +++ b/.github/workflows/build-environment-image.yml @@ -6,6 +6,7 @@ on: paths: - "environments/alpine-3.24-musl/Containerfile" - "environments/alpine-3.24-musl/requirements-builder.txt" + - "environments/alpine-3.24-musl/assert-sccache-build.sh" - "environments/alpine-3.24-musl/apk.lock.*" - ".github/workflows/build-environment-image.yml" workflow_dispatch: {} @@ -42,7 +43,7 @@ jobs: id: tag env: ARCH: ${{ matrix.arch }} - run: echo "tag=${{ hashFiles('environments/alpine-3.24-musl/Containerfile', 'environments/alpine-3.24-musl/requirements-builder.txt') }}-${ARCH}" >> "$GITHUB_OUTPUT" + run: echo "tag=${{ hashFiles('environments/alpine-3.24-musl/Containerfile', 'environments/alpine-3.24-musl/requirements-builder.txt', 'environments/alpine-3.24-musl/assert-sccache-build.sh') }}-${ARCH}" >> "$GITHUB_OUTPUT" - name: Log in to GHCR env: @@ -148,6 +149,8 @@ jobs: --arg rustup_sha256 "$(arg "$rustup_sha_var")" \ --arg rust_version "$(arg RUST_VERSION)" \ --arg sccache_version "$(arg SCCACHE_VERSION)" \ + --arg cargo_auditable_version "$(arg CARGO_AUDITABLE_VERSION)" \ + --arg assert_script_sha256 "$(sha256sum "$CONTEXT/assert-sccache-build.sh" | cut -d' ' -f1)" \ --arg requirements_sha256 "$(sha256sum "$CONTEXT/requirements-builder.txt" | cut -d' ' -f1)" \ --arg apk_lock_sha256 "$(sha256sum "$CONTEXT/apk.lock.$ARCH" | cut -d' ' -f1)" \ --arg builder_image "$IMAGE@$DIGEST" \ @@ -155,6 +158,7 @@ jobs: '{base_image_ref:$base_ref, base_image_index_digest:$base_index, base_platform:$base_platform, base_platform_manifest_digest:$base_platform_digest, rustup_version:$rustup_version, rustup_sha256:$rustup_sha256, rust_version:$rust_version, sccache_version:$sccache_version, + cargo_auditable_version:$cargo_auditable_version, assert_script_sha256:$assert_script_sha256, requirements_builder_sha256:$requirements_sha256, apk_lock_sha256:$apk_lock_sha256, builder_image:$builder_image, source_commit:$commit}' > "builder-inputs.$ARCH.json" cat "builder-inputs.$ARCH.json" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6c56ff1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +__pycache__/ +.pytest_cache/ diff --git a/environments/alpine-3.24-musl/Containerfile b/environments/alpine-3.24-musl/Containerfile index 2f04bb2..1160a98 100644 --- a/environments/alpine-3.24-musl/Containerfile +++ b/environments/alpine-3.24-musl/Containerfile @@ -84,16 +84,19 @@ ARG CARGO_AUDITABLE_VERSION=0.7.7 # in the binary so Syft/Trivy scan what was actually compiled. The tool, the crate # sources and the registry are removed in the same layer. The guard fails the build if # the network stack is back. +COPY assert-sccache-build.sh /tmp/assert-sccache-build.sh RUN set -eux; \ cargo install cargo-auditable --version "${CARGO_AUDITABLE_VERSION}" --locked --root /tmp/auditable; \ PATH="/tmp/auditable/bin:${PATH}" \ cargo auditable install sccache --version "${SCCACHE_VERSION}" --locked --no-default-features; \ - rm -rf /tmp/auditable /opt/cargo/registry /opt/cargo/git; \ + sh /tmp/assert-sccache-build.sh "${SCCACHE_VERSION}"; \ + rm -rf /tmp/auditable /tmp/assert-sccache-build.sh /opt/cargo/registry /opt/cargo/git; \ sccache --version | grep -F "${SCCACHE_VERSION}"; \ if grep -q -a -e opendal -e reqwest /opt/cargo/bin/sccache; then \ echo "sccache contains network backend code" >&2; exit 1; fi ENV RUSTC_WRAPPER=/opt/cargo/bin/sccache \ SCCACHE_DIR=/sccache \ + SCCACHE_MAX_FRAME_LENGTH=8388608 \ CARGO_TARGET_DIR=/cargo-target # maturin: exact version with hashes for both architectures. diff --git a/environments/alpine-3.24-musl/assert-sccache-build.sh b/environments/alpine-3.24-musl/assert-sccache-build.sh new file mode 100755 index 0000000..b9d487a --- /dev/null +++ b/environments/alpine-3.24-musl/assert-sccache-build.sh @@ -0,0 +1,157 @@ +#!/bin/sh +# Build-time gate for the sccache install. Runs in the same layer as the +# install, before the cargo registry is deleted. Exit non-zero => no image. +# +# The VEX statements in vex/builder.openvex.json depend on these facts: +# change a pin, review the VEX (tests/ checks the two stay in sync). +# +# Each check is its own assert_* function so tests/ can prove every one of +# them rejects a deliberate violation. The Containerfile passes only the +# version; the --*-dir/--tree-file flags exist for the tests. +set -eu + +usage() { + echo "usage: $0 [--tree-file F] [--src-dir D] [--bin-dir D] [--out-dir D] [--cargo-home D]" >&2 + exit 2 +} +[ $# -ge 1 ] || usage +V="$1"; shift +TREE_FILE=""; SRC_DIR=""; BIN_DIR=/opt/cargo/bin; OUT_DIR=/usr/share/builder; CARGO_HOME_DIR=/opt/cargo +while [ $# -gt 0 ]; do + [ $# -ge 2 ] || usage + case "$1" in + --tree-file) TREE_FILE="$2" ;; + --src-dir) SRC_DIR="$2" ;; + --bin-dir) BIN_DIR="$2" ;; + --out-dir) OUT_DIR="$2" ;; + --cargo-home) CARGO_HOME_DIR="$2" ;; + *) usage ;; + esac + shift 2 +done + +# crate version: the VEX file must mention each as @. +PINNED_CRATES='tar 0.4.40 +bytes 1.10.1 +rand 0.8.5' +FORBIDDEN_CRATES='opendal reqwest rustls rustls-webpki openssl hyper-rustls' + +fail() { echo "ASSERT FAIL: $*" >&2; exit 1; } + +load_inputs() { + if [ -n "$TREE_FILE" ]; then + TREE=$(sort -u "$TREE_FILE") + fi + if [ -z "$SRC_DIR" ]; then + SRC_DIR=$(ls -d /opt/cargo/registry/src/*/sccache-"$V" 2>/dev/null) \ + || fail "sccache $V sources not found in the cargo registry" + fi + [ -d "$SRC_DIR" ] || fail "source dir missing: $SRC_DIR" + cd "$SRC_DIR" + if [ -z "$TREE_FILE" ]; then + TREE=$(cargo tree --locked --no-default-features -e normal --prefix none | sort -u) + fi + [ -n "$TREE" ] || fail "empty dependency tree" + SRC=$(find src -name '*.rs') + [ -n "$SRC" ] || fail "no .rs files found under src/" +} + +# The dependency tree below is computed with --no-default-features. That only +# describes the real binary if the install used the same flags, so verify what +# cargo recorded for the actual install (not what the Containerfile says). +assert_install_flags() { + msg=$(python3 - "$CARGO_HOME_DIR/.crates2.json" "$V" <<'PYEOF' +import json, sys +path, v = sys.argv[1:3] +try: + installs = json.load(open(path))["installs"] +except Exception as e: + print(f"cannot read cargo install record {path}: {e}") + sys.exit(1) +keys = [k for k in installs if k.startswith(f"sccache {v} ")] +if len(keys) != 1: + print(f"expected exactly one install record for sccache {v}, found {len(keys)}") + sys.exit(1) +r = installs[keys[0]] +if r.get("no_default_features") is not True: + print("sccache was installed WITHOUT --no-default-features") + sys.exit(1) +if r.get("features") != [] or r.get("all_features") is not False: + print(f"sccache was installed with extra features: {r.get('features')} all={r.get('all_features')}") + sys.exit(1) +if r.get("bins") != ["sccache"]: + print(f"unexpected installed binaries: {r.get('bins')}") + sys.exit(1) +PYEOF + ) || fail "install flags: $msg" +} + +assert_version_matches() { + found=$(sed -n 's/^version = "\(.*\)"$/\1/p' Cargo.toml | head -n 1) + [ "$found" = "$V" ] || fail "Cargo.toml version is '$found', expected $V" +} + +assert_no_network_crates() { + for c in $FORBIDDEN_CRATES; do + if echo "$TREE" | grep -q "^$c "; then fail "network-stack crate in dependency tree: $c"; fi + done +} + +assert_pinned_crates() { + while read -r name ver; do + [ -n "$name" ] || continue + ver_re=$(printf '%s' "$ver" | sed 's/\./\\./g') + echo "$TREE" | grep -Eq "^$name v$ver_re( |\$)" \ + || fail "expected $name v$ver in tree; review the VEX before changing pins" + done < "$OUT_DIR/sccache-$V.deps.txt" +} + +main() { + load_inputs + assert_install_flags + assert_version_matches + assert_no_network_crates + assert_pinned_crates + assert_tar_confined_to_dist + assert_dist_gated + assert_no_thread_rng + assert_no_custom_logger + assert_no_dist_binary + write_deps_record + echo "sccache build assertions passed" +} + +main \ No newline at end of file diff --git a/environments/alpine-3.24-musl/vex/builder.openvex.json b/environments/alpine-3.24-musl/vex/builder.openvex.json new file mode 100644 index 0000000..24af275 --- /dev/null +++ b/environments/alpine-3.24-musl/vex/builder.openvex.json @@ -0,0 +1,43 @@ +{ + "@context": "https://openvex.dev/ns/v0.2.0", + "@id": "https://github.com/patrickryankenneth/python-wheels-builder/vex/2026-10-03", + "author": "patrickryankenneth@gmail.com", + "timestamp": "2026-10-03T18:49:01Z", + "version": 1, + "statements": [ + { + "vulnerability": {"name": "CVE-2026-33055"}, + "products": [{"@id": "pkg:cargo/tar@0.4.40"}], + "status": "not_affected", + "justification": "vulnerable_code_not_in_execute_path", + "impact_statement": "tar 0.4.40 is linked into sccache 0.18.0 only for creating archives (tar::Builder). The vulnerable behavior is in reading archives. tar::Archive is used only under src/bin/sccache-dist/, which requires the dist-server feature. The builder installs with --no-default-features and the image has no sccache-dist binary (asserted by assert-image.sh). Review by 2027-01-03, or on any sccache or tar version change." + }, + { + "vulnerability": {"name": "CVE-2026-33056"}, + "products": [{"@id": "pkg:cargo/tar@0.4.40"}], + "status": "not_affected", + "justification": "vulnerable_code_not_in_execute_path", + "impact_statement": "The flaw is in unpacking crafted archives. sccache calls no unpack path outside sccache-dist, which is not built or shipped (see CVE-2026-33055). Review by 2027-01-03, or on any sccache or tar version change." + }, + { + "vulnerability": {"name": "GHSA-3pv8-6f4r-ffg2"}, + "products": [{"@id": "pkg:cargo/tar@0.4.40"}], + "status": "not_affected", + "justification": "vulnerable_code_not_in_execute_path", + "impact_statement": "The flaw is PAX header desynchronization when reading a tar stream. sccache reads tar streams only in sccache-dist, which is not built or shipped (see CVE-2026-33055). Review by 2027-01-03, or on any sccache or tar version change." + }, + { + "vulnerability": {"name": "GHSA-cq8v-f236-94qc"}, + "products": [{"@id": "pkg:cargo/rand@0.8.5"}], + "status": "not_affected", + "justification": "vulnerable_code_not_in_execute_path", + "impact_statement": "The advisory requires a custom logger that calls into rand. sccache 0.18.0 source has no set_logger, set_boxed_logger or impl Log, and its only rand usage is OsRng and RngCore in four files. Review by 2027-01-03, or on any sccache or rand version change." + }, + { + "vulnerability": {"name": "CVE-2026-25541", "aliases": ["GHSA-434x-w66g-qw3r"]}, + "products": [{"@id": "pkg:cargo/bytes@1.10.1"}], + "status": "affected", + "action_statement": "Accepted, not suppressed. bytes 1.10.1 is in sccache's server protocol path (BytesMut in src/server.rs). Supporting controls, not a proof of non-exploitability: the server binds 127.0.0.1 or a Unix socket, and the frame length is capped at 8 MiB. Upstream main still resolves bytes 1.10.1 and has no release after 0.18.0. Severity MEDIUM, below the HIGH gate. Review by 2027-01-03, or when sccache updates bytes." + } + ] +} diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..d75fb9f --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,24 @@ +import shutil + +import pytest + + +def pytest_addoption(parser): + parser.addoption("--integration", action="store_true", + help="also run the slow podman image-build tests") + + +def pytest_configure(config): + config.addinivalue_line("markers", "integration: builds the real image with podman") + + +def pytest_collection_modifyitems(config, items): + skip = None + if not config.getoption("--integration"): + skip = pytest.mark.skip(reason="needs --integration") + elif shutil.which("podman") is None: + skip = pytest.mark.skip(reason="podman not installed") + if skip: + for item in items: + if "integration" in item.keywords: + item.add_marker(skip) \ No newline at end of file diff --git a/tests/test_assert_image.py b/tests/test_assert_image.py new file mode 100644 index 0000000..0e5f896 --- /dev/null +++ b/tests/test_assert_image.py @@ -0,0 +1,104 @@ +"""Tests for the post-build audit (.github/scripts/assert-image.sh). + +podman is replaced by a stub on PATH, so these run in milliseconds. The point is +the lesson from the vacuous-SBOM bug: a negative assertion ("no reqwest") must FAIL +when reqwest is present, and a SBOM with no evidence at all must not look clean. +""" +import json +import os +import re +import shlex +import shutil +import stat +import subprocess +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[1] +HARNESS = ROOT / ".github" / "scripts" / "assert-image.sh" +SH = shlex.split(os.environ.get("TEST_SH", "sh")) + +pytestmark = pytest.mark.skipif(shutil.which("jq") is None, reason="jq not installed") + +PODMAN_STUB = """#!/bin/sh +case "$*" in + *"image inspect"*) + echo "PATH=/usr/bin" + [ "${STUB_FRAME:-1}" = 1 ] && echo "SCCACHE_MAX_FRAME_LENGTH=8388608" + exit 0 ;; + *"test -e /opt/cargo/bin/sccache-dist"*) [ "${STUB_DIST:-0}" = 1 ] && exit 0; exit 1 ;; + *"test -x /opt/cargo/bin/sccache"*) [ "${STUB_SCCACHE:-1}" = 1 ] && exit 0; exit 1 ;; + *"/listen-check.sh"*) [ "${STUB_LISTEN:-1}" = 1 ] && exit 0; exit 1 ;; +esac +exit 3 +""" + +PINS = ["tar@0.4.40", "bytes@1.10.1", "rand@0.8.5"] +NETWORK_CRATES = ["opendal", "reqwest", "rustls", "openssl", "hyper-rustls"] + + +def sbom(extra=(), drop=(), filler=120): + purls = [f"pkg:cargo/filler{i}@1.0.0" for i in range(filler)] + purls += [f"pkg:cargo/{p}" for p in PINS if p not in drop] + purls += [f"pkg:cargo/{p}" for p in extra] + return {"components": [{"type": "library", "purl": p} for p in purls]} + + +def run_harness(tmp_path, sbom_doc, **stub_env): + bindir = tmp_path / "bin" + bindir.mkdir(exist_ok=True) + stub = bindir / "podman" + stub.write_text(PODMAN_STUB) + stub.chmod(stub.stat().st_mode | stat.S_IXUSR) + f = tmp_path / "sbom.json" + f.write_text(json.dumps(sbom_doc)) + env = {**os.environ, "PATH": f"{bindir}:{os.environ['PATH']}", **{k: str(v) for k, v in stub_env.items()}} + return subprocess.run(SH + [str(HARNESS), "localhost/stub:image", str(f)], + capture_output=True, text=True, env=env, timeout=60) + + +def harness_checks() -> list[str]: + return re.findall(r'^check "([^"]+)"', HARNESS.read_text(), flags=re.M) + + +# (id, the check that must report FAIL, how to build the bad input) +VIOLATIONS = [ + ("sccache-binary-missing", "control: sccache binary exists in image", dict(sbom_doc=sbom(), STUB_SCCACHE=0)), + ("dist-binary-present", "no sccache-dist binary", dict(sbom_doc=sbom(), STUB_DIST=1)), + ("sbom-has-no-cargo-evidence", "control: SBOM has >=100 cargo components", dict(sbom_doc={"components": []})), + ("sbom-nearly-empty", "control: SBOM has >=100 cargo components", dict(sbom_doc=sbom(filler=5))), + *[(f"network-crate-{c}", "no network-stack crates in SBOM", dict(sbom_doc=sbom(extra=[f"{c}@1.0.0"]))) + for c in NETWORK_CRATES], + ("tar-pin-missing", "pinned tar@0.4.40 present", dict(sbom_doc=sbom(drop=["tar@0.4.40"]))), + ("bytes-pin-missing", "pinned bytes@1.10.1 present", dict(sbom_doc=sbom(drop=["bytes@1.10.1"]))), + ("rand-pin-missing", "pinned rand@0.8.5 present", dict(sbom_doc=sbom(drop=["rand@0.8.5"]))), + ("frame-env-missing", "SCCACHE_MAX_FRAME_LENGTH set in image", dict(sbom_doc=sbom(), STUB_FRAME=0)), + ("unexpected-listener", "server seen on loopback; no other listeners", dict(sbom_doc=sbom(), STUB_LISTEN=0)), +] + + +def test_good_image_and_sbom_pass_every_check(tmp_path): + r = run_harness(tmp_path, sbom()) + assert r.returncode == 0, r.stdout + assert r.stdout.count("PASS ") == len(harness_checks()) and "FAIL" not in r.stdout + + +@pytest.mark.parametrize("vid,check,kwargs", VIOLATIONS, ids=[v[0] for v in VIOLATIONS]) +def test_each_violation_fails_exactly_the_intended_check(tmp_path, vid, check, kwargs): + r = run_harness(tmp_path, **kwargs) + assert r.returncode != 0, "violation was NOT rejected" + assert f"FAIL {check}" in r.stdout, r.stdout + + +def test_every_harness_check_has_a_violation_case(): + assert set(harness_checks()) == {v[1] for v in VIOLATIONS} + + +def test_empty_sbom_is_not_mistaken_for_clean(tmp_path): + """The original bug: 'no reqwest' passes on an SBOM with no cargo components at all. + That negative check still passes vacuously here; only the control check saves us.""" + r = run_harness(tmp_path, {"components": []}) + assert "PASS no network-stack crates in SBOM" in r.stdout # vacuous pass, by design + assert "FAIL control: SBOM has >=100 cargo components" in r.stdout + assert r.returncode != 0 \ No newline at end of file diff --git a/tests/test_assert_sccache_build.py b/tests/test_assert_sccache_build.py new file mode 100644 index 0000000..8394fbe --- /dev/null +++ b/tests/test_assert_sccache_build.py @@ -0,0 +1,385 @@ +"""Tests for the build-time gate (assert-sccache-build.sh) and its wiring. + +Three layers: + 1. Gate tests: the good fixture passes; every deliberate violation is rejected + for the intended reason. + 2. Meta tests: disable each assert_* function in turn; the suite must notice + (some violation slips through). Every assert_* must have violation cases. + 3. Wiring tests: the Containerfile/workflow actually run the gate, in the right + place, and the VEX file matches the pins. Real image builds are opt-in + (pytest --integration). +""" +import json +import os +import re +import shlex +import shutil +import subprocess +from dataclasses import dataclass +from pathlib import Path +from typing import Callable + +import pytest + +ROOT = Path(__file__).resolve().parents[1] +ENV_DIR = ROOT / "environments" / "alpine-3.24-musl" +SCRIPT = ENV_DIR / "assert-sccache-build.sh" +CONTAINERFILE = ENV_DIR / "Containerfile" +VEX = ENV_DIR / "vex" / "builder.openvex.json" +WORKFLOW = ROOT / ".github" / "workflows" / "build-environment-image.yml" +VERSION = "0.18.0" +# Production runs the gate under Alpine busybox ash, so CI should also run: +# TEST_SH="busybox sh" pytest tests +SH = shlex.split(os.environ.get("TEST_SH", "sh")) + +GOOD_TREE = """\ +sccache v0.18.0 +addr2line v0.25.1 +aho-corasick v1.1.5 +aho-corasick v1.1.5 (*) +bytes v1.10.1 +bytes v1.10.1 (*) +rand v0.8.5 +tar v0.4.40 +""" +GOOD_CARGO_TOML = """\ +[package] +edition = "2018" +name = "sccache" +version = "0.18.0" + +[[bin]] +name = "sccache-dist" +required-features = ["dist-server"] +""" +GOOD_INSTALLS = {"installs": { + "sccache 0.18.0 (registry+https://github.com/rust-lang/crates.io-index)": { + "version_req": "=0.18.0", "bins": ["sccache"], "features": [], "all_features": False, + "no_default_features": True, "profile": "release", "target": "x86_64-unknown-linux-musl", + "rustc": "rustc 1.98.1"}}} +FORBIDDEN_CRATES = ["opendal", "reqwest", "rustls", "rustls-webpki", "openssl", "hyper-rustls"] + + +class Fixture: + def __init__(self, base: Path): + self.base = base + self.src = base / "sccache-src" + self.bin = base / "bin" + self.out = base / "out" + self.cargo_home = base / "cargo" + self.tree = base / "tree.txt" + (self.src / "src" / "bin" / "sccache-dist").mkdir(parents=True) + self.bin.mkdir() + (self.src / "Cargo.toml").write_text(GOOD_CARGO_TOML) + (self.src / "src" / "lib.rs").write_text("pub fn ok() {}\n") + (self.src / "src" / "bin" / "sccache-dist" / "main.rs").write_text("use tar::Archive;\n") + (self.bin / "sccache").write_text("") + self.tree.write_text(GOOD_TREE) + self.cargo_home.mkdir() + (self.cargo_home / ".crates2.json").write_text(json.dumps(GOOD_INSTALLS)) + + def run(self, script: Path = SCRIPT, version: str = VERSION): + return subprocess.run( + SH + [str(script), version, "--tree-file", str(self.tree), "--src-dir", str(self.src), + "--bin-dir", str(self.bin), "--out-dir", str(self.out), + "--cargo-home", str(self.cargo_home)], + capture_output=True, text=True, timeout=60) + + +@dataclass +class Case: + id: str + guard: str # the assert_* function that must catch it + mutate: Callable[[Fixture], None] + expect: str # substring that must appear in stderr + + +def _tree(fx, old, new): + text = fx.tree.read_text() + assert old in text, old + fx.tree.write_text(text.replace(old, new)) + + +def _cargo(fx, old, new): + text = (fx.src / "Cargo.toml").read_text() + assert old in text, old + (fx.src / "Cargo.toml").write_text(text.replace(old, new)) + + +def _install(fx, **changes): + data = json.loads((fx.cargo_home / ".crates2.json").read_text()) + rec = next(iter(data["installs"].values())) + rec.update(changes) + (fx.cargo_home / ".crates2.json").write_text(json.dumps(data)) + + +def _append_src(fx, rel, line): + p = fx.src / rel + p.write_text(p.read_text() + line + "\n") + + +CASES = [ + Case("sccache-version-drift", "assert_version_matches", + lambda fx: _cargo(fx, 'version = "0.18.0"', 'version = "0.18.1"'), "expected 0.18.0"), + Case("tar-version-drift", "assert_pinned_crates", + lambda fx: _tree(fx, "tar v0.4.40", "tar v0.4.41"), "review the VEX"), + Case("bytes-version-drift", "assert_pinned_crates", + lambda fx: _tree(fx, "bytes v1.10.1", "bytes v1.11.0"), "review the VEX"), + Case("rand-version-drift", "assert_pinned_crates", + lambda fx: _tree(fx, "rand v0.8.5", "rand v0.9.0"), "review the VEX"), + Case("pinned-crate-missing", "assert_pinned_crates", + lambda fx: _tree(fx, "rand v0.8.5\n", ""), "review the VEX"), + *[Case(f"network-crate-{c}", "assert_no_network_crates", + (lambda c: lambda fx: fx.tree.write_text(GOOD_TREE + f"{c} v1.0.0\n"))(c), + f"dependency tree: {c}") for c in FORBIDDEN_CRATES], + Case("dist-server-gating-removed", "assert_dist_gated", + lambda fx: _cargo(fx, 'required-features = ["dist-server"]', ""), "no longer gated"), + Case("tar-archive-used-outside-dist", "assert_tar_confined_to_dist", + lambda fx: _append_src(fx, "src/lib.rs", "use tar::Archive;"), "outside sccache-dist"), + Case("tar-archive-gone-from-dist-control", "assert_tar_confined_to_dist", + lambda fx: (fx.src / "src/bin/sccache-dist/main.rs").write_text("fn main() {}\n"), "control:"), + Case("thread-rng-introduced", "assert_no_thread_rng", + lambda fx: _append_src(fx, "src/lib.rs", "let r = rand::thread_rng();"), "rand pattern found"), + Case("thread-rng-new-api", "assert_no_thread_rng", + lambda fx: _append_src(fx, "src/lib.rs", "let r = rand::rng();"), "rand pattern found"), + Case("custom-logger-set", "assert_no_custom_logger", + lambda fx: _append_src(fx, "src/lib.rs", "log::set_logger(&L).unwrap();"), "custom-logger pattern"), + Case("custom-log-impl", "assert_no_custom_logger", + lambda fx: _append_src(fx, "src/lib.rs", "impl log::Log for L {}"), "custom-logger pattern"), + Case("installed-with-default-features", "assert_install_flags", + lambda fx: _install(fx, no_default_features=False), "WITHOUT --no-default-features"), + Case("installed-with-dist-server-feature", "assert_install_flags", + lambda fx: _install(fx, features=["dist-server"]), "extra features"), + Case("installed-with-all-features", "assert_install_flags", + lambda fx: _install(fx, all_features=True), "extra features"), + Case("dist-binary-recorded-as-installed", "assert_install_flags", + lambda fx: _install(fx, bins=["sccache", "sccache-dist"]), "unexpected installed binaries"), + Case("install-record-missing", "assert_install_flags", + lambda fx: (fx.cargo_home / ".crates2.json").unlink(), "cannot read cargo install record"), + Case("sccache-dist-binary-present", "assert_no_dist_binary", + lambda fx: (fx.bin / "sccache-dist").write_text(""), "binary present"), +] + + +def script_guards() -> list[str]: + return re.findall(r"^(assert_\w+)\(\)", SCRIPT.read_text(), flags=re.M) + + +# ---------- 1. gate tests ---------- + +def test_good_input_passes_and_records_tree(tmp_path): + fx = Fixture(tmp_path) + r = fx.run() + assert r.returncode == 0, r.stderr + assert "sccache build assertions passed" in r.stdout + assert (fx.out / f"sccache-{VERSION}.deps.txt").read_text().startswith("addr2line") + + +@pytest.mark.parametrize("case", CASES, ids=lambda c: c.id) +def test_violation_is_rejected_for_the_intended_reason(tmp_path, case): + fx = Fixture(tmp_path) + case.mutate(fx) + r = fx.run() + assert r.returncode != 0, "violation was NOT rejected" + assert case.expect in r.stderr, r.stderr + + +@pytest.mark.parametrize("case", CASES, ids=lambda c: c.id) +def test_restoring_the_condition_passes_again(tmp_path, case): + fx = Fixture(tmp_path) + case.mutate(fx) + assert fx.run().returncode != 0 + shutil.rmtree(fx.base) + assert Fixture(fx.base).run().returncode == 0 + + +# ---------- 2. meta tests: removing an assertion must be noticed ---------- + +def test_every_assert_function_has_violation_cases(): + assert set(script_guards()) == {c.guard for c in CASES}, \ + "an assert_* function has no violation case (or a case names a missing function)" + + +def test_script_ends_with_main_call(): + assert SCRIPT.read_text().rstrip("\n").splitlines()[-1] == "main" + + +@pytest.mark.parametrize("guard", sorted({c.guard for c in CASES})) +def test_disabling_a_guard_lets_a_violation_through(tmp_path, guard): + """If someone deletes or neuters an assert_*, a violation test above starts + failing. Prove it: neuter the guard and require that its violation slips through.""" + lines = SCRIPT.read_text().rstrip("\n").splitlines() + mutant = tmp_path / "mutant.sh" + mutant.write_text("\n".join(lines[:-1] + [f"{guard}() {{ return 0; }}", "main"]) + "\n") + slipped = [] + for i, case in enumerate(c for c in CASES if c.guard == guard): + fx = Fixture(tmp_path / f"case{i}") + case.mutate(fx) + if fx.run(script=mutant).returncode == 0: + slipped.append(case.id) + assert slipped, f"neutering {guard} changed nothing; its violation cases prove nothing" + + +# ---------- 3. wiring tests ---------- + +def instructions(text: str) -> list[str]: + """Containerfile instructions with line continuations joined.""" + out, cur = [], "" + for line in text.splitlines(): + if not cur and (not line.strip() or line.lstrip().startswith("#")): + continue + if line.rstrip().endswith("\\"): + cur += line.rstrip()[:-1] + " " + continue + out.append((cur + line).strip()) + cur = "" + return out + + +COPY_LINE = "COPY assert-sccache-build.sh /tmp/assert-sccache-build.sh" +GATE_CALL = 'sh /tmp/assert-sccache-build.sh "${SCCACHE_VERSION}"' + + +def containerfile_problems(text: str) -> list[str]: + problems = [] + ins = instructions(text) + copy_idx = [i for i, x in enumerate(ins) if x == COPY_LINE] + installs = [i for i, x in enumerate(ins) if x.startswith("RUN") and "cargo auditable install sccache" in x] + if not copy_idx: + problems.append("script is not COPY'd into the image build") + if len(installs) != 1: + problems.append("expected exactly one RUN that installs sccache") + else: + run = ins[installs[0]] + if copy_idx and copy_idx[0] > installs[0]: + problems.append("script is COPY'd after the sccache install") + inst = run.find("cargo auditable install sccache") + gate = run.find(GATE_CALL) + rm = run.find("/opt/cargo/registry") + # The gate must run in the SAME RUN (layer) as the install, so the crate + # sources, cargo's install record and the binaries it inspects exist. + if gate == -1: + problems.append("gate is not run in the same RUN as the sccache install") + else: + if gate < inst: + problems.append("gate runs before sccache is installed") + if rm == -1 or gate > rm: + problems.append("gate runs after the cargo registry is deleted") + cmd = re.search(r"cargo auditable install sccache[^;]*", run).group(0) + if "--no-default-features" not in cmd: + problems.append("sccache install lacks --no-default-features") + if "--features" in cmd or "--all-features" in cmd: + problems.append("sccache install enables extra features") + if "--locked" not in cmd: + problems.append("sccache install lacks --locked") + if not re.search(r"^\s*SCCACHE_MAX_FRAME_LENGTH=\d+", text, flags=re.M): + problems.append("SCCACHE_MAX_FRAME_LENGTH not set in ENV") + return problems + + +def test_containerfile_wires_the_gate(): + assert containerfile_problems(CONTAINERFILE.read_text()) == [] + + +def _move_gate_to_own_layer(t): + t = t.replace(' ' + GATE_CALL + '; \\\n', "") + return t.replace("ENV RUSTC_WRAPPER", "RUN " + GATE_CALL + "\nENV RUSTC_WRAPPER", 1) + + +@pytest.mark.parametrize("name,mutate", [ + ("copy-removed", lambda t: t.replace(COPY_LINE + "\n", "")), + ("gate-call-removed", lambda t: t.replace(' ' + GATE_CALL + '; \\\n', "")), + ("gate-moved-to-another-layer", _move_gate_to_own_layer), + ("frame-env-removed", lambda t: re.sub(r"^\s*SCCACHE_MAX_FRAME_LENGTH=\d+ \\\n", "", t, flags=re.M)), + ("gate-after-registry-delete", lambda t: t.replace(' ' + GATE_CALL + '; \\\n', "").replace( + " sccache --version", ' ' + GATE_CALL + '; \\\n sccache --version')), + ("no-default-features-removed", lambda t: t.replace(" --no-default-features", "")), + ("extra-feature-added", lambda t: t.replace("--no-default-features", "--no-default-features --features dist-server")), + ("locked-removed", lambda t: t.replace('"${SCCACHE_VERSION}" --locked', '"${SCCACHE_VERSION}"')), +]) +def test_wiring_check_detects_breakage(name, mutate): + text = CONTAINERFILE.read_text() + broken = mutate(text) + assert broken != text, "mutation did not change the Containerfile" + assert containerfile_problems(broken), name + + +def test_ci_rebuilds_when_the_script_changes(): + wf = WORKFLOW.read_text() + full = "environments/alpine-3.24-musl/assert-sccache-build.sh" + paths_block = wf.split("paths:", 1)[1].split("workflow_dispatch", 1)[0] + assert full in paths_block, "script missing from on.push.paths" + assert re.search(r"hashFiles\([^)]*" + re.escape(full), wf), "script missing from the recipe tag hash" + assert '$CONTEXT/assert-sccache-build.sh' in wf, "script hash not recorded in builder-inputs" + + +# ---------- VEX <-> pins, both directions ---------- + +def script_pins() -> dict[str, str]: + block = re.search(r"PINNED_CRATES='([^']*)'", SCRIPT.read_text()).group(1) + return dict(line.split() for line in block.split("\n")) + + +def vex_problems(vex_text: str, pins: dict[str, str]) -> list[str]: + problems = [] + for name, ver in pins.items(): + found = set(re.findall(rf"\b{re.escape(name)}@([0-9][\w.+-]*)", vex_text)) + if ver not in found: + problems.append(f"VEX has no statement for {name}@{ver}") + for stale in sorted(found - {ver}): + problems.append(f"VEX mentions {name}@{stale} but the pin is {ver}") + return problems + + +def test_vex_matches_pins_exactly(): + assert vex_problems(VEX.read_text(), script_pins()) == [] + + +def test_vex_check_detects_missing_stale_and_wrong_versions(): + pins = {"tar": "0.4.40", "bytes": "1.10.1"} + good = '"pkg:cargo/tar@0.4.40" "pkg:cargo/bytes@1.10.1"' + assert vex_problems(good, pins) == [] + assert vex_problems('"pkg:cargo/tar@0.4.40"', pins) # missing + assert vex_problems(good + ' "pkg:cargo/bytes@1.9.0"', pins) # stale extra + assert vex_problems('"pkg:cargo/tar@0.4.39" "pkg:cargo/bytes@1.10.1"', pins) # wrong + assert vex_problems(good + ' "pkg:cargo/rand_core@0.6.4"', pins) == [] # other crates ignored + + +# ---------- integration (pytest --integration): the real Containerfile, real builds ---------- + +def _build(ctx: Path, tag: str): + return subprocess.run(["podman", "build", "-f", "Containerfile", "-t", tag, "."], + cwd=ctx, capture_output=True, text=True) + + +def _mutated_context(tmp_path: Path, file: str, old: str, new: str) -> Path: + ctx = tmp_path / "ctx" + shutil.copytree(ENV_DIR, ctx) + f = ctx / file + text = f.read_text() + assert old in text, old + f.write_text(text.replace(old, new)) + return ctx + + +@pytest.mark.integration +def test_real_build_passes_the_gate(): + r = _build(ENV_DIR, "builder:pytest") + assert r.returncode == 0, r.stdout[-2000:] + r.stderr[-2000:] + assert "sccache build assertions passed" in r.stdout + r.stderr + + +@pytest.mark.integration +@pytest.mark.parametrize("name,file,old,new", [ + ("pin-drift", "assert-sccache-build.sh", "bytes 1.10.1", "bytes 9.9.9"), + ("default-features-enabled", "Containerfile", " --locked --no-default-features;", " --locked;"), + ("dist-server-enabled", "Containerfile", "--locked --no-default-features;", + "--locked --no-default-features --features dist-server;"), +]) +def test_real_build_fails_at_the_gate(tmp_path, name, file, old, new): + """Each of these is a real change to the real build; it must stop at ASSERT FAIL + (not at some unrelated compile error, and not later at the legacy grep guard).""" + r = _build(_mutated_context(tmp_path, file, old, new), f"builder:pytest-{name}") + out = r.stdout + r.stderr + assert r.returncode != 0, "mutated build SUCCEEDED" + assert "ASSERT FAIL" in out, out[-3000:] \ No newline at end of file