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