From 94f16662d56389669ef2ec6c7e0a721f7b422a66 Mon Sep 17 00:00:00 2001 From: joshlf's Agent Date: Tue, 25 Aug 2026 12:40:34 +0000 Subject: [PATCH] [ci] Audit typed matrix execution path Require reviewed planned roles to equal `build_test` and `miri`. Audit their exact top-level shapes, display names, hosted runners, read-only permissions, `plan_ci` and `build_docker_env` dependencies, and build run defaults. Reject unreviewed job controls and strategy fields. Require exact matrix gates and fan-out expressions. Include every Miri selector, including toolchain, in its display name. Bind one named executor step to each job's `steps` mapping, with the exact selector environment and checked CLI arguments for its role. Audit the image producer's exact fields, permissions, output, and five steps. Derive its artifact output and each consumer input from shared job, output, and upload-step identifiers so a protocol rename cannot silently update only one side. Build the image from an isolated context containing only an audited Dockerfile and an audited ignore file which excludes every context path. Reject any extra context entry. Install the three common Rust toolchains directly instead of executing checkout code, and require their Docker argument defaults to match the validated inventory. This also avoids compiling cargo-zerocopy only to seed the image and prevents ordinary tools-tree changes from invalidating that layer. Expected latency improves through more reliable Docker cache hits and less work when the final image layer does need to rebuild. Audit every mutable local action and image source against an independent compiled snapshot. Open each source once for both its identity and its contents. Reject symbolic links, paths outside the checkout, non-files, and hard-link aliases across the complete reviewed source set. Reintroduce shared YAML anchors only after the complete matrix bridge is audited. Use one central list for the parser allowlist and matrix ownership checks. Require one exact build definition and one exact Miri alias of each of the four setup steps, in the reviewed sequences. Reject all other anchors and aliases, redefinition, reuse, and explicit tags. Preserve comment-looking data beneath every YAML block scalar when comparing exact steps, rather than only beneath run blocks. Run a trusted Git integrity gate after setup. Build a disposable index from the expected commit under an empty Git configuration. Mutable index flags, local attributes, and clean filters cannot hide changes. Reject any changed tracked, untracked, or ignored checkout path before invoking the typed executor. Audit privileged custom shells and explicit absolute Docker bridges. Require the fixed Bash entrypoint, no-startup privileged arguments, and Docker option terminator, with every run line treated as load-bearing. Tests: CARGO_NET_OFFLINE=true ./tools/cargo.sh test --locked -p zc Tests: warning-denied Clippy for all zc targets Tests: CARGO_NET_OFFLINE=true ./zerocopy/cargo.sh ci audit Tests: ./ci/check_actions.sh Tests: ./ci/check_fmt.sh Tests: git diff --check gherrit-pr-id: Gcl7ijadfh2m7eft4ucy5czoaghddreiq --- .gitattributes | 10 + .../setup-docker-with-retry/action.yml | 5 + .../actions/upload-file-artifact/action.yml | 5 + .github/ci-image/.dockerignore | 14 + .github/{workflows => ci-image}/Dockerfile | 35 +- .github/workflows/ci.yml | 202 +- tools/zc/src/ci.rs | 21 +- tools/zc/src/planned_adapter/image.rs | 552 +++++ tools/zc/src/planned_adapter/matrix.rs | 1951 +++++++++++++++++ tools/zc/src/planned_adapter/mod.rs | 143 +- tools/zc/src/planned_adapter/planner.rs | 26 +- .../zc/src/planned_adapter/reviewed_source.rs | 254 +++ tools/zc/src/planned_adapter/source.rs | 98 +- tools/zc/src/planned_adapter/test_support.rs | 70 + tools/zc/src/planned_adapter/yaml_source.rs | 41 +- tools/zc/src/repository_file.rs | 5 + tools/zc/src/workflow.rs | 12 + tools/zc/src/workflow_protocol.rs | 31 + tools/zc/testdata/ci-image.Dockerfile | 73 + tools/zc/testdata/ci-image.dockerignore | 14 + .../download-artifact-with-retry.action.yml | 106 + .../setup-docker-with-retry.action.yml | 107 + .../testdata/upload-file-artifact.action.yml | 99 + 23 files changed, 3755 insertions(+), 119 deletions(-) create mode 100644 .github/ci-image/.dockerignore rename .github/{workflows => ci-image}/Dockerfile (61%) create mode 100644 tools/zc/src/planned_adapter/image.rs create mode 100644 tools/zc/src/planned_adapter/matrix.rs create mode 100644 tools/zc/src/planned_adapter/reviewed_source.rs create mode 100644 tools/zc/src/planned_adapter/test_support.rs create mode 100644 tools/zc/testdata/ci-image.Dockerfile create mode 100644 tools/zc/testdata/ci-image.dockerignore create mode 100644 tools/zc/testdata/download-artifact-with-retry.action.yml create mode 100644 tools/zc/testdata/setup-docker-with-retry.action.yml create mode 100644 tools/zc/testdata/upload-file-artifact.action.yml diff --git a/.gitattributes b/.gitattributes index 7edea75741..54589d441d 100644 --- a/.gitattributes +++ b/.gitattributes @@ -12,3 +12,13 @@ githooks/pre-push text eol=lf *.yaml text eol=lf *.toml text eol=lf *.tsv text eol=lf + +# The Docker producer audit compares these extensionless sources byte-for-byte +# after the shared CRLF normalization boundary. Keep the live inputs and +# independent snapshots coordinated with +# `tools/zc/src/planned_adapter/image.rs` and the explicit attribute test in +# `tools/zc/src/ci.rs`. +.github/ci-image/.dockerignore text eol=lf +.github/ci-image/Dockerfile text eol=lf +tools/zc/testdata/ci-image.Dockerfile text eol=lf +tools/zc/testdata/ci-image.dockerignore text eol=lf diff --git a/.github/actions/setup-docker-with-retry/action.yml b/.github/actions/setup-docker-with-retry/action.yml index 21f1be74e9..b985c5b6e2 100644 --- a/.github/actions/setup-docker-with-retry/action.yml +++ b/.github/actions/setup-docker-with-retry/action.yml @@ -1,3 +1,8 @@ +# `build_docker_env` executes this mutable local action before producing the CI +# image. Keep this complete file coordinated with +# `tools/zc/testdata/setup-docker-with-retry.action.yml` and the source list in +# `tools/zc/src/planned_adapter/image.rs`; the typed adapter intentionally +# rejects a one-sided edit. name: Set up Docker with retry description: Set up Buildx and authenticate to a registry, retrying transient failures diff --git a/.github/actions/upload-file-artifact/action.yml b/.github/actions/upload-file-artifact/action.yml index 2741acda04..e54215218d 100644 --- a/.github/actions/upload-file-artifact/action.yml +++ b/.github/actions/upload-file-artifact/action.yml @@ -1,3 +1,8 @@ +# `build_docker_env` uses this mutable local action to publish the CI image. +# Keep this complete file coordinated with +# `tools/zc/testdata/upload-file-artifact.action.yml` and the source list in +# `tools/zc/src/planned_adapter/image.rs`. Other callers share the same exact +# implementation, so a behavior change requires deliberate adapter review. name: Upload file artifact description: Publish one exact file for jobs in this workflow run diff --git a/.github/ci-image/.dockerignore b/.github/ci-image/.dockerignore new file mode 100644 index 0000000000..7e5752089e --- /dev/null +++ b/.github/ci-image/.dockerignore @@ -0,0 +1,14 @@ +# Copyright 2026 The Fuchsia Authors +# +# Licensed under the 2-Clause BSD License , Apache License, Version 2.0 +# , or the MIT +# license , at your option. +# This file may not be copied, modified, or distributed except according to +# those terms. + +# The CI image must not receive repository files as build-context inputs. This +# isolated directory and its complete entry set are audited by +# `tools/zc/src/planned_adapter/image.rs`; Docker still receives its Dockerfile +# and this ignore file for the build, but neither is available to COPY. +* diff --git a/.github/workflows/Dockerfile b/.github/ci-image/Dockerfile similarity index 61% rename from .github/workflows/Dockerfile rename to .github/ci-image/Dockerfile index 1060ab8ec9..9dd0bd1f46 100644 --- a/.github/workflows/Dockerfile +++ b/.github/ci-image/Dockerfile @@ -6,6 +6,14 @@ # This file may not be copied, modified, or distributed except according to # those terms. +# `build_docker_env` builds this file as the runtime for typed matrix cells. +# This directory is the complete, isolated Docker context; the producer audit +# rejects any entry other than this file and `.dockerignore`. Keep the complete +# source coordinated with +# `tools/zc/testdata/ci-image.Dockerfile` and +# `tools/zc/src/planned_adapter/image.rs`; the producer audit rejects a +# one-sided change before matrix fan-out. + FROM ubuntu:24.04 # These are the same bounded, download-only retry counts configured in @@ -42,19 +50,24 @@ RUN cargo install cargo-nextest --locked && \ cargo install --locked action-validator --version 0.8.0 && \ rm -rf /root/.cargo/registry /root/.cargo/git -WORKDIR /setup - -COPY zerocopy/Cargo.toml ./zerocopy/Cargo.toml -COPY zerocopy/cargo.sh ./zerocopy/cargo.sh -COPY tools ./tools - -ENV CARGO_ZEROCOPY_AUTO_INSTALL_TOOLCHAIN=1 -WORKDIR /setup/zerocopy -RUN ./cargo.sh +stable --version && \ - ./cargo.sh +nightly --version && \ - ./cargo.sh +msrv --version && \ +# Install the three high-traffic toolchains without executing code from the +# checkout. The build previously copied and ran cargo-zerocopy, which made the +# image depend on the entire mutable `tools` tree and allowed a change there to +# replace Cargo before matrix execution. `planned_adapter/image.rs` checks +# these defaults against the validated toolchain inventory, so changing a pin +# in `zerocopy/Cargo.toml` fails CI until this cache seed is updated too. +ARG ZC_MSRV_TOOLCHAIN=1.56.0 +ARG ZC_STABLE_TOOLCHAIN=1.93.1 +ARG ZC_NIGHTLY_TOOLCHAIN=nightly-2026-01-25 +RUN rustup toolchain install "$ZC_MSRV_TOOLCHAIN" \ + -c rust-src -c rustfmt -c clippy && \ + rustup toolchain install "$ZC_STABLE_TOOLCHAIN" \ + -c rust-src -c rustfmt -c clippy && \ + rustup toolchain install "$ZC_NIGHTLY_TOOLCHAIN" \ + -c rust-src -c rustfmt -c clippy -c miri && \ # Remove large intermediate artifacts to ensure that this step doesn't bloat # the Docker image cache. rm -rf /root/.cargo/registry /root/.cargo/git /root/.rustup/toolchains/*/share/doc +ENV CARGO_ZEROCOPY_AUTO_INSTALL_TOOLCHAIN=1 WORKDIR /workspace diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 011442293b..58808b22ff 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -139,7 +139,8 @@ jobs: # By default, this is set to `true`, which means that a single CI job # failure will cause all outstanding jobs to be canceled. This slows down # development because it means that errors need to be encountered and - # fixed one at a time. + # fixed one at a time. The matrix audit also rejects `max-parallel` so + # this fan-out cannot be silently serialized. fail-fast: false # `plan_ci` emits a complete `include` object, including an empty one if # no cells are selected. Do not add handwritten axes or exclusions here: @@ -150,7 +151,7 @@ jobs: name: Build & Test (${{ matrix.crate }} / ${{ matrix.toolchain }} / ${{ matrix.feature_profile }} / ${{ matrix.target }}) steps: - - + - &matrix_checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: # `Prepare cargo-semver-checks` reads the pull request head commit by @@ -164,7 +165,7 @@ jobs: # artifact name. The ID identifies one immutable artifact from this exact # run, so a renamed, stale, or Buildx-generated artifact cannot be selected # accidentally. Checkout must remain first because this is a local action. - - + - &download_ci_image name: Download prebuilt Docker image uses: ./.github/actions/download-artifact-with-retry with: @@ -177,33 +178,36 @@ jobs: # producer and every consumer intentionally use the same ubuntu-latest # runner architecture. If CI gains another architecture, build and select a # distinct artifact ID for it rather than silently sharing this archive. - - + - &load_ci_image name: Load prebuilt Docker image shell: bash env: IMAGE_ARCHIVE: ${{ runner.temp }}/${{ env.ZC_CI_IMAGE_ARCHIVE }} IMAGE_NAME: ${{ env.ZC_CI_IMAGE }} + # Keep the large archive only as long as `docker load` needs it. The trap + # also reclaims it if loading or validation fails, avoiding archive plus + # expanded-image disk pressure for later diagnostic steps. `inspect` + # proves the tag exists; the trivial run also proves that the archive + # matches this runner's architecture. run: | set -euo pipefail - # Keep the large archive only as long as `docker load` needs it. The - # trap also reclaims it if loading or validation fails, avoiding archive - # plus expanded-image disk pressure for later diagnostic steps. trap 'rm -f -- "$IMAGE_ARCHIVE"' EXIT docker load --input "$IMAGE_ARCHIVE" docker image inspect "$IMAGE_NAME" >/dev/null - # `inspect` proves the tag exists; running a trivial command also proves - # that the archive matches this runner's architecture. docker run --rm "$IMAGE_NAME" true - - - name: Create Docker Shell Wrapper + # This wrapper remains necessary for the semver preparation later in this + # job. The typed executor below deliberately does not use it. The matrix + # audit checks this complete step because it runs before that executor and + # therefore must not acquire any unreviewed authority over the checkout. + - name: Create Docker Shell Wrapper shell: bash + # Keep the Docker steps' Cargo cache separate from the host Cargo home. + # The container runs as root, while later host-side actions run as the + # runner user and need to write their own Cargo registry cache. run: | set -eo pipefail - # Keep the Docker steps' Cargo cache separate from the host Cargo home. - # The container runs as root, while later host-side actions run as the - # runner user and need to write their own Cargo registry cache. mkdir -p /home/runner/.docker-cargo/registry /home/runner/.docker-cargo/git cat << 'EOF' > /tmp/docker-shell.sh @@ -228,6 +232,92 @@ jobs: EOF chmod +x /tmp/docker-shell.sh + # Setup includes repository-owned code, so checking only the executor's + # source would not prove which checkout that executor invokes. Run this + # exact host-side gate after every setup step and share it with Miri below. + # It checks the expected commit with an empty environment, then constructs + # fresh Git metadata from that commit so setup cannot hide a change in the + # checkout's mutable index, local config, or attributes. It rejects every + # tracked, untracked, or ignored path. Keep this step and its alias + # coordinated with `tools/zc/src/planned_adapter/matrix.rs`. + - &verify_matrix_checkout + name: Verify matrix checkout is unchanged + shell: /usr/bin/env -u BASH_ENV -u ENV -u SHELLOPTS -u BASHOPTS /bin/bash --noprofile --norc -p -euo pipefail -- {0} + env: + EXPECTED_COMMIT: ${{ github.sha }} + run: | + set -euo pipefail + builtin cd -- "$GITHUB_WORKSPACE" + readonly -a source_git=( + /usr/bin/env -i + GIT_CONFIG_GLOBAL=/dev/null + GIT_CONFIG_NOSYSTEM=1 + GIT_NO_REPLACE_OBJECTS=1 + HOME=/dev/null + PATH=/usr/bin:/bin + /usr/bin/git + "--git-dir=$GITHUB_WORKSPACE/.git" + "--work-tree=$GITHUB_WORKSPACE" + ) + + actual_commit="$("${source_git[@]}" rev-parse --verify HEAD^{commit})" + if [[ "$actual_commit" != "$EXPECTED_COMMIT" ]]; then + printf 'Expected checkout commit %s, found %s\n' \ + "$EXPECTED_COMMIT" "$actual_commit" >&2 + exit 1 + fi + + # Do not trust the checkout's mutable index, local Git config, or + # attributes. Build a temporary repository whose object store is the + # checkout's content-addressed store, whose index comes from the + # expected commit, and whose attributes also come from that commit. + verification_directory="$( + /usr/bin/mktemp -d "$RUNNER_TEMP/matrix-checkout.XXXXXX" + )" + readonly verification_directory + trap '/usr/bin/rm -rf -- "$verification_directory"' EXIT + /usr/bin/env -i \ + GIT_CONFIG_GLOBAL=/dev/null \ + GIT_CONFIG_NOSYSTEM=1 \ + HOME=/dev/null \ + PATH=/usr/bin:/bin \ + /usr/bin/git init --quiet --initial-branch=verified \ + "$verification_directory/repository" + + readonly -a trusted_git=( + /usr/bin/env -i + GIT_ATTR_NOSYSTEM=1 + "GIT_ATTR_SOURCE=$EXPECTED_COMMIT" + GIT_CONFIG_GLOBAL=/dev/null + GIT_CONFIG_NOSYSTEM=1 + "GIT_INDEX_FILE=$verification_directory/index" + GIT_NO_REPLACE_OBJECTS=1 + "GIT_OBJECT_DIRECTORY=$GITHUB_WORKSPACE/.git/objects" + HOME=/dev/null + PATH=/usr/bin:/bin + /usr/bin/git + "--git-dir=$verification_directory/repository/.git" + "--work-tree=$GITHUB_WORKSPACE" + -c core.filemode=true + -c core.fsmonitor=false + -c core.ignoreCase=false + -c core.sparseCheckout=false + -c core.symlinks=true + -c core.untrackedCache=false + ) + "${trusted_git[@]}" update-ref HEAD "$EXPECTED_COMMIT" + "${trusted_git[@]}" read-tree "$EXPECTED_COMMIT" + checkout_status="$( + "${trusted_git[@]}" status \ + --porcelain=v1 --untracked-files=all --ignored=matching -- \ + . ':(exclude).git' + )" + if [[ -n "$checkout_status" ]]; then + printf 'Matrix setup modified the checkout:\n%s\n' \ + "$checkout_status" >&2 + exit 1 + fi + # The matrix values are data, not shell fragments. `cargo-zerocopy` # validates these selectors against the plan for this event, reconstructs # the typed argv and environment, and executes the complete cell. This step @@ -238,8 +328,10 @@ jobs: # returning success without starting a container. The entrypoint override # and Bash startup options prevent image startup behavior or exported # functions from intercepting the typed executor argv. The option - # terminator forces the inherited image value to be parsed as an image. - # Keep this command and its options coordinated with `tools/zc/src/cli.rs`. + # terminator forces the inherited image value to be parsed as an image. The + # planned-job workflow audit in `planned_adapter/matrix.rs` checks the step + # fields exactly and this run block line-for-line. Keep protocol spellings + # coordinated through `tools/zc/src/workflow_protocol.rs`. - name: Execute checked build cell shell: /usr/bin/env -u BASH_ENV -u ENV -u SHELLOPTS -u BASHOPTS /bin/bash --noprofile --norc -p -euo pipefail -- {0} working-directory: zerocopy @@ -382,59 +474,21 @@ jobs: permissions: contents: read strategy: + # The matrix audit requires exactly this concurrency setting and the + # matching plan output; extra strategy fields are rejected. fail-fast: false # A planner-disabled job skips before matrix expansion. When enabled, it # consumes exactly the Miri cells selected by the same checked plan used # by the executor below. matrix: ${{ fromJSON(needs.plan_ci.outputs.miri_matrix) }} - name: Miri (${{ matrix.crate }} / ${{ matrix.feature_profile }} / ${{ matrix.miri_model }} / ${{ matrix.target }}) + name: Miri (${{ matrix.crate }} / ${{ matrix.toolchain }} / ${{ matrix.feature_profile }} / ${{ matrix.miri_model }} / ${{ matrix.target }}) steps: - - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - # `Prepare cargo-semver-checks` reads the pull request head commit by - # its explicit SHA. Keep this depth large enough to include that parent - # of GitHub's synthetic pull request merge commit. A missing object - # makes `git log` fail rather than checking a different message. - fetch-depth: 2 - persist-credentials: false - - # The producer exposes the ID assigned by upload-artifact, rather than an - # artifact name. The ID identifies one immutable artifact from this exact - # run, so a renamed, stale, or Buildx-generated artifact cannot be selected - # accidentally. Checkout must remain first because this is a local action. - - - name: Download prebuilt Docker image - uses: ./.github/actions/download-artifact-with-retry - with: - artifact-id: ${{ needs.build_docker_env.outputs.image_artifact_id }} - path: ${{ runner.temp }} - expected-file: ${{ env.ZC_CI_IMAGE_ARCHIVE }} - - # This step runs before /tmp/docker-shell.sh exists, so its explicit Bash - # shell is load-bearing. The Docker exporter is single-platform; the - # producer and every consumer intentionally use the same ubuntu-latest - # runner architecture. If CI gains another architecture, build and select a - # distinct artifact ID for it rather than silently sharing this archive. - - - name: Load prebuilt Docker image - shell: bash - env: - IMAGE_ARCHIVE: ${{ runner.temp }}/${{ env.ZC_CI_IMAGE_ARCHIVE }} - IMAGE_NAME: ${{ env.ZC_CI_IMAGE }} - run: | - set -euo pipefail - # Keep the large archive only as long as `docker load` needs it. The - # trap also reclaims it if loading or validation fails, avoiding archive - # plus expanded-image disk pressure for later diagnostic steps. - trap 'rm -f -- "$IMAGE_ARCHIVE"' EXIT - docker load --input "$IMAGE_ARCHIVE" - docker image inspect "$IMAGE_NAME" >/dev/null - # `inspect` proves the tag exists; running a trivial command also proves - # that the archive matches this runner's architecture. - docker run --rm "$IMAGE_NAME" true + - *matrix_checkout + - *download_ci_image + - *load_ci_image + - *verify_matrix_checkout # As with ordinary cells, the workflow passes only selectors. Model flags, # feature arguments, the Cargo configuration transaction, and target @@ -442,8 +496,9 @@ jobs: # This explicit Docker bridge does not rely on the generated job shell # actually executing its input. Its absolute Docker path, entrypoint # override, Bash startup options, and option terminator provide the same - # fail-closed boundary as the ordinary build bridge. Keep the separate - # Miri-model selector coordinated with `tools/zc/src/cli.rs`. + # fail-closed boundary as the ordinary build bridge. The matrix audit + # checks the fields exactly and this run block line-for-line. Keep the + # Miri-model and command spellings in `workflow_protocol.rs` coordinated. - name: Execute checked Miri cell shell: /usr/bin/env -u BASH_ENV -u ENV -u SHELLOPTS -u BASHOPTS /bin/bash --noprofile --norc -p -euo pipefail -- {0} working-directory: zerocopy @@ -807,6 +862,11 @@ jobs: # the action only does if advanced-security is false. advanced-security: false + # `tools/zc/src/planned_adapter/image.rs` audits this complete producer job. + # Its build, export, upload, and local-action choices are coupled to that + # Rust contract and to the independent sources under `tools/zc/testdata`. + # Update all coordinated copies deliberately when changing image production; + # otherwise `zc ci check` fails before a matrix can consume the artifact. build_docker_env: name: Build Docker image runs-on: ubuntu-latest @@ -817,13 +877,17 @@ jobs: image_artifact_id: ${{ steps.upload_image.outputs.artifact-id }} permissions: contents: read - packages: write # required to push docker caches to ghcr.io + # Required to push Docker caches to GHCR. The exact permission map is + # part of the producer audit in `planned_adapter/image.rs`. + packages: write steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - name: Set up Docker + # The complete local action is coordinated with `image.rs` and + # `tools/zc/testdata/setup-docker-with-retry.action.yml`. uses: ./.github/actions/setup-docker-with-retry with: registry: ghcr.io @@ -841,8 +905,12 @@ jobs: - name: Build, cache, and export image uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 with: - context: . - file: .github/workflows/Dockerfile + # This directory contains only the audited Dockerfile and an audited + # `.dockerignore` which excludes every context file. Keep the path + # coordinated with `planned_adapter/image.rs`; adding any directory + # entry fails `zc ci check`. + context: .github/ci-image + file: .github/ci-image/Dockerfile tags: ${{ env.ZC_CI_IMAGE }} provenance: false # The Docker exporter creates a single archive accepted directly by @@ -862,6 +930,8 @@ jobs: # on an image that was only partially published. - name: Upload image for matrix jobs id: upload_image + # The complete local action is coordinated with `image.rs` and + # `tools/zc/testdata/upload-file-artifact.action.yml`. uses: ./.github/actions/upload-file-artifact with: name: ${{ env.ZC_CI_IMAGE_ARCHIVE }} diff --git a/tools/zc/src/ci.rs b/tools/zc/src/ci.rs index d535a4d443..c2de3eeeb3 100644 --- a/tools/zc/src/ci.rs +++ b/tools/zc/src/ci.rs @@ -11,7 +11,7 @@ //! Loading CI inputs is intentionally all-or-nothing. A caller cannot obtain a //! [`CiInputs`] until the policy is valid, its references agree with live Cargo //! metadata and repository files, every workflow job has an exact reviewed -//! role, the handwritten plan publisher exactly exposes typed outputs, the +//! role, the handwritten matrix jobs exactly publish and consume typed plans, //! independently recorded legacy baseline parses canonically, and the typed //! execution model exactly reproduces that legacy evidence. Planners //! therefore consume checked data rather than remembering which validation @@ -95,16 +95,19 @@ impl CiInputs { let (workflow_jobs, workflow_sources) = audit_workflows(&repository_root, reviewed_workflow_jobs) .map_err(|error| LoadCiError::Workflow(Box::new(error)))?; - // Job-ID inventory cannot prove that the producer publishes the exact - // typed outputs through a real command. Audit that small planned-job - // workflow bridge using the exact bytes retained by the inventory - // pass, rather than reopening a possibly replaced path. + // Job-ID inventory cannot prove that a planned job publishes or + // consumes its typed matrix through the complete checked CLI. Audit + // that bridge using the exact bytes retained by the inventory pass, + // rather than reopening a possibly replaced path. The image producer + // also consumes validated inventory so its preinstalled compiler pins + // cannot drift from the toolchains selected by the typed plan. let workflow_source = workflow_sources.source(WORKFLOW_PATH).ok_or_else(|| { LoadCiError::RequiredWorkflowMissing { path: WORKFLOW_PATH.to_owned() } })?; - audit_planned_adapter(workflow_source).map_err(LoadCiError::PlannedAdapter)?; let repository = RepositoryInventory::audit(&repository_root, &policy) .map_err(LoadCiError::Inventory)?; + audit_planned_adapter(&repository_root, workflow_source, &workflow_jobs, &repository) + .map_err(LoadCiError::PlannedAdapter)?; let baseline_files = OpenLegacyBaselineFiles::open(&repository_root, policy.baselines())?; let paths = baseline_files.paths(); // Policy validation rejects two fields with the same lexical path. @@ -348,7 +351,7 @@ pub enum LoadCiError { /// A behavioral audit expected a workflow absent from the checked tree. #[error("required CI workflow `{path}` was not discovered")] RequiredWorkflowMissing { path: String }, - /// The planned-job workflow bridge did not publish typed outputs exactly. + /// The planned-job workflow bridge did not publish or execute plans exactly. #[error(transparent)] PlannedAdapter(PlannedAdapterAuditError), /// The frozen legacy evidence was unreadable or noncanonical. @@ -422,11 +425,15 @@ mod tests { let paths = [ "tools/toolchain.sh", "githooks/pre-push", + ".github/ci-image/.dockerignore", + ".github/ci-image/Dockerfile", ".github/workflows/ci.yml", "ci/future-input.yaml", "ci/zc.toml", "ci/workflow-jobs.tsv", "ci/baselines/command-goldens.tsv", + "tools/zc/testdata/ci-image.Dockerfile", + "tools/zc/testdata/ci-image.dockerignore", "zerocopy/Cargo.toml", ]; let output = Command::new("git") diff --git a/tools/zc/src/planned_adapter/image.rs b/tools/zc/src/planned_adapter/image.rs new file mode 100644 index 0000000000..56e1e12998 --- /dev/null +++ b/tools/zc/src/planned_adapter/image.rs @@ -0,0 +1,552 @@ +// Copyright 2026 The Fuchsia Authors +// +// Licensed under a BSD-style license , Apache License, Version 2.0 +// , or the MIT +// license , at your option. +// This file may not be copied, modified, or distributed except according to +// those terms. + +//! Exact production audit for the Docker image trusted by typed executors. +//! +//! Matrix consumers already prove which artifact they download and how they +//! invoke the loaded image. That does not establish that `build_docker_env` +//! built the reviewed Dockerfile or uploaded its export. This module closes +//! that upstream boundary: the producer job, its five steps, both mutable local +//! actions, Dockerfile, and build-context ignore file are all exact contracts. +//! +//! This is a repository-source proof, not cryptographic attestation against a +//! hostile checkout or mutable external registry content. A future design can +//! obtain that stronger property by publishing an image from trusted main and +//! consuming it by digest. Until then, failing on any unreviewed producer edit +//! prevents ordinary CI maintenance from silently replacing typed execution +//! with an arbitrary or no-op image. + +use std::{ + collections::{BTreeMap, BTreeSet}, + fs, + path::Path, +}; + +use super::{ + reviewed_source::ReviewedSource, + source::{ + audit_exact_job_fields, audit_exact_mapping, audit_exact_scalar_field, + audit_host_job_contract, audited_steps_block, exact_step_lines, find_job, + job_field_location, job_fields, MappingExpectation, + }, + PlannedAdapterAuditError, ViolationSink, +}; +use crate::workflow_protocol::{ + image_artifact_producer_expression, IMAGE_ARTIFACT_OUTPUT, IMAGE_JOB, IMAGE_UPLOAD_STEP_ID, +}; + +const JOB_FIELDS: &[&str] = &["name", "runs-on", "outputs", "permissions", "steps"]; +const JOB_NAME: &str = "Build Docker image"; + +const SETUP_ACTION_PATH: &str = ".github/actions/setup-docker-with-retry/action.yml"; +const SETUP_ACTION_SNAPSHOT_PATH: &str = "tools/zc/testdata/setup-docker-with-retry.action.yml"; +const SETUP_ACTION_EXPECTED: &str = + include_str!("../../testdata/setup-docker-with-retry.action.yml"); +const UPLOAD_ACTION_PATH: &str = ".github/actions/upload-file-artifact/action.yml"; +const UPLOAD_ACTION_SNAPSHOT_PATH: &str = "tools/zc/testdata/upload-file-artifact.action.yml"; +const UPLOAD_ACTION_EXPECTED: &str = include_str!("../../testdata/upload-file-artifact.action.yml"); +const IMAGE_CONTEXT_PATH: &str = ".github/ci-image"; +const DOCKERFILE_PATH: &str = ".github/ci-image/Dockerfile"; +const DOCKERFILE_SNAPSHOT_PATH: &str = "tools/zc/testdata/ci-image.Dockerfile"; +const DOCKERFILE_EXPECTED: &str = include_str!("../../testdata/ci-image.Dockerfile"); +const DOCKERIGNORE_PATH: &str = ".github/ci-image/.dockerignore"; +const DOCKERIGNORE_SNAPSHOT_PATH: &str = "tools/zc/testdata/ci-image.dockerignore"; +const DOCKERIGNORE_EXPECTED: &str = include_str!("../../testdata/ci-image.dockerignore"); +const IMAGE_CONTEXT_ENTRIES: &[&str] = &[".dockerignore", "Dockerfile"]; +const TOOLCHAIN_ARGUMENTS: &[(&str, &str)] = &[ + ("msrv", "ZC_MSRV_TOOLCHAIN"), + ("stable", "ZC_STABLE_TOOLCHAIN"), + ("nightly", "ZC_NIGHTLY_TOOLCHAIN"), +]; + +const REVIEWED_SOURCES: &[ReviewedSource] = &[ + ReviewedSource { + live_path: SETUP_ACTION_PATH, + snapshot_path: SETUP_ACTION_SNAPSHOT_PATH, + expected: SETUP_ACTION_EXPECTED, + }, + ReviewedSource { + live_path: UPLOAD_ACTION_PATH, + snapshot_path: UPLOAD_ACTION_SNAPSHOT_PATH, + expected: UPLOAD_ACTION_EXPECTED, + }, + ReviewedSource { + live_path: DOCKERFILE_PATH, + snapshot_path: DOCKERFILE_SNAPSHOT_PATH, + expected: DOCKERFILE_EXPECTED, + }, + ReviewedSource { + live_path: DOCKERIGNORE_PATH, + snapshot_path: DOCKERIGNORE_SNAPSHOT_PATH, + expected: DOCKERIGNORE_EXPECTED, + }, +]; + +const CHECKOUT_STEP: &[&str] = &[ + " - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1", + " with:", + " persist-credentials: false", +]; +const SETUP_STEP: &[&str] = &[ + " - name: Set up Docker", + " uses: ./.github/actions/setup-docker-with-retry", + " with:", + " registry: ghcr.io", + " username: ${{ github.actor }}", + " password: ${{ secrets.GITHUB_TOKEN }}", +]; +const TAG_STEP: &[&str] = &[ + " - name: Generate sanitized Docker tag", + " id: docker_tag", + " env:", + " REF_NAME: ${{ github.ref_name }}", + " shell: bash", + " run: |", + r#" echo "tag=${REF_NAME//\//-}" >> "$GITHUB_OUTPUT""#, +]; +const BUILD_STEP: &[&str] = &[ + " - name: Build, cache, and export image", + " uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0", + " with:", + " context: .github/ci-image", + " file: .github/ci-image/Dockerfile", + " tags: ${{ env.ZC_CI_IMAGE }}", + " provenance: false", + " outputs: type=docker,dest=${{ runner.temp }}/${{ env.ZC_CI_IMAGE_ARCHIVE }},compression=gzip", + " cache-from: |", + " type=registry,ref=ghcr.io/google/zerocopy/zerocopy-ci-cache:${{ steps.docker_tag.outputs.tag }}", + " type=registry,ref=ghcr.io/google/zerocopy/zerocopy-ci-cache:main", + " cache-to: ${{ (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) && format('type=registry,ref=ghcr.io/google/zerocopy/zerocopy-ci-cache:{0},mode=max', steps.docker_tag.outputs.tag) || '' }}", +]; +fn expected_steps() -> Vec> { + let owned = |step: &[&str]| step.iter().map(|line| (*line).to_owned()).collect(); + let upload = vec![ + " - name: Upload image for matrix jobs".to_owned(), + format!(" id: {IMAGE_UPLOAD_STEP_ID}"), + " uses: ./.github/actions/upload-file-artifact".to_owned(), + " with:".to_owned(), + " name: ${{ env.ZC_CI_IMAGE_ARCHIVE }}".to_owned(), + " path: ${{ runner.temp }}/${{ env.ZC_CI_IMAGE_ARCHIVE }}".to_owned(), + ]; + vec![owned(CHECKOUT_STEP), owned(SETUP_STEP), owned(TAG_STEP), owned(BUILD_STEP), upload] +} + +pub(super) fn reviewed_sources() -> &'static [ReviewedSource] { + REVIEWED_SOURCES +} + +/// Requires the image's cache seeds to follow the validated compiler pins. +/// +/// The Dockerfile intentionally contains no `COPY` instruction and executes +/// no checkout code. Its three argument defaults are therefore the complete +/// repository-derived input to toolchain installation. Keeping the defaults +/// here, rather than passing mutable build arguments in the workflow, leaves +/// the exact producer audit in control of which values reach rustup. Comparing +/// them with inventory makes a manifest or policy change fail closed instead +/// of merely turning a preinstalled compiler into a cache miss. +pub(super) fn audit_toolchain_defaults( + toolchains: &BTreeMap, + errors: &mut ViolationSink, +) { + for &(toolchain, argument) in TOOLCHAIN_ARGUMENTS { + let Some(version) = toolchains.get(toolchain) else { + errors.push( + format!("{DOCKERFILE_PATH}.ARG.{argument}"), + format!("image cache requires validated `{toolchain}` toolchain inventory"), + ); + continue; + }; + let prefix = format!("ARG {argument}="); + let declarations = DOCKERFILE_EXPECTED + .lines() + .filter(|line| line.starts_with(&prefix)) + .collect::>(); + let expected = format!("{prefix}{version}"); + if declarations.as_slice() != [expected.as_str()] { + errors.push( + format!("{DOCKERFILE_PATH}.ARG.{argument}"), + format!( + "image cache must declare exactly `{expected}` for validated toolchain `{toolchain}`, found {declarations:?}" + ), + ); + } + } +} + +pub(super) fn audit(lines: &[&str], errors: &mut ViolationSink) { + let Some(job) = find_job(lines, IMAGE_JOB, errors) else { + return; + }; + let fields = job_fields(lines, job.clone(), IMAGE_JOB, errors); + audit_exact_job_fields(&fields, IMAGE_JOB, JOB_FIELDS, errors); + audit_exact_scalar_field(&fields, IMAGE_JOB, "name", JOB_NAME, errors); + audit_host_job_contract(&fields, IMAGE_JOB, errors); + + let outputs = + BTreeMap::from([(IMAGE_ARTIFACT_OUTPUT.to_owned(), image_artifact_producer_expression())]); + audit_exact_mapping( + lines, + job.end, + &fields, + MappingExpectation { job: IMAGE_JOB, field: "outputs", values: &outputs }, + errors, + ); + let permissions = BTreeMap::from([ + ("contents".to_owned(), "read".to_owned()), + ("packages".to_owned(), "write".to_owned()), + ]); + audit_exact_mapping( + lines, + job.end, + &fields, + MappingExpectation { job: IMAGE_JOB, field: "permissions", values: &permissions }, + errors, + ); + + if let Some(steps) = audited_steps_block(&fields, job, IMAGE_JOB, 6, errors) { + let actual = exact_step_lines(lines, &steps); + let expected_steps = expected_steps(); + if actual.len() != expected_steps.len() { + errors.push( + job_field_location(IMAGE_JOB, "steps"), + format!( + "image producer must contain exactly {} steps, found {}", + expected_steps.len(), + actual.len() + ), + ); + } + for (index, expected) in expected_steps.iter().enumerate() { + let matches = actual.get(index).is_some_and(|actual| { + actual.iter().copied().eq(expected.iter().map(String::as_str)) + }); + if !matches { + errors.push( + job_field_location(IMAGE_JOB, "steps"), + format!( + "image producer step {} must match the exact canonical contract {:?}", + index + 1, + expected + ), + ); + } + } + } +} + +/// Requires the Docker build context to contain only its two reviewed files. +/// +/// This is a structural authority boundary rather than a blacklist of current +/// Dockerfile instructions. `COPY`, `ADD`, a BuildKit context bind, or an +/// `ONBUILD` trigger in a future base image cannot reach the repository when +/// no other repository file enters the context. The exact `.dockerignore` +/// independently excludes every path; the directory inventory makes adding a +/// candidate input fail before a coordinated Rust review expands the boundary. +pub(super) fn audit_context_shape(repository_root: &Path) -> Result<(), PlannedAdapterAuditError> { + let path = repository_root.join(IMAGE_CONTEXT_PATH); + let metadata = fs::symlink_metadata(&path).map_err(|source| { + PlannedAdapterAuditError::InspectReviewedSource { path: path.clone(), source } + })?; + if !metadata.is_dir() { + let mut errors = ViolationSink::default(); + errors.push(IMAGE_CONTEXT_PATH, "image context must be one ordinary directory"); + return Err(PlannedAdapterAuditError::Invalid(errors.finish())); + } + + let entries = fs::read_dir(&path) + .map_err(|source| PlannedAdapterAuditError::InspectReviewedSource { + path: path.clone(), + source, + })? + .map(|entry| { + entry.map(|entry| entry.file_name()).map_err(|source| { + PlannedAdapterAuditError::InspectReviewedSource { path: path.clone(), source } + }) + }) + .collect::, _>>()?; + let expected = + IMAGE_CONTEXT_ENTRIES.iter().map(|entry| (*entry).into()).collect::>(); + if entries == expected { + return Ok(()); + } + + let display = entries.iter().map(|entry| entry.to_string_lossy()).collect::>(); + let mut errors = ViolationSink::default(); + errors.push( + IMAGE_CONTEXT_PATH, + format!("image context must contain exactly {IMAGE_CONTEXT_ENTRIES:?}, found {display:?}"), + ); + Err(PlannedAdapterAuditError::Invalid(errors.finish())) +} + +#[cfg(test)] +mod tests { + use std::{ + collections::BTreeMap, + fs, + path::Path, + sync::atomic::{AtomicU64, Ordering}, + }; + + use super::{ + audit, audit_context_shape, audit_toolchain_defaults, reviewed_sources, + IMAGE_CONTEXT_ENTRIES, IMAGE_CONTEXT_PATH, + }; + use crate::{ + planned_adapter::{ + reviewed_source::audit_exact_source, + test_support::{assert_rejected, audit_feature, replace_in_job}, + ViolationSink, + }, + workflow_protocol::IMAGE_JOB, + }; + + const LIVE_WORKFLOW: &str = include_str!("../../../../.github/workflows/ci.yml"); + + fn audit_image(source: &str) -> Result<(), super::super::PlannedAdapterViolations> { + audit_feature(source, audit) + } + + fn rejected(label: &str, source: &str, expected: &str) { + assert_rejected(label, audit_image(source), expected); + } + + fn replace(from: &str, to: &str) -> String { + replace_in_job(LIVE_WORKFLOW, IMAGE_JOB, from, to) + } + + #[test] + fn accepts_the_live_image_producer() { + audit_image(LIVE_WORKFLOW).unwrap(); + } + + #[test] + fn producer_job_shape_outputs_and_permissions_are_exact() { + for (label, source, expected) in [ + ( + "job name", + replace(" name: Build Docker image", " name: Build something else"), + ".name", + ), + ( + "runner", + replace(" runs-on: ubuntu-latest", " runs-on: self-hosted"), + ".runs-on", + ), + ( + "condition", + replace( + " name: Build Docker image\n", + " name: Build Docker image\n if: always()\n", + ), + ".if", + ), + ( + "output producer", + replace( + " image_artifact_id: ${{ steps.upload_image.outputs.artifact-id }}", + " image_artifact_id: ${{ steps.other.outputs.artifact-id }}", + ), + ".outputs.image_artifact_id", + ), + ( + "extra output", + replace( + " image_artifact_id: ${{ steps.upload_image.outputs.artifact-id }}", + " image_artifact_id: ${{ steps.upload_image.outputs.artifact-id }}\n other: value", + ), + ".outputs.other", + ), + ( + "package permission", + replace(" packages: write", " packages: read"), + ".permissions.packages", + ), + ( + "extra permission", + replace(" contents: read", " actions: write\n contents: read"), + ".permissions.actions", + ), + ] { + rejected(label, &source, expected); + } + } + + #[test] + fn producer_build_export_and_upload_steps_are_exact() { + let mutations = [ + ( + "checkout pin", + "actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1", + "actions/checkout@0000000000000000000000000000000000000000", + ), + ( + "setup action", + "uses: ./.github/actions/setup-docker-with-retry", + "uses: ./.github/actions/other-setup", + ), + ("setup registry", "registry: ghcr.io", "registry: example.invalid"), + ( + "tag command", + "echo \"tag=${REF_NAME//\\//-}\" >> \"$GITHUB_OUTPUT\"", + "echo tag=other >> \"$GITHUB_OUTPUT\"", + ), + ( + "builder pin", + "docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a", + "docker/build-push-action@0000000000000000000000000000000000000000", + ), + ( + "build context", + "context: .github/ci-image", + "context: unexpected", + ), + ( + "Dockerfile", + "file: .github/ci-image/Dockerfile", + "file: unexpected.Dockerfile", + ), + ("image tag", "tags: ${{ env.ZC_CI_IMAGE }}", "tags: other:latest"), + ("provenance", "provenance: false", "provenance: true"), + ( + "export path", + "outputs: type=docker,dest=${{ runner.temp }}/${{ env.ZC_CI_IMAGE_ARCHIVE }},compression=gzip", + "outputs: type=docker,dest=/tmp/other.tar", + ), + ( + "cache source", + "type=registry,ref=ghcr.io/google/zerocopy/zerocopy-ci-cache:main", + "type=local,src=/tmp/cache", + ), + ( + "upload action", + "uses: ./.github/actions/upload-file-artifact", + "uses: ./.github/actions/other-upload", + ), + ("upload ID", "id: upload_image", "id: other"), + ( + "upload path", + "path: ${{ runner.temp }}/${{ env.ZC_CI_IMAGE_ARCHIVE }}", + "path: /tmp/other.tar", + ), + ]; + for (label, from, to) in mutations { + rejected(label, &replace(from, to), ".steps"); + } + + let extra = replace( + " steps:\n - uses: actions/checkout@", + " steps:\n - run: echo unexpected\n - uses: actions/checkout@", + ); + rejected("extra step", &extra, "exactly 5 steps"); + + // YAML permits this alternate sequence spelling. The shared source + // scanner must expose it as an item boundary rather than hiding it + // before the exact producer comparison. + let bare_item = replace( + " steps:\n - uses: actions/checkout@", + " steps:\n -\n run: echo unexpected\n - uses: actions/checkout@", + ); + rejected("bare sequence item", &bare_item, "exactly 5 steps"); + + // This line starts like a YAML comment, but inside `cache-from: |` it + // is literal action input. The shared scanner must not discard it. + let scalar_data = replace( + " type=registry,ref=ghcr.io/google/zerocopy/zerocopy-ci-cache:main", + " type=registry,ref=ghcr.io/google/zerocopy/zerocopy-ci-cache:main\n # ${{ github.token }}", + ); + rejected("comment-looking block scalar data", &scalar_data, ".steps"); + } + + #[test] + fn image_toolchain_defaults_match_validated_inventory() { + let matching = BTreeMap::from([ + ("msrv".to_owned(), "1.56.0".to_owned()), + ("stable".to_owned(), "1.93.1".to_owned()), + ("nightly".to_owned(), "nightly-2026-01-25".to_owned()), + ]); + let mut errors = ViolationSink::default(); + audit_toolchain_defaults(&matching, &mut errors); + assert!(errors.is_empty()); + + for (label, inventory, expected) in [ + ( + "changed pin", + BTreeMap::from([ + ("msrv".to_owned(), "1.56.0".to_owned()), + ("stable".to_owned(), "1.94.0".to_owned()), + ("nightly".to_owned(), "nightly-2026-01-25".to_owned()), + ]), + "ARG ZC_STABLE_TOOLCHAIN=1.94.0", + ), + ( + "missing semantic toolchain", + BTreeMap::from([ + ("stable".to_owned(), "1.93.1".to_owned()), + ("nightly".to_owned(), "nightly-2026-01-25".to_owned()), + ]), + "requires validated `msrv`", + ), + ] { + let mut errors = ViolationSink::default(); + audit_toolchain_defaults(&inventory, &mut errors); + let error = errors.finish().to_string(); + assert!(error.contains(expected), "{label}: {error}"); + } + } + + #[test] + fn every_mutable_producer_source_has_a_complete_compiled_snapshot() { + for reviewed in reviewed_sources() { + audit_exact_source( + reviewed.expected, + reviewed.live_path, + reviewed.expected, + reviewed.snapshot_path, + ) + .unwrap(); + + let changed = format!("{}# changed after review\n", reviewed.expected); + let error = audit_exact_source( + &changed, + reviewed.live_path, + reviewed.expected, + reviewed.snapshot_path, + ) + .unwrap_err() + .to_string(); + assert!(error.contains(reviewed.live_path), "{error}"); + assert!(error.contains(reviewed.snapshot_path), "{error}"); + } + } + + #[test] + fn image_context_contains_only_the_reviewed_inputs() { + static NEXT_DIRECTORY: AtomicU64 = AtomicU64::new(0); + let unique = NEXT_DIRECTORY.fetch_add(1, Ordering::Relaxed); + let temporary = std::env::temp_dir() + .join(format!("zerocopy-image-context-{}-{unique}", std::process::id(),)); + fs::create_dir_all(&temporary).unwrap(); + let root = temporary.canonicalize().unwrap(); + let context = root.join(IMAGE_CONTEXT_PATH); + fs::create_dir_all(&context).unwrap(); + for entry in IMAGE_CONTEXT_ENTRIES { + fs::write(context.join(entry), "reviewed\n").unwrap(); + } + audit_context_shape(&root).unwrap(); + + let unexpected = context.join("checkout-input"); + fs::write(&unexpected, "unreviewed\n").unwrap(); + let error = audit_context_shape(&root).unwrap_err().to_string(); + assert!(error.contains(IMAGE_CONTEXT_PATH), "{error}"); + assert!(error.contains("checkout-input"), "{error}"); + assert!(error.contains("must contain exactly"), "{error}"); + + fs::remove_dir_all(Path::new(&temporary)).unwrap(); + } +} diff --git a/tools/zc/src/planned_adapter/matrix.rs b/tools/zc/src/planned_adapter/matrix.rs new file mode 100644 index 0000000000..72b4940ea9 --- /dev/null +++ b/tools/zc/src/planned_adapter/matrix.rs @@ -0,0 +1,1951 @@ +// Copyright 2026 The Fuchsia Authors +// +// Licensed under a BSD-style license , Apache License, Version 2.0 +// , or the MIT +// license , at your option. +// This file may not be copied, modified, or distributed except according to +// those terms. + +//! Exact consumption and execution audit for typed build and Miri matrices. + +use std::collections::{BTreeMap, BTreeSet}; +#[cfg(test)] +use std::path::Path; + +use super::{ + reviewed_source::ReviewedSource, + source::{ + audit_exact_job_fields, audit_exact_scalar_field, audit_host_job_contract, + audit_read_permissions, audit_step, audit_unique_run_mentions, audited_steps_block, + compare_map, escape_control_characters, exact_step_lines, find_job, job_field_location, + job_fields, nested_fields, nested_mapping, parse_needs, unique_field, RunForm, + StepExpectation, + }, + ViolationSink, +}; +#[cfg(test)] +use super::{ + reviewed_source::{audit_exact_source, audit_reviewed_sources, read_reviewed_source}, + PlannedAdapterAuditError, PlannedAdapterViolations, +}; +use crate::{ + workflow::WORKFLOW_REGISTRY_PATH, + workflow_protocol::{ + image_artifact_consumer_line, BUILD_JOB, BUILD_MATRIX_OUTPUT, BUILD_STEP_NAME, + CELL_FEATURE_PROFILE_OPTION, CELL_MIRI_MODEL_OPTION, CELL_PACKAGE_OPTION, + CELL_TARGET_OPTION, CELL_TOOLCHAIN_OPTION, CI_EVENT_OPTION, DOCKER_ENTRYPOINT_ARGUMENT, + DOCKER_OPTION_TERMINATOR, EXECUTE_BUILD_CELL_COMMAND, EXECUTE_MIRI_CELL_COMMAND, + HOST_DOCKER_RUN, IMAGE_JOB, MATRIX_STEP_ANCHORS, MIRI_ENABLED_OUTPUT, MIRI_JOB, + MIRI_MATRIX_OUTPUT, MIRI_STEP_NAME, PLAN_JOB, REPOSITORY_WORKING_DIRECTORY, TRUSTED_SHELL, + WORKFLOW_PATH, + }, +}; + +#[derive(Clone, Copy)] +struct SelectorExpectation { + environment_name: &'static str, + matrix_field_name: &'static str, + cli_option: &'static str, +} + +#[derive(Clone, Copy)] +enum JobConditionExpectation { + Absent, + MiriEnabled, +} + +#[derive(Clone, Copy)] +struct MatrixJobExpectation { + job_name: &'static str, + display_name: &'static str, + top_level_fields: &'static [&'static str], + matrix_output_name: &'static str, + executor_step_name: &'static str, + executor_command: &'static str, + selectors: &'static [SelectorExpectation], + forwarded_selector_environment: &'static str, + condition: JobConditionExpectation, + run_defaults: Option, +} + +#[derive(Clone, Copy)] +struct RunDefaultsExpectation { + shell: &'static str, + working_directory: &'static str, +} + +const DOWNLOAD_ACTION_PATH: &str = ".github/actions/download-artifact-with-retry/action.yml"; +const DOWNLOAD_ACTION_SNAPSHOT_PATH: &str = + "tools/zc/testdata/download-artifact-with-retry.action.yml"; +// A local `uses` path executes mutable code from the checkout. Keep this +// independent snapshot's complete normalized source coordinated with the +// action above. Any functional or documentary edit must update both +// deliberately, which makes the action's complete pre-executor authority +// visible in the matrix-audit review rather than trying to blacklist +// particular shell forms. +const DOWNLOAD_ACTION_EXPECTED_SOURCE: &str = + include_str!("../../testdata/download-artifact-with-retry.action.yml"); +const REVIEWED_SOURCES: &[ReviewedSource] = &[ReviewedSource { + live_path: DOWNLOAD_ACTION_PATH, + snapshot_path: DOWNLOAD_ACTION_SNAPSHOT_PATH, + expected: DOWNLOAD_ACTION_EXPECTED_SOURCE, +}]; +const BUILD_JOB_FIELDS: &[&str] = + &["runs-on", "needs", "permissions", "defaults", "strategy", "name", "steps"]; +const MIRI_JOB_FIELDS: &[&str] = + &["if", "runs-on", "needs", "permissions", "strategy", "name", "steps"]; +const BUILD_DEFAULT_SHELL: &str = "/tmp/docker-shell.sh {0} # zizmor: ignore[misfeature] (CI intentionally routes build matrix commands through the prebuilt Docker image)"; +const BUILD_DISPLAY_NAME: &str = "Build & Test (${{ matrix.crate }} / ${{ matrix.toolchain }} / ${{ matrix.feature_profile }} / ${{ matrix.target }})"; +const MIRI_DISPLAY_NAME: &str = "Miri (${{ matrix.crate }} / ${{ matrix.toolchain }} / ${{ matrix.feature_profile }} / ${{ matrix.miri_model }} / ${{ matrix.target }})"; + +// The build job owns the setup definitions used by both typed matrix jobs. +// Their exact source is part of the execution boundary: a preceding step can +// otherwise alter the checkout, selected image, or process environment before +// an exactly audited executor runs. Keep these definitions coordinated with +// the corresponding steps and anchors in `.github/workflows/ci.yml`. +// +// YAML comments outside a run block may change freely. Comments inside a run +// block are shell input, so `exact_step_lines` retains them and the constants +// below include them. The repository-owned downloader receives a separate +// source audit because its local `uses` path cannot pin its implementation. +const CHECKOUT_STEP: &[&str] = &[ + " - &matrix_checkout", + " uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1", + " with:", + " fetch-depth: 2", + " persist-credentials: false", +]; +fn download_image_step() -> Vec { + vec![ + " - &download_ci_image".to_owned(), + " name: Download prebuilt Docker image".to_owned(), + " uses: ./.github/actions/download-artifact-with-retry".to_owned(), + " with:".to_owned(), + image_artifact_consumer_line(), + " path: ${{ runner.temp }}".to_owned(), + " expected-file: ${{ env.ZC_CI_IMAGE_ARCHIVE }}".to_owned(), + ] +} +const LOAD_IMAGE_STEP: &[&str] = &[ + " - &load_ci_image", + " name: Load prebuilt Docker image", + " shell: bash", + " env:", + " IMAGE_ARCHIVE: ${{ runner.temp }}/${{ env.ZC_CI_IMAGE_ARCHIVE }}", + " IMAGE_NAME: ${{ env.ZC_CI_IMAGE }}", + " run: |", + " set -euo pipefail", + " trap 'rm -f -- \"$IMAGE_ARCHIVE\"' EXIT", + " docker load --input \"$IMAGE_ARCHIVE\"", + " docker image inspect \"$IMAGE_NAME\" >/dev/null", + " docker run --rm \"$IMAGE_NAME\" true", +]; +const CREATE_DOCKER_SHELL_STEP: &[&str] = &[ + " - name: Create Docker Shell Wrapper", + " shell: bash", + " run: |", + " set -eo pipefail", + " mkdir -p /home/runner/.docker-cargo/registry /home/runner/.docker-cargo/git", + " cat << 'EOF' > /tmp/docker-shell.sh", + " #!/bin/bash", + " # Boot an ephemeral container for the step, mounting the workspace and", + " # temp dirs. Explicitly forward GitHub Actions internal state and matrix", + " # environment variables.", + " docker run --rm -i \\", + " --workdir \"$PWD\" \\", + " -v /home/runner/work:/home/runner/work \\", + " -v /home/runner/.docker-cargo/registry:/root/.cargo/registry \\", + " -v /home/runner/.docker-cargo/git:/root/.cargo/git \\", + " -e GITHUB_ENV -e GITHUB_PATH -e GITHUB_STEP_SUMMARY -e GITHUB_OUTPUT -e GITHUB_WORKSPACE \\", + " -e CI -e GITHUB_ACTIONS -e GITHUB_ACTOR -e GITHUB_REPOSITORY -e GITHUB_SHA -e GITHUB_REF -e GITHUB_EVENT_NAME \\", + " -e TOOLCHAIN -e CRATE -e TARGET -e FEATURE_PROFILE \\", + " -e MIRI_MODEL -e ZC_TOOLCHAIN -e PR_HEAD_SHA \\", + " -e RUSTFLAGS -e RUSTDOCFLAGS -e MIRIFLAGS \\", + " -e CARGO_NET_RETRY -e RUSTUP_MAX_RETRIES \\", + " -e ZC_NIGHTLY_RUSTFLAGS -e ZC_NIGHTLY_MIRIFLAGS \\", + " -e ZC_SKIP_CARGO_SEMVER_CHECKS \\", + " \"$ZC_CI_IMAGE\" bash -c \"git config --global --add safe.directory '*' && exec bash -e -o pipefail \\\"\\$1\\\"\" -- \"$1\"", + " EOF", + " chmod +x /tmp/docker-shell.sh", +]; +const VERIFY_CHECKOUT_STEP: &[&str] = &[ + " - &verify_matrix_checkout", + " name: Verify matrix checkout is unchanged", + TRUSTED_SHELL_LINE, + " env:", + " EXPECTED_COMMIT: ${{ github.sha }}", + " run: |", + " set -euo pipefail", + " builtin cd -- \"$GITHUB_WORKSPACE\"", + " readonly -a source_git=(", + " /usr/bin/env -i", + " GIT_CONFIG_GLOBAL=/dev/null", + " GIT_CONFIG_NOSYSTEM=1", + " GIT_NO_REPLACE_OBJECTS=1", + " HOME=/dev/null", + " PATH=/usr/bin:/bin", + " /usr/bin/git", + " \"--git-dir=$GITHUB_WORKSPACE/.git\"", + " \"--work-tree=$GITHUB_WORKSPACE\"", + " )", + " actual_commit=\"$(\"${source_git[@]}\" rev-parse --verify HEAD^{commit})\"", + " if [[ \"$actual_commit\" != \"$EXPECTED_COMMIT\" ]]; then", + " printf 'Expected checkout commit %s, found %s\\n' \\", + " \"$EXPECTED_COMMIT\" \"$actual_commit\" >&2", + " exit 1", + " fi", + " # Do not trust the checkout's mutable index, local Git config, or", + " # attributes. Build a temporary repository whose object store is the", + " # checkout's content-addressed store, whose index comes from the", + " # expected commit, and whose attributes also come from that commit.", + " verification_directory=\"$(", + " /usr/bin/mktemp -d \"$RUNNER_TEMP/matrix-checkout.XXXXXX\"", + " )\"", + " readonly verification_directory", + " trap '/usr/bin/rm -rf -- \"$verification_directory\"' EXIT", + " /usr/bin/env -i \\", + " GIT_CONFIG_GLOBAL=/dev/null \\", + " GIT_CONFIG_NOSYSTEM=1 \\", + " HOME=/dev/null \\", + " PATH=/usr/bin:/bin \\", + " /usr/bin/git init --quiet --initial-branch=verified \\", + " \"$verification_directory/repository\"", + " readonly -a trusted_git=(", + " /usr/bin/env -i", + " GIT_ATTR_NOSYSTEM=1", + " \"GIT_ATTR_SOURCE=$EXPECTED_COMMIT\"", + " GIT_CONFIG_GLOBAL=/dev/null", + " GIT_CONFIG_NOSYSTEM=1", + " \"GIT_INDEX_FILE=$verification_directory/index\"", + " GIT_NO_REPLACE_OBJECTS=1", + " \"GIT_OBJECT_DIRECTORY=$GITHUB_WORKSPACE/.git/objects\"", + " HOME=/dev/null", + " PATH=/usr/bin:/bin", + " /usr/bin/git", + " \"--git-dir=$verification_directory/repository/.git\"", + " \"--work-tree=$GITHUB_WORKSPACE\"", + " -c core.filemode=true", + " -c core.fsmonitor=false", + " -c core.ignoreCase=false", + " -c core.sparseCheckout=false", + " -c core.symlinks=true", + " -c core.untrackedCache=false", + " )", + " \"${trusted_git[@]}\" update-ref HEAD \"$EXPECTED_COMMIT\"", + " \"${trusted_git[@]}\" read-tree \"$EXPECTED_COMMIT\"", + " checkout_status=\"$(", + " \"${trusted_git[@]}\" status \\", + " --porcelain=v1 --untracked-files=all --ignored=matching -- \\", + " . ':(exclude).git'", + " )\"", + " if [[ -n \"$checkout_status\" ]]; then", + " printf 'Matrix setup modified the checkout:\\n%s\\n' \\", + " \"$checkout_status\" >&2", + " exit 1", + " fi", +]; + +const TRUSTED_SHELL_LINE: &str = " shell: /usr/bin/env -u BASH_ENV -u ENV -u SHELLOPTS -u BASHOPTS /bin/bash --noprofile --norc -p -euo pipefail -- {0}"; + +const BUILD_STEP_MARKERS: &[&str] = &[ + "- &matrix_checkout", + "- &download_ci_image", + "- &load_ci_image", + "- name: Create Docker Shell Wrapper", + "- &verify_matrix_checkout", + "- name: Execute checked build cell", + "- name: Prepare cargo-semver-checks", + "- name: Check semver compatibility", +]; +const MIRI_STEP_MARKERS: &[&str] = &[ + "- *matrix_checkout", + "- *download_ci_image", + "- *load_ci_image", + "- *verify_matrix_checkout", + "- name: Execute checked Miri cell", +]; + +const BUILD_SELECTORS: [SelectorExpectation; 4] = [ + SelectorExpectation { + environment_name: "CRATE", + matrix_field_name: "crate", + cli_option: CELL_PACKAGE_OPTION, + }, + SelectorExpectation { + environment_name: "TOOLCHAIN", + matrix_field_name: "toolchain", + cli_option: CELL_TOOLCHAIN_OPTION, + }, + SelectorExpectation { + environment_name: "FEATURE_PROFILE", + matrix_field_name: "feature_profile", + cli_option: CELL_FEATURE_PROFILE_OPTION, + }, + SelectorExpectation { + environment_name: "TARGET", + matrix_field_name: "target", + cli_option: CELL_TARGET_OPTION, + }, +]; + +const MIRI_SELECTORS: [SelectorExpectation; 5] = [ + SelectorExpectation { + environment_name: "CRATE", + matrix_field_name: "crate", + cli_option: CELL_PACKAGE_OPTION, + }, + SelectorExpectation { + environment_name: "TOOLCHAIN", + matrix_field_name: "toolchain", + cli_option: CELL_TOOLCHAIN_OPTION, + }, + SelectorExpectation { + environment_name: "FEATURE_PROFILE", + matrix_field_name: "feature_profile", + cli_option: CELL_FEATURE_PROFILE_OPTION, + }, + SelectorExpectation { + environment_name: "TARGET", + matrix_field_name: "target", + cli_option: CELL_TARGET_OPTION, + }, + SelectorExpectation { + environment_name: "MIRI_MODEL", + matrix_field_name: "miri_model", + cli_option: CELL_MIRI_MODEL_OPTION, + }, +]; + +const BUILD_EXPECTATION: MatrixJobExpectation = MatrixJobExpectation { + job_name: BUILD_JOB, + display_name: BUILD_DISPLAY_NAME, + top_level_fields: BUILD_JOB_FIELDS, + matrix_output_name: BUILD_MATRIX_OUTPUT, + executor_step_name: BUILD_STEP_NAME, + executor_command: EXECUTE_BUILD_CELL_COMMAND, + selectors: &BUILD_SELECTORS, + forwarded_selector_environment: " -e TOOLCHAIN -e CRATE -e TARGET -e FEATURE_PROFILE \\", + condition: JobConditionExpectation::Absent, + run_defaults: Some(RunDefaultsExpectation { + shell: BUILD_DEFAULT_SHELL, + working_directory: REPOSITORY_WORKING_DIRECTORY, + }), +}; + +const MIRI_EXPECTATION: MatrixJobExpectation = MatrixJobExpectation { + job_name: MIRI_JOB, + display_name: MIRI_DISPLAY_NAME, + top_level_fields: MIRI_JOB_FIELDS, + matrix_output_name: MIRI_MATRIX_OUTPUT, + executor_step_name: MIRI_STEP_NAME, + executor_command: EXECUTE_MIRI_CELL_COMMAND, + selectors: &MIRI_SELECTORS, + forwarded_selector_environment: + " -e TOOLCHAIN -e CRATE -e TARGET -e FEATURE_PROFILE -e MIRI_MODEL \\", + condition: JobConditionExpectation::MiriEnabled, + run_defaults: None, +}; + +pub(super) fn audit( + lines: &[&str], + reviewed_planned_jobs: &BTreeSet<(String, String)>, + errors: &mut ViolationSink, +) { + audit_reviewed_roles(reviewed_planned_jobs, errors); + audit_anchor_ownership(lines, errors); + for expected in [BUILD_EXPECTATION, MIRI_EXPECTATION] { + if let Some(job) = find_job(lines, expected.job_name, errors) { + audit_matrix_job(lines, job, expected, errors); + } + audit_unique_run_mentions( + lines, + expected.executor_step_name, + expected.executor_command, + errors, + ); + } +} + +fn audit_anchor_ownership(lines: &[&str], errors: &mut ViolationSink) { + // YAML aliases bind to anchor declarations outside the aliasing step. The + // exact build definition and Miri alias checks are insufficient if another + // job can redefine the same anchor between them, so reserve each name for + // exactly one definition and one use in the whole workflow. Full-line + // comments remain documentation and do not participate in YAML binding. + for anchor in MATRIX_STEP_ANCHORS { + for (sigil, role) in [('&', "definition"), ('*', "alias")] { + let token = format!("{sigil}{anchor}"); + let mentions = lines + .iter() + .filter(|line| !line.trim_start().starts_with('#')) + .map(|line| token_mentions(line, &token)) + .sum::(); + if mentions != 1 { + errors.push( + WORKFLOW_PATH, + format!( + "matrix step anchor `{anchor}` must have exactly one {role}, found {mentions}" + ), + ); + } + } + } +} + +/// Returns mutable downloader source reviewed by the planned-job boundary. +/// +/// External actions in the exact step contract are pinned by commit ID. This +/// downloader is repository-owned, so its `uses` path alone does not constrain +/// code which runs before the checkout-integrity gate. The orchestrator joins +/// this source with every image-producer source before checking contents and +/// file identity, so no two supposedly independent review files can alias. +pub(super) fn reviewed_sources() -> &'static [ReviewedSource] { + REVIEWED_SOURCES +} + +#[cfg(test)] +fn audit_download_action(repository_root: &Path) -> Result<(), PlannedAdapterAuditError> { + audit_reviewed_sources(repository_root, REVIEWED_SOURCES) +} + +#[cfg(test)] +fn read_download_action(repository_root: &Path) -> Result { + read_reviewed_source(repository_root, DOWNLOAD_ACTION_PATH).map(|(_, source, _)| source) +} + +/// Requires the complete local-action source reviewed with this adapter. +#[cfg(test)] +fn audit_download_action_source(source: &str) -> Result<(), PlannedAdapterViolations> { + audit_exact_source( + source, + DOWNLOAD_ACTION_PATH, + DOWNLOAD_ACTION_EXPECTED_SOURCE, + DOWNLOAD_ACTION_SNAPSHOT_PATH, + ) +} + +fn token_mentions(text: &str, token: &str) -> usize { + text.match_indices(token) + .filter(|(start, _)| { + let end = start + token.len(); + let before = text[..*start].bytes().next_back(); + let after = text[end..].bytes().next(); + !before.is_some_and(is_identifier_byte) && !after.is_some_and(is_identifier_byte) + }) + .count() +} + +fn is_identifier_byte(byte: u8) -> bool { + byte.is_ascii_alphanumeric() || byte == b'_' +} + +fn audit_reviewed_roles(reviewed: &BTreeSet<(String, String)>, errors: &mut ViolationSink) { + let expected = [BUILD_EXPECTATION, MIRI_EXPECTATION] + .into_iter() + .map(|spec| (WORKFLOW_PATH.to_owned(), spec.job_name.to_owned())) + .collect::>(); + for (workflow, job) in expected.difference(reviewed) { + errors.push( + format!("{WORKFLOW_REGISTRY_PATH}:{workflow}:{job}"), + "planned matrix job must have the reviewed `planned` role", + ); + } + for (workflow, job) in reviewed.difference(&expected) { + errors.push( + format!("{WORKFLOW_REGISTRY_PATH}:{workflow}:{job}"), + "job has the reviewed `planned` role but no planned-job workflow audit", + ); + } +} + +fn audit_matrix_job( + lines: &[&str], + job: std::ops::Range, + expected: MatrixJobExpectation, + errors: &mut ViolationSink, +) { + let fields = job_fields(lines, job.clone(), expected.job_name, errors); + audit_exact_job_fields(&fields, expected.job_name, expected.top_level_fields, errors); + audit_exact_scalar_field(&fields, expected.job_name, "name", expected.display_name, errors); + audit_needs(&fields, expected.job_name, errors); + audit_condition(&fields, expected, errors); + audit_host_job_contract(&fields, expected.job_name, errors); + audit_read_permissions(lines, job.end, &fields, expected.job_name, errors); + if let Some(defaults) = expected.run_defaults { + audit_run_defaults(lines, job.end, &fields, expected.job_name, defaults, errors); + } + audit_strategy(lines, job.end, &fields, expected, errors); + + if let Some(steps) = audited_steps_block(&fields, job, expected.job_name, 4, errors) { + audit_matrix_step_contract(lines, &steps, expected, errors); + audit_executor_step(lines, &steps, expected, errors); + } +} + +fn audit_matrix_step_contract( + lines: &[&str], + steps: &super::source::StepsBlock, + expected: MatrixJobExpectation, + errors: &mut ViolationSink, +) { + let actual = exact_step_lines(lines, steps); + let expected_markers = + if expected.job_name == BUILD_JOB { BUILD_STEP_MARKERS } else { MIRI_STEP_MARKERS }; + let actual_markers = actual + .iter() + .filter_map(|step| step.first()) + .map(|line| line.strip_prefix(" ").unwrap_or(line).to_owned()) + .collect::>(); + if actual_markers != expected_markers { + errors.push( + job_field_location(expected.job_name, "steps"), + format!( + "steps must be exactly {expected_markers:?} in order, found {actual_markers:?}" + ), + ); + } + + // `build_test` owns the definitions and Miri consumes exact aliases. The + // terminal executors have richer field-by-field audits below. The two + // semver steps follow the build executor and cannot affect it; their exact + // behavior remains outside this matrix-execution boundary until the + // standalone semver audit replaces them later in the stack. + let owned = |step: &[&str]| step.iter().map(|line| (*line).to_owned()).collect(); + let expected_setup: Vec> = if expected.job_name == BUILD_JOB { + vec![ + owned(CHECKOUT_STEP), + download_image_step(), + owned(LOAD_IMAGE_STEP), + owned(CREATE_DOCKER_SHELL_STEP), + owned(VERIFY_CHECKOUT_STEP), + ] + } else { + vec![ + vec![" - *matrix_checkout".to_owned()], + vec![" - *download_ci_image".to_owned()], + vec![" - *load_ci_image".to_owned()], + vec![" - *verify_matrix_checkout".to_owned()], + ] + }; + for (index, expected_step) in expected_setup.into_iter().enumerate() { + let matches = actual.get(index).is_some_and(|actual| { + actual.iter().copied().eq(expected_step.iter().map(String::as_str)) + }); + if !matches { + errors.push( + job_field_location(expected.job_name, "steps"), + format!( + "setup step {} must match the exact canonical contract {:?}", + index + 1, + expected_step + ), + ); + } + } +} + +fn audit_needs(fields: &[super::source::Field<'_>], job: &str, errors: &mut ViolationSink) { + let Some(needs) = unique_field(fields, "needs", job, errors) else { + return; + }; + let dependencies = match parse_needs(needs.value) { + Ok(dependencies) => dependencies, + Err(message) => { + errors.push(job_field_location(job, "needs"), message); + return; + } + }; + let expected = BTreeSet::from([IMAGE_JOB, PLAN_JOB]); + for missing in expected.difference(&dependencies) { + errors + .push(job_field_location(job, "needs"), format!("must depend directly on `{missing}`")); + } + for extra in dependencies.difference(&expected) { + errors.push( + job_field_location(job, "needs"), + format!("unexpected direct dependency `{extra}`"), + ); + } +} + +fn audit_run_defaults( + lines: &[&str], + job_end: usize, + fields: &[super::source::Field<'_>], + job: &str, + expected: RunDefaultsExpectation, + errors: &mut ViolationSink, +) { + let Some(defaults) = unique_field(fields, "defaults", job, errors) else { + return; + }; + let defaults_job = format!("{job}.defaults"); + let Some(default_fields) = nested_fields(lines, defaults, job_end, &defaults_job, errors) + else { + return; + }; + audit_exact_job_fields(&default_fields, &defaults_job, &["run"], errors); + + let Some(run) = unique_field(&default_fields, "run", &defaults_job, errors) else { + return; + }; + let run_job = format!("{defaults_job}.run"); + let Some(run_fields) = nested_fields(lines, run, job_end, &run_job, errors) else { + return; + }; + audit_exact_job_fields(&run_fields, &run_job, &["shell", "working-directory"], errors); + audit_exact_scalar_field(&run_fields, &run_job, "shell", expected.shell, errors); + audit_exact_scalar_field( + &run_fields, + &run_job, + "working-directory", + expected.working_directory, + errors, + ); +} + +fn audit_condition( + fields: &[super::source::Field<'_>], + expected: MatrixJobExpectation, + errors: &mut ViolationSink, +) { + let conditions = fields.iter().filter(|field| field.key == "if").collect::>(); + match expected.condition { + JobConditionExpectation::Absent => { + if !conditions.is_empty() { + errors.push( + job_field_location(expected.job_name, "if"), + "the ordinary build job must run on every planned event", + ); + } + } + JobConditionExpectation::MiriEnabled => { + let required = miri_job_condition(); + match conditions.as_slice() { + [condition] if condition.value == required => {} + [condition] => errors.push( + job_field_location(expected.job_name, "if"), + format!( + "expected `{required}`, found `{}`", + escape_control_characters(condition.value) + ), + ), + [] => errors.push( + job_field_location(expected.job_name, "if"), + format!("required `{required}` condition is absent"), + ), + _ => errors.push( + job_field_location(expected.job_name, "if"), + "job repeats its condition; use one canonical scalar field", + ), + } + } + } +} + +fn audit_strategy( + lines: &[&str], + job_end: usize, + fields: &[super::source::Field<'_>], + expected: MatrixJobExpectation, + errors: &mut ViolationSink, +) { + let Some(strategy) = unique_field(fields, "strategy", expected.job_name, errors) else { + return; + }; + if !strategy.value.is_empty() { + errors.push( + job_field_location(expected.job_name, "strategy"), + "strategy must use the canonical nested mapping form", + ); + return; + } + + let actual = nested_mapping(lines, strategy, job_end, expected.job_name, errors); + let expected_fields = BTreeMap::from([ + ("fail-fast".to_owned(), "false".to_owned()), + ("matrix".to_owned(), matrix_expression(expected.matrix_output_name)), + ]); + compare_map( + job_field_location(expected.job_name, "strategy"), + &expected_fields, + &actual, + errors, + ); +} + +fn audit_executor_step( + lines: &[&str], + steps: &super::source::StepsBlock, + expected: MatrixJobExpectation, + errors: &mut ViolationSink, +) { + let scalar_fields = BTreeMap::from([ + ("shell".to_owned(), TRUSTED_SHELL.to_owned()), + ("working-directory".to_owned(), REPOSITORY_WORKING_DIRECTORY.to_owned()), + ]); + let environment = expected + .selectors + .iter() + .map(|selector| { + ( + selector.environment_name.to_owned(), + matrix_selector_expression(selector.matrix_field_name), + ) + }) + .collect::>(); + let run = executor_run(expected); + audit_step( + lines, + steps, + StepExpectation { + job: expected.job_name, + name: expected.executor_step_name, + root_fields: &["shell", "working-directory", "env", "run"], + scalar_fields: &scalar_fields, + environment: &environment, + run: &run, + run_form: RunForm::Block, + }, + errors, + ); +} + +fn executor_run(expected: MatrixJobExpectation) -> Vec { + let mut run = vec![ + "set -euo pipefail".to_owned(), + HOST_DOCKER_RUN.to_owned(), + " --workdir \"$PWD\" \\".to_owned(), + " -v /home/runner/work:/home/runner/work \\".to_owned(), + " -v /home/runner/.docker-cargo/registry:/root/.cargo/registry \\".to_owned(), + " -v /home/runner/.docker-cargo/git:/root/.cargo/git \\".to_owned(), + " -e GITHUB_ENV -e GITHUB_PATH -e GITHUB_STEP_SUMMARY -e GITHUB_OUTPUT -e GITHUB_WORKSPACE \\".to_owned(), + " -e CI -e GITHUB_ACTIONS -e GITHUB_ACTOR -e GITHUB_REPOSITORY -e GITHUB_SHA -e GITHUB_REF -e GITHUB_EVENT_NAME \\".to_owned(), + expected.forwarded_selector_environment.to_owned(), + " -e RUSTFLAGS -e RUSTDOCFLAGS -e MIRIFLAGS \\".to_owned(), + " -e CARGO_NET_RETRY -e RUSTUP_MAX_RETRIES \\".to_owned(), + " -e ZC_NIGHTLY_RUSTFLAGS -e ZC_NIGHTLY_MIRIFLAGS \\".to_owned(), + " -e ZC_SKIP_CARGO_SEMVER_CHECKS \\".to_owned(), + " -e GIT_CONFIG_COUNT=1 \\".to_owned(), + " -e GIT_CONFIG_KEY_0=safe.directory \\".to_owned(), + " -e \"GIT_CONFIG_VALUE_0=*\" \\".to_owned(), + DOCKER_ENTRYPOINT_ARGUMENT.to_owned(), + DOCKER_OPTION_TERMINATOR.to_owned(), + " \"$ZC_CI_IMAGE\" \\".to_owned(), + " --noprofile \\".to_owned(), + " --norc \\".to_owned(), + " -p \\".to_owned(), + format!(" ./cargo.sh ci {} \\", expected.executor_command), + format!(" {CI_EVENT_OPTION} \"$GITHUB_EVENT_NAME\" \\"), + ]; + let last_selector = expected.selectors.len() - 1; + run.extend(expected.selectors.iter().enumerate().map(|(index, selector)| { + let continuation = if index == last_selector { "" } else { " \\" }; + format!(" {} \"${}\"{continuation}", selector.cli_option, selector.environment_name) + })); + run +} + +fn miri_job_condition() -> String { + format!("needs.{PLAN_JOB}.outputs.{MIRI_ENABLED_OUTPUT} == 'true'") +} + +fn matrix_expression(output: &str) -> String { + format!("${{{{ fromJSON(needs.{PLAN_JOB}.outputs.{output}) }}}}") +} + +fn matrix_selector_expression(field: &str) -> String { + format!("${{{{ matrix.{field} }}}}") +} + +#[cfg(test)] +mod tests { + use std::{ + collections::BTreeSet, + fs, + path::{Path, PathBuf}, + process::{self, Command, Output}, + sync::atomic::{AtomicU64, Ordering}, + }; + + use super::{ + audit, audit_download_action, audit_download_action_source, download_image_step, + executor_run, read_download_action, BUILD_DEFAULT_SHELL, BUILD_DISPLAY_NAME, + BUILD_EXPECTATION, CHECKOUT_STEP, CREATE_DOCKER_SHELL_STEP, + DOWNLOAD_ACTION_EXPECTED_SOURCE, DOWNLOAD_ACTION_PATH, DOWNLOAD_ACTION_SNAPSHOT_PATH, + LOAD_IMAGE_STEP, MIRI_DISPLAY_NAME, MIRI_EXPECTATION, TRUSTED_SHELL_LINE, + VERIFY_CHECKOUT_STEP, + }; + use crate::{ + ci::POLICY_PATH, + inventory::RepositoryInventory, + planned_adapter::{ + audit_planned_adapter, + test_support::{ + assert_rejected, audit_feature, canonical_planned_jobs, replace_in_job, + }, + }, + policy::Policy, + workflow::{ReviewedWorkflowJobs, WORKFLOW_REGISTRY_PATH}, + workflow_protocol::{ + BUILD_JOB, EXECUTE_BUILD_CELL_COMMAND, EXECUTE_MIRI_CELL_COMMAND, MIRI_JOB, + TRUSTED_SHELL, WORKFLOW_PATH, + }, + }; + + const CANONICAL_SOURCE: &str = r#"jobs: + build_test: + runs-on: ubuntu-latest + needs: [build_docker_env, plan_ci] + permissions: + contents: read + defaults: + run: + shell: /tmp/docker-shell.sh {0} # zizmor: ignore[misfeature] (CI intentionally routes build matrix commands through the prebuilt Docker image) + working-directory: zerocopy + strategy: + fail-fast: false + matrix: ${{ fromJSON(needs.plan_ci.outputs.build_matrix) }} + name: Build & Test (${{ matrix.crate }} / ${{ matrix.toolchain }} / ${{ matrix.feature_profile }} / ${{ matrix.target }}) + steps: + - &matrix_checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 2 + persist-credentials: false + - &download_ci_image + name: Download prebuilt Docker image + uses: ./.github/actions/download-artifact-with-retry + with: + artifact-id: ${{ needs.build_docker_env.outputs.image_artifact_id }} + path: ${{ runner.temp }} + expected-file: ${{ env.ZC_CI_IMAGE_ARCHIVE }} + - &load_ci_image + name: Load prebuilt Docker image + shell: bash + env: + IMAGE_ARCHIVE: ${{ runner.temp }}/${{ env.ZC_CI_IMAGE_ARCHIVE }} + IMAGE_NAME: ${{ env.ZC_CI_IMAGE }} + run: | + set -euo pipefail + trap 'rm -f -- "$IMAGE_ARCHIVE"' EXIT + docker load --input "$IMAGE_ARCHIVE" + docker image inspect "$IMAGE_NAME" >/dev/null + docker run --rm "$IMAGE_NAME" true + - name: Create Docker Shell Wrapper + shell: bash + run: | + set -eo pipefail + mkdir -p /home/runner/.docker-cargo/registry /home/runner/.docker-cargo/git + cat << 'EOF' > /tmp/docker-shell.sh + #!/bin/bash + # Boot an ephemeral container for the step, mounting the workspace and + # temp dirs. Explicitly forward GitHub Actions internal state and matrix + # environment variables. + docker run --rm -i \ + --workdir "$PWD" \ + -v /home/runner/work:/home/runner/work \ + -v /home/runner/.docker-cargo/registry:/root/.cargo/registry \ + -v /home/runner/.docker-cargo/git:/root/.cargo/git \ + -e GITHUB_ENV -e GITHUB_PATH -e GITHUB_STEP_SUMMARY -e GITHUB_OUTPUT -e GITHUB_WORKSPACE \ + -e CI -e GITHUB_ACTIONS -e GITHUB_ACTOR -e GITHUB_REPOSITORY -e GITHUB_SHA -e GITHUB_REF -e GITHUB_EVENT_NAME \ + -e TOOLCHAIN -e CRATE -e TARGET -e FEATURE_PROFILE \ + -e MIRI_MODEL -e ZC_TOOLCHAIN -e PR_HEAD_SHA \ + -e RUSTFLAGS -e RUSTDOCFLAGS -e MIRIFLAGS \ + -e CARGO_NET_RETRY -e RUSTUP_MAX_RETRIES \ + -e ZC_NIGHTLY_RUSTFLAGS -e ZC_NIGHTLY_MIRIFLAGS \ + -e ZC_SKIP_CARGO_SEMVER_CHECKS \ + "$ZC_CI_IMAGE" bash -c "git config --global --add safe.directory '*' && exec bash -e -o pipefail \"\$1\"" -- "$1" + EOF + chmod +x /tmp/docker-shell.sh + - &verify_matrix_checkout + name: Verify matrix checkout is unchanged + shell: /usr/bin/env -u BASH_ENV -u ENV -u SHELLOPTS -u BASHOPTS /bin/bash --noprofile --norc -p -euo pipefail -- {0} + env: + EXPECTED_COMMIT: ${{ github.sha }} + run: | + set -euo pipefail + builtin cd -- "$GITHUB_WORKSPACE" + readonly -a source_git=( + /usr/bin/env -i + GIT_CONFIG_GLOBAL=/dev/null + GIT_CONFIG_NOSYSTEM=1 + GIT_NO_REPLACE_OBJECTS=1 + HOME=/dev/null + PATH=/usr/bin:/bin + /usr/bin/git + "--git-dir=$GITHUB_WORKSPACE/.git" + "--work-tree=$GITHUB_WORKSPACE" + ) + actual_commit="$("${source_git[@]}" rev-parse --verify HEAD^{commit})" + if [[ "$actual_commit" != "$EXPECTED_COMMIT" ]]; then + printf 'Expected checkout commit %s, found %s\n' \ + "$EXPECTED_COMMIT" "$actual_commit" >&2 + exit 1 + fi + # Do not trust the checkout's mutable index, local Git config, or + # attributes. Build a temporary repository whose object store is the + # checkout's content-addressed store, whose index comes from the + # expected commit, and whose attributes also come from that commit. + verification_directory="$( + /usr/bin/mktemp -d "$RUNNER_TEMP/matrix-checkout.XXXXXX" + )" + readonly verification_directory + trap '/usr/bin/rm -rf -- "$verification_directory"' EXIT + /usr/bin/env -i \ + GIT_CONFIG_GLOBAL=/dev/null \ + GIT_CONFIG_NOSYSTEM=1 \ + HOME=/dev/null \ + PATH=/usr/bin:/bin \ + /usr/bin/git init --quiet --initial-branch=verified \ + "$verification_directory/repository" + readonly -a trusted_git=( + /usr/bin/env -i + GIT_ATTR_NOSYSTEM=1 + "GIT_ATTR_SOURCE=$EXPECTED_COMMIT" + GIT_CONFIG_GLOBAL=/dev/null + GIT_CONFIG_NOSYSTEM=1 + "GIT_INDEX_FILE=$verification_directory/index" + GIT_NO_REPLACE_OBJECTS=1 + "GIT_OBJECT_DIRECTORY=$GITHUB_WORKSPACE/.git/objects" + HOME=/dev/null + PATH=/usr/bin:/bin + /usr/bin/git + "--git-dir=$verification_directory/repository/.git" + "--work-tree=$GITHUB_WORKSPACE" + -c core.filemode=true + -c core.fsmonitor=false + -c core.ignoreCase=false + -c core.sparseCheckout=false + -c core.symlinks=true + -c core.untrackedCache=false + ) + "${trusted_git[@]}" update-ref HEAD "$EXPECTED_COMMIT" + "${trusted_git[@]}" read-tree "$EXPECTED_COMMIT" + checkout_status="$( + "${trusted_git[@]}" status \ + --porcelain=v1 --untracked-files=all --ignored=matching -- \ + . ':(exclude).git' + )" + if [[ -n "$checkout_status" ]]; then + printf 'Matrix setup modified the checkout:\n%s\n' \ + "$checkout_status" >&2 + exit 1 + fi + - name: Execute checked build cell + shell: /usr/bin/env -u BASH_ENV -u ENV -u SHELLOPTS -u BASHOPTS /bin/bash --noprofile --norc -p -euo pipefail -- {0} + working-directory: zerocopy + env: + TOOLCHAIN: ${{ matrix.toolchain }} + CRATE: ${{ matrix.crate }} + FEATURE_PROFILE: ${{ matrix.feature_profile }} + TARGET: ${{ matrix.target }} + run: | + set -euo pipefail + /usr/bin/docker run --rm \ + --workdir "$PWD" \ + -v /home/runner/work:/home/runner/work \ + -v /home/runner/.docker-cargo/registry:/root/.cargo/registry \ + -v /home/runner/.docker-cargo/git:/root/.cargo/git \ + -e GITHUB_ENV -e GITHUB_PATH -e GITHUB_STEP_SUMMARY -e GITHUB_OUTPUT -e GITHUB_WORKSPACE \ + -e CI -e GITHUB_ACTIONS -e GITHUB_ACTOR -e GITHUB_REPOSITORY -e GITHUB_SHA -e GITHUB_REF -e GITHUB_EVENT_NAME \ + -e TOOLCHAIN -e CRATE -e TARGET -e FEATURE_PROFILE \ + -e RUSTFLAGS -e RUSTDOCFLAGS -e MIRIFLAGS \ + -e CARGO_NET_RETRY -e RUSTUP_MAX_RETRIES \ + -e ZC_NIGHTLY_RUSTFLAGS -e ZC_NIGHTLY_MIRIFLAGS \ + -e ZC_SKIP_CARGO_SEMVER_CHECKS \ + -e GIT_CONFIG_COUNT=1 \ + -e GIT_CONFIG_KEY_0=safe.directory \ + -e "GIT_CONFIG_VALUE_0=*" \ + --entrypoint /bin/bash \ + -- \ + "$ZC_CI_IMAGE" \ + --noprofile \ + --norc \ + -p \ + ./cargo.sh ci execute-build-cell \ + --event "$GITHUB_EVENT_NAME" \ + --package "$CRATE" \ + --toolchain "$TOOLCHAIN" \ + --feature-profile "$FEATURE_PROFILE" \ + --target "$TARGET" + - name: Prepare cargo-semver-checks + run: echo audited separately later + - name: Check semver compatibility + run: echo audited separately later + miri: + if: needs.plan_ci.outputs.miri_enabled == 'true' + runs-on: ubuntu-latest + needs: [build_docker_env, plan_ci] + permissions: + contents: read + strategy: + fail-fast: false + matrix: ${{ fromJSON(needs.plan_ci.outputs.miri_matrix) }} + name: Miri (${{ matrix.crate }} / ${{ matrix.toolchain }} / ${{ matrix.feature_profile }} / ${{ matrix.miri_model }} / ${{ matrix.target }}) + steps: + - *matrix_checkout + - *download_ci_image + - *load_ci_image + - *verify_matrix_checkout + - name: Execute checked Miri cell + shell: /usr/bin/env -u BASH_ENV -u ENV -u SHELLOPTS -u BASHOPTS /bin/bash --noprofile --norc -p -euo pipefail -- {0} + working-directory: zerocopy + env: + TOOLCHAIN: ${{ matrix.toolchain }} + CRATE: ${{ matrix.crate }} + FEATURE_PROFILE: ${{ matrix.feature_profile }} + TARGET: ${{ matrix.target }} + MIRI_MODEL: ${{ matrix.miri_model }} + run: | + set -euo pipefail + /usr/bin/docker run --rm \ + --workdir "$PWD" \ + -v /home/runner/work:/home/runner/work \ + -v /home/runner/.docker-cargo/registry:/root/.cargo/registry \ + -v /home/runner/.docker-cargo/git:/root/.cargo/git \ + -e GITHUB_ENV -e GITHUB_PATH -e GITHUB_STEP_SUMMARY -e GITHUB_OUTPUT -e GITHUB_WORKSPACE \ + -e CI -e GITHUB_ACTIONS -e GITHUB_ACTOR -e GITHUB_REPOSITORY -e GITHUB_SHA -e GITHUB_REF -e GITHUB_EVENT_NAME \ + -e TOOLCHAIN -e CRATE -e TARGET -e FEATURE_PROFILE -e MIRI_MODEL \ + -e RUSTFLAGS -e RUSTDOCFLAGS -e MIRIFLAGS \ + -e CARGO_NET_RETRY -e RUSTUP_MAX_RETRIES \ + -e ZC_NIGHTLY_RUSTFLAGS -e ZC_NIGHTLY_MIRIFLAGS \ + -e ZC_SKIP_CARGO_SEMVER_CHECKS \ + -e GIT_CONFIG_COUNT=1 \ + -e GIT_CONFIG_KEY_0=safe.directory \ + -e "GIT_CONFIG_VALUE_0=*" \ + --entrypoint /bin/bash \ + -- \ + "$ZC_CI_IMAGE" \ + --noprofile \ + --norc \ + -p \ + ./cargo.sh ci execute-miri-cell \ + --event "$GITHUB_EVENT_NAME" \ + --package "$CRATE" \ + --toolchain "$TOOLCHAIN" \ + --feature-profile "$FEATURE_PROFILE" \ + --target "$TARGET" \ + --miri-model "$MIRI_MODEL" + next_job: + runs-on: ubuntu-latest +"#; + + fn audit_source( + source: &str, + reviewed: &BTreeSet<(String, String)>, + ) -> Result<(), super::super::PlannedAdapterViolations> { + audit_feature(source, |lines, errors| audit(lines, reviewed, errors)) + } + + fn audit_canonical(source: &str) -> Result<(), super::super::PlannedAdapterViolations> { + audit_source(source, &canonical_planned_jobs()) + } + + fn rejected(label: &str, source: &str, expected: &str) { + assert_rejected(label, audit_canonical(source), expected); + } + + fn replace_in_step(source: &str, marker: &str, from: &str, to: &str) -> String { + let start = + source.find(marker).unwrap_or_else(|| panic!("missing fixture step marker {marker:?}")); + let remainder = &source[start + marker.len()..]; + let end = remainder + .find("\n - ") + .map(|offset| start + marker.len() + offset + 1) + .unwrap_or(source.len()); + let block = &source[start..end]; + assert!(block.contains(from), "step {marker:?} did not contain {from:?}"); + format!("{}{}{}", &source[..start], block.replacen(from, to, 1), &source[end..]) + } + + #[cfg(target_os = "linux")] + fn checkout_verification_script() -> String { + let run = VERIFY_CHECKOUT_STEP + .iter() + .position(|line| *line == " run: |") + .expect("verification step must have a run block"); + VERIFY_CHECKOUT_STEP[run + 1..] + .iter() + .map(|line| { + line.strip_prefix(" ") + .expect("verification script lines must have block indentation") + }) + .collect::>() + .join("\n") + + "\n" + } + + #[cfg(target_os = "linux")] + fn run_git(repository: &Path, arguments: &[&str]) -> Output { + let output = + Command::new("/usr/bin/git").current_dir(repository).args(arguments).output().unwrap(); + assert!( + output.status.success(), + "git {arguments:?} failed:\n{}", + String::from_utf8_lossy(&output.stderr) + ); + output + } + + #[cfg(target_os = "linux")] + fn run_checkout_verification( + repository: &TemporaryRepository, + expected_commit: &str, + ) -> Output { + let runner_temp = repository.directory.join("runner-temp"); + fs::create_dir_all(&runner_temp).unwrap(); + Command::new("/bin/bash") + .args([ + "--noprofile", + "--norc", + "-p", + "-euo", + "pipefail", + "-c", + &checkout_verification_script(), + ]) + .env_clear() + .env("EXPECTED_COMMIT", expected_commit) + .env("GITHUB_WORKSPACE", &repository.root) + .env("RUNNER_TEMP", runner_temp) + .output() + .unwrap() + } + + struct TemporaryRepository { + directory: PathBuf, + root: PathBuf, + } + + impl TemporaryRepository { + fn new(label: &str) -> Self { + static NEXT_DIRECTORY: AtomicU64 = AtomicU64::new(0); + let directory = loop { + let unique = NEXT_DIRECTORY.fetch_add(1, Ordering::Relaxed); + let candidate = std::env::temp_dir() + .join(format!("zerocopy-planned-matrix-{label}-{}-{unique}", process::id())); + match fs::create_dir(&candidate) { + Ok(()) => break candidate, + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue, + Err(error) => { + panic!("failed to reserve {}: {error}", candidate.display()); + } + } + }; + let root = directory.join("repository"); + fs::create_dir(&root).unwrap(); + let root = root.canonicalize().unwrap(); + Self { directory, root } + } + + fn action_path(&self) -> PathBuf { + self.root.join(DOWNLOAD_ACTION_PATH) + } + + fn snapshot_path(&self) -> PathBuf { + self.root.join(DOWNLOAD_ACTION_SNAPSHOT_PATH) + } + + fn write_action(&self, source: &str) { + let action = self.action_path(); + fs::create_dir_all(action.parent().unwrap()).unwrap(); + fs::write(action, source).unwrap(); + } + + fn write_snapshot(&self, source: &str) { + let snapshot = self.snapshot_path(); + fs::create_dir_all(snapshot.parent().unwrap()).unwrap(); + fs::write(snapshot, source).unwrap(); + } + } + + impl Drop for TemporaryRepository { + fn drop(&mut self) { + if let Err(error) = fs::remove_dir_all(&self.directory) { + if !std::thread::panicking() { + panic!("failed to remove {}: {error}", self.directory.display()); + } + } + } + } + + #[test] + fn accepts_the_literal_fixture_and_live_workflow() { + audit_canonical(CANONICAL_SOURCE).unwrap(); + + let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../..").canonicalize().unwrap(); + let reviewed = ReviewedWorkflowJobs::read(root.join(WORKFLOW_REGISTRY_PATH)).unwrap(); + let policy = Policy::read(root.join(POLICY_PATH)).unwrap(); + let repository = RepositoryInventory::audit(&root, &policy).unwrap(); + let workflow = crate::repository_text::read(&root.join(WORKFLOW_PATH)).unwrap(); + audit_planned_adapter(&root, &workflow, &reviewed, &repository).unwrap(); + } + + #[test] + fn matrix_setup_definitions_are_exact() { + assert_eq!(TRUSTED_SHELL_LINE, format!(" shell: {TRUSTED_SHELL}")); + let owned = |step: &[&str]| step.iter().map(|line| (*line).to_owned()).collect(); + for (step_name, step) in [ + ("checkout", owned(CHECKOUT_STEP)), + ("download", download_image_step()), + ("load", owned(LOAD_IMAGE_STEP)), + ("Docker shell", owned(CREATE_DOCKER_SHELL_STEP)), + ("checkout verification", owned(VERIFY_CHECKOUT_STEP)), + ] { + for line in &step { + let changed = format!("{line} unexpected"); + let source = replace_in_step(CANONICAL_SOURCE, &step[0], line, &changed); + rejected(&format!("{step_name}: {line}"), &source, "exact canonical contract"); + } + } + } + + #[test] + fn prerequisite_checkout_overwrites_are_rejected() { + let overwrite_from_wrapper = replace_in_step( + CANONICAL_SOURCE, + CREATE_DOCKER_SHELL_STEP[0], + " set -eo pipefail\n", + " set -eo pipefail\n printf malicious > zerocopy/cargo.sh\n", + ); + rejected( + "wrapper overwrites cargo.sh", + &overwrite_from_wrapper, + "exact canonical contract", + ); + + let inserted_overwrite = replace_in_job( + CANONICAL_SOURCE, + BUILD_JOB, + " - &verify_matrix_checkout\n", + " - name: Replace the executor\n run: printf malicious > zerocopy/cargo.sh\n - &verify_matrix_checkout\n", + ); + rejected("inserted checkout overwrite", &inserted_overwrite, ".steps"); + + let weakened_gate = replace_in_step( + CANONICAL_SOURCE, + VERIFY_CHECKOUT_STEP[0], + "--untracked-files=all --ignored=matching", + "--untracked-files=no", + ); + rejected("weakened checkout gate", &weakened_gate, "exact canonical contract"); + } + + #[cfg(target_os = "linux")] + #[test] + fn checkout_verifier_ignores_mutable_index_config_and_attributes() { + let repository = TemporaryRepository::new("checkout-verifier"); + let cargo = repository.root.join("cargo.sh"); + fs::write(&cargo, "trusted\n").unwrap(); + run_git(&repository.root, &["init", "--quiet", "--initial-branch=main"]); + run_git(&repository.root, &["add", "--", "cargo.sh"]); + run_git( + &repository.root, + &[ + "-c", + "user.name=CI", + "-c", + "user.email=ci@example.invalid", + "commit", + "--quiet", + "-m", + "initial", + ], + ); + let expected = run_git(&repository.root, &["rev-parse", "HEAD"]); + let expected = String::from_utf8(expected.stdout).unwrap(); + let expected = expected.trim(); + + let clean = run_checkout_verification(&repository, expected); + assert!(clean.status.success(), "{}", String::from_utf8_lossy(&clean.stderr)); + + fs::write(&cargo, "skip-worktree replacement\n").unwrap(); + run_git(&repository.root, &["update-index", "--skip-worktree", "cargo.sh"]); + let skipped = run_checkout_verification(&repository, expected); + assert!(!skipped.status.success(), "skip-worktree concealed the overwrite"); + assert!( + String::from_utf8_lossy(&skipped.stderr).contains("Matrix setup modified the checkout"), + "{}", + String::from_utf8_lossy(&skipped.stderr) + ); + + run_git(&repository.root, &["update-index", "--no-skip-worktree", "cargo.sh"]); + fs::write(&cargo, "trusted\n").unwrap(); + run_git(&repository.root, &["add", "--", "cargo.sh"]); + fs::write(repository.root.join(".git/trusted-cargo"), "trusted\n").unwrap(); + fs::write(repository.root.join(".git/info/attributes"), "cargo.sh filter=hide\n").unwrap(); + run_git(&repository.root, &["config", "filter.hide.clean", "cat .git/trusted-cargo"]); + fs::write(&cargo, "filtered replacement\n").unwrap(); + run_git(&repository.root, &["add", "--", "cargo.sh"]); + let concealed = run_git( + &repository.root, + &[ + "status", + "--porcelain=v1", + "--untracked-files=all", + "--ignored=matching", + "--", + ".", + ":(exclude).git", + ], + ); + assert!(concealed.stdout.is_empty(), "filter bypass setup was not concealed"); + + let filtered = run_checkout_verification(&repository, expected); + assert!(!filtered.status.success(), "local clean filter concealed the overwrite"); + assert!( + String::from_utf8_lossy(&filtered.stderr) + .contains("Matrix setup modified the checkout"), + "{}", + String::from_utf8_lossy(&filtered.stderr) + ); + } + + #[test] + fn matrix_step_sequences_and_miri_aliases_are_exact() { + let cases = [ + ( + "build step before checkout", + replace_in_job( + CANONICAL_SOURCE, + BUILD_JOB, + " - &matrix_checkout\n", + " - name: Unexpected setup\n run: true\n - &matrix_checkout\n", + ), + ), + ( + "Miri setup reordered", + replace_in_job( + CANONICAL_SOURCE, + MIRI_JOB, + " - *matrix_checkout\n - *download_ci_image\n", + " - *download_ci_image\n - *matrix_checkout\n", + ), + ), + ( + "Miri alias extended", + replace_in_job( + CANONICAL_SOURCE, + MIRI_JOB, + " - *verify_matrix_checkout\n", + " - *verify_matrix_checkout\n if: success()\n", + ), + ), + ( + "step after Miri executor", + replace_in_job( + CANONICAL_SOURCE, + MIRI_JOB, + " --miri-model \"$MIRI_MODEL\"\n", + " --miri-model \"$MIRI_MODEL\"\n - name: Unexpected tail\n run: true\n", + ), + ), + ]; + for (label, source) in cases { + rejected(label, &source, ".steps"); + } + } + + #[test] + fn matrix_anchor_names_cannot_be_redefined_or_reused_elsewhere() { + for (label, addition, expected) in [ + ( + "second checkout definition", + " steps:\n - &matrix_checkout\n run: echo replacement\n", + "exactly one definition", + ), + ( + "second verification alias", + " steps:\n - *verify_matrix_checkout\n", + "exactly one alias", + ), + ] { + let source = CANONICAL_SOURCE.replace( + " next_job:\n runs-on: ubuntu-latest\n", + &format!(" next_job:\n runs-on: ubuntu-latest\n{addition}"), + ); + rejected(label, &source, expected); + } + + let comments = format!( + "# &matrix_checkout and *verify_matrix_checkout are documentation.\n{CANONICAL_SOURCE}" + ); + audit_canonical(&comments).unwrap(); + } + + #[test] + fn local_download_action_source_is_exact() { + audit_download_action_source(DOWNLOAD_ACTION_EXPECTED_SOURCE).unwrap(); + + for (label, source) in [ + ( + "workspace overwrite", + DOWNLOAD_ACTION_EXPECTED_SOURCE.replacen( + " set -eu\n", + " set -eu\n printf malicious > zerocopy/cargo.sh\n", + 1, + ), + ), + ( + "delayed overwrite", + DOWNLOAD_ACTION_EXPECTED_SOURCE.replacen( + " set -eu\n", + " set -eu\n (sleep 1; printf malicious > zerocopy/cargo.sh) &\n", + 1, + ), + ), + ( + "environment file command", + DOWNLOAD_ACTION_EXPECTED_SOURCE.replacen( + " set -eu\n", + " set -eu\n echo 'RUSTFLAGS=other' >> \"$GITHUB_ENV\"\n", + 1, + ), + ), + ( + "transitive local action", + DOWNLOAD_ACTION_EXPECTED_SOURCE.replacen( + "uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1", + "uses: ../mutable-download", + 1, + ), + ), + ] { + let error = audit_download_action_source(&source).unwrap_err().to_string(); + assert!(error.contains(DOWNLOAD_ACTION_PATH), "{label}: {error}"); + assert!(error.contains("complete compiled source"), "{label}: {error}"); + } + } + + #[test] + fn local_download_action_path_is_contained_and_regular() { + let missing = TemporaryRepository::new("missing"); + let error = read_download_action(&missing.root).unwrap_err(); + assert!(matches!( + error, + super::super::PlannedAdapterAuditError::InspectReviewedSource { .. } + )); + + let repository = TemporaryRepository::new("valid"); + repository.write_action(DOWNLOAD_ACTION_EXPECTED_SOURCE); + repository.write_snapshot(DOWNLOAD_ACTION_EXPECTED_SOURCE); + audit_download_action(&repository.root).unwrap(); + + let directory = TemporaryRepository::new("not-file"); + fs::create_dir_all(directory.action_path()).unwrap(); + let error = read_download_action(&directory.root).unwrap_err(); + assert!(matches!( + error, + super::super::PlannedAdapterAuditError::ReviewedSourceNotFile { .. } + )); + } + + #[test] + fn runtime_snapshot_must_match_the_compiled_snapshot() { + let repository = TemporaryRepository::new("changed-snapshot"); + repository.write_action(DOWNLOAD_ACTION_EXPECTED_SOURCE); + repository.write_snapshot(&format!("{DOWNLOAD_ACTION_EXPECTED_SOURCE}# changed\n")); + + let error = audit_download_action(&repository.root).unwrap_err().to_string(); + assert!(error.contains(DOWNLOAD_ACTION_SNAPSHOT_PATH), "{error}"); + assert!(error.contains("complete compiled source"), "{error}"); + } + + #[cfg(unix)] + #[test] + fn local_download_action_rejects_a_symlink() { + use std::os::unix::fs::symlink; + + let repository = TemporaryRepository::new("symlink"); + let outside = repository.directory.join("outside/action.yml"); + fs::create_dir_all(outside.parent().unwrap()).unwrap(); + fs::write(&outside, "runs:\n using: composite\n steps: []\n").unwrap(); + fs::create_dir_all(repository.action_path().parent().unwrap()).unwrap(); + symlink(&outside, repository.action_path()).unwrap(); + + let error = read_download_action(&repository.root).unwrap_err(); + assert!(matches!( + error, + super::super::PlannedAdapterAuditError::ReviewedSourceSymlink { .. } + )); + } + + #[cfg(unix)] + #[test] + fn local_action_and_snapshot_cannot_alias_each_other() { + use std::os::unix::fs::symlink; + + for direction in ["action-to-snapshot", "snapshot-to-action"] { + let repository = TemporaryRepository::new(direction); + if direction == "action-to-snapshot" { + repository.write_snapshot(DOWNLOAD_ACTION_EXPECTED_SOURCE); + fs::create_dir_all(repository.action_path().parent().unwrap()).unwrap(); + symlink(repository.snapshot_path(), repository.action_path()).unwrap(); + } else { + repository.write_action(DOWNLOAD_ACTION_EXPECTED_SOURCE); + fs::create_dir_all(repository.snapshot_path().parent().unwrap()).unwrap(); + symlink(repository.action_path(), repository.snapshot_path()).unwrap(); + } + + let error = audit_download_action(&repository.root).unwrap_err(); + assert!( + matches!( + error, + super::super::PlannedAdapterAuditError::ReviewedSourceSymlink { .. } + ), + "{direction}: {error:?}" + ); + } + } + + #[test] + fn local_action_and_snapshot_cannot_be_hard_links() { + let repository = TemporaryRepository::new("hard-link"); + repository.write_action(DOWNLOAD_ACTION_EXPECTED_SOURCE); + fs::create_dir_all(repository.snapshot_path().parent().unwrap()).unwrap(); + fs::hard_link(repository.action_path(), repository.snapshot_path()).unwrap(); + + let error = audit_download_action(&repository.root).unwrap_err(); + assert!( + matches!(error, super::super::PlannedAdapterAuditError::DuplicateReviewedSource { .. }), + "{error:?}" + ); + } + + #[test] + fn reviewed_planned_roles_equal_the_two_audited_jobs() { + let mut missing = canonical_planned_jobs(); + missing.remove(&(WORKFLOW_PATH.to_owned(), BUILD_JOB.to_owned())); + assert_rejected( + "missing build role", + audit_source(CANONICAL_SOURCE, &missing), + "must have the reviewed `planned` role", + ); + + let mut extra = canonical_planned_jobs(); + extra.insert((WORKFLOW_PATH.to_owned(), "surprise".to_owned())); + assert_rejected( + "extra planned role", + audit_source(CANONICAL_SOURCE, &extra), + "no planned-job workflow audit", + ); + } + + #[test] + fn matrix_jobs_reject_concurrency_and_require_exact_strategies() { + let cases = [ + ( + "build fail-fast", + replace_in_job(CANONICAL_SOURCE, BUILD_JOB, "fail-fast: false", "fail-fast: true"), + ".strategy.fail-fast", + ), + ( + "missing fail-fast", + replace_in_job(CANONICAL_SOURCE, BUILD_JOB, " fail-fast: false\n", ""), + ".strategy.fail-fast", + ), + ( + "latency serialization", + replace_in_job( + CANONICAL_SOURCE, + BUILD_JOB, + " fail-fast: false\n", + " fail-fast: false\n max-parallel: 1\n", + ), + ".strategy.max-parallel", + ), + ( + "job-level serialization", + replace_in_job( + CANONICAL_SOURCE, + BUILD_JOB, + " strategy:\n", + " concurrency: one-at-a-time\n strategy:\n", + ), + ".concurrency", + ), + ( + "wrong build matrix", + replace_in_job( + CANONICAL_SOURCE, + BUILD_JOB, + "outputs.build_matrix", + "outputs.miri_matrix", + ), + ".strategy.matrix", + ), + ( + "wrong Miri matrix", + replace_in_job( + CANONICAL_SOURCE, + MIRI_JOB, + "outputs.miri_matrix", + "outputs.build_matrix", + ), + ".strategy.matrix", + ), + ( + "scalar strategy", + replace_in_job( + CANONICAL_SOURCE, + BUILD_JOB, + " strategy:\n", + " strategy: fast\n", + ), + "canonical nested mapping", + ), + ]; + for (label, source, expected) in cases { + rejected(label, &source, expected); + } + } + + #[test] + fn matrix_jobs_require_the_planner_gate_and_exact_host_contract() { + let cases = [ + ( + "build dependency", + replace_in_job( + CANONICAL_SOURCE, + BUILD_JOB, + "[build_docker_env, plan_ci]", + "build_docker_env", + ), + "must depend directly", + ), + ( + "Miri dependency", + replace_in_job( + CANONICAL_SOURCE, + MIRI_JOB, + "[build_docker_env, plan_ci]", + "build_docker_env", + ), + "must depend directly", + ), + ( + "extra dependency", + replace_in_job( + CANONICAL_SOURCE, + BUILD_JOB, + "[build_docker_env, plan_ci]", + "[build_docker_env, plan_ci, surprise]", + ), + "unexpected direct dependency `surprise`", + ), + ( + "build condition", + replace_in_job( + CANONICAL_SOURCE, + BUILD_JOB, + " runs-on: ubuntu-latest\n", + " if: success()\n runs-on: ubuntu-latest\n", + ), + ".if", + ), + ( + "Miri condition", + replace_in_job( + CANONICAL_SOURCE, + MIRI_JOB, + "miri_enabled == 'true'", + "miri_enabled == 'false'", + ), + ".if", + ), + ( + "changed runner", + replace_in_job( + CANONICAL_SOURCE, + BUILD_JOB, + "runs-on: ubuntu-latest", + "runs-on: self-hosted", + ), + ".runs-on", + ), + ( + "job container", + replace_in_job( + CANONICAL_SOURCE, + MIRI_JOB, + " runs-on: ubuntu-latest\n", + " runs-on: ubuntu-latest\n container: ignored.invalid/noop\n", + ), + ".container", + ), + ( + "continue on error", + replace_in_job( + CANONICAL_SOURCE, + BUILD_JOB, + " runs-on: ubuntu-latest\n", + " runs-on: ubuntu-latest\n continue-on-error: true\n", + ), + ".continue-on-error", + ), + ]; + for (label, source, expected) in cases { + rejected(label, &source, expected); + } + } + + #[test] + fn matrix_job_fields_permissions_names_and_defaults_are_exact() { + let additions = [ + ("concurrency", " concurrency: one-at-a-time\n"), + ("environment", " environment: protected\n"), + ("env", " env:\n SURPRISE: value\n"), + ("services", " services: {}\n"), + ("timeout-minutes", " timeout-minutes: 1\n"), + ("uses", " uses: example.invalid/owner/workflow@main\n"), + ("with", " with: {}\n"), + ("secrets", " secrets: inherit\n"), + ]; + for expected in [BUILD_EXPECTATION, MIRI_EXPECTATION] { + for (field, addition) in additions { + let source = replace_in_job( + CANONICAL_SOURCE, + expected.job_name, + " runs-on: ubuntu-latest\n", + &format!(" runs-on: ubuntu-latest\n{addition}"), + ); + rejected( + &format!("{} {field}", expected.job_name), + &source, + &format!("{}.{field}", expected.job_name), + ); + } + } + + let cases = [ + ( + "build display name", + replace_in_job( + CANONICAL_SOURCE, + BUILD_JOB, + BUILD_DISPLAY_NAME, + "Build something", + ), + "build_test.name", + ), + ( + "Miri display omits toolchain", + replace_in_job( + CANONICAL_SOURCE, + MIRI_JOB, + MIRI_DISPLAY_NAME, + "Miri (${{ matrix.crate }} / ${{ matrix.feature_profile }} / ${{ matrix.miri_model }} / ${{ matrix.target }})", + ), + "miri.name", + ), + ( + "write permission", + replace_in_job( + CANONICAL_SOURCE, + BUILD_JOB, + " contents: read", + " contents: write", + ), + "build_test.permissions.contents", + ), + ( + "extra permission", + replace_in_job( + CANONICAL_SOURCE, + MIRI_JOB, + " contents: read\n", + " contents: read\n id-token: write\n", + ), + "miri.permissions.id-token", + ), + ( + "default shell", + replace_in_job( + CANONICAL_SOURCE, + BUILD_JOB, + BUILD_DEFAULT_SHELL, + "/tmp/other-shell.sh {0}", + ), + "build_test.defaults.run.shell", + ), + ( + "default working directory", + replace_in_job( + CANONICAL_SOURCE, + BUILD_JOB, + " working-directory: zerocopy", + " working-directory: .", + ), + "build_test.defaults.run.working-directory", + ), + ( + "extra run default", + replace_in_job( + CANONICAL_SOURCE, + BUILD_JOB, + " working-directory: zerocopy\n", + " working-directory: zerocopy\n timeout-minutes: 1\n", + ), + "build_test.defaults.run.timeout-minutes", + ), + ( + "scalar defaults", + replace_in_job( + CANONICAL_SOURCE, + BUILD_JOB, + " defaults:\n", + " defaults: {}\n", + ), + "canonical nested mapping", + ), + ( + "Miri defaults", + replace_in_job( + CANONICAL_SOURCE, + MIRI_JOB, + " permissions:\n", + " defaults: {}\n permissions:\n", + ), + "miri.defaults", + ), + ]; + for (label, source, expected) in cases { + rejected(label, &source, expected); + } + } + + #[test] + fn executor_names_are_unique_only_inside_their_job_steps_mapping() { + let duplicate_mapping = replace_in_job( + CANONICAL_SOURCE, + BUILD_JOB, + " steps:\n", + " steps: []\n steps:\n", + ); + rejected("duplicate steps", &duplicate_mapping, ".steps"); + + let duplicate_step = replace_in_job( + CANONICAL_SOURCE, + BUILD_JOB, + " - name: Execute checked build cell\n", + " - name: Execute checked build cell\n run: echo unrelated\n - name: Execute checked build cell\n", + ); + rejected("duplicate step", &duplicate_step, "inside `build_test.steps`"); + + let same_name_elsewhere = CANONICAL_SOURCE.replace( + " next_job:\n runs-on: ubuntu-latest\n", + " next_job:\n runs-on: ubuntu-latest\n steps:\n - name: Execute checked build cell\n run: echo unrelated\n", + ); + audit_canonical(&same_name_elsewhere).unwrap(); + } + + #[test] + fn selector_environments_are_exact_for_each_matrix_role() { + for expected in [BUILD_EXPECTATION, MIRI_EXPECTATION] { + for selector in expected.selectors { + let canonical = format!( + "{}: ${{{{ matrix.{} }}}}", + selector.environment_name, selector.matrix_field_name + ); + let changed = format!("{}: ${{{{ matrix.wrong }}}}", selector.environment_name); + let source = + replace_in_job(CANONICAL_SOURCE, expected.job_name, &canonical, &changed); + rejected(selector.environment_name, &source, ".env."); + } + } + } + + #[test] + fn both_executor_shells_reject_startup_influence() { + let mut replacements = vec!["bash".to_owned(), TRUSTED_SHELL.replace(" -p ", " ")]; + for variable in ["BASH_ENV", "ENV", "SHELLOPTS", "BASHOPTS"] { + replacements.push(TRUSTED_SHELL.replace(&format!("-u {variable} "), "")); + } + for expected in [BUILD_EXPECTATION, MIRI_EXPECTATION] { + for replacement in &replacements { + let marker = format!(" - name: {}", expected.executor_step_name); + let source = replace_in_step( + CANONICAL_SOURCE, + &marker, + &format!("shell: {TRUSTED_SHELL}"), + &format!("shell: {replacement}"), + ); + rejected(expected.job_name, &source, ".fields.shell"); + } + } + } + + #[test] + fn executor_runs_enforce_absolute_docker_and_container_bash_invariants() { + let cases = [ + (BUILD_JOB, "/usr/bin/docker run --rm", "docker run --rm", "absolute Docker"), + (BUILD_JOB, "--entrypoint /bin/bash", "--entrypoint /bin/sh", "entrypoint"), + (BUILD_JOB, " -- \\\n", "", "option terminator"), + (MIRI_JOB, " --noprofile \\\n", "", "no profile"), + (MIRI_JOB, " --norc \\\n", "", "no rc"), + (MIRI_JOB, " -p \\\n", "", "privileged child"), + ( + BUILD_JOB, + "-e TOOLCHAIN -e CRATE -e TARGET -e FEATURE_PROFILE \\", + "-e TOOLCHAIN -e CRATE -e TARGET -e FEATURE_PROFILE -e MIRI_MODEL \\", + "build forwarding", + ), + ( + MIRI_JOB, + "-e TOOLCHAIN -e CRATE -e TARGET -e FEATURE_PROFILE -e MIRI_MODEL \\", + "-e TOOLCHAIN -e CRATE -e TARGET -e FEATURE_PROFILE \\", + "Miri forwarding", + ), + (BUILD_JOB, EXECUTE_BUILD_CELL_COMMAND, "wrong-build-command", "build command"), + (MIRI_JOB, EXECUTE_MIRI_CELL_COMMAND, "wrong-miri-command", "Miri command"), + ]; + for (job, from, to, label) in cases { + let marker = if job == BUILD_JOB { + " - name: Execute checked build cell" + } else { + " - name: Execute checked Miri cell" + }; + let source = replace_in_step(CANONICAL_SOURCE, marker, from, to); + rejected(label, &source, ".run"); + } + } + + #[test] + fn every_executor_run_line_is_load_bearing() { + for expected in [BUILD_EXPECTATION, MIRI_EXPECTATION] { + for line in executor_run(expected) { + let changed = if line.contains(expected.executor_command) { + line.replace(expected.executor_command, "wrong-command") + } else if line == "set -euo pipefail" { + "set -eo pipefail".to_owned() + } else if line.contains('"') { + line.replacen('"', "", 1) + } else if let Some(line) = line.strip_suffix(" \\") { + line.to_owned() + } else { + format!("{line} --unexpected") + }; + let marker = format!(" - name: {}", expected.executor_step_name); + let source = replace_in_step(CANONICAL_SOURCE, &marker, &line, &changed); + rejected(&line, &source, ".run"); + } + } + } + + #[test] + fn executor_commands_are_globally_unique_and_comments_are_not_runs() { + let duplicate = replace_in_job( + CANONICAL_SOURCE, + MIRI_JOB, + " steps:\n", + &format!(" steps:\n - run: ./cargo.sh ci {EXECUTE_BUILD_CELL_COMMAND}\n"), + ); + rejected("duplicate build command", &duplicate, "command mention"); + + let comment = format!( + "# ./cargo.sh ci {EXECUTE_BUILD_CELL_COMMAND}\n# ./cargo.sh ci {EXECUTE_MIRI_CELL_COMMAND}\n{CANONICAL_SOURCE}" + ); + audit_canonical(&comment).unwrap(); + } +} diff --git a/tools/zc/src/planned_adapter/mod.rs b/tools/zc/src/planned_adapter/mod.rs index dde6aea031..8a4ce2937c 100644 --- a/tools/zc/src/planned_adapter/mod.rs +++ b/tools/zc/src/planned_adapter/mod.rs @@ -10,41 +10,91 @@ //! //! The general workflow inventory in [`crate::workflow`] proves that every job //! has a reviewed role, but it intentionally does not inspect job behavior. -//! The plan producer is a smaller handwritten boundary: it must publish the -//! planner's exact outputs through one unconditional singleton job and one -//! bounded step. A missing output, changed producer, no-op interpreter, or -//! stale CLI command could otherwise silently reduce coverage while the Rust -//! plan and job-ID inventory remained valid. +//! The plan producer and its ordinary build and Miri consumers form a smaller +//! handwritten boundary. The producer must publish exact outputs through one +//! unconditional singleton job. Each planned matrix job must consume the +//! matching output, run only its exact setup sequence, prove that setup left +//! the checkout unchanged, and pass every selector through a real Docker +//! invocation to the typed executor. The local artifact action is mutable +//! repository code, so this boundary also resolves it inside the checkout and +//! requires its complete source to match an independent reviewed snapshot. A +//! missing output, substituted setup step, changed matrix expression, no-op +//! interpreter, conditional step, or dropped selector could otherwise +//! silently reduce coverage while the Rust plan and job-ID inventory remained +//! valid. //! //! This module is deliberately not a YAML or GitHub Actions interpreter. It //! recognizes the canonical source forms which carry the planned-job workflow //! bridge and rejects ambiguous or extended forms. `action-validator` //! continues to own the complete workflow schema. -use std::{collections::BTreeSet, error::Error, fmt}; +use std::{ + collections::BTreeSet, + error::Error, + fmt, io, + path::{Path, PathBuf}, +}; use thiserror::Error; +use crate::{inventory::RepositoryInventory, workflow::ReviewedWorkflowJobs}; + +mod image; +mod matrix; mod planner; +mod reviewed_source; mod source; +#[cfg(test)] +mod test_support; mod yaml_source; -/// Audits the checked workflow's typed plan publication bridge. +/// Audits the checked planned-job workflow and local-action bridge. /// /// `workflow` must be the exact source retained by the earlier workflow /// inventory pass. Taking source rather than a path prevents this behavioral /// audit from reopening a replacement file after its job IDs were approved. -pub(crate) fn audit_planned_adapter(workflow: &str) -> Result<(), PlannedAdapterAuditError> { - audit_source(workflow)?; +/// `repository_root` remains necessary for the other reviewed adapter sources. +pub(crate) fn audit_planned_adapter( + repository_root: &Path, + workflow: &str, + reviewed_jobs: &ReviewedWorkflowJobs, + repository: &RepositoryInventory, +) -> Result<(), PlannedAdapterAuditError> { + let reviewed_planned_jobs = reviewed_jobs + .planned_jobs() + .map(|job| (job.workflow.as_str().to_owned(), job.job.as_str().to_owned())) + .collect::>(); + audit_source(workflow, &reviewed_planned_jobs)?; + // Audit every mutable source and every independent review copy in one + // identity set. Keeping this aggregation here prevents two modules from + // accidentally accepting hard-linked evidence because each saw only its + // own subset of files. + let reviewed_sources = matrix::reviewed_sources() + .iter() + .chain(image::reviewed_sources()) + .copied() + .collect::>(); + reviewed_source::audit_reviewed_sources(repository_root, &reviewed_sources)?; + image::audit_context_shape(repository_root)?; + let mut errors = ViolationSink::default(); + image::audit_toolchain_defaults(repository.toolchain_versions(), &mut errors); + if !errors.is_empty() { + return Err(PlannedAdapterAuditError::Invalid(errors.finish())); + } Ok(()) } -fn audit_source(workflow: &str) -> Result<(), PlannedAdapterViolations> { +fn audit_source( + workflow: &str, + reviewed_planned_jobs: &BTreeSet<(String, String)>, +) -> Result<(), PlannedAdapterViolations> { let mut errors = ViolationSink::default(); let Some(lines) = source::canonical_workflow_lines(workflow, &mut errors) else { return Err(errors.finish()); }; planner::audit(&lines, &mut errors); + image::audit(&lines, &mut errors); + matrix::audit(&lines, reviewed_planned_jobs, &mut errors); if errors.is_empty() { Ok(()) @@ -56,7 +106,78 @@ fn audit_source(workflow: &str) -> Result<(), PlannedAdapterViolations> { /// A failure reading or validating the planned-job workflow bridge. #[derive(Debug, Error)] pub enum PlannedAdapterAuditError { - /// The handwritten bridge no longer publishes the typed plan exactly. + /// A fixed reviewed source could not be resolved or inspected. + #[error("failed to inspect reviewed CI source `{path}`: {source}")] + InspectReviewedSource { + /// Repository-relative audit path joined to the canonical root. + path: PathBuf, + /// Underlying file-system failure. + #[source] + source: io::Error, + }, + /// A fixed reviewed source path contains a symbolic link. + #[error("reviewed CI source path contains symbolic link `{path}`")] + ReviewedSourceSymlink { + /// First symbolic-link component found beneath the canonical root. + path: PathBuf, + }, + /// A fixed reviewed source resolved outside the canonical checkout. + #[error( + "reviewed CI source `{path}` resolves outside repository `{repository_root}` to `{resolved}`" + )] + ReviewedSourceOutsideRepository { + /// Repository-relative audit path joined to the canonical root. + path: PathBuf, + /// Canonical target reached through any symlinks. + resolved: PathBuf, + /// Canonical repository root which must contain the target. + repository_root: PathBuf, + }, + /// A fixed reviewed source path resolved to something other than a file. + #[error("reviewed CI source `{path}` is not a regular file")] + ReviewedSourceNotFile { + /// Canonical path which was inspected. + path: PathBuf, + }, + /// A fixed reviewed source could not be read as repository text. + #[error("failed to read reviewed CI source `{path}`: {source}")] + ReadReviewedSource { + /// Canonical audit path. + path: PathBuf, + /// Underlying file-system or text-normalization failure. + #[source] + source: io::Error, + }, + /// A reviewed source's cross-platform file identity could not be read. + #[error("failed to inspect reviewed CI source identity `{path}`: {source}")] + ReviewedSourceIdentity { + /// Reviewed source path. + path: PathBuf, + /// Underlying file-system failure. + #[source] + source: io::Error, + }, + /// A reviewed source changed between containment and identity checks. + #[error( + "reviewed CI source `{path}` changed while it was opened: first resolved to `{first}`, then to `{second}`" + )] + ReviewedSourceChangedDuringOpen { + /// Direct reviewed path below the repository root. + path: PathBuf, + /// Canonical destination checked before opening. + first: PathBuf, + /// Canonical destination checked after opening. + second: PathBuf, + }, + /// Two supposedly independent reviewed sources are the same file. + #[error("reviewed CI sources `{first_path}` and `{second_path}` resolve to the same file")] + DuplicateReviewedSource { + /// First path encountered for this file identity. + first_path: PathBuf, + /// Later path with the same file identity. + second_path: PathBuf, + }, + /// The handwritten bridge no longer publishes or executes typed plans exactly. #[error(transparent)] Invalid(#[from] PlannedAdapterViolations), } diff --git a/tools/zc/src/planned_adapter/planner.rs b/tools/zc/src/planned_adapter/planner.rs index 70392b593e..2fc7644c61 100644 --- a/tools/zc/src/planned_adapter/planner.rs +++ b/tools/zc/src/planned_adapter/planner.rs @@ -210,7 +210,7 @@ fn audit_job( let environment = BTreeMap::from([("EVENT_NAME".to_owned(), "${{ github.event_name }}".to_owned())]); let run = planner_run(); - if let Some(steps) = audited_steps_block(fields, job, PLAN_JOB, errors) { + if let Some(steps) = audited_steps_block(fields, job, PLAN_JOB, 6, errors) { let actual_steps = exact_step_lines(lines, &steps); if actual_steps.len() != 3 { errors.push( @@ -288,9 +288,18 @@ fn plan_output_expression(output: &str) -> String { mod tests { use std::path::Path; - use super::super::{audit_planned_adapter, audit_source, PlannedAdapterViolations}; - use crate::workflow_protocol::{ - GITHUB_PLAN_COMMAND, PLAN_JOB, PLAN_STEP_NAME, TRUSTED_SHELL, WORKFLOW_PATH, + use super::{ + super::{audit_planned_adapter, test_support::audit_feature, PlannedAdapterViolations}, + audit, + }; + use crate::{ + ci::POLICY_PATH, + inventory::RepositoryInventory, + policy::Policy, + workflow::{ReviewedWorkflowJobs, WORKFLOW_REGISTRY_PATH}, + workflow_protocol::{ + GITHUB_PLAN_COMMAND, PLAN_JOB, PLAN_STEP_NAME, TRUSTED_SHELL, WORKFLOW_PATH, + }, }; const CANONICAL_SOURCE: &str = r#"name: Build & Tests @@ -352,6 +361,10 @@ jobs: runs-on: ubuntu-latest "#; + fn audit_source(source: &str) -> Result<(), PlannedAdapterViolations> { + audit_feature(source, audit) + } + fn rejected(label: &str, source: &str, expected: &str) { let error = match audit_source(source) { Ok(()) => panic!("{label}: mutation was accepted"), @@ -393,8 +406,11 @@ jobs: audit_source(CANONICAL_SOURCE).unwrap(); let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../..").canonicalize().unwrap(); + let reviewed = ReviewedWorkflowJobs::read(root.join(WORKFLOW_REGISTRY_PATH)).unwrap(); + let policy = Policy::read(root.join(POLICY_PATH)).unwrap(); + let repository = RepositoryInventory::audit(&root, &policy).unwrap(); let workflow = crate::repository_text::read(&root.join(WORKFLOW_PATH)).unwrap(); - audit_planned_adapter(&workflow).unwrap(); + audit_planned_adapter(&root, &workflow, &reviewed, &repository).unwrap(); } #[test] diff --git a/tools/zc/src/planned_adapter/reviewed_source.rs b/tools/zc/src/planned_adapter/reviewed_source.rs new file mode 100644 index 0000000000..e7d93c970d --- /dev/null +++ b/tools/zc/src/planned_adapter/reviewed_source.rs @@ -0,0 +1,254 @@ +// Copyright 2026 The Fuchsia Authors +// +// Licensed under a BSD-style license , Apache License, Version 2.0 +// , or the MIT +// license , at your option. +// This file may not be copied, modified, or distributed except according to +// those terms. + +//! Complete-source review boundary for mutable repository-owned CI code. + +use std::{ + collections::HashMap, + fs, + path::{Path, PathBuf}, +}; + +use same_file::Handle; + +use super::{PlannedAdapterAuditError, PlannedAdapterViolations, ViolationSink}; +use crate::repository_file::{self, OpenRepositoryFileError, OpenedRepositoryFile}; + +/// One live CI source and its independent, compiled review copy. +#[derive(Clone, Copy)] +pub(super) struct ReviewedSource { + /// Repository path which GitHub Actions or Docker executes. + pub live_path: &'static str, + /// Independent checked-in copy reviewed with the Rust adapter. + pub snapshot_path: &'static str, + /// Snapshot contents captured when this crate was compiled. + pub expected: &'static str, +} + +/// Checks complete contents and distinct file identity for reviewed sources. +/// +/// A local `uses` path, Dockerfile, or ignore file remains mutable code from +/// the checkout. Comparing its complete normalized text avoids an incomplete +/// blacklist of dangerous commands. Reading the snapshot at runtime as well +/// as compiling it into the binary prevents either path from changing after +/// compilation without detection. +/// +/// The open identity handles stay in `identities` for the whole pass. That +/// makes the comparison stable on platforms which may reuse a file identifier +/// after its last handle closes. It also rejects hard links, which path +/// canonicalization cannot distinguish, while permitting distinct files with +/// identical reviewed contents. +pub(super) fn audit_reviewed_sources( + repository_root: &Path, + sources: &[ReviewedSource], +) -> Result<(), PlannedAdapterAuditError> { + let mut identities = HashMap::new(); + for reviewed in sources { + for path in [reviewed.snapshot_path, reviewed.live_path] { + let (resolved, source, handle) = read_reviewed_source(repository_root, path)?; + if let Some(first_path) = identities.insert(handle, resolved.clone()) { + return Err(PlannedAdapterAuditError::DuplicateReviewedSource { + first_path, + second_path: resolved, + }); + } + audit_exact_source(&source, path, reviewed.expected, reviewed.snapshot_path)?; + } + } + Ok(()) +} + +/// Reads one fixed reviewed path without following checked-in symbolic links. +pub(super) fn read_reviewed_source( + repository_root: &Path, + relative_path: &str, +) -> Result<(PathBuf, String, Handle), PlannedAdapterAuditError> { + let path = inspect_reviewed_source_path(repository_root, relative_path)?; + let opened = open_reviewed_source(repository_root, relative_path, &path)?; + let source = opened.read_to_string().map_err(|source| { + PlannedAdapterAuditError::ReadReviewedSource { path: opened.path().to_path_buf(), source } + })?; + let resolved = opened.path().to_path_buf(); + Ok((resolved, source, opened.into_identity())) +} + +/// Rejects every symbolic-link component visible before a reviewed open. +fn inspect_reviewed_source_path( + repository_root: &Path, + relative_path: &str, +) -> Result { + let path = repository_root.join(relative_path); + let mut component_path = repository_root.to_path_buf(); + for component in Path::new(relative_path) { + component_path.push(component); + let metadata = fs::symlink_metadata(&component_path).map_err(|source| { + PlannedAdapterAuditError::InspectReviewedSource { path: component_path.clone(), source } + })?; + if metadata.file_type().is_symlink() { + return Err(PlannedAdapterAuditError::ReviewedSourceSymlink { path: component_path }); + } + } + Ok(path) +} + +/// Opens exactly the direct path inspected by [`inspect_reviewed_source_path`]. +fn open_reviewed_source( + repository_root: &Path, + relative_path: &str, + path: &Path, +) -> Result { + let opened = + repository_file::open(repository_root, Path::new(relative_path)).map_err(|error| { + match error { + OpenRepositoryFileError::Path { path, source } => { + PlannedAdapterAuditError::InspectReviewedSource { path, source } + } + OpenRepositoryFileError::Identity { path, source } => { + PlannedAdapterAuditError::ReviewedSourceIdentity { path, source } + } + OpenRepositoryFileError::ChangedDuringOpen { path, first, second } => { + PlannedAdapterAuditError::ReviewedSourceChangedDuringOpen { + path, + first, + second, + } + } + OpenRepositoryFileError::OutsideRepository { path, resolved, repository_root } => { + PlannedAdapterAuditError::ReviewedSourceOutsideRepository { + path, + resolved, + repository_root, + } + } + OpenRepositoryFileError::NotFile { path } => { + PlannedAdapterAuditError::ReviewedSourceNotFile { path } + } + } + })?; + // The component inspection preserves a precise diagnostic for a static + // symlink. Requiring exact path equality here closes the later replacement + // window, including a redirection whose target remains in the repository. + if opened.path() != path { + return Err(PlannedAdapterAuditError::ReviewedSourceSymlink { path: path.to_path_buf() }); + } + Ok(opened) +} + +/// Requires one source to equal the snapshot compiled into the audit. +pub(super) fn audit_exact_source( + source: &str, + path: &str, + expected: &str, + snapshot_path: &str, +) -> Result<(), PlannedAdapterViolations> { + if source == expected { + return Ok(()); + } + + let mismatch = source + .lines() + .zip(expected.lines()) + .position(|(actual, expected)| actual != expected) + .map(|index| index + 1) + .unwrap_or_else(|| source.lines().count().min(expected.lines().count()) + 1); + let mut errors = ViolationSink::default(); + errors.push( + format!("{path}:{mismatch}"), + format!( + "reviewed CI source must match the complete compiled source snapshot from `{snapshot_path}`" + ), + ); + Err(errors.finish()) +} + +#[cfg(test)] +mod tests { + use std::{ + fs, + sync::atomic::{AtomicU64, Ordering}, + }; + + use super::{ + audit_reviewed_sources, inspect_reviewed_source_path, open_reviewed_source, ReviewedSource, + }; + + const EXPECTED: &str = "reviewed source\n"; + const SOURCES: &[ReviewedSource] = &[ + ReviewedSource { + live_path: "live/matrix-action.yml", + snapshot_path: "snapshots/matrix-action.yml", + expected: EXPECTED, + }, + ReviewedSource { + live_path: "live/image-action.yml", + snapshot_path: "snapshots/image-action.yml", + expected: EXPECTED, + }, + ]; + + #[test] + fn identity_set_spans_independent_reviewed_source_groups() { + static NEXT_DIRECTORY: AtomicU64 = AtomicU64::new(0); + let unique = NEXT_DIRECTORY.fetch_add(1, Ordering::Relaxed); + let temporary = std::env::temp_dir() + .join(format!("zerocopy-reviewed-source-{}-{unique}", std::process::id())); + let root = temporary.join("repository"); + fs::create_dir_all(root.join("live")).unwrap(); + fs::create_dir_all(root.join("snapshots")).unwrap(); + let root = root.canonicalize().unwrap(); + + for source in SOURCES { + fs::write(root.join(source.live_path), EXPECTED).unwrap(); + fs::write(root.join(source.snapshot_path), EXPECTED).unwrap(); + } + audit_reviewed_sources(&root, SOURCES).unwrap(); + + // Model an alias between paths contributed by two different audit + // modules. Checking each module separately would miss this; the one + // aggregated identity set must reject it. + fs::remove_file(root.join(SOURCES[1].live_path)).unwrap(); + fs::hard_link(root.join(SOURCES[0].live_path), root.join(SOURCES[1].live_path)).unwrap(); + let error = audit_reviewed_sources(&root, SOURCES).unwrap_err().to_string(); + assert!(error.contains("resolve to the same file"), "{error}"); + assert!(error.contains(SOURCES[0].live_path), "{error}"); + assert!(error.contains(SOURCES[1].live_path), "{error}"); + + fs::remove_dir_all(temporary).unwrap(); + } + + #[cfg(unix)] + #[test] + fn replacement_after_symlink_inspection_cannot_redirect_the_open() { + use std::os::unix::fs::symlink; + + static NEXT_DIRECTORY: AtomicU64 = AtomicU64::new(0); + let unique = NEXT_DIRECTORY.fetch_add(1, Ordering::Relaxed); + let temporary = std::env::temp_dir() + .join(format!("zerocopy-reviewed-source-replacement-{}-{unique}", std::process::id())); + let root = temporary.join("repository"); + let candidate = root.join("live/source.yml"); + let retained = root.join("live/retained.yml"); + let outside = temporary.join("outside.yml"); + fs::create_dir_all(candidate.parent().unwrap()).unwrap(); + fs::write(&candidate, EXPECTED).unwrap(); + fs::write(&outside, EXPECTED).unwrap(); + let root = root.canonicalize().unwrap(); + + let path = inspect_reviewed_source_path(&root, "live/source.yml").unwrap(); + fs::rename(&candidate, &retained).unwrap(); + symlink(&outside, &candidate).unwrap(); + + let error = open_reviewed_source(&root, "live/source.yml", &path).unwrap_err(); + assert!(matches!( + error, + super::PlannedAdapterAuditError::ReviewedSourceOutsideRepository { .. } + )); + + fs::remove_dir_all(temporary).unwrap(); + } +} diff --git a/tools/zc/src/planned_adapter/source.rs b/tools/zc/src/planned_adapter/source.rs index a4f709a498..b3525ca669 100644 --- a/tools/zc/src/planned_adapter/source.rs +++ b/tools/zc/src/planned_adapter/source.rs @@ -415,6 +415,24 @@ pub(super) fn audit_read_permissions( ); } +pub(super) fn nested_fields<'a>( + lines: &'a [&'a str], + parent: &Field<'_>, + block_end: usize, + job: &str, + errors: &mut ViolationSink, +) -> Option>> { + if !parent.value.is_empty() { + errors.push( + job_field_location(job, parent.key), + format!("{} must use the canonical nested mapping form", parent.key), + ); + return None; + } + let end = nested_block_end(lines, parent, block_end); + Some(job_fields_at_indent(lines, parent.line..end, job, parent.indent + 2, errors)) +} + pub(super) fn nested_mapping( lines: &[&str], parent: &Field<'_>, @@ -462,6 +480,7 @@ pub(super) fn audited_steps_block( fields: &[Field<'_>], job: Range, job_name: &str, + marker_indent: usize, errors: &mut ViolationSink, ) -> Option { let steps = unique_field(fields, "steps", job_name, errors)?; @@ -477,7 +496,7 @@ pub(super) fn audited_steps_block( .filter_map(|field| (field.line > steps.line).then_some(field.line)) .min() .unwrap_or(job.end); - Some(StepsBlock { range: steps.line + 1..end, marker_indent: steps.indent + 2 }) + Some(StepsBlock { range: steps.line + 1..end, marker_indent }) } /// Returns the significant source lines for each top-level item in `steps`. @@ -485,10 +504,11 @@ pub(super) fn audited_steps_block( /// This is intentionally source-oriented rather than a general YAML parser: /// the planner workflow is a reviewed bridge whose exact checkout, planner, /// and artifact-upload steps must remain coordinated with the Rust protocol. -/// Full-line YAML comments remain free, but a comment indented beneath -/// `run: |` is shell-script content. Preserve the latter because Actions -/// expands `${{ ... }}` before invoking the shell, even on a line Bash will -/// otherwise treat as a comment. +/// Full-line YAML comments remain free, but a comment indented beneath any +/// block scalar is data rather than a YAML comment. Preserve those lines. In +/// particular, Actions expands `${{ ... }}` in a `run: |` scalar before +/// invoking the shell, even on a line Bash will otherwise treat as a comment; +/// non-shell scalars such as `cache-from: |` also treat the line literally. pub(super) fn exact_step_lines<'a>(lines: &'a [&'a str], steps: &StepsBlock) -> Vec> { let starts = lines .iter() @@ -531,7 +551,7 @@ pub(super) fn exact_step_lines<'a>(lines: &'a [&'a str], steps: &StepsBlock) -> continue; } exact.push(*line); - if &line[indent..] == "run: |" { + if is_block_scalar_header(&line[indent..]) { block_scalar_indent = Some(indent); } } @@ -540,6 +560,34 @@ pub(super) fn exact_step_lines<'a>(lines: &'a [&'a str], steps: &StepsBlock) -> .collect() } +/// Recognizes the complete YAML block-scalar indicator grammar. +/// +/// Exact callers accept only their canonical source lines, so this helper is +/// not a general YAML key parser. It only determines whether following +/// comment-looking lines are scalar data which must remain visible to the +/// exact comparison. YAML permits `|` or `>` followed by at most one chomping +/// indicator and at most one nonzero indentation indicator, in either order. +fn is_block_scalar_header(line: &str) -> bool { + let Some((_, value)) = line.split_once(':') else { + return false; + }; + let mut characters = value.trim().chars(); + if !matches!(characters.next(), Some('|' | '>')) { + return false; + } + + let mut saw_chomping = false; + let mut saw_indentation = false; + for character in characters { + match character { + '+' | '-' if !saw_chomping => saw_chomping = true, + '1'..='9' if !saw_indentation => saw_indentation = true, + _ => return false, + } + } + true +} + pub(super) fn audit_step( lines: &[&str], steps: &StepsBlock, @@ -729,6 +777,16 @@ pub(super) fn audit_singleton_job_contract( job: &str, errors: &mut ViolationSink, ) { + audit_host_job_contract(fields, job, errors); + if fields.iter().any(|field| field.key == "strategy") { + errors.push( + job_field_location(job, "strategy"), + "audited singleton jobs must run exactly once, without a strategy", + ); + } +} + +pub(super) fn audit_host_job_contract(fields: &[Field<'_>], job: &str, errors: &mut ViolationSink) { if let Some(runner) = unique_field(fields, "runs-on", job, errors) { if runner.value != HOST_RUNNER { errors.push( @@ -752,12 +810,30 @@ pub(super) fn audit_singleton_job_contract( "audited jobs must not turn failures into successful conclusions", ); } - if fields.iter().any(|field| field.key == "strategy") { - errors.push( - job_field_location(job, "strategy"), - "audited singleton jobs must run exactly once, without a strategy", - ); +} + +pub(super) fn parse_needs(value: &str) -> Result, String> { + let dependencies = if is_job_id(value) { + vec![value] + } else { + let Some(value) = value.strip_prefix('[').and_then(|value| value.strip_suffix(']')) else { + return Err("needs must be one job ID or a canonical inline list".into()); + }; + if value.is_empty() { + return Err("needs list must not be empty".into()); + } + value.split(", ").collect() + }; + let mut unique = BTreeSet::new(); + for dependency in dependencies { + if !is_job_id(dependency) { + return Err(format!("needs contains unsupported job ID `{dependency}`")); + } + if !unique.insert(dependency) { + return Err(format!("needs repeats job `{dependency}`")); + } } + Ok(unique) } pub(super) fn compare_map( diff --git a/tools/zc/src/planned_adapter/test_support.rs b/tools/zc/src/planned_adapter/test_support.rs new file mode 100644 index 0000000000..c762763359 --- /dev/null +++ b/tools/zc/src/planned_adapter/test_support.rs @@ -0,0 +1,70 @@ +// Copyright 2026 The Fuchsia Authors +// +// Licensed under a BSD-style license , Apache License, Version 2.0 +// , or the MIT +// license , at your option. +// This file may not be copied, modified, or distributed except according to +// those terms. + +//! Small helpers for focused audits of literal workflow fixtures. + +use std::collections::BTreeSet; + +use super::{source::canonical_workflow_lines, PlannedAdapterViolations, ViolationSink}; +use crate::workflow_protocol::{BUILD_JOB, MIRI_JOB, WORKFLOW_PATH}; + +pub(super) fn audit_feature( + source: &str, + audit: impl FnOnce(&[&str], &mut ViolationSink), +) -> Result<(), PlannedAdapterViolations> { + let mut errors = ViolationSink::default(); + // Focused fixture audits must apply the same source precondition as the + // full adapter audit. Otherwise a regression test could prove that one + // feature rejects a mutation while production interprets its line + // boundaries differently. + let Some(lines) = canonical_workflow_lines(source, &mut errors) else { + return Err(errors.finish()); + }; + audit(&lines, &mut errors); + if errors.is_empty() { + Ok(()) + } else { + Err(errors.finish()) + } +} + +pub(super) fn assert_rejected( + label: &str, + result: Result<(), PlannedAdapterViolations>, + expected: &str, +) { + let error = match result { + Ok(()) => panic!("{label}: mutation was accepted"), + Err(error) => error, + }; + assert!(error.to_string().contains(expected), "{label}: {error}"); +} + +pub(super) fn replace_in_job(source: &str, job: &str, from: &str, to: &str) -> String { + let marker = format!(" {job}:\n"); + let start = source.find(&marker).unwrap_or_else(|| panic!("missing fixture job {job}")); + let remainder = &source[start + marker.len()..]; + let end = remainder + .match_indices('\n') + .find_map(|(offset, _)| { + let next = &remainder[offset + 1..]; + (next.starts_with(" ") && !next.starts_with(" ")) + .then_some(start + marker.len() + offset + 1) + }) + .unwrap_or(source.len()); + let block = &source[start..end]; + assert!(block.contains(from), "job {job} did not contain {from:?}"); + format!("{}{}{}", &source[..start], block.replacen(from, to, 1), &source[end..]) +} + +pub(super) fn canonical_planned_jobs() -> BTreeSet<(String, String)> { + [BUILD_JOB, MIRI_JOB] + .into_iter() + .map(|job| (WORKFLOW_PATH.to_owned(), job.to_owned())) + .collect() +} diff --git a/tools/zc/src/planned_adapter/yaml_source.rs b/tools/zc/src/planned_adapter/yaml_source.rs index 0d54203f64..ca79b44994 100644 --- a/tools/zc/src/planned_adapter/yaml_source.rs +++ b/tools/zc/src/planned_adapter/yaml_source.rs @@ -8,7 +8,7 @@ //! Parser-backed preconditions for the canonical workflow source scanner. -use std::{marker::PhantomData, mem::MaybeUninit}; +use std::{ffi::CStr, marker::PhantomData, mem::MaybeUninit}; use libyaml_rs::{ yaml_event_delete, yaml_event_t, yaml_parser_delete, yaml_parser_initialize, yaml_parser_parse, @@ -21,6 +21,8 @@ use libyaml_rs::{ YAML_STREAM_END_EVENT, YAML_STREAM_START_EVENT, YAML_UTF8_ENCODING, }; +use crate::workflow_protocol::MATRIX_STEP_ANCHORS; + #[derive(Debug)] pub(super) struct Violation { pub line: Option, @@ -175,9 +177,12 @@ pub(super) fn require_line_local_flow_nodes(source: &str) -> Result<(), Violatio "flow sequences must stay on one source line so canonical indentation remains structural", ), YAML_ALIAS_EVENT => { - Some(Violation { + // SAFETY: this event tag activates the alias arm, whose + // anchor remains allocated until the event is deleted. + let anchor = unsafe { event.data.alias.anchor }; + (!reviewed_anchor(anchor)).then_some(Violation { line: Some(start_line), - message: "YAML aliases are not supported by the canonical workflow source", + message: "only the reviewed matrix step aliases may appear in the canonical workflow source", }) } YAML_STREAM_START_EVENT | YAML_STREAM_END_EVENT | YAML_DOCUMENT_END_EVENT => None, @@ -215,12 +220,24 @@ fn node_property_violation(anchor: *const u8, tag: *const u8, line: usize) -> Op message: "explicit YAML tags are not supported by the canonical workflow source", }); } - (!anchor.is_null()).then_some(Violation { + (!anchor.is_null() && !reviewed_anchor(anchor)).then_some(Violation { line: Some(line), - message: "YAML anchors are not supported by the canonical workflow source", + message: + "only the reviewed matrix step anchors may appear in the canonical workflow source", }) } +fn reviewed_anchor(anchor: *const u8) -> bool { + if anchor.is_null() { + return false; + } + // SAFETY: libyaml supplies every non-null anchor as a NUL-terminated byte + // string owned by the current event. Callers invoke this helper before + // deleting that event, and the bytes are only compared, never retained. + let anchor = unsafe { CStr::from_ptr(anchor.cast()) }.to_bytes(); + MATRIX_STEP_ANCHORS.iter().any(|reviewed| anchor == reviewed.as_bytes()) +} + fn close_collection( collections: &mut Vec, expected: CollectionKind, @@ -340,9 +357,17 @@ mod tests { } #[test] - fn anchors_and_aliases_are_rejected() { - rejected("value: &replay_planner text\n", "YAML anchors are not supported"); - rejected("first: value\nsecond: *replay_planner\n", "YAML aliases are not supported"); + fn only_reviewed_matrix_anchors_and_aliases_are_supported() { + rejected("value: &replay_planner text\n", "reviewed matrix step anchors"); + rejected( + "first: &matrix_checkout value\nsecond: *replay_planner\n", + "reviewed matrix step aliases", + ); + + require_line_local_flow_nodes( + "first: &matrix_checkout {value: one}\nsecond: *matrix_checkout\n", + ) + .unwrap(); } #[test] diff --git a/tools/zc/src/repository_file.rs b/tools/zc/src/repository_file.rs index 7a9f52bfc2..c9b76c9588 100644 --- a/tools/zc/src/repository_file.rs +++ b/tools/zc/src/repository_file.rs @@ -68,6 +68,11 @@ impl OpenedRepositoryFile { let current = Handle::from_path(path)?; Ok(retained == current) } + + /// Consumes this input and retains its independently open identity handle. + pub(crate) fn into_identity(self) -> Handle { + self.identity + } } /// Opens one repository input through canonical containment and identity diff --git a/tools/zc/src/workflow.rs b/tools/zc/src/workflow.rs index 806cced92e..ccc514a6d1 100644 --- a/tools/zc/src/workflow.rs +++ b/tools/zc/src/workflow.rs @@ -280,6 +280,18 @@ impl ReviewedWorkflowJobs { pub fn role(&self, job: &WorkflowJob) -> Option { self.jobs.get(job).copied() } + + /// Iterates over the exact reviewed set delegated to the typed planner. + /// + /// The planned-job workflow audit compares this set with its fixed matrix + /// expectations. A registry edit therefore cannot label another job + /// `planned` without extending the behavioral audit which proves that job + /// consumes and executes a checked plan. + pub fn planned_jobs(&self) -> impl Iterator { + self.jobs + .iter() + .filter_map(|(job, role)| (*role == WorkflowJobRole::Planned).then_some(job)) + } } /// Checks every live workflow job against its reviewed role assignment. diff --git a/tools/zc/src/workflow_protocol.rs b/tools/zc/src/workflow_protocol.rs index 794a4b51fd..6483f68f96 100644 --- a/tools/zc/src/workflow_protocol.rs +++ b/tools/zc/src/workflow_protocol.rs @@ -15,9 +15,37 @@ pub(crate) const WORKFLOW_PATH: &str = ".github/workflows/ci.yml"; pub(crate) const PLAN_JOB: &str = "plan_ci"; +pub(crate) const BUILD_JOB: &str = "build_test"; +pub(crate) const MIRI_JOB: &str = "miri"; +pub(crate) const IMAGE_JOB: &str = "build_docker_env"; + +// These are the workflow's only permitted YAML anchors. The build matrix owns +// one exact definition of each and Miri owns one exact alias of each. Keep this +// list coordinated with `.github/workflows/ci.yml`, the complete step snippets +// and ownership counts in `planned_adapter/matrix.rs`, and the parser-event +// allowlist in `planned_adapter/yaml_source.rs`. The parser boundary rejects +// every other anchor or alias so an audited command cannot be replayed from a +// second job while appearing only once in source. +pub(crate) const MATRIX_STEP_ANCHORS: &[&str] = + &["matrix_checkout", "download_ci_image", "load_ci_image", "verify_matrix_checkout"]; + +pub(crate) const IMAGE_ARTIFACT_OUTPUT: &str = "image_artifact_id"; +pub(crate) const IMAGE_UPLOAD_STEP_ID: &str = "upload_image"; + +/// Derives the producer output from the same step ID audited on the job. +pub(crate) fn image_artifact_producer_expression() -> String { + format!("${{{{ steps.{IMAGE_UPLOAD_STEP_ID}.outputs.artifact-id }}}}") +} + +/// Derives the consumer input from the producer's shared job and output IDs. +pub(crate) fn image_artifact_consumer_line() -> String { + format!(" artifact-id: ${{{{ needs.{IMAGE_JOB}.outputs.{IMAGE_ARTIFACT_OUTPUT} }}}}") +} pub(crate) const PLAN_STEP_NAME: &str = "Validate inputs and project the plan"; pub(crate) const PLAN_STEP_ID: &str = "plan"; +pub(crate) const BUILD_STEP_NAME: &str = "Execute checked build cell"; +pub(crate) const MIRI_STEP_NAME: &str = "Execute checked Miri cell"; pub(crate) const GITHUB_PLAN_COMMAND: &str = "github-plan"; pub(crate) const EXECUTE_BUILD_CELL_COMMAND: &str = "execute-build-cell"; @@ -40,3 +68,6 @@ pub(crate) const HOST_RUNNER: &str = "ubuntu-latest"; pub(crate) const REPOSITORY_WORKING_DIRECTORY: &str = "zerocopy"; pub(crate) const TRUSTED_SHELL: &str = "/usr/bin/env -u BASH_ENV -u ENV -u SHELLOPTS -u BASHOPTS /bin/bash --noprofile --norc -p -euo pipefail -- {0}"; pub(crate) const PLANNER_PATH: &str = "/home/runner/.cargo/bin:/usr/local/bin:/usr/bin:/bin"; +pub(crate) const HOST_DOCKER_RUN: &str = "/usr/bin/docker run --rm \\"; +pub(crate) const DOCKER_ENTRYPOINT_ARGUMENT: &str = " --entrypoint /bin/bash \\"; +pub(crate) const DOCKER_OPTION_TERMINATOR: &str = " -- \\"; diff --git a/tools/zc/testdata/ci-image.Dockerfile b/tools/zc/testdata/ci-image.Dockerfile new file mode 100644 index 0000000000..9dd0bd1f46 --- /dev/null +++ b/tools/zc/testdata/ci-image.Dockerfile @@ -0,0 +1,73 @@ +# Copyright 2026 The Fuchsia Authors +# +# Licensed under a BSD-style license , Apache License, Version 2.0 +# , or the MIT +# license , at your option. +# This file may not be copied, modified, or distributed except according to +# those terms. + +# `build_docker_env` builds this file as the runtime for typed matrix cells. +# This directory is the complete, isolated Docker context; the producer audit +# rejects any entry other than this file and `.dockerignore`. Keep the complete +# source coordinated with +# `tools/zc/testdata/ci-image.Dockerfile` and +# `tools/zc/src/planned_adapter/image.rs`; the producer audit rejects a +# one-sided change before matrix fan-out. + +FROM ubuntu:24.04 + +# These are the same bounded, download-only retry counts configured in +# `ci.yml`. Defining them before the first networked build step covers rustup +# and Cargo operations while the image is built; the workflow-level values +# cover host operations and are forwarded into containers at runtime. +ENV CARGO_NET_RETRY=10 \ + RUSTUP_MAX_RETRIES=10 + +# Use `DEBIAN_FRONTEND=noninteractive` to prevent timezone prompts. +RUN apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y \ + gcc-multilib \ + llvm \ + curl \ + jq \ + build-essential \ + pkg-config \ + libssl-dev \ + bc \ + git \ + # Remove large intermediate artifacts to ensure that this step doesn't bloat + # the Docker image cache. + && rm -rf /var/lib/apt/lists/* + +RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal && \ + # Remove large intermediate artifacts to ensure that this step doesn't bloat + # the Docker image cache. + rm -rf /root/.cargo/registry /root/.cargo/git + +ENV PATH="/root/.cargo/bin:${PATH}" + +RUN cargo install cargo-nextest --locked && \ + cargo install cargo-readme --version 3.2.0 && \ + cargo install --locked action-validator --version 0.8.0 && \ + rm -rf /root/.cargo/registry /root/.cargo/git + +# Install the three high-traffic toolchains without executing code from the +# checkout. The build previously copied and ran cargo-zerocopy, which made the +# image depend on the entire mutable `tools` tree and allowed a change there to +# replace Cargo before matrix execution. `planned_adapter/image.rs` checks +# these defaults against the validated toolchain inventory, so changing a pin +# in `zerocopy/Cargo.toml` fails CI until this cache seed is updated too. +ARG ZC_MSRV_TOOLCHAIN=1.56.0 +ARG ZC_STABLE_TOOLCHAIN=1.93.1 +ARG ZC_NIGHTLY_TOOLCHAIN=nightly-2026-01-25 +RUN rustup toolchain install "$ZC_MSRV_TOOLCHAIN" \ + -c rust-src -c rustfmt -c clippy && \ + rustup toolchain install "$ZC_STABLE_TOOLCHAIN" \ + -c rust-src -c rustfmt -c clippy && \ + rustup toolchain install "$ZC_NIGHTLY_TOOLCHAIN" \ + -c rust-src -c rustfmt -c clippy -c miri && \ + # Remove large intermediate artifacts to ensure that this step doesn't bloat + # the Docker image cache. + rm -rf /root/.cargo/registry /root/.cargo/git /root/.rustup/toolchains/*/share/doc + +ENV CARGO_ZEROCOPY_AUTO_INSTALL_TOOLCHAIN=1 +WORKDIR /workspace diff --git a/tools/zc/testdata/ci-image.dockerignore b/tools/zc/testdata/ci-image.dockerignore new file mode 100644 index 0000000000..7e5752089e --- /dev/null +++ b/tools/zc/testdata/ci-image.dockerignore @@ -0,0 +1,14 @@ +# Copyright 2026 The Fuchsia Authors +# +# Licensed under the 2-Clause BSD License , Apache License, Version 2.0 +# , or the MIT +# license , at your option. +# This file may not be copied, modified, or distributed except according to +# those terms. + +# The CI image must not receive repository files as build-context inputs. This +# isolated directory and its complete entry set are audited by +# `tools/zc/src/planned_adapter/image.rs`; Docker still receives its Dockerfile +# and this ignore file for the build, but neither is available to COPY. +* diff --git a/tools/zc/testdata/download-artifact-with-retry.action.yml b/tools/zc/testdata/download-artifact-with-retry.action.yml new file mode 100644 index 0000000000..810586e073 --- /dev/null +++ b/tools/zc/testdata/download-artifact-with-retry.action.yml @@ -0,0 +1,106 @@ +name: Download artifact with retry +description: Download one artifact, retrying transient service and transfer failures + +inputs: + artifact-id: + description: Immutable artifact ID received from the producer job + required: true + path: + description: Directory into which the artifact is extracted + required: true + expected-file: + description: File expected directly under path after extraction + required: true + +runs: + using: composite + steps: + - name: Validate artifact contract + shell: bash + env: + ARTIFACT_ID: ${{ inputs.artifact-id }} + ARTIFACT_PATH: ${{ inputs.path }} + EXPECTED_FILE: ${{ inputs.expected-file }} + run: | + set -eu + + # With neither `name` nor `artifact-ids`, download-artifact downloads + # *every* artifact in the run. Validate the producer-provided ID before + # invoking it so an accidentally empty job output fails closed. IDs also + # avoid binding to a stale or similarly named artifact: v4+ artifacts + # are immutable, and each successful upload receives a unique ID. + if [[ ! "$ARTIFACT_ID" =~ ^[0-9]+$ ]]; then + echo "Artifact ID must be a nonempty integer: $ARTIFACT_ID" >&2 + exit 1 + fi + if [[ -z "$ARTIFACT_PATH" ]]; then + echo "Artifact destination path must not be empty" >&2 + exit 1 + fi + if [[ -z "$EXPECTED_FILE" || "$EXPECTED_FILE" == */* || "$EXPECTED_FILE" == "." || "$EXPECTED_FILE" == ".." ]]; then + echo "Expected artifact file must be a filename, not a path: $EXPECTED_FILE" >&2 + exit 1 + fi + + # download-artifact retries some blob transfers internally, but failures + # while looking up an artifact or obtaining a signed URL happen outside + # that retry loop. Retrying the complete action reacquires both. A failed + # extraction can leave a partial file behind, so remove exactly the one + # expected file before retrying; never clear the caller's whole directory. + - name: Download artifact (attempt 1) + id: download_1 + continue-on-error: true + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + artifact-ids: ${{ inputs.artifact-id }} + path: ${{ inputs.path }} + + - name: Clean up and wait to retry artifact download + if: ${{ !cancelled() && steps.download_1.outcome == 'failure' }} + shell: bash + env: + ARTIFACT_PATH: ${{ inputs.path }} + EXPECTED_FILE: ${{ inputs.expected-file }} + run: | + set -eu + rm -f -- "$ARTIFACT_PATH/$EXPECTED_FILE" + delay=$((5 + RANDOM % 11)) + echo "Artifact download failed; retrying in ${delay}s" + sleep "$delay" + + - name: Download artifact (attempt 2) + id: download_2 + if: ${{ !cancelled() && steps.download_1.outcome == 'failure' }} + continue-on-error: true + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + artifact-ids: ${{ inputs.artifact-id }} + path: ${{ inputs.path }} + + - name: Clean up and wait to retry artifact download again + if: ${{ !cancelled() && steps.download_2.outcome == 'failure' }} + shell: bash + env: + ARTIFACT_PATH: ${{ inputs.path }} + EXPECTED_FILE: ${{ inputs.expected-file }} + run: | + set -eu + rm -f -- "$ARTIFACT_PATH/$EXPECTED_FILE" + delay=$((15 + RANDOM % 16)) + echo "Artifact download failed again; retrying in ${delay}s" + sleep "$delay" + + - name: Download artifact (attempt 3) + if: ${{ !cancelled() && steps.download_2.outcome == 'failure' }} + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + artifact-ids: ${{ inputs.artifact-id }} + path: ${{ inputs.path }} + + - name: Verify downloaded artifact + if: ${{ !cancelled() }} + shell: bash + env: + ARTIFACT_PATH: ${{ inputs.path }} + EXPECTED_FILE: ${{ inputs.expected-file }} + run: test -s "$ARTIFACT_PATH/$EXPECTED_FILE" diff --git a/tools/zc/testdata/setup-docker-with-retry.action.yml b/tools/zc/testdata/setup-docker-with-retry.action.yml new file mode 100644 index 0000000000..b985c5b6e2 --- /dev/null +++ b/tools/zc/testdata/setup-docker-with-retry.action.yml @@ -0,0 +1,107 @@ +# `build_docker_env` executes this mutable local action before producing the CI +# image. Keep this complete file coordinated with +# `tools/zc/testdata/setup-docker-with-retry.action.yml` and the source list in +# `tools/zc/src/planned_adapter/image.rs`; the typed adapter intentionally +# rejects a one-sided edit. +name: Set up Docker with retry +description: Set up Buildx and authenticate to a registry, retrying transient failures + +inputs: + registry: + description: Container registry hostname + required: false + default: ghcr.io + username: + description: Container registry username + required: true + password: + description: Container registry password or token + required: true + +runs: + using: composite + steps: + # Retry the actions themselves rather than reproducing their behavior in + # shell. This preserves Buildx's builder cleanup and login-action's use of + # password-stdin and its end-of-job logout. The first two attempts use + # `continue-on-error` only so this composite can inspect `outcome`; the last + # attempt fails the caller normally. + - name: Set up Docker Buildx (attempt 1) + id: buildx_1 + continue-on-error: true + uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 + + - name: Wait to retry Docker Buildx + if: ${{ !cancelled() && steps.buildx_1.outcome == 'failure' }} + shell: bash + run: | + set -eu + delay=$((5 + RANDOM % 11)) + echo "Docker Buildx setup failed; retrying in ${delay}s" + sleep "$delay" + + - name: Set up Docker Buildx (attempt 2) + id: buildx_2 + if: ${{ !cancelled() && steps.buildx_1.outcome == 'failure' }} + continue-on-error: true + uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 + + - name: Wait to retry Docker Buildx again + if: ${{ !cancelled() && steps.buildx_2.outcome == 'failure' }} + shell: bash + run: | + set -eu + delay=$((15 + RANDOM % 16)) + echo "Docker Buildx setup failed again; retrying in ${delay}s" + sleep "$delay" + + - name: Set up Docker Buildx (attempt 3) + if: ${{ !cancelled() && steps.buildx_2.outcome == 'failure' }} + uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 + + # Keep the token inside action inputs. In particular, do not pass it to a + # shell or expose it in a `docker login` command line. + - name: Log in to the container registry (attempt 1) + id: login_1 + continue-on-error: true + uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1 + with: + registry: ${{ inputs.registry }} + username: ${{ inputs.username }} + password: ${{ inputs.password }} + + - name: Wait to retry container registry login + if: ${{ !cancelled() && steps.login_1.outcome == 'failure' }} + shell: bash + run: | + set -eu + delay=$((5 + RANDOM % 11)) + echo "Container registry login failed; retrying in ${delay}s" + sleep "$delay" + + - name: Log in to the container registry (attempt 2) + id: login_2 + if: ${{ !cancelled() && steps.login_1.outcome == 'failure' }} + continue-on-error: true + uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1 + with: + registry: ${{ inputs.registry }} + username: ${{ inputs.username }} + password: ${{ inputs.password }} + + - name: Wait to retry container registry login again + if: ${{ !cancelled() && steps.login_2.outcome == 'failure' }} + shell: bash + run: | + set -eu + delay=$((15 + RANDOM % 16)) + echo "Container registry login failed again; retrying in ${delay}s" + sleep "$delay" + + - name: Log in to the container registry (attempt 3) + if: ${{ !cancelled() && steps.login_2.outcome == 'failure' }} + uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1 + with: + registry: ${{ inputs.registry }} + username: ${{ inputs.username }} + password: ${{ inputs.password }} diff --git a/tools/zc/testdata/upload-file-artifact.action.yml b/tools/zc/testdata/upload-file-artifact.action.yml new file mode 100644 index 0000000000..e54215218d --- /dev/null +++ b/tools/zc/testdata/upload-file-artifact.action.yml @@ -0,0 +1,99 @@ +# `build_docker_env` uses this mutable local action to publish the CI image. +# Keep this complete file coordinated with +# `tools/zc/testdata/upload-file-artifact.action.yml` and the source list in +# `tools/zc/src/planned_adapter/image.rs`. Other callers share the same exact +# implementation, so a behavior change requires deliberate adapter review. +name: Upload file artifact +description: Publish one exact file for jobs in this workflow run + +inputs: + name: + description: Artifact name; must exactly match the basename of path + required: true + path: + description: Exact path of the nonempty file to publish + required: true + retention-days: + description: Days to retain the artifact, from 1 through 90 + required: false + default: "1" + +outputs: + artifact-id: + description: Immutable ID assigned to the published artifact + value: ${{ steps.upload.outputs.artifact-id }} + artifact-digest: + description: SHA-256 digest assigned to the published artifact + value: ${{ steps.upload.outputs.artifact-digest }} + +runs: + using: composite + steps: + - name: Validate artifact contract + shell: bash + env: + ARTIFACT_NAME: ${{ inputs.name }} + ARTIFACT_PATH: ${{ inputs.path }} + RETENTION_DAYS: ${{ inputs.retention-days }} + run: | + set -euo pipefail + + # upload-artifact v7's direct-file mode derives the published name from + # the file basename, even though its overwrite path still looks up the + # explicit `name` input. Requiring them to match makes overwrite reliable + # on a full workflow rerun and prevents that subtle action contract from + # becoming an implicit coupling in each caller. + if [[ -z "$ARTIFACT_NAME" || "$ARTIFACT_NAME" == */* || "$ARTIFACT_NAME" == "." || "$ARTIFACT_NAME" == ".." ]]; then + echo "Artifact name must be a nonempty filename, not a path: $ARTIFACT_NAME" >&2 + exit 1 + fi + if [[ "${ARTIFACT_PATH##*/}" != "$ARTIFACT_NAME" ]]; then + echo "Artifact name '$ARTIFACT_NAME' does not match path basename '${ARTIFACT_PATH##*/}'" >&2 + exit 1 + fi + if [[ ! -s "$ARTIFACT_PATH" ]]; then + echo "Artifact is missing or empty: $ARTIFACT_PATH" >&2 + exit 1 + fi + if [[ ! "$RETENTION_DAYS" =~ ^([1-9]|[1-8][0-9]|90)$ ]]; then + echo "Artifact retention must be an integer from 1 through 90: $RETENTION_DAYS" >&2 + exit 1 + fi + + artifact_size=$(stat --format=%s "$ARTIFACT_PATH") + echo "Uploading $ARTIFACT_NAME ($artifact_size bytes)" | tee -a "$GITHUB_STEP_SUMMARY" + + # The large tar callers are already compressed (Docker's image layers use + # gzip; Anneal uses zstd), while the CI plan caller needs its exact JSON + # bytes preserved for review. Version 7's direct-file mode handles both + # without a redundant ZIP. Keep this coordinated with download-artifact v8 + # in `../download-artifact-with-retry/action.yml`, which understands direct + # artifacts and verifies their service-provided digest. + - name: Upload artifact + id: upload + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: ${{ inputs.name }} + path: ${{ inputs.path }} + if-no-files-found: error + retention-days: ${{ inputs.retention-days }} + archive: false + # Artifacts are immutable. Delete an artifact with the same validated + # name first so "Re-run all jobs" can republish it under a new ID. + overwrite: true + + - name: Verify published artifact metadata + shell: bash + env: + ARTIFACT_DIGEST: ${{ steps.upload.outputs.artifact-digest }} + ARTIFACT_ID: ${{ steps.upload.outputs.artifact-id }} + run: | + set -eu + if [[ ! "$ARTIFACT_ID" =~ ^[0-9]+$ ]]; then + echo "Upload did not return a numeric artifact ID: $ARTIFACT_ID" >&2 + exit 1 + fi + if [[ -z "$ARTIFACT_DIGEST" ]]; then + echo "Upload did not return an artifact digest" >&2 + exit 1 + fi