diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..e777876c --- /dev/null +++ b/.dockerignore @@ -0,0 +1,21 @@ +** +!.dockerignore +!.tool-versions +!.github/ +!.github/actions/ +!.github/actions/deploy-fastly/ +!.github/actions/deploy-fastly/versions.json +!.github/docker/ +!.github/docker/build-app-cli/ +!.github/docker/build-app-cli/Dockerfile +!.github/docker/build-app-cli/fixtures/ +!.github/docker/build-app-cli/fixtures/gnu-smoke.rs +!.github/docker/build-app-cli/fixtures/provenance/ +!.github/docker/build-app-cli/fixtures/provenance/** +!.github/docker/build-app-cli/fixtures/wasm-smoke.rs +!.github/docker/build-app-cli/image-context-paths.txt +!.github/docker/build-app-cli/provenance.schema.json +!.github/docker/build-app-cli/verify-toolchain.sh +!.github/tools/ +!.github/tools/edgezero-provenance-validator/ +!.github/tools/edgezero-provenance-validator/** diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 00000000..22245a2e --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,108 @@ +/.dockerignore @stackpop/edgezero-build-container-gate-reviewers +/.github/CODEOWNERS @stackpop/edgezero-build-container-gate-reviewers +/.github/actions/deploy-core/tests/assert-build-container-app-token.test.sh @stackpop/edgezero-build-container-gate-reviewers +/.github/actions/deploy-core/tests/assert-build-container-completion.test.sh @stackpop/edgezero-build-container-gate-reviewers +/.github/actions/deploy-core/tests/assert-build-container-context.test.sh @stackpop/edgezero-build-container-gate-reviewers +/.github/actions/deploy-core/tests/assert-build-container-dispatch-context.test.sh @stackpop/edgezero-build-container-gate-reviewers +/.github/actions/deploy-core/tests/build-container-workflows.test.sh @stackpop/edgezero-build-container-gate-reviewers +/.github/actions/deploy-core/tests/check-action-pins.sh @stackpop/edgezero-build-container-gate-reviewers +/.github/actions/deploy-core/tests/check-build-container-publisher.test.sh @stackpop/edgezero-build-container-gate-reviewers +/.github/actions/deploy-core/tests/check-doc-action-pins.mjs @stackpop/edgezero-build-container-gate-reviewers +/.github/actions/deploy-core/tests/check-doc-action-pins.sh @stackpop/edgezero-build-container-gate-reviewers +/.github/actions/deploy-core/tests/check-doc-action-pins.test.mjs @stackpop/edgezero-build-container-gate-reviewers +/.github/actions/deploy-core/tests/check-image-pin.test.sh @stackpop/edgezero-build-container-gate-reviewers +/.github/actions/deploy-core/tests/classify-build-container-change.test.sh @stackpop/edgezero-build-container-gate-reviewers +/.github/actions/deploy-core/tests/install-actionlint.test.sh @stackpop/edgezero-build-container-gate-reviewers +/.github/actions/deploy-core/tests/install-yq.test.sh @stackpop/edgezero-build-container-gate-reviewers +/.github/actions/deploy-core/tests/release-approval-gate.test.sh @stackpop/edgezero-build-container-gate-reviewers +/.github/actions/deploy-core/tests/run-actionlint.test.sh @stackpop/edgezero-build-container-gate-reviewers +/.github/actions/deploy-core/tests/run-build-container-gate.test.sh @stackpop/edgezero-build-container-gate-reviewers +/.github/actions/deploy-core/tests/run.sh @stackpop/edgezero-build-container-gate-reviewers +/.github/actions/deploy-core/tests/select-build-container-range.test.sh @stackpop/edgezero-build-container-gate-reviewers +/.github/actions/deploy-core/tests/stage-build-context.test.sh @stackpop/edgezero-build-container-gate-reviewers +/.github/actions/deploy-core/tests/update-image-pin-pr.test.sh @stackpop/edgezero-build-container-gate-reviewers +/.github/actions/deploy-core/tests/verify-build-container-publication.test.sh @stackpop/edgezero-build-container-gate-reviewers +/.github/actions/deploy-core/tests/verify-gate-rotation-lock.test.sh @stackpop/edgezero-build-container-gate-reviewers +/.github/actions/deploy-core/tests/verify-published-image.test.sh @stackpop/edgezero-build-container-gate-reviewers +/.github/actions/deploy-core/tests/verify-release-prerequisites.test.sh @stackpop/edgezero-build-container-gate-reviewers +/.github/actions/deploy-core/tests/verify-toolchain.test.sh @stackpop/edgezero-build-container-gate-reviewers +/.github/actions/deploy-core/tests/write-image-release-record.test.sh @stackpop/edgezero-build-container-gate-reviewers +/.github/actions/deploy-core/tests/write-publisher-prerequisite.test.sh @stackpop/edgezero-build-container-gate-reviewers +/.github/actions/deploy-fastly/versions.json @stackpop/edgezero-build-container-gate-reviewers +/.github/docker/build-app-cli/Dockerfile @stackpop/edgezero-build-container-gate-reviewers +/.github/docker/build-app-cli/assert-build-container-app-token.sh @stackpop/edgezero-build-container-gate-reviewers +/.github/docker/build-app-cli/assert-build-container-completion.sh @stackpop/edgezero-build-container-gate-reviewers +/.github/docker/build-app-cli/assert-build-container-context.sh @stackpop/edgezero-build-container-gate-reviewers +/.github/docker/build-app-cli/assert-build-container-dispatch-context.sh @stackpop/edgezero-build-container-gate-reviewers +/.github/docker/build-app-cli/check-build-container-publisher.sh @stackpop/edgezero-build-container-gate-reviewers +/.github/docker/build-app-cli/check-image-pin.sh @stackpop/edgezero-build-container-gate-reviewers +/.github/docker/build-app-cli/classify-build-container-change.sh @stackpop/edgezero-build-container-gate-reviewers +/.github/docker/build-app-cli/fixtures/gnu-smoke.rs @stackpop/edgezero-build-container-gate-reviewers +/.github/docker/build-app-cli/fixtures/provenance/invalid/archive-base256.tar @stackpop/edgezero-build-container-gate-reviewers +/.github/docker/build-app-cli/fixtures/provenance/invalid/archive-checksum.tar @stackpop/edgezero-build-container-gate-reviewers +/.github/docker/build-app-cli/fixtures/provenance/invalid/archive-duplicate.tar @stackpop/edgezero-build-container-gate-reviewers +/.github/docker/build-app-cli/fixtures/provenance/invalid/archive-embedded-nul.tar @stackpop/edgezero-build-container-gate-reviewers +/.github/docker/build-app-cli/fixtures/provenance/invalid/archive-end-block.tar @stackpop/edgezero-build-container-gate-reviewers +/.github/docker/build-app-cli/fixtures/provenance/invalid/archive-extra-end-block.tar @stackpop/edgezero-build-container-gate-reviewers +/.github/docker/build-app-cli/fixtures/provenance/invalid/archive-extra.tar @stackpop/edgezero-build-container-gate-reviewers +/.github/docker/build-app-cli/fixtures/provenance/invalid/archive-gnu.tar @stackpop/edgezero-build-container-gate-reviewers +/.github/docker/build-app-cli/fixtures/provenance/invalid/archive-header.tar @stackpop/edgezero-build-container-gate-reviewers +/.github/docker/build-app-cli/fixtures/provenance/invalid/archive-link.tar @stackpop/edgezero-build-container-gate-reviewers +/.github/docker/build-app-cli/fixtures/provenance/invalid/archive-max-size.tar @stackpop/edgezero-build-container-gate-reviewers +/.github/docker/build-app-cli/fixtures/provenance/invalid/archive-octal-digit.tar @stackpop/edgezero-build-container-gate-reviewers +/.github/docker/build-app-cli/fixtures/provenance/invalid/archive-octal-padding.tar @stackpop/edgezero-build-container-gate-reviewers +/.github/docker/build-app-cli/fixtures/provenance/invalid/archive-order.tar @stackpop/edgezero-build-container-gate-reviewers +/.github/docker/build-app-cli/fixtures/provenance/invalid/archive-padding.tar @stackpop/edgezero-build-container-gate-reviewers +/.github/docker/build-app-cli/fixtures/provenance/invalid/archive-pax.tar @stackpop/edgezero-build-container-gate-reviewers +/.github/docker/build-app-cli/fixtures/provenance/invalid/archive-size-limit.tar @stackpop/edgezero-build-container-gate-reviewers +/.github/docker/build-app-cli/fixtures/provenance/invalid/archive-size.tar @stackpop/edgezero-build-container-gate-reviewers +/.github/docker/build-app-cli/fixtures/provenance/invalid/archive-sparse.tar @stackpop/edgezero-build-container-gate-reviewers +/.github/docker/build-app-cli/fixtures/provenance/invalid/archive-special.tar @stackpop/edgezero-build-container-gate-reviewers +/.github/docker/build-app-cli/fixtures/provenance/invalid/archive-trailing.tar @stackpop/edgezero-build-container-gate-reviewers +/.github/docker/build-app-cli/fixtures/provenance/invalid/archive-traversal.tar @stackpop/edgezero-build-container-gate-reviewers +/.github/docker/build-app-cli/fixtures/provenance/invalid/elf-malformed/app-cli @stackpop/edgezero-build-container-gate-reviewers +/.github/docker/build-app-cli/fixtures/provenance/invalid/expected-duplicate.json @stackpop/edgezero-build-container-gate-reviewers +/.github/docker/build-app-cli/fixtures/provenance/invalid/meta-missing-interpreter.json @stackpop/edgezero-build-container-gate-reviewers +/.github/docker/build-app-cli/fixtures/provenance/valid/archive.tar @stackpop/edgezero-build-container-gate-reviewers +/.github/docker/build-app-cli/fixtures/provenance/valid/dynamic-meta.json @stackpop/edgezero-build-container-gate-reviewers +/.github/docker/build-app-cli/fixtures/provenance/valid/elf-static/app-cli @stackpop/edgezero-build-container-gate-reviewers +/.github/docker/build-app-cli/fixtures/provenance/valid/expected.json @stackpop/edgezero-build-container-gate-reviewers +/.github/docker/build-app-cli/fixtures/provenance/valid/static-meta.json @stackpop/edgezero-build-container-gate-reviewers +/.github/docker/build-app-cli/fixtures/wasm-smoke.rs @stackpop/edgezero-build-container-gate-reviewers +/.github/docker/build-app-cli/gate-paths.txt @stackpop/edgezero-build-container-gate-reviewers +/.github/docker/build-app-cli/image-context-paths.txt @stackpop/edgezero-build-container-gate-reviewers +/.github/docker/build-app-cli/provenance.schema.json @stackpop/edgezero-build-container-gate-reviewers +/.github/docker/build-app-cli/release-approval-gate.sh @stackpop/edgezero-build-container-gate-reviewers +/.github/docker/build-app-cli/run-build-container-gate.sh @stackpop/edgezero-build-container-gate-reviewers +/.github/docker/build-app-cli/select-build-container-range.sh @stackpop/edgezero-build-container-gate-reviewers +/.github/docker/build-app-cli/stage-build-context.sh @stackpop/edgezero-build-container-gate-reviewers +/.github/docker/build-app-cli/update-image-pin-pr.sh @stackpop/edgezero-build-container-gate-reviewers +/.github/docker/build-app-cli/verify-build-container-publication.sh @stackpop/edgezero-build-container-gate-reviewers +/.github/docker/build-app-cli/verify-gate-rotation-lock.sh @stackpop/edgezero-build-container-gate-reviewers +/.github/docker/build-app-cli/verify-published-image.sh @stackpop/edgezero-build-container-gate-reviewers +/.github/docker/build-app-cli/verify-release-prerequisites.sh @stackpop/edgezero-build-container-gate-reviewers +/.github/docker/build-app-cli/verify-toolchain.sh @stackpop/edgezero-build-container-gate-reviewers +/.github/docker/build-app-cli/write-image-release-record.sh @stackpop/edgezero-build-container-gate-reviewers +/.github/docker/build-app-cli/write-publisher-prerequisite.sh @stackpop/edgezero-build-container-gate-reviewers +/.github/tools/edgezero-provenance-validator/Cargo.lock @stackpop/edgezero-build-container-gate-reviewers +/.github/tools/edgezero-provenance-validator/Cargo.toml @stackpop/edgezero-build-container-gate-reviewers +/.github/tools/edgezero-provenance-validator/src/archive.rs @stackpop/edgezero-build-container-gate-reviewers +/.github/tools/edgezero-provenance-validator/src/command.rs @stackpop/edgezero-build-container-gate-reviewers +/.github/tools/edgezero-provenance-validator/src/elf.rs @stackpop/edgezero-build-container-gate-reviewers +/.github/tools/edgezero-provenance-validator/src/extract.rs @stackpop/edgezero-build-container-gate-reviewers +/.github/tools/edgezero-provenance-validator/src/json_contract.rs @stackpop/edgezero-build-container-gate-reviewers +/.github/tools/edgezero-provenance-validator/src/lib.rs @stackpop/edgezero-build-container-gate-reviewers +/.github/tools/edgezero-provenance-validator/src/main.rs @stackpop/edgezero-build-container-gate-reviewers +/.github/tools/edgezero-provenance-validator/src/orchestration.rs @stackpop/edgezero-build-container-gate-reviewers +/.github/tools/edgezero-provenance-validator/src/self_test.rs @stackpop/edgezero-build-container-gate-reviewers +/.github/tools/edgezero-provenance-validator/tests/cli.rs @stackpop/edgezero-build-container-gate-reviewers +/.github/workflows/build-container-ci.yml @stackpop/edgezero-build-container-gate-reviewers +/.github/workflows/publish-build-container.yml @stackpop/edgezero-build-container-gate-reviewers +/.github/workflows/rotate-build-container-gate.yml @stackpop/edgezero-build-container-gate-reviewers +/.github/zizmor.yml @stackpop/edgezero-build-container-gate-reviewers +/.tool-versions @stackpop/edgezero-build-container-gate-reviewers +/docs/package-lock.json @stackpop/edgezero-build-container-gate-reviewers +/docs/package.json @stackpop/edgezero-build-container-gate-reviewers +/scripts/install-actionlint.sh @stackpop/edgezero-build-container-gate-reviewers +/scripts/install-yq.sh @stackpop/edgezero-build-container-gate-reviewers +/scripts/run-actionlint.sh @stackpop/edgezero-build-container-gate-reviewers diff --git a/.github/actions/build-app-cli/action.yml b/.github/actions/build-app-cli/action.yml index ecad412f..81006db4 100644 --- a/.github/actions/build-app-cli/action.yml +++ b/.github/actions/build-app-cli/action.yml @@ -245,7 +245,7 @@ runs: # step's note): a custom provider secret should be scoped to the one step that # needs it, never to job-level `env:` shared with the build. - name: Upload CLI artifact - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@v7.0.1 env: FASTLY_API_TOKEN: "" FASTLY_SERVICE_ID: "" diff --git a/.github/actions/config-push-fastly/action.yml b/.github/actions/config-push-fastly/action.yml index 533de968..8edcabc7 100644 --- a/.github/actions/config-push-fastly/action.yml +++ b/.github/actions/config-push-fastly/action.yml @@ -120,7 +120,7 @@ runs: run: exec "$GITHUB_ACTION_PATH/scripts/validate.sh" - name: Download CLI artifact - uses: actions/download-artifact@v8 + uses: actions/download-artifact@v8.0.1 with: name: ${{ inputs['app-cli-artifact'] }} path: ${{ steps.ws.outputs.root }}/cli-download diff --git a/.github/actions/deploy-core/tests/assert-build-container-app-token.test.sh b/.github/actions/deploy-core/tests/assert-build-container-app-token.test.sh new file mode 100755 index 00000000..3ec982c3 --- /dev/null +++ b/.github/actions/deploy-core/tests/assert-build-container-app-token.test.sh @@ -0,0 +1,117 @@ +#!/usr/bin/env bash +set -euo pipefail + +DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +ASSERT="$DIR/../../../docker/build-app-cli/assert-build-container-app-token.sh" +WORK=$(mktemp -d) +WORK=$(cd -- "$WORK" && pwd -P) +trap 'rm -rf "$WORK"' EXIT +FAKE_BIN="$WORK/bin" +mkdir -p "$FAKE_BIN" + +cat >"$FAKE_BIN/curl" <<'SH' +#!/usr/bin/env bash +set -euo pipefail +fixture=$(cd -- "$(dirname -- "$0")" && pwd) +[[ "$LC_ALL" == C ]] +[[ -z "${GITHUB_TOKEN+x}${GH_TOKEN+x}${HTTPS_PROXY+x}${HTTP_PROXY+x}${ALL_PROXY+x}${HOME+x}${XDG_CONFIG_HOME+x}${CURL_HOME+x}" ]] +printf '%s\n' "$@" >"$fixture/args" +cat >"$fixture/config" +cat "$fixture/reply" +SH +chmod 0755 "$FAKE_BIN/curl" + +pass=0 +fail=0 + +write_reply() { + local full_name=${1:-stackpop/edgezero} status=${2:-200} + local version=${3:-2026-03-10} media=${4:-application/json} + jq -cn --arg full_name "$full_name" \ + '{id:123456789,full_name:$full_name,private:false,visibility:"public"}' >"$FAKE_BIN/body" + { + cat "$FAKE_BIN/body" + printf '\n%s\n%s\n%s' "$status" "$version" "$media" + } >"$FAKE_BIN/reply" +} + +run_assert() { + PATH="$FAKE_BIN:$PATH" \ + EDGEZERO_APP_TOKEN="${TOKEN:-fixture-token}" \ + EDGEZERO_INSTALLATION_ID="${INSTALLATION_ID:-12345}" \ + EDGEZERO_EXPECTED_INSTALLATION_ID="${EXPECTED_INSTALLATION_ID:-12345}" \ + bash "$ASSERT" +} + +assert_pass() { + local description=$1 + if run_assert >"$WORK/stdout" 2>"$WORK/stderr" && + [[ ! -s "$WORK/stdout" && ! -s "$WORK/stderr" ]]; then + printf ' \033[32mok\033[0m %s\n' "$description" + pass=$((pass + 1)) + else + cat "$WORK/stdout" "$WORK/stderr" >&2 + printf ' \033[31mFAIL\033[0m %s\n' "$description" >&2 + fail=$((fail + 1)) + fi +} + +assert_fail() { + local description=$1 + if run_assert >"$WORK/stdout" 2>"$WORK/stderr"; then + printf ' \033[31mFAIL\033[0m %s\n' "$description" >&2 + fail=$((fail + 1)) + elif [[ -s "$WORK/stdout" ]]; then + cat "$WORK/stdout" >&2 + printf ' \033[31mFAIL\033[0m %s emitted stdout\n' "$description" >&2 + fail=$((fail + 1)) + else + printf ' \033[32mok\033[0m %s\n' "$description" + pass=$((pass + 1)) + fi +} + +echo '== protected publisher App token probe ==' +write_reply +assert_pass 'exact installation token can read only the expected repository identity' +if grep -qF 'Authorization: Bearer fixture-token' "$FAKE_BIN/config" && + ! grep -qF 'fixture-token' "$FAKE_BIN/args"; then + printf ' \033[32mok\033[0m token is passed through config stdin and never argv\n' + pass=$((pass + 1)) +else + printf ' \033[31mFAIL\033[0m token is passed through config stdin and never argv\n' >&2 + fail=$((fail + 1)) +fi +if awk 'previous == "--connect-timeout" && $0 == "10" { found = 1 } { previous = $0 } END { exit !found }' \ + "$FAKE_BIN/args"; then + printf ' \033[32mok\033[0m API probe fixes the connection timeout at 10 seconds\n' + pass=$((pass + 1)) +else + printf ' \033[31mFAIL\033[0m API probe fixes the connection timeout at 10 seconds\n' >&2 + fail=$((fail + 1)) +fi + +INSTALLATION_ID=12346 +assert_fail 'action installation output must equal protected environment value' +unset INSTALLATION_ID +EXPECTED_INSTALLATION_ID=012345 +assert_fail 'installation IDs must be canonical positive integers' +unset EXPECTED_INSTALLATION_ID +TOKEN=$'bad\ntoken' +assert_fail 'line-breaking token is rejected' +unset TOKEN + +write_reply other/repository +assert_fail 'repository substitution is rejected' +write_reply stackpop/edgezero 302 +assert_fail 'redirect response is rejected' +write_reply stackpop/edgezero 200 2022-11-28 +assert_fail 'wrong selected API version is rejected' +write_reply stackpop/edgezero 200 2026-03-10 text/json +assert_fail 'wrong response media type is rejected' + +if ((fail)); then + printf '\n%d passed, %d failed\n' "$pass" "$fail" >&2 + exit 1 +fi +printf '\n%d passed, %d failed\n' "$pass" "$fail" diff --git a/.github/actions/deploy-core/tests/assert-build-container-completion.test.sh b/.github/actions/deploy-core/tests/assert-build-container-completion.test.sh new file mode 100755 index 00000000..d628818f --- /dev/null +++ b/.github/actions/deploy-core/tests/assert-build-container-completion.test.sh @@ -0,0 +1,97 @@ +#!/usr/bin/env bash +set -euo pipefail + +DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +ASSERT="$DIR/../../../docker/build-app-cli/assert-build-container-completion.sh" +WORK=$(mktemp -d) +WORK=$(cd -- "$WORK" && pwd -P) +trap 'rm -rf "$WORK"' EXIT +MARKER="$WORK/completion" + +pass=0 +fail=0 + +should_pass() { + local description=$1 kind=$2 value=$3 + printf '%s' "$value" >"$MARKER" + if bash "$ASSERT" --file "$MARKER" --kind "$kind" >"$WORK/stdout" 2>"$WORK/stderr" && + [[ ! -s "$WORK/stdout" && ! -s "$WORK/stderr" ]]; then + printf ' \033[32mok\033[0m %s\n' "$description" + pass=$((pass + 1)) + else + cat "$WORK/stdout" "$WORK/stderr" >&2 + printf ' \033[31mFAIL\033[0m %s\n' "$description" >&2 + fail=$((fail + 1)) + fi +} + +should_fail() { + local description=$1 kind=$2 value=$3 + rm -f "$MARKER" + [[ "$value" == absent ]] || printf '%s' "$value" >"$MARKER" + if bash "$ASSERT" --file "$MARKER" --kind "$kind" >"$WORK/stdout" 2>"$WORK/stderr"; then + printf ' \033[31mFAIL\033[0m %s\n' "$description" >&2 + fail=$((fail + 1)) + elif [[ -s "$WORK/stdout" ]]; then + cat "$WORK/stdout" >&2 + printf ' \033[31mFAIL\033[0m %s emitted stdout\n' "$description" >&2 + fail=$((fail + 1)) + else + printf ' \033[32mok\033[0m %s\n' "$description" + pass=$((pass + 1)) + fi +} + +echo '== build-container completion marker ==' + +for kind in local pin; do + printf -v marker 'kind=%s\nmode=ordinary\nbranch=relevant\n' "$kind" + should_pass "$kind ordinary relevant marker is exact" "$kind" "$marker" + printf -v marker 'kind=%s\nmode=ordinary\nbranch=not-applicable\n' "$kind" + should_pass "$kind ordinary not-applicable marker is exact" "$kind" "$marker" + printf -v marker 'kind=%s\nmode=gate-update\nbranch=gate-update\n' "$kind" + should_pass "$kind gate-update marker is exact" "$kind" "$marker" + printf -v marker 'kind=%s\nmode=gate-rollback\nbranch=gate-rollback\n' "$kind" + should_pass "$kind gate-rollback marker is exact" "$kind" "$marker" +done + +should_fail 'missing marker fails' local absent +should_fail 'empty marker fails' local '' +should_fail 'wrong kind fails' local $'kind=pin\nmode=ordinary\nbranch=relevant\n' +should_fail 'unknown mode fails' local $'kind=local\nmode=other\nbranch=relevant\n' +should_fail 'contradictory ordinary branch fails' local \ + $'kind=local\nmode=ordinary\nbranch=gate-update\n' +should_fail 'contradictory gate branch fails' local \ + $'kind=local\nmode=gate-update\nbranch=relevant\n' +should_fail 'duplicate fields fail' local \ + $'kind=local\nkind=local\nmode=ordinary\nbranch=relevant\n' +should_fail 'extra fields fail' local \ + $'kind=local\nmode=ordinary\nbranch=relevant\nextra=true\n' +should_fail 'missing final LF fails' local \ + $'kind=local\nmode=ordinary\nbranch=relevant' + +printf 'kind=local\0\nmode=ordinary\nbranch=relevant\n' >"$MARKER" +if bash "$ASSERT" --file "$MARKER" --kind local >"$WORK/stdout" 2>"$WORK/stderr"; then + printf ' \033[31mFAIL\033[0m NUL-bearing marker fails\n' >&2 + fail=$((fail + 1)) +else + printf ' \033[32mok\033[0m NUL-bearing marker fails\n' + pass=$((pass + 1)) +fi + +printf 'kind=local\nmode=ordinary\nbranch=relevant\n' >"$WORK/target" +rm -f "$MARKER" +ln -s "$WORK/target" "$MARKER" +if bash "$ASSERT" --file "$MARKER" --kind local >"$WORK/stdout" 2>"$WORK/stderr"; then + printf ' \033[31mFAIL\033[0m symlink marker fails\n' >&2 + fail=$((fail + 1)) +else + printf ' \033[32mok\033[0m symlink marker fails\n' + pass=$((pass + 1)) +fi + +if ((fail)); then + printf '\n%d passed, %d failed\n' "$pass" "$fail" >&2 + exit 1 +fi +printf '\n%d passed, %d failed\n' "$pass" "$fail" diff --git a/.github/actions/deploy-core/tests/assert-build-container-context.test.sh b/.github/actions/deploy-core/tests/assert-build-container-context.test.sh new file mode 100755 index 00000000..d9d026a4 --- /dev/null +++ b/.github/actions/deploy-core/tests/assert-build-container-context.test.sh @@ -0,0 +1,125 @@ +#!/usr/bin/env bash +set -euo pipefail + +DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +ROOT=$(cd -- "$DIR/../../../.." && pwd) +ASSERT="$DIR/../../../docker/build-app-cli/assert-build-container-context.sh" +WORK=$(mktemp -d) +trap 'rm -rf "$WORK"' EXIT + +pass=0 +fail=0 +ok() { + printf ' \033[32mok\033[0m %s\n' "$1" + pass=$((pass + 1)) +} +no() { + printf ' \033[31mFAIL\033[0m %s\n' "$1" >&2 + fail=$((fail + 1)) +} +assert_pass() { + local description=$1 + shift + local log="$WORK/assert-pass.log" + if "$@" >"$log" 2>&1; then + ok "$description" + else + cat "$log" >&2 + no "$description" + fi +} +assert_fail() { + local description=$1 + shift + if "$@" >/dev/null 2>&1; then no "$description"; else ok "$description"; fi +} + +run_assert() { + bash "$ASSERT" --context "$1" +} + +copy_context() { + local name=$1 destination="$WORK/$1" + mkdir "$destination" + while IFS= read -r path; do + mkdir -p "$destination/$(dirname -- "$path")" + cp "$ROOT/$path" "$destination/$path" + done <"$ROOT/.github/docker/build-app-cli/image-context-paths.txt" + printf '%s\n' "$destination" +} + +mutate_dockerfile() { + local name=$1 instruction=$2 context + context=$(copy_context "$name") + printf '%s\n' "$instruction" >>"$context/.github/docker/build-app-cli/Dockerfile" + run_assert "$context" +} + +insert_before_user() { + local name=$1 instruction=$2 context + context=$(copy_context "$name") + awk -v instruction="$instruction" ' + /^USER 1001:1001$/ { print instruction } + { print } + ' "$context/.github/docker/build-app-cli/Dockerfile" >"$context/Dockerfile.new" + mv "$context/Dockerfile.new" "$context/.github/docker/build-app-cli/Dockerfile" + run_assert "$context" +} + +echo "== build-container context contract ==" + +if grep -Eq '^#[[:space:]]*syntax=' "$ROOT/.github/docker/build-app-cli/Dockerfile"; then + no "the Dockerfile does not select a mutable external frontend" +else + ok "the Dockerfile does not select a mutable external frontend" +fi + +context=$(copy_context valid) +assert_pass "the staged image context is closed and buildable" run_assert "$context" + +context=$(copy_context missing-validator-source) +rm "$context/.github/tools/edgezero-provenance-validator/src/lib.rs" +assert_fail "a missing standalone-validator source is rejected" run_assert "$context" + +context=$(copy_context extra-context-file) +printf extra >"$context/extra" +assert_fail "an extra context file is rejected" run_assert "$context" + +context=$(copy_context missing-source-revision-guard) +awk ' + /^RUN test "\$\{#IMAGE_SOURCE_REVISION\}"/ { skip = 2 } + skip > 0 { skip--; next } + { print } +' "$context/.github/docker/build-app-cli/Dockerfile" >"$context/Dockerfile.new" +mv "$context/Dockerfile.new" "$context/.github/docker/build-app-cli/Dockerfile" +assert_fail "a missing source-revision build guard is rejected" run_assert "$context" + +context=$(copy_context outside-path-dependency) +printf '\noutside = { path = "../../../../outside" }\n' \ + >>"$context/.github/tools/edgezero-provenance-validator/Cargo.toml" +assert_fail "a path dependency outside the validator directory is rejected" run_assert "$context" + +assert_fail "a remote ADD is rejected" \ + mutate_dockerfile remote-add 'ADD https://attacker.invalid/tool /usr/local/bin/tool' +assert_fail "a build-context bind mount is rejected" \ + mutate_dockerfile bind-mount 'RUN --mount=type=bind,source=.,target=/src true' +assert_fail "a broad context copy is rejected" \ + mutate_dockerfile broad-copy 'COPY . /src' +assert_fail "a post-install validator replacement is rejected" \ + mutate_dockerfile replace-validator \ + 'COPY .tool-versions /usr/local/bin/edgezero-provenance-validator' +assert_fail "a pre-USER validator replacement is rejected" \ + insert_before_user replace-validator-before-user \ + 'COPY .tool-versions /usr/local/bin/edgezero-provenance-validator' +assert_fail "a pre-USER command cannot mutate installed assets" \ + insert_before_user replace-tool-before-user \ + 'RUN rm -f /usr/local/bin/fastly' + +context=$(copy_context second-validator-build) +printf '%s\n' \ + 'RUN cargo build --locked --release --manifest-path .github/tools/edgezero-provenance-validator/Cargo.toml' \ + >>"$context/.github/docker/build-app-cli/Dockerfile" +assert_fail "a second validator build invocation is rejected" run_assert "$context" + +printf 'Passed: %d Failed: %d\n' "$pass" "$fail" +[[ "$fail" -eq 0 ]] diff --git a/.github/actions/deploy-core/tests/assert-build-container-dispatch-context.test.sh b/.github/actions/deploy-core/tests/assert-build-container-dispatch-context.test.sh new file mode 100755 index 00000000..06eb714b --- /dev/null +++ b/.github/actions/deploy-core/tests/assert-build-container-dispatch-context.test.sh @@ -0,0 +1,285 @@ +#!/usr/bin/env bash +# shellcheck disable=SC2016 +set -euo pipefail + +DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +REAL_ASSERT="$DIR/../../../docker/build-app-cli/assert-build-container-dispatch-context.sh" +WORK=$(mktemp -d) +WORK=$(cd -- "$WORK" && pwd -P) +trap 'rm -rf "$WORK"' EXIT + +pass=0 +fail=0 +case_number=0 + +ok() { + printf ' \033[32mok\033[0m %s\n' "$1" + pass=$((pass + 1)) +} + +no() { + printf ' \033[31mFAIL\033[0m %s\n' "$1" >&2 + fail=$((fail + 1)) +} + +assert_pass() { + local description=$1 status=0 + shift + CASE_ROOT="$WORK/case-$((case_number += 1))" + mkdir -p "$CASE_ROOT" + if (($#)); then + "$@" >"$CASE_ROOT/stdout" 2>"$CASE_ROOT/stderr" || status=$? + else + run_assert >"$CASE_ROOT/stdout" 2>"$CASE_ROOT/stderr" || status=$? + fi + if [[ "$status" -eq 0 && ! -s "$CASE_ROOT/stdout" && ! -s "$CASE_ROOT/stderr" ]]; then + ok "$description" + else + cat "$CASE_ROOT/stdout" "$CASE_ROOT/stderr" >&2 + no "$description" + fi +} + +assert_fail() { + local description=$1 + CASE_ROOT="$WORK/case-$((case_number += 1))" + mkdir -p "$CASE_ROOT" + if run_assert >"$CASE_ROOT/stdout" 2>"$CASE_ROOT/stderr"; then + no "$description" + elif [[ -s "$CASE_ROOT/stdout" ]]; then + cat "$CASE_ROOT/stdout" >&2 + no "$description emitted stdout" + else + ok "$description" + fi +} + +assert_fail_message() { + local description=$1 expected=$2 + CASE_ROOT="$WORK/case-$((case_number += 1))" + mkdir -p "$CASE_ROOT" + if run_assert >"$CASE_ROOT/stdout" 2>"$CASE_ROOT/stderr"; then + no "$description" + elif [[ -s "$CASE_ROOT/stdout" ]] || ! grep -Fq -e "$expected" "$CASE_ROOT/stderr"; then + cat "$CASE_ROOT/stdout" "$CASE_ROOT/stderr" >&2 + no "$description" + else + ok "$description" + fi +} + +REPO="$WORK/gate" +FAKE_BIN="$WORK/bin" +mkdir -p "$REPO/.github/docker/build-app-cli" "$REPO/.github" "$FAKE_BIN" +cp "$REAL_ASSERT" "$REPO/.github/docker/build-app-cli/assert-build-container-dispatch-context.sh" +chmod 0755 "$REPO/.github/docker/build-app-cli/assert-build-container-dispatch-context.sh" +printf 'FROM scratch\n' >"$REPO/.github/docker/build-app-cli/Dockerfile" +printf 'manifested\n' >"$REPO/.github/docker/build-app-cli/helper.sh" +printf '%s\n' \ + '.github/docker/build-app-cli/Dockerfile' \ + '.github/docker/build-app-cli/image-context-paths.txt' \ + >"$REPO/.github/docker/build-app-cli/image-context-paths.txt" +printf '%s\n' \ + '.github/CODEOWNERS' \ + '.github/docker/build-app-cli/Dockerfile' \ + '.github/docker/build-app-cli/assert-build-container-dispatch-context.sh' \ + '.github/docker/build-app-cli/gate-paths.txt' \ + '.github/docker/build-app-cli/helper.sh' \ + '.github/docker/build-app-cli/image-context-paths.txt' \ + >"$REPO/.github/docker/build-app-cli/gate-paths.txt" +while IFS= read -r path; do + printf '/%s @stackpop/edgezero-build-container-gate-reviewers\n' "$path" +done <"$REPO/.github/docker/build-app-cli/gate-paths.txt" >"$REPO/.github/CODEOWNERS" +git -C "$REPO" init -q -b main +git -C "$REPO" config user.name fixture +git -C "$REPO" config user.email fixture@example.invalid +git -C "$REPO" add . +git -C "$REPO" commit -q -m gate +G=$(git -C "$REPO" rev-parse HEAD) +printf 'later non-gate state\n' >"$REPO/app.txt" +git -C "$REPO" add app.txt +git -C "$REPO" commit -q -m snapshot +Q=$(git -C "$REPO" rev-parse HEAD) +git -C "$REPO" checkout -q --detach "$G" +ASSERT="$REPO/.github/docker/build-app-cli/assert-build-container-dispatch-context.sh" + +cat >"$FAKE_BIN/curl" <<'SH' +#!/usr/bin/env bash +set -euo pipefail +fixture=$(cd -- "$(dirname -- "$0")" && pwd) +[[ "$LC_ALL" == C ]] +[[ -z "${GITHUB_TOKEN+x}${GH_TOKEN+x}${HTTPS_PROXY+x}${HTTP_PROXY+x}${ALL_PROXY+x}${HOME+x}${XDG_CONFIG_HOME+x}${CURL_HOME+x}" ]] +printf '%s\n' "$@" >"$fixture/args" +cat >"$fixture/config" +cat "$fixture/reply" +SH +chmod 0755 "$FAKE_BIN/curl" + +write_reply() { + local head_repo=${1:-stackpop/edgezero} head_sha=${2:-$Q} + local status=${3:-200} version=${4:-2026-03-10} media=${5:-application/json} + jq -cn \ + --arg head_repo "$head_repo" \ + --arg head_sha "$head_sha" \ + '{number:17,state:"open",merged:false,base:{ref:"main",repo:{full_name:"stackpop/edgezero"}},head:{sha:$head_sha,repo:{full_name:$head_repo}}}' \ + >"$FAKE_BIN/body" + { + cat "$FAKE_BIN/body" + printf '\n%s\n%s\n%s' "$status" "$version" "$media" + } >"$FAKE_BIN/reply" +} + +clear_overrides() { + unset EVENT_NAME REPOSITORY REF REF_PROTECTED SHA WORKFLOW_SHA WORKFLOW_REF GATE_SHA RELEASE_STATE + unset PR_NUMBER HEAD_REPOSITORY HEAD_SHA TOKEN GATE_ROOT +} + +run_assert() { + PATH="$FAKE_BIN:$PATH" \ + GITHUB_TOKEN="${TOKEN:-fixture-token}" \ + EDGEZERO_EVENT_NAME="${EVENT_NAME:-workflow_dispatch}" \ + EDGEZERO_REPOSITORY="${REPOSITORY:-stackpop/edgezero}" \ + EDGEZERO_REF="${REF:-refs/heads/main}" \ + EDGEZERO_REF_PROTECTED="${REF_PROTECTED:-true}" \ + EDGEZERO_SHA="${SHA:-$Q}" \ + EDGEZERO_WORKFLOW_SHA="${WORKFLOW_SHA:-$Q}" \ + EDGEZERO_WORKFLOW_REF="${WORKFLOW_REF:-stackpop/edgezero/.github/workflows/build-container-ci.yml@refs/heads/main}" \ + EDGEZERO_GATE_SHA="${GATE_SHA:-$G}" \ + EDGEZERO_RELEASE_STATE="${RELEASE_STATE:-enabled}" \ + EDGEZERO_CANDIDATE_PR_NUMBER="${PR_NUMBER:-17}" \ + EDGEZERO_CANDIDATE_HEAD_REPOSITORY="${HEAD_REPOSITORY:-stackpop/edgezero}" \ + EDGEZERO_CANDIDATE_HEAD_SHA="${HEAD_SHA:-$Q}" \ + bash "$ASSERT" --gate-root "${GATE_ROOT:-$REPO}" +} + +run_assert_with_ambient_fsmonitor() { + GIT_CONFIG_COUNT=1 \ + GIT_CONFIG_KEY_0=core.fsmonitor \ + GIT_CONFIG_VALUE_0="$FSMONITOR" \ + run_assert || return + [[ ! -e "$FSMONITOR_SENTINEL" ]] +} + +echo '== protected build-container dispatch context ==' + +write_reply +assert_pass 'later protected snapshot with unchanged gate bytes is accepted' +if [[ -f "$FAKE_BIN/config" && -f "$FAKE_BIN/args" ]] && + grep -qF 'Authorization: Bearer fixture-token' "$FAKE_BIN/config" && + ! grep -qF 'fixture-token' "$FAKE_BIN/args"; then + ok 'API token is passed through config stdin and never argv' +else + no 'API token is passed through config stdin and never argv' +fi +if awk 'previous == "--connect-timeout" && $0 == "10" { found = 1 } { previous = $0 } END { exit !found }' \ + "$FAKE_BIN/args"; then + ok 'API lookup fixes the connection timeout at 10 seconds' +else + no 'API lookup fixes the connection timeout at 10 seconds' +fi + +SHA=$G +WORKFLOW_SHA=$G +HEAD_SHA=$G +write_reply stackpop/edgezero "$G" +assert_pass 'bootstrap snapshot Q equal to G is accepted' +clear_overrides +write_reply + +# These fixed fixture assignments intentionally expand only when eval applies them. +# shellcheck disable=SC2016 +for assignment in \ + 'EVENT_NAME=pull_request' \ + 'REPOSITORY=other/repository' \ + 'REF=refs/heads/develop' \ + 'REF_PROTECTED=false' \ + 'WORKFLOW_SHA=$G' \ + 'WORKFLOW_REF=stackpop/edgezero/.github/workflows/build-container-ci.yml@main' \ + 'GATE_SHA=$Q' \ + 'RELEASE_STATE=disabled:1:old' \ + 'PR_NUMBER=0' \ + 'HEAD_REPOSITORY=other/repository' \ + 'HEAD_SHA=$G'; do + eval "$assignment" + assert_fail "dispatch rejects inconsistent input: $assignment" + clear_overrides +done + +write_reply other/repository "$Q" +assert_fail 'API head repository must match the exact input' +write_reply stackpop/edgezero "$G" +assert_fail 'API head SHA must match the exact input' +write_reply stackpop/edgezero "$Q" 302 +assert_fail 'redirect response is rejected' +write_reply stackpop/edgezero "$Q" 200 2022-11-28 +assert_fail 'wrong selected API version is rejected' +write_reply stackpop/edgezero "$Q" 200 2026-03-10 text/json +assert_fail 'wrong API media type is rejected' +write_reply + +TOKEN=$'bad\ntoken' +assert_fail 'line-breaking token is rejected before curl' +clear_overrides + +git -C "$REPO" switch -q -c changed-gate "$Q" +printf 'changed\n' >>"$REPO/.github/docker/build-app-cli/helper.sh" +git -C "$REPO" add .github/docker/build-app-cli/helper.sh +git -C "$REPO" commit -q -m changed-gate +CHANGED_Q=$(git -C "$REPO" rev-parse HEAD) +git -C "$REPO" checkout -q --detach "$G" +SHA=$CHANGED_Q +WORKFLOW_SHA=$CHANGED_Q +HEAD_SHA=$CHANGED_Q +write_reply stackpop/edgezero "$CHANGED_Q" +assert_fail 'changed gate-owned bytes at Q are rejected' +clear_overrides +write_reply + +printf 'dirty\n' >"$REPO/dirty.txt" +assert_fail 'dirty gate checkout is rejected' +rm "$REPO/dirty.txt" + +git -C "$REPO" replace "$G" "$Q" +assert_fail 'gate replacement refs are rejected' +git -C "$REPO" replace -d "$G" + +GRAFTS="$(git -C "$REPO" rev-parse --absolute-git-dir)/info/grafts" +printf '%s\n' "$G" >"$GRAFTS" +assert_fail 'gate legacy grafts are rejected' +rm "$GRAFTS" + +git -C "$REPO" checkout -q --detach "$Q" +assert_fail 'gate checkout HEAD must equal active G' +git -C "$REPO" checkout -q --detach "$G" + +mkdir "$REPO/nested-root" +GATE_ROOT="$REPO/nested-root" +assert_fail_message 'gate root must be the exact repository top level' \ + 'gate root must be the exact repository top level' +clear_overrides + +FSMONITOR="$WORK/fsmonitor" +FSMONITOR_SENTINEL="$WORK/fsmonitor-ran" +{ + printf '#!/usr/bin/env bash\nset -euo pipefail\n' + printf '%s\n' 'touch "$FAKE_FSMONITOR_SENTINEL"' 'printf "\\n"' +} >"$FSMONITOR" +chmod 0755 "$FSMONITOR" +export FAKE_FSMONITOR_SENTINEL=$FSMONITOR_SENTINEL +assert_pass 'ambient Git fsmonitor cannot execute in dispatch preflight' \ + run_assert_with_ambient_fsmonitor +unset FAKE_FSMONITOR_SENTINEL + +ORIGINAL_REPO=$REPO +REPO="$WORK/linked-gate" +git -C "$ORIGINAL_REPO" worktree add -q --detach "$REPO" "$G" +ASSERT="$REPO/.github/docker/build-app-cli/assert-build-container-dispatch-context.sh" +COMMON_GIT_DIRECTORY=$(git -C "$REPO" rev-parse --path-format=absolute --git-common-dir) +printf '%s\n' "$G" >"$COMMON_GIT_DIRECTORY/info/grafts" +assert_fail 'linked-worktree common-directory grafts are rejected' + +if ((fail)); then + printf '\n%d passed, %d failed\n' "$pass" "$fail" >&2 + exit 1 +fi +printf '\n%d passed, %d failed\n' "$pass" "$fail" diff --git a/.github/actions/deploy-core/tests/build-container-workflows.test.sh b/.github/actions/deploy-core/tests/build-container-workflows.test.sh new file mode 100755 index 00000000..01580634 --- /dev/null +++ b/.github/actions/deploy-core/tests/build-container-workflows.test.sh @@ -0,0 +1,317 @@ +#!/usr/bin/env bash +# GitHub expressions below are deliberate literal fixture data. +# shellcheck disable=SC2016 +set -euo pipefail + +ROOT=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../../../.." && pwd -P) +WORKFLOW="$ROOT/.github/workflows/build-container-ci.yml" +GATE_TEST_RUNNER="$ROOT/.github/actions/deploy-core/tests/run.sh" +GATE_MANIFEST="$ROOT/.github/docker/build-app-cli/gate-paths.txt" +IMAGE_MANIFEST="$ROOT/.github/docker/build-app-cli/image-context-paths.txt" +CODEOWNERS="$ROOT/.github/CODEOWNERS" +PLAN="$ROOT/docs/superpowers/plans/2026-08-20-build-cache-container.md" +WORK=$(mktemp -d) +trap 'rm -rf "$WORK"' EXIT + +[[ -f "$WORKFLOW" && ! -L "$WORKFLOW" ]] || { + printf 'missing regular workflow: %s\n' "$WORKFLOW" >&2 + exit 1 +} + +for required in "$GATE_MANIFEST" "$IMAGE_MANIFEST" "$CODEOWNERS" "$PLAN"; do + [[ -f "$required" && ! -L "$required" ]] || { + printf 'missing regular gate contract file: %s\n' "$required" >&2 + exit 1 + } +done + +LC_ALL=C sort -cu "$GATE_MANIFEST" +LC_ALL=C sort -cu "$IMAGE_MANIFEST" +grep -Fqx '.github/CODEOWNERS' "$GATE_MANIFEST" +grep -Fqx '.github/docker/build-app-cli/gate-paths.txt' "$GATE_MANIFEST" +EXPECTED_CODEOWNERS="$WORK/CODEOWNERS.expected" +: >"$EXPECTED_CODEOWNERS" +while IFS= read -r path; do + printf '/%s @stackpop/edgezero-build-container-gate-reviewers\n' "$path" +done <"$GATE_MANIFEST" >"$EXPECTED_CODEOWNERS" +cmp -s "$EXPECTED_CODEOWNERS" "$CODEOWNERS" || { + printf 'CODEOWNERS is not the exact gate-manifest expansion\n' >&2 + exit 1 +} + +PLAN_MANIFEST="$WORK/plan-gate-paths.txt" +awk ' + $0 == " The frozen manifest is:" { found = 1; next } + found && $0 == "```text" { block = 1; next } + block && $0 == "```" { closed = 1; exit } + block { print } + END { if (!found || !block || !closed) exit 1 } +' "$PLAN" >"$PLAN_MANIFEST" || { + printf 'plan does not contain one closed frozen gate manifest block\n' >&2 + exit 1 +} +cmp -s "$PLAN_MANIFEST" "$GATE_MANIFEST" || { + printf 'plan frozen manifest differs from gate-paths.txt\n' >&2 + exit 1 +} + +while IFS= read -r path; do + [[ -f "$ROOT/$path" && ! -L "$ROOT/$path" ]] || { + printf 'gate manifest path is missing or not a regular file: %s\n' "$path" >&2 + exit 1 + } + grep -Fqx "$path" "$GATE_MANIFEST" || exit 1 +done <"$IMAGE_MANIFEST" +gate_count=$(wc -l <"$GATE_MANIFEST" | tr -d '[:space:]') +image_count=$(wc -l <"$IMAGE_MANIFEST" | tr -d '[:space:]') +((image_count < gate_count)) || { + printf 'image-context manifest is not a strict gate-manifest subset\n' >&2 + exit 1 +} + +: >"$WORK/codeowners.expected" +while IFS= read -r path; do + [[ -f "$ROOT/$path" && ! -L "$ROOT/$path" ]] || { + printf 'gate manifest path is missing or not a regular file: %s\n' "$path" >&2 + exit 1 + } + printf '/%s @stackpop/edgezero-build-container-gate-reviewers\n' "$path" +done <"$GATE_MANIFEST" >"$WORK/codeowners.expected" +cmp -s "$WORK/codeowners.expected" "$CODEOWNERS" || { + printf 'CODEOWNERS is not the exact gate-manifest expansion\n' >&2 + exit 1 +} + +awk ' + $0 == " The frozen manifest is:" { found = 1; next } + found && $0 == "```text" { block = 1; next } + block && $0 == "```" { complete = 1; exit } + block { print } + END { if (!found || !complete) exit 1 } +' "$PLAN" >"$WORK/plan-gate-paths.txt" || { + printf 'implementation plan omits the frozen gate manifest\n' >&2 + exit 1 +} +cmp -s "$WORK/plan-gate-paths.txt" "$GATE_MANIFEST" || { + printf 'implementation plan gate manifest differs from gate-paths.txt\n' >&2 + exit 1 +} + +for focused_test in \ + assert-build-container-app-token.test.sh \ + assert-build-container-completion.test.sh \ + assert-build-container-context.test.sh \ + assert-build-container-dispatch-context.test.sh \ + build-container-workflows.test.sh \ + check-build-container-publisher.test.sh \ + check-doc-action-pins.test.mjs \ + check-image-pin.test.sh \ + classify-build-container-change.test.sh \ + install-actionlint.test.sh \ + install-yq.test.sh \ + release-approval-gate.test.sh \ + run-actionlint.test.sh \ + run-build-container-gate.test.sh \ + select-build-container-range.test.sh \ + stage-build-context.test.sh \ + update-image-pin-pr.test.sh \ + verify-build-container-publication.test.sh \ + verify-gate-rotation-lock.test.sh \ + verify-published-image.test.sh \ + verify-release-prerequisites.test.sh \ + verify-toolchain.test.sh \ + write-image-release-record.test.sh \ + write-publisher-prerequisite.test.sh; do + grep -Fq "deploy-core/tests/$focused_test" "$GATE_TEST_RUNNER" || { + printf 'central gate test runner omits %s\n' "$focused_test" >&2 + exit 1 + } +done + +validate() { + local input=$1 parsed="$WORK/parsed.json" + yq -o=json -I=0 \ + '{"document": ., "aliases": [... | select(kind == "alias")], "duplicates": [.. | select(kind == "map") | to_entries | group_by(.key) | .[] | select(length > 1)]}' \ + "$input" >"$parsed" || return 1 + jq -e ' + def no_continuation: + [.. | objects | select(has("continue-on-error"))] | length == 0; + def bootstrap: + .steps[0].name == "assert-hosted-runner-context" and + .steps[0].shell == "bash" and + .steps[0].env.BASH_ENV == "" and .steps[0].env.ENV == "" and + .steps[0].env.EDGEZERO_RUNNER_ENVIRONMENT == "${{ runner.environment }}" and + .steps[0].env.EDGEZERO_RUNNER_OS == "${{ runner.os }}" and + .steps[0].env.EDGEZERO_RUNNER_ARCH == "${{ runner.arch }}" and + .steps[0].env.EDGEZERO_REPOSITORY == "${{ github.repository }}" and + .steps[0].env.EDGEZERO_WORKFLOW_REF == "${{ github.workflow_ref }}" and + .steps[0].env.EDGEZERO_WORKFLOW_SHA == "${{ github.workflow_sha }}" and + .steps[0].env.EDGEZERO_GITHUB_SHA == "${{ github.sha }}" and + .steps[0].env.EDGEZERO_GATE_SHA == "${{ vars.EDGEZERO_BUILD_CONTAINER_GATE_SHA }}" and + (.steps[0] | has("if") | not) and + (.steps[0] | has("continue-on-error") | not) and + (.steps[0].run | contains("EDGEZERO_RUNNER_ENVIRONMENT") and + contains("EDGEZERO_RUNNER_OS") and contains("EDGEZERO_RUNNER_ARCH") and + contains("[[ \"$EDGEZERO_RUNNER_ENVIRONMENT\" == github-hosted ]]") and + contains("[[ \"$EDGEZERO_RUNNER_OS\" == Linux ]]") and + contains("[[ \"$EDGEZERO_RUNNER_ARCH\" == X64 ]]") and + contains("[[ \"$EDGEZERO_REPOSITORY\" == stackpop/edgezero ]]") and + contains("[[ \"$EDGEZERO_GATE_SHA\" =~ ^[0-9a-f]{40}$ ]]") and + startswith("set -euo pipefail\n")); + def checkout($name; $path; $ref): + [.steps[] | select(.name == $name)] == [{ + name: $name, + if: "${{ success() }}", + uses: "actions/checkout@v7.0.1", + with: { + repository: "stackpop/edgezero", + ref: $ref, + path: $path, + "persist-credentials": false, + "fetch-depth": 0 + } + }]; + def stable($name; $kind): + .jobs[$name] as $job | + $job["runs-on"] == "ubuntu-24.04" and + $job.if == "${{ github.event_name != '\''workflow_dispatch'\'' }}" and + ($job | has("environment") | not) and + ($job | has("permissions") | not) and + $job.env == {BASH_ENV:"", ENV:""} and + ($job | tostring | contains("secrets.") | not) and + ($job | bootstrap) and + ($job | checkout("checkout-active-gate"; ".edgezero-gate"; + "${{ vars.EDGEZERO_BUILD_CONTAINER_GATE_SHA }}")) and + ($job | checkout("checkout-subject"; ".edgezero-subject"; "${{ github.sha }}")) and + ($job.steps | map(.name)) == [ + "assert-hosted-runner-context", + "checkout-active-gate", + "checkout-subject", + "setup-trusted-node", + "prepare-trusted-documentation-tools", + "run-protected-gate-tests", + "select-candidate-range", + "assert-exact-main-push-context", + "check-documentation-references", + "run-build-container-gate", + "assert-terminal-completion" + ] and + $job.steps[6].id == "candidate_range" and $job.steps[7].id == "push_range" and + ($job.steps[6].env == { + EDGEZERO_EVENT_NAME:"${{ github.event_name }}", + EDGEZERO_REPOSITORY:"${{ github.repository }}", + EDGEZERO_SHA:"${{ github.sha }}", + EDGEZERO_WORKFLOW_SHA:"${{ github.workflow_sha }}", + EDGEZERO_REF:"${{ github.ref }}", + EDGEZERO_REF_PROTECTED:"${{ github.ref_protected }}", + EDGEZERO_GATE_SHA:"${{ vars.EDGEZERO_BUILD_CONTAINER_GATE_SHA }}" + }) and + ($job.steps[7].env == $job.steps[6].env) and + ($job.steps[1:6] | all(.if == "${{ success() }}")) and + $job.steps[6].if == "${{ success() && github.event_name != '\''push'\'' }}" and + $job.steps[7].if == "${{ success() && github.event_name == '\''push'\'' }}" and + ($job.steps[8:10] | all(.if == "${{ success() }}")) and + $job.steps[10].if == "${{ always() }}" and + ([ $job.steps[] | select(.if == "${{ always() }}") | .name ] == + ["assert-terminal-completion"]) and + ([ $job.steps[] | select(.name == "assert-exact-main-push-context") ] | length == 1) and + $job.steps[3].uses == "actions/setup-node@v6.5.0" and + $job.steps[3].with["node-version-file"] == ".edgezero-gate/.tool-versions" and + ($job.steps[4].run | contains("$GITHUB_WORKSPACE/.edgezero-gate/scripts/install-yq.sh") and + contains("$GITHUB_WORKSPACE/.edgezero-gate/scripts/install-actionlint.sh") and + contains("npm --prefix \"$GITHUB_WORKSPACE/.edgezero-gate/docs\" ci --ignore-scripts")) and + $job.steps[5].env == {CI:"true"} and + ($job.steps[5].run | contains("$GITHUB_WORKSPACE/.edgezero-gate/.github/actions/deploy-core/tests/run.sh")) and + ($job.steps[6].run | contains("$GITHUB_WORKSPACE/.edgezero-gate/.github/docker/build-app-cli/select-build-container-range.sh")) and + ($job.steps[7].run | contains("$GITHUB_WORKSPACE/.edgezero-gate/.github/docker/build-app-cli/select-build-container-range.sh")) and + ($job.steps[8].run | contains("$GITHUB_WORKSPACE/.edgezero-gate/.github/actions/deploy-core/tests/check-doc-action-pins.sh") and + contains("--base \"$EDGEZERO_BASE\"") and contains("--candidate \"$EDGEZERO_HEAD\"")) and + ($job.steps[9].run | contains("$GITHUB_WORKSPACE/.edgezero-gate/.github/docker/build-app-cli/run-build-container-gate.sh") and + contains("--kind \"$EDGEZERO_KIND\"") and + ($job.steps[9].env.EDGEZERO_KIND == $kind)) and + ($job.steps[10].run | contains("$GITHUB_WORKSPACE/.edgezero-gate/.github/docker/build-app-cli/assert-build-container-completion.sh")) and + ([ $job.steps[] | .run? // empty | select(contains(".edgezero-subject/.github")) ] | length == 0); + (.aliases == [] and .duplicates == []) and + (.document as $w | + ($w | no_continuation) and + ($w.name == "Build container gate") and + ($w["run-name"] == "${{ github.event_name == '\''workflow_dispatch'\'' && format('\''build-container-release-preflight pr={0} repo={1} sha={2}'\'', inputs.candidate-pr-number, inputs.candidate-head-repository, inputs.candidate-head-sha) || '\''Build container gate'\'' }}") and + ($w.on | keys | sort) == ["merge_group","pull_request","push","workflow_dispatch"] and + ($w.on.pull_request == null) and + ($w.on.merge_group == {types:["checks_requested"]}) and + ($w.on.push == {branches:["main"]}) and + ($w.on.workflow_dispatch.inputs == { + "candidate-pr-number": {description:"Candidate pull request number",required:true,type:"number"}, + "candidate-head-repository": {description:"Candidate head repository",required:true,type:"string"}, + "candidate-head-sha": {description:"Candidate full head SHA",required:true,type:"string"} + }) and + ([ $w.on | .. | objects | select(has("paths") or has("paths-ignore")) ] | length == 0) and + ($w.permissions == {contents:"read",actions:"read","pull-requests":"read"}) and + ($w.jobs | keys | sort) == ["build-container-local","build-container-pin","build-container-release-preflight"] and + ($w | stable("build-container-local"; "local")) and + ($w | stable("build-container-pin"; "pin")) and + ($w.jobs["build-container-release-preflight"] as $preflight | + $preflight.if == "${{ github.event_name == '\''workflow_dispatch'\'' }}" and + $preflight["runs-on"] == "ubuntu-24.04" and + $preflight.environment == {name:"build-container-release",deployment:false} and + $preflight.env == {BASH_ENV:"",ENV:""} and + ($preflight | has("permissions") | not) and + ($preflight | bootstrap) and + ($preflight.steps | map(.name)) == ["assert-hosted-runner-context","checkout-active-gate","assert-exact-g-dispatch-context","mint-publisher-probe-token","verify-publisher-probe-token"] and + ([ $preflight.steps[] | select(.uses == "actions/checkout@v7.0.1") ] | length == 1) and + ([ $preflight.steps[] | select(.name == "assert-exact-g-dispatch-context") ] | length == 1) and + ([ $preflight.steps[] | select(.uses == "actions/create-github-app-token@v3.2.0") ] | length == 1) and + $preflight.steps[2].if == "${{ success() }}" and + $preflight.steps[2].env.EDGEZERO_WORKFLOW_REF == "${{ github.workflow_ref }}" and + $preflight.steps[2].env.EDGEZERO_RELEASE_STATE == "${{ vars.EDGEZERO_BUILD_CONTAINER_RELEASE_STATE }}" and + ($preflight.steps[2].run | contains("$GITHUB_WORKSPACE/.edgezero-gate/.github/docker/build-app-cli/assert-build-container-dispatch-context.sh")) and + $preflight.steps[3].with["app-id"] == "${{ vars.EDGEZERO_BUILD_CONTAINER_APP_ID }}" and + $preflight.steps[3].with["private-key"] == "${{ secrets.EDGEZERO_BUILD_CONTAINER_APP_PRIVATE_KEY }}" and + $preflight.steps[4].env.EDGEZERO_EXPECTED_INSTALLATION_ID == "${{ vars.EDGEZERO_BUILD_CONTAINER_APP_INSTALLATION_ID }}" and + ([ $preflight.steps[] | .with?.path? // empty | select(. == ".edgezero-subject") ] | length == 0)) and + ([ $w.jobs[] | .steps[] | .uses? // empty ] | + all(test("@v(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)$")))) + ' "$parsed" >/dev/null +} + +validate "$WORKFLOW" || { + printf 'build-container workflow violates its structural contract\n' >&2 + exit 1 +} + +reject_mutation() { + local expression=$1 + yq "$expression" "$WORKFLOW" >"$WORK/candidate.yml" + if validate "$WORK/candidate.yml"; then + printf 'workflow contract accepted mutation: %s\n' "$expression" >&2 + exit 1 + fi +} + +for mutation in \ + 'del(."run-name")' \ + 'del(.on.merge_group)' \ + '.on.workflow_dispatch.inputs.candidate-head-sha.required = false' \ + '.on.pull_request.paths = [".github/**"]' \ + '.permissions.contents = "write"' \ + '.jobs.build-container-local.runs-on = "ubuntu-latest"' \ + '.jobs.build-container-local.steps[0].if = "${{ success() }}"' \ + '.jobs.build-container-local.steps[0].env.EDGEZERO_RUNNER_OS = "Linux"' \ + '.jobs.build-container-local.steps[1].uses = "actions/checkout@v7"' \ + '.jobs.build-container-local.steps[1].with.persist-credentials = true' \ + '.jobs.build-container-local.steps[1].with.ref = "${{ github.sha }}"' \ + 'del(.jobs.build-container-local.steps[7])' \ + '.jobs.build-container-local.steps[8].if = "${{ always() }}"' \ + '.jobs.build-container-local.steps[9].continue-on-error = true' \ + '.jobs.build-container-local.steps[10].if = "${{ success() }}"' \ + '.jobs.build-container-local.environment = "build-container-release"' \ + '.jobs.build-container-pin.env.TOKEN = "${{ secrets.PUBLISHER }}"' \ + '.jobs.build-container-pin.steps[6].env.EDGEZERO_WORKFLOW_SHA = "${{ github.sha }}"' \ + '.jobs.build-container-pin.steps[9].run |= sub(".edgezero-gate"; ".edgezero-subject")' \ + '.jobs.build-container-release-preflight.permissions.contents = "write"' \ + '.jobs.build-container-release-preflight.steps[2].env.EDGEZERO_WORKFLOW_REF = "${{ github.ref }}"' \ + 'del(.jobs.build-container-release-preflight.steps[] | select(.name == "assert-exact-g-dispatch-context"))'; do + reject_mutation "$mutation" +done + +printf 'build-container workflow contract passed\n' diff --git a/.github/actions/deploy-core/tests/check-action-pins.sh b/.github/actions/deploy-core/tests/check-action-pins.sh index 2fc290fd..91335b2d 100755 --- a/.github/actions/deploy-core/tests/check-action-pins.sh +++ b/.github/actions/deploy-core/tests/check-action-pins.sh @@ -1,19 +1,8 @@ #!/usr/bin/env bash set -euo pipefail -# Verifies every `uses:` reference — across ALL repository workflows and composite -# action metadata — is pinned to a CONCRETE ref: a released VERSION TAG (a major tag -# `@v4`, or a fuller `@v4.3.0`) or a full commit SHA, never a mutable branch/floating -# ref like `@main`, `@develop`, or `@latest`, or an unpinned reference. -# -# This does NOT assert immutability. A version tag — a major tag such as `@v4` -# especially — is repointed by the action's publisher on every release, so it can -# move under you (the tj-actions/changed-files compromise is exactly this). The gate -# enforces the repo's version-tag policy (see .github/zizmor.yml) and a concrete, -# reviewable ref; it rejects refs that move on their own (branches) but not a -# publisher re-tag. Pin to a full commit SHA where cryptographic immutability -# matters. Local (`./...`) refs are exempt; a `docker://` ref must itself be pinned — -# by an `@sha256:` digest or a version tag, never a floating `:latest`/bare image. +# Public action/workflow refs use exact stable vMAJOR.MINOR.PATCH tags. Docker +# actions use lowercase sha256 digests. Tag movement is an accepted release risk. # # The `uses` values are extracted STRUCTURALLY with yq, so no YAML spelling — a space # before the colon, a quoted or unicode-escaped key, a `!!str`-tagged or multiline @@ -24,11 +13,15 @@ set -euo pipefail REPO_ROOT=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../../../.." && pwd) -# Require mikefarah yq v4: its expression syntax below is version-specific, and the -# alternative (kislyuk's python yq) is a different tool. Fail closed if it is absent -# so a gate that silently checked nothing can never pass. -if ! command -v yq >/dev/null 2>&1 || ! yq --version 2>&1 | grep -qE 'mikefarah/yq.*version v?4\.'; then - echo "::error::check-action-pins.sh requires mikefarah yq v4 for structural YAML parsing" >&2 +# Require the reviewed yq binary exactly. Its parser is part of the gate's policy, +# so accepting an arbitrary future v4 would silently change the language accepted. +if ! command -v yq >/dev/null 2>&1 || + [[ "$(yq --version 2>&1)" != 'yq (https://github.com/mikefarah/yq/) version v4.53.3' ]]; then + echo "::error::check-action-pins.sh requires mikefarah yq v4.53.3 for structural YAML parsing" >&2 + exit 2 +fi +if ! command -v jq >/dev/null 2>&1; then + echo "::error::check-action-pins.sh requires jq" >&2 exit 2 fi @@ -36,74 +29,63 @@ fi # workflow calls), workflow step `uses`, and composite-action `runs.steps[].uses`. # A blanket "any map with a `uses` key" would also reject unrelated fields such as # `jobs..env.uses`. -uses_query='[(.jobs[]? | .uses), (.jobs[]? | .steps[]? | .uses), (.runs.steps[]? | .uses)] | .[] | select(. != null)' +# has() preserves explicit nulls. JSON keeps multiline scalars in one record. +uses_query='[(.jobs[]? | select(has("uses")) | .uses), (.jobs[]? | .steps[]? | select(has("uses")) | .uses), (.runs.steps[]? | select(has("uses")) | .uses)]' +parse_query='{"document": ., "aliases": [... | select(kind == "alias")], "duplicates": [.. | select(kind == "map") | to_entries | group_by(.key) | .[] | select(length > 1)]}' files=() if [[ "$#" -gt 0 ]]; then files=("$@") else - while IFS= read -r found; do files+=("$found"); done < <( - # GitHub only reads workflows from .github/workflows (no nesting), so maxdepth 1 - # is correct there. Composite/local actions, however, can live ANYWHERE in the - # repo (e.g. tools/deploy/action.yml), so scan action.yml repo-wide — pruning - # build/vendor/VCS trees — rather than only under .github/actions. - find "$REPO_ROOT/.github/workflows" -maxdepth 1 -type f \( -name '*.yml' -o -name '*.yaml' \) 2>/dev/null - find "$REPO_ROOT" \ - \( -path '*/.git' -o -name target -o -name node_modules \) -prune -o \ - -type f \( -name 'action.yml' -o -name 'action.yaml' \) -print 2>/dev/null - ) + inventory=$(mktemp) + trap 'rm -f "$inventory"' EXIT + # Workflows are direct children; local actions may live anywhere in the repo. + find "$REPO_ROOT/.github/workflows" -maxdepth 1 -type f \( -name '*.yml' -o -name '*.yaml' \) -print0 >"$inventory" + find "$REPO_ROOT" \ + \( -path '*/.git' -o -name target -o -name node_modules \) -prune -o \ + -type f \( -name 'action.yml' -o -name 'action.yaml' \) -print0 >>"$inventory" + while IFS= read -r -d '' found; do files+=("$found"); done <"$inventory" fi -# A full commit SHA, or a release version tag. The tag allows an optional semver -# prerelease AND build-metadata suffix together (v1.2.3-rc.1+build.5), not just one. -# Regexes live in variables (the bash-3.2-safe idiom for `=~`). -sha_re='^[0-9a-fA-F]{40}$' -tag_re='^v?[0-9]+(\.[0-9]+)*(-[0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?$' +policy=' + def valid: + if type != "string" then false + elif test("[\\s\\x00-\\x1f\\x7f]") then false + elif startswith("./") then length > 2 + elif startswith("docker://") then + test("^docker://[a-z0-9][a-z0-9._:/-]*@sha256:[0-9a-f]{64}$") + else + test("^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+(/[A-Za-z0-9_.-]+)*@v(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)$") + end; + [.[] | select(valid | not)]' status=0 refs_seen=0 -for file in "${files[@]}"; do - [[ -f "$file" ]] || continue +# Bash 3.2 treats an empty array as unset under nounset. +for file in ${files[@]+"${files[@]}"}; do + if [[ ! -f "$file" ]]; then + echo "::error::missing action metadata or workflow: $file" >&2 + status=1 + continue + fi # FAIL CLOSED on a parse/tool failure: if yq cannot read the file, a `2>/dev/null` # process substitution would yield no refs and the gate would silently pass a file # it never checked. Capture the output and the exit status instead. - if ! uses_list=$(yq "$uses_query" "$file" 2>/dev/null); then + if ! parsed=$(yq -o=json -I=0 "$parse_query" "$file" | + jq -cse 'if length == 1 and (.[0] | type == "object") then .[0] else error("expected one YAML document") end') || + ! jq -e '.aliases == [] and .duplicates == [] and (.document | type) == "object"' <<<"$parsed" >/dev/null || + ! uses_list=$(jq -c ".document | $uses_query" <<<"$parsed"); then echo "::error::could not parse '$file' as YAML — refusing to pass a file the pin gate cannot read" >&2 status=1 continue fi - while IFS= read -r ref; do - [[ -z "$ref" || "$ref" == "null" ]] && continue - refs_seen=$((refs_seen + 1)) - case "$ref" in - ./*) continue ;; - docker://*) - # A docker ref is pinned by an `@:` (immutable) or a version - # tag; a bare image or a floating `:latest` is rejected like a branch ref. - docker_ref="${ref#docker://}" - if [[ "$docker_ref" == *@*:* ]]; then continue; fi - docker_tag="${docker_ref##*:}" - if [[ "$docker_ref" == *:* && "$docker_tag" != *"/"* && "$docker_tag" =~ $tag_re ]]; then - continue - fi - echo "::error::docker action ref must be pinned by an @: or a version tag (floating ':latest'/bare images are not allowed): '$ref' in $file" >&2 - status=1 - continue - ;; - esac - if [[ "$ref" != *@* ]]; then - echo "::error::unpinned action reference (no @ref): '$ref' in $file" >&2 - status=1 - continue - fi - suffix="${ref##*@}" - if [[ "$suffix" =~ $sha_re || "$suffix" =~ $tag_re ]]; then - : - else - echo "::error::action ref '@$suffix' is neither a full commit SHA nor a release version tag (mutable branch/floating refs are not allowed): '$ref' in $file" >&2 - status=1 - fi - done <<<"$uses_list" + invalid=$(jq -c "$policy" <<<"$uses_list") + if [[ "$invalid" != '[]' ]]; then + echo "::error::expected an exact stable version tag, local action, or Docker sha256 digest in $file: $invalid" >&2 + status=1 + fi + count=$(jq '[.[] | select(type == "string") | select(startswith("./") | not)] | length' <<<"$uses_list") + refs_seen=$((refs_seen + count)) done # A default (whole-repo) scan that finds ZERO action references is not a pass: the @@ -112,11 +94,11 @@ done # green while checking nothing. Fail closed. (An explicit FILE... run may legitimately # target a file with no refs, so only guard the default scan.) if [[ "$#" -eq 0 && "$refs_seen" -eq 0 ]]; then - echo "::error::pin gate parsed 0 action references across the repository — expected many; refusing to pass (is yq working?)" >&2 + echo "::error::pin gate parsed 0 external action references; refusing a vacuous repository scan" >&2 status=1 fi if [[ "$status" -eq 0 ]]; then - echo "all action references (repository-wide) are pinned to a concrete ref" + echo "action reference policy passed ($refs_seen external references)" fi exit "$status" diff --git a/.github/actions/deploy-core/tests/check-build-container-publisher.test.sh b/.github/actions/deploy-core/tests/check-build-container-publisher.test.sh new file mode 100755 index 00000000..46fce481 --- /dev/null +++ b/.github/actions/deploy-core/tests/check-build-container-publisher.test.sh @@ -0,0 +1,271 @@ +#!/usr/bin/env bash +# GitHub expressions below are deliberate literal fixture data. +# shellcheck disable=SC2016 +set -euo pipefail + +ROOT=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../../../.." && pwd -P) +CHECKER_REL=.github/docker/build-app-cli/check-build-container-publisher.sh +PUBLISH_REL=.github/workflows/publish-build-container.yml +ROTATE_REL=.github/workflows/rotate-build-container-gate.yml +CHECKER="$ROOT/$CHECKER_REL" +WORK=$(mktemp -d) +WORK=$(cd -- "$WORK" && pwd -P) +trap 'rm -rf "$WORK"' EXIT + +pass=0 +fail=0 +ok() { pass=$((pass + 1)); printf ' ok %s\n' "$1"; } +no() { fail=$((fail + 1)); printf ' FAIL %s\n' "$1" >&2; } + +printf '== build container publisher structural checker ==\n' + +for required in "$CHECKER" "$ROOT/$PUBLISH_REL" "$ROOT/$ROTATE_REL"; do + [[ -f "$required" && ! -L "$required" ]] || { + printf 'missing required implementation file: %s\n' "$required" >&2 + exit 1 + } +done +[[ -x "$CHECKER" ]] || { + printf 'checker is not executable: %s\n' "$CHECKER" >&2 + exit 1 +} +yq -o=json -I=0 ' + .jobs.wait.steps[] | select(.name == "assert-exact-rotation-context") +' "$ROOT/$ROTATE_REL" >"$WORK/rotation-step.json" +jq -e '.env == { + "GITHUB_TOKEN":"${{ github.token }}", + "EDGEZERO_OLD_GATE_SHA":"${{ needs.acquire.outputs.old-gate-sha }}", + "EDGEZERO_DISPATCH_SHA":"${{ needs.acquire.outputs.dispatch-sha }}", + "EDGEZERO_RUN_ID":"${{ github.run_id }}", + "EDGEZERO_RUN_ATTEMPT":"${{ github.run_attempt }}", + "EDGEZERO_RUN_ACTOR_LOGIN":"${{ needs.acquire.outputs.run-actor-login }}" +}' "$WORK/rotation-step.json" >/dev/null || { + printf 'rotation lock step omits its captured context bindings\n' >&2 + exit 1 +} +yq -o=json -I=0 '.jobs.build-and-verify.steps' "$ROOT/$PUBLISH_REL" >"$WORK/build-steps.json" +jq -e ' + (map(.name) | index("validate-release-request")) as $validate + | (map(.name) | index("stage-trusted-build-context")) as $stage + | (map(.name) | index("build-publish-and-verify")) as $publish + | $validate != null and $stage != null and $publish != null + and $validate < $stage and $stage < $publish + and (.[ $validate ].run | contains("classify-build-container-change.sh") + and contains("--base") and contains("--head") + and contains("--kind local") and contains("mode=ordinary") + and contains("relevant=true") and contains("release-request.json") + and contains("EDGEZERO_RELEASE_TAG")) + and (.[ $publish ].run | contains("docker login ghcr.io") + and contains("trap cleanup_registry EXIT") + and contains("--provenance=false") and contains("--sbom=false") + and contains("--file") and contains("verify-published-image.sh")) +' "$WORK/build-steps.json" >/dev/null || { + printf 'publisher omits pre-build isolated release-request and full gate-manifest validation\n' >&2 + exit 1 +} +yq -o=json -I=0 ' + .jobs.update-pin.steps[] | select(.name == "update-pin-branch") +' "$ROOT/$PUBLISH_REL" >"$WORK/update-pin-step.json" +jq -e ' + .env.EDGEZERO_APPROVAL_JSON == "${{ steps.approval.outputs.approval-json }}" + and (.run | contains("--approval-json \"$EDGEZERO_APPROVAL_JSON\"") + and (contains("${{ steps.approval.outputs.approval-json }}") | not)) +' "$WORK/update-pin-step.json" >/dev/null || { + printf 'publisher interpolates approval JSON into its shell program\n' >&2 + exit 1 +} +yq -o=json -I=0 ' + .jobs.acquire.steps[] | select(.name == "capture-protected-dispatch") +' "$ROOT/$ROTATE_REL" >"$WORK/rotation-capture.json" +jq -e ' + (.env | has("EDGEZERO_PUBLISHER_PREREQUISITE") | not) + and (.run | contains("gate-paths.txt") and contains("ls-tree") + and contains("gate_entry") and contains("dispatch_entry")) +' "$WORK/rotation-capture.json" >/dev/null || { + printf 'rotation acquire omits complete gate-manifest validation\n' >&2 + exit 1 +} + +git_env() { + env -i PATH="$PATH" LC_ALL=C HOME="$WORK/home" TMPDIR=/tmp \ + GIT_CONFIG_NOSYSTEM=1 GIT_CONFIG_GLOBAL=/dev/null GIT_TERMINAL_PROMPT=0 \ + git "$@" +} + +init_repo() { + local repo=$1 + mkdir -p "$repo/.github/docker/build-app-cli" "$repo/.github/workflows" "$WORK/home" + cp "$CHECKER" "$repo/$CHECKER_REL" + chmod 0755 "$repo/$CHECKER_REL" + cp "$ROOT/$PUBLISH_REL" "$repo/$PUBLISH_REL" + cp "$ROOT/$ROTATE_REL" "$repo/$ROTATE_REL" + printf '%s\n' 'name: Unrelated' 'on: {push: null}' 'jobs: {}' >"$repo/.github/workflows/unrelated.yml" + git_env -C "$repo" init -q + git_env -C "$repo" config user.name tester + git_env -C "$repo" config user.email tester@example.invalid + git_env -C "$repo" add . + git_env -C "$repo" commit -qm initial + git_env -C "$repo" switch --detach -q HEAD +} + +GATE="$WORK/gate" +SUBJECT="$WORK/subject" +init_repo "$GATE" +init_repo "$SUBJECT" +G=$(git_env -C "$GATE" rev-parse HEAD) + +run_checker() { + local candidate=$1 stdout="$WORK/stdout" stderr="$WORK/stderr" + shift + env -i PATH="$PATH" LC_ALL=C \ + bash "$GATE/$CHECKER_REL" \ + --gate-root "$GATE" --subject-root "$SUBJECT" --gate-sha "$G" \ + --candidate-sha "$candidate" "$@" >"$stdout" 2>"$stderr" +} + +T=$(git_env -C "$SUBJECT" rev-parse HEAD) +if run_checker "$T" && [[ ! -s "$WORK/stdout" ]]; then + ok 'accepts the reviewed publisher and rotation topology without stdout' +else + no 'accepts the reviewed publisher and rotation topology without stdout' + sed -n '1,20p' "$WORK/stderr" >&2 +fi + +if run_checker "$T" --gate-sha "$G"; then + no 'rejects duplicate flags' +else + ok 'rejects duplicate flags' +fi +if run_checker "$T" --unknown x; then + no 'rejects unknown flags' +else + ok 'rejects unknown flags' +fi + +commit_mutation() { + local file=$1 expression=$2 + yq "$expression" "$SUBJECT/$file" >"$WORK/mutated.yml" + cp "$WORK/mutated.yml" "$SUBJECT/$file" + git_env -C "$SUBJECT" add "$file" + git_env -C "$SUBJECT" commit -qm mutation + git_env -C "$SUBJECT" rev-parse HEAD +} + +restore_subject() { + git_env -C "$SUBJECT" reset --hard -q "$T" +} + +reject_mutation() { + local description=$1 file=$2 expression=$3 candidate + candidate=$(commit_mutation "$file" "$expression") + if run_checker "$candidate"; then no "$description"; else ok "$description"; fi + restore_subject +} + +reject_mutation 'rejects a publisher branch trigger' "$PUBLISH_REL" '.on.push.branches = ["main"]' +reject_mutation 'rejects a different publication concurrency group' "$PUBLISH_REL" '.concurrency.group = "other"' +reject_mutation 'rejects cancellation of an active publisher' "$PUBLISH_REL" '.concurrency.cancel-in-progress = true' +reject_mutation 'rejects a non-FIFO concurrency queue' "$PUBLISH_REL" '.concurrency.queue = "replace"' +reject_mutation 'rejects a dynamic publisher runner label' "$PUBLISH_REL" '.jobs.build-and-verify.runs-on = "${{ matrix.runner }}"' +reject_mutation 'rejects a skipped first publisher guard' "$PUBLISH_REL" '.jobs.build-and-verify.steps[0].if = "${{ success() }}"' +reject_mutation 'rejects inherited BASH_ENV at the first guard' "$PUBLISH_REL" 'del(.jobs.build-and-verify.steps[0].env.BASH_ENV)' +reject_mutation 'rejects a writable contents grant in the build job' "$PUBLISH_REL" '.jobs.build-and-verify.permissions.contents = "write"' +reject_mutation 'rejects package deletion authority' "$PUBLISH_REL" '.jobs.build-and-verify.permissions.delete-packages = "write"' +reject_mutation 'rejects exposing the protected environment to the build job' "$PUBLISH_REL" '.jobs.build-and-verify.environment = "build-container-release"' +reject_mutation 'rejects an unpinned checkout action' "$PUBLISH_REL" '.jobs.build-and-verify.steps[1].uses = "actions/checkout@v7"' +reject_mutation 'rejects a candidate-controlled gate checkout' "$PUBLISH_REL" '.jobs.build-and-verify.steps[1].with.ref = "${{ github.sha }}"' +reject_mutation 'rejects omitted release-request classification' "$PUBLISH_REL" '.jobs.build-and-verify.steps[] |= (select(.name == "validate-release-request") .run = "set -euo pipefail\ntrue")' +reject_mutation 'rejects provenance-enabled publication builds' "$PUBLISH_REL" '.jobs.build-and-verify.steps[] |= (select(.name == "build-publish-and-verify") .run |= sub("--provenance=false"; "--provenance=true"))' +reject_mutation 'rejects publication without credential cleanup trap' "$PUBLISH_REL" '.jobs.build-and-verify.steps[] |= (select(.name == "build-publish-and-verify") .run |= sub("trap cleanup_registry EXIT"; "true"))' +reject_mutation 'rejects commands prepended to a credential-bearing run block' "$PUBLISH_REL" '.jobs.build-and-verify.steps[] |= (select(.name == "build-publish-and-verify") .run = "printf leak >&2\n" + .run)' +reject_mutation 'rejects persistent checkout credentials' "$PUBLISH_REL" '.jobs.update-pin.steps[1].with.persist-credentials = true' +reject_mutation 'rejects extra token-action inputs' "$PUBLISH_REL" '.jobs.update-pin.steps[] |= (select(.name == "mint-publisher-token") .with.skip-token-revoke = true)' +reject_mutation 'rejects extra token-action permission scopes' "$PUBLISH_REL" '.jobs.update-pin.steps[] |= (select(.name == "mint-publisher-token") .with.permission-issues = "write")' +reject_mutation 'rejects extra updater environment values' "$PUBLISH_REL" '.jobs.update-pin.steps[] |= (select(.name == "update-pin-branch") .env.EXTRA = "candidate")' +reject_mutation 'rejects updater execution from the subject checkout' "$PUBLISH_REL" '.jobs.update-pin.steps[] |= (select(.name == "update-pin-branch") .run |= sub(".edgezero-gate"; ".edgezero-source"))' +reject_mutation 'rejects token minting before approval verification' "$PUBLISH_REL" '.jobs.update-pin.steps |= ([.[0], .[1], .[2], .[3], .[4], .[6], .[5]] + .[7:])' +reject_mutation 'rejects an always-run non-cleanup publisher step' "$PUBLISH_REL" '.jobs.update-pin.steps += [{"name":"masked","if":"${{ always() }}","run":"true"}]' + +reject_mutation 'rejects a rotation push trigger' "$ROTATE_REL" '.on.push = null' +reject_mutation 'rejects a different rotation concurrency group' "$ROTATE_REL" '.concurrency.group = "other"' +reject_mutation 'rejects a late rotation guard' "$ROTATE_REL" '.jobs.acquire.steps |= [.[1], .[0]]' +reject_mutation 'rejects a dynamic rotation runner label' "$ROTATE_REL" '.jobs.wait.runs-on = "ubuntu-latest"' +reject_mutation 'rejects secret access in the acquire job' "$ROTATE_REL" '.jobs.acquire.env.LEAK = "${{ secrets.KEY }}"' +reject_mutation 'rejects omitted dispatch gate-manifest comparison' "$ROTATE_REL" '.jobs.acquire.steps[] |= (select(.name == "capture-protected-dispatch") .run = "set -euo pipefail\ntrue")' +reject_mutation 'rejects rotation helper execution from a candidate root' "$ROTATE_REL" '.jobs.wait.steps[] |= (select(.name == "assert-exact-rotation-context") .run |= sub(".edgezero-gate"; ".edgezero-subject"))' +reject_mutation 'rejects a conditional exact rotation assertion' "$ROTATE_REL" '.jobs.wait.steps[] |= (select(.name == "assert-exact-rotation-context") .if = "${{ success() }}")' + +printf '%s\n' \ + 'name: Collision' \ + 'on: {workflow_dispatch: null}' \ + 'concurrency:' \ + ' group: edgezero-build-container-publication' \ + ' cancel-in-progress: false' \ + ' queue: max' \ + 'jobs: {}' >"$SUBJECT/.github/workflows/collision.yml" +git_env -C "$SUBJECT" add .github/workflows/collision.yml +git_env -C "$SUBJECT" commit -qm collision +COLLISION=$(git_env -C "$SUBJECT" rev-parse HEAD) +if run_checker "$COLLISION"; then no 'rejects a third workflow claiming the shared group'; else ok 'rejects a third workflow claiming the shared group'; fi +restore_subject + +printf '%s\n' \ + 'name: Case collision' \ + 'on: {workflow_dispatch: null}' \ + 'concurrency:' \ + ' group: EdgeZero-Build-Container-Publication' \ + ' cancel-in-progress: false' \ + ' queue: max' \ + 'jobs: {}' >"$SUBJECT/.github/workflows/collision.yml" +git_env -C "$SUBJECT" add .github/workflows/collision.yml +git_env -C "$SUBJECT" commit -qm case-collision +CASE_COLLISION=$(git_env -C "$SUBJECT" rev-parse HEAD) +if run_checker "$CASE_COLLISION"; then no 'rejects a case-variant concurrency collision'; else ok 'rejects a case-variant concurrency collision'; fi +restore_subject + +printf '%s\n' \ + 'name: Dynamic collision' \ + 'on: {workflow_dispatch: null}' \ + 'concurrency:' \ + " group: \${{ 'edgezero-build-container-publication' }}" \ + ' cancel-in-progress: true' \ + 'jobs: {}' >"$SUBJECT/.github/workflows/collision.yml" +git_env -C "$SUBJECT" add .github/workflows/collision.yml +git_env -C "$SUBJECT" commit -qm dynamic-collision +DYNAMIC_COLLISION=$(git_env -C "$SUBJECT" rev-parse HEAD) +if run_checker "$DYNAMIC_COLLISION"; then no 'rejects a dynamically equivalent concurrency collision'; else ok 'rejects a dynamically equivalent concurrency collision'; fi +restore_subject + +printf '%s\n' \ + 'name: Job collision' \ + 'on: {workflow_dispatch: null}' \ + 'jobs:' \ + ' collide:' \ + ' runs-on: ubuntu-24.04' \ + ' concurrency:' \ + ' group: edgezero-build-container-publication' \ + ' cancel-in-progress: true' \ + ' steps: []' >"$SUBJECT/.github/workflows/collision.yml" +git_env -C "$SUBJECT" add .github/workflows/collision.yml +git_env -C "$SUBJECT" commit -qm job-collision +JOB_COLLISION=$(git_env -C "$SUBJECT" rev-parse HEAD) +if run_checker "$JOB_COLLISION"; then no 'rejects a job-level concurrency collision'; else ok 'rejects a job-level concurrency collision'; fi +restore_subject + +ln -s unrelated.yml "$SUBJECT/.github/workflows/linked.yml" +git_env -C "$SUBJECT" add .github/workflows/linked.yml +git_env -C "$SUBJECT" commit -qm linked +LINKED=$(git_env -C "$SUBJECT" rev-parse HEAD) +if run_checker "$LINKED"; then no 'rejects linked candidate workflow blobs'; else ok 'rejects linked candidate workflow blobs'; fi +restore_subject + +SHARED_GIT="$WORK/shared-git" +cp -R "$GATE/.git" "$SHARED_GIT" +mv "$GATE/.git" "$WORK/gate-git" +mv "$SUBJECT/.git" "$WORK/subject-git" +ln -s "$SHARED_GIT" "$GATE/.git" +ln -s "$SHARED_GIT" "$SUBJECT/.git" +if run_checker "$G"; then no 'rejects symlinked shared Git storage'; else ok 'rejects symlinked shared Git storage'; fi + +printf 'Passed: %d Failed: %d\n' "$pass" "$fail" +[[ "$fail" -eq 0 ]] diff --git a/.github/actions/deploy-core/tests/check-doc-action-pins.mjs b/.github/actions/deploy-core/tests/check-doc-action-pins.mjs new file mode 100644 index 00000000..a9de7ce6 --- /dev/null +++ b/.github/actions/deploy-core/tests/check-doc-action-pins.mjs @@ -0,0 +1,609 @@ +import { execFileSync } from "node:child_process"; +import { + existsSync, + lstatSync, + mkdtempSync, + readFileSync, + realpathSync, + rmSync, +} from "node:fs"; +import { createRequire } from "node:module"; +import { dirname, resolve } from "node:path"; +import { tmpdir } from "node:os"; +import { fileURLToPath } from "node:url"; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), "../../../.."); +const require = createRequire(resolve(root, "docs/package.json")); +const MarkdownIt = require("markdown-it"); +if (require("markdown-it/package.json").version !== "15.0.1") + throw Error("markdown-it 15.0.1 is required"); +const markdown = new MarkdownIt("commonmark"); +const recordPath = "docs/.edgezero-action-release.json"; +const placeholder = ""; +const adoption = new Set([ + "docs/superpowers/specs/edgezero-deploy-github-action.md", + "docs/superpowers/plans/edgezero-deploy-action-implementation-plan.md", + "docs/superpowers/specs/edgezero-deploy-adoption-guide.md", + "docs/guide/deploy-github-actions.md", +]); +const versionPattern = /^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$/; +const isSha = (value) => + typeof value === "string" && + /^[0-9a-f]{40}$/.test(value) && + !/^0+$/.test(value); +const isMarkdown = (path) => /\.(md|markdown)$/i.test(path); +const own = (value, key) => + value !== null && typeof value === "object" && Object.hasOwn(value, key); +function requireThat(condition, message) { + if (!condition) throw Error(message); +} +function command(program, args, options = {}) { + return execFileSync(program, args, { + encoding: "utf8", + maxBuffer: 32 * 1024 * 1024, + ...options, + }); +} + +export function parseRecord(bytes) { + requireThat(bytes.length <= 16384, "oversized release record"); + const text = new TextDecoder("utf-8", { fatal: true }).decode(bytes); + const value = JSON.parse(text); + requireThat( + value && !Array.isArray(value) && typeof value === "object", + "release record must be an object", + ); + requireThat(isSha(value["action-revision"]), "invalid action revision"); + requireThat( + typeof value["action-version"] === "string" && + versionPattern.test(value["action-version"]), + "invalid action version", + ); + requireThat(value["schema-version"] === 1, "invalid release schema"); + const canonical = JSON.stringify({ + "action-revision": value["action-revision"], + "action-version": value["action-version"], + "schema-version": 1, + }); + requireThat( + Buffer.from(canonical).equals(bytes), + "release record must have exact JCS bytes and fields", + ); + return value; +} + +export function checkTransition(base, candidate, changed, verifyRelease) { + if (base === null && candidate === null) return; + requireThat(candidate !== null, "release record cannot be deleted"); + if (base !== null && JSON.stringify(base) === JSON.stringify(candidate)) + return; + requireThat( + changed.every((path) => isMarkdown(path) || path === recordPath), + "release transition must change only documentation", + ); + if (base !== null) { + const previous = base["action-version"].slice(1).split(".").map(BigInt); + const next = candidate["action-version"].slice(1).split(".").map(BigInt); + const differing = next.findIndex((part, index) => part !== previous[index]); + requireThat( + differing !== -1 && next[differing] > previous[differing], + "release version must strictly increase", + ); + } + verifyRelease(candidate); +} + +function yamlReferences(document) { + const result = []; + const take = (value, job = null, reusable = false) => { + if (own(value, "uses")) result.push({ ref: value.uses, job, reusable }); + }; + if (Array.isArray(document)) document.forEach((step) => take(step)); + else if (document && typeof document === "object") { + take(document); + for (const job of Object.values(document.jobs ?? {})) { + take(job, job, true); + for (const step of job?.steps ?? []) take(step, job); + } + for (const step of document.steps ?? []) take(step); + for (const step of document.runs?.steps ?? []) take(step); + } + return result; +} + +export function scanDocument(path, text, record) { + if (record !== null) + requireThat( + !text.includes(placeholder), + `${path}: released Markdown still has an action placeholder`, + ); + let count = 0; + for (const token of markdown.parse(text, {})) { + if ( + token.type !== "fence" || + !/^(yaml|yml)(?=[\s{:[]|$)/i.test(token.info.trim()) + ) + continue; + const location = `${path}:${token.map[0] + 1}`; + const parsed = command( + "yq", + [ + "-o=json", + "-I=0", + '{"value": ., "duplicates": [.. | select(kind == "map") | to_entries | group_by(.key) | .[] | select(length > 1)]}', + "-", + ], + { input: token.content }, + ); + let document; + try { + document = JSON.parse(parsed); + } catch { + throw Error(`${location}: expected one YAML document`); + } + requireThat( + document.duplicates.length === 0, + `${location}: duplicate YAML keys`, + ); + if (record !== null) + requireThat( + !JSON.stringify(document.value).includes(placeholder), + `${location}: decoded YAML still has an action placeholder`, + ); + for (const { ref, job, reusable } of yamlReferences(document.value)) { + requireThat( + typeof ref === "string" && + ref.length > 0 && + !/[\s\u0000-\u001f\u007f]/u.test(ref), + `${location}: invalid uses value`, + ); + if (ref.startsWith("./")) continue; + count += 1; + if (ref.startsWith("docker://")) { + requireThat( + /^docker:\/\/[a-z0-9][a-z0-9._:/-]*@sha256:[0-9a-f]{64}$/.test(ref), + `${location}: Docker action requires a sha256 digest`, + ); + continue; + } + const match = + /^([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+(?:\/[A-Za-z0-9_.-]+)*)@([^@]+)$/.exec( + ref, + ); + requireThat(match !== null, `${location}: malformed external reference`); + const [, action, version] = match; + const edgezero = /^stackpop\/edgezero(?:\/|$)/i.test(action); + if (edgezero) { + requireThat( + reusable + ? action === "stackpop/edgezero/.github/workflows/build-app-cli.yml" + : /^stackpop\/edgezero\/\.github\/actions\/[^/]+$/.test(action), + `${location}: unsupported EdgeZero action or workflow invocation`, + ); + requireThat( + job !== null, + `${location}: EdgeZero examples require a complete job and runner contract`, + ); + if (reusable) + requireThat( + !own(job, "runs-on") && !own(job, "steps"), + `${location}: reusable caller must omit steps and runs-on`, + ); + else + requireThat( + !own(job, "uses") && job["runs-on"] === "ubuntu-24.04", + `${location}: public action job requires literal ubuntu-24.04`, + ); + if (record === null && version === placeholder) { + requireThat( + adoption.has(path), + `${location}: placeholder outside prepublication documents`, + ); + continue; + } + if (record !== null) + requireThat( + version === record["action-version"], + `${location}: action version differs from release record`, + ); + } + requireThat( + versionPattern.test(version), + `${location}: external reference requires an exact stable patch version`, + ); + } + } + return count; +} + +export function selectRange(env, event, git) { + const candidate = env.GITHUB_SHA; + requireThat(isSha(candidate), "invalid hosted candidate SHA"); + let base; + switch (env.GITHUB_EVENT_NAME) { + case "pull_request": { + const pr = event.pull_request; + const payloadBase = pr?.base?.sha; + requireThat( + pr?.base?.repo?.full_name === "stackpop/edgezero" && + pr?.base?.ref === "main", + "wrong pull request base", + ); + requireThat( + Number.isSafeInteger(event.number) && + event.number > 0 && + env.GITHUB_REF === `refs/pull/${event.number}/merge`, + "wrong pull request merge ref", + ); + requireThat( + isSha(payloadBase) && isSha(pr?.head?.sha), + "invalid pull request source SHA", + ); + const observedParents = git.parents(candidate); + requireThat( + observedParents.length === 2 && + observedParents.every(isSha) && + observedParents[1] === pr.head.sha, + `invalid synthetic merge parents: ${JSON.stringify(observedParents)}`, + ); + base = observedParents[0]; + requireThat( + [payloadBase, base, pr.head.sha, candidate].every((sha) => + git.availableCommit(sha), + ), + "pull request commit object is unavailable", + ); + requireThat( + git.ancestor(payloadBase, base), + "pull request base is not an ancestor of its merge parent", + ); + break; + } + case "merge_group": { + const group = event.merge_group; + base = group?.base_sha; + requireThat( + event.action === "checks_requested" && + group?.base_ref === "refs/heads/main", + "wrong merge group event", + ); + requireThat( + typeof group?.head_ref === "string" && + group.head_ref.startsWith("refs/heads/gh-readonly-queue/main/") && + group.head_ref === env.GITHUB_REF && + group.head_sha === candidate, + "wrong merge group head", + ); + break; + } + case "push": + base = event.before; + requireThat( + env.GITHUB_REF === "refs/heads/main" && + event.after === candidate && + env.GITHUB_WORKFLOW_SHA === candidate, + "wrong protected-main push", + ); + break; + default: + throw Error("unsupported documentation-gate event"); + } + requireThat( + isSha(base) && git.ancestor(base, candidate), + "invalid or unrelated comparison base", + ); + return { base, candidate }; +} + +export function verifyRelease(record) { + const version = record["action-version"]; + const token = process.env.GITHUB_TOKEN ?? ""; + requireThat(!/[\r\n"\\]/.test(token), "invalid API credential encoding"); + const curlEnv = { PATH: process.env.PATH ?? "", LC_ALL: "C" }; + const headers = [ + 'header = "Accept: application/vnd.github+json"', + 'header = "X-GitHub-Api-Version: 2026-03-10"', + 'header = "User-Agent: edgezero-build-container-gate/1"', + ]; + if (token) headers.push(`header = "Authorization: Bearer ${token}"`); + const reply = command( + "curl", + [ + "--disable", + "--silent", + "--show-error", + "--connect-timeout", + "10", + "--max-time", + "30", + "--max-redirs", + "0", + "--request", + "GET", + "--config", + "-", + "--write-out", + "\n%{http_code}\n%header{x-github-api-version-selected}\n%header{content-type}", + `https://api.github.com/repos/stackpop/edgezero/releases/tags/${version}`, + ], + { input: headers.join("\n") + "\n", env: curlEnv }, + ); + const metadata = /\n([0-9]{3})\n([^\r\n]*)\n([^\r\n]*)$/.exec(reply); + requireThat(metadata !== null, "invalid release API response metadata"); + const [, status, selectedVersion, contentType] = metadata; + requireThat(status === "200", "immutable release lookup failed"); + requireThat( + selectedVersion === "2026-03-10", + "release API selected an unexpected version", + ); + requireThat( + /^application\/json(?:\s*;\s*charset\s*=\s*utf-8)?$/i.test(contentType), + "release API returned an unsupported content type", + ); + const release = JSON.parse(reply.slice(0, metadata.index)); + requireThat( + release.tag_name === version && + release.draft === false && + release.prerelease === false && + release.immutable === true && + release.target_commitish === record["action-revision"], + "release API identity does not match record", + ); + const verificationRoot = realpathSync( + mkdtempSync(resolve(tmpdir(), "edgezero-release-ref-")), + ); + let refs; + try { + refs = command( + "git", + [ + "--no-replace-objects", + "-c", + "credential.helper=", + "-c", + "http.extraHeader=", + "-c", + "http.followRedirects=false", + "ls-remote", + "https://github.com/stackpop/edgezero.git", + `refs/tags/${version}`, + `refs/tags/${version}^{}`, + `refs/heads/${version}`, + ], + { + cwd: verificationRoot, + env: { + PATH: process.env.PATH, + HOME: verificationRoot, + GIT_CEILING_DIRECTORIES: dirname(verificationRoot), + GIT_CONFIG_NOSYSTEM: "1", + GIT_CONFIG_GLOBAL: "/dev/null", + GIT_TERMINAL_PROMPT: "0", + GIT_NO_REPLACE_OBJECTS: "1", + }, + }, + ) + .trim() + .split("\n"); + } finally { + rmSync(verificationRoot, { recursive: true, force: true }); + } + const values = new Map(); + for (const line of refs) { + const fields = line.split("\t"); + requireThat( + fields.length === 2 && + isSha(fields[0]) && + !values.has(fields[1]) && + [ + `refs/tags/${version}`, + `refs/tags/${version}^{}`, + `refs/heads/${version}`, + ].includes(fields[1]), + "invalid remote release refs", + ); + values.set(fields[1], fields[0]); + } + requireThat( + values.has(`refs/tags/${version}`) && + !values.has(`refs/heads/${version}`) && + (values.get(`refs/tags/${version}^{}`) ?? + values.get(`refs/tags/${version}`)) === record["action-revision"], + "anonymous peeled release tag does not match record", + ); +} + +function main(args) { + const explicitRange = args.length === 6; + requireThat( + args.length === 0 || + (args.length === 2 && args[0] === "--subject-root") || + (explicitRange && + args[0] === "--subject-root" && + args[2] === "--base" && + args[4] === "--candidate"), + "usage: check-doc-action-pins.sh [--subject-root PATH [--base SHA --candidate SHA]]", + ); + const subject = realpathSync(args[1] ?? root); + const gitEnv = { + PATH: process.env.PATH ?? "", + LC_ALL: "C", + HOME: "/dev/null", + TMPDIR: "/tmp", + GIT_CEILING_DIRECTORIES: dirname(subject), + GIT_NO_REPLACE_OBJECTS: "1", + GIT_CONFIG_NOSYSTEM: "1", + GIT_CONFIG_GLOBAL: "/dev/null", + GIT_TERMINAL_PROMPT: "0", + }; + const git = (...args) => + command("git", ["--no-replace-objects", "-C", subject, ...args], { + env: gitEnv, + }); + requireThat( + git("rev-parse", "--show-toplevel").trim() === subject, + "subject must be a repository root", + ); + requireThat( + command("yq", ["--version"]).trim() === + "yq (https://github.com/mikefarah/yq/) version v4.53.3", + "mikefarah yq 4.53.3 is required", + ); + requireThat( + git("rev-parse", "--is-shallow-repository").trim() === "false", + "shallow repository history is not allowed", + ); + requireThat( + git("for-each-ref", "--format=%(refname)", "refs/replace").trim() === "", + "repository replace refs are not allowed", + ); + requireThat( + !existsSync( + resolve(subject, git("rev-parse", "--git-path", "info/grafts").trim()), + ), + "repository grafts are not allowed", + ); + let range = { base: git("rev-parse", "HEAD").trim(), candidate: null }; + if (explicitRange) { + const base = args[3]; + const candidate = args[5]; + requireThat( + isSha(base) && isSha(candidate), + "explicit range requires full nonzero SHAs", + ); + requireThat( + git("rev-parse", "--verify", "HEAD").trim() === candidate, + "explicit candidate must equal subject HEAD", + ); + for (const revision of [base, candidate]) + requireThat( + git("cat-file", "-t", revision).trim() === "commit", + "explicit range object is not a commit", + ); + try { + git("merge-base", "--is-ancestor", base, candidate); + } catch { + throw Error("explicit base is not an ancestor of candidate"); + } + range = { base, candidate }; + } else if ( + process.env.GITHUB_ACTIONS === "true" || + process.env.CI === "true" + ) { + requireThat( + process.env.GITHUB_ACTIONS === "true" && process.env.GITHUB_EVENT_PATH, + "hosted GitHub event context is required in CI", + ); + const event = JSON.parse( + readFileSync(process.env.GITHUB_EVENT_PATH, "utf8"), + ); + range = selectRange(process.env, event, { + parents: (sha) => + git("show", "-s", "--format=%P", sha) + .trim() + .split(/\s+/) + .filter(Boolean), + availableCommit: (sha) => { + try { + git("cat-file", "-e", `${sha}^{commit}`); + return true; + } catch { + return false; + } + }, + ancestor: (base, head) => { + try { + git("merge-base", "--is-ancestor", base, head); + return true; + } catch { + return false; + } + }, + }); + } + function pathsAt(sha) { + return ( + sha === null + ? git("ls-files", "-z") + : git("ls-tree", "-rz", "--name-only", sha) + ) + .split("\0") + .filter(Boolean); + } + function readAt(sha, path) { + if (sha !== null) { + const mode = git("ls-tree", sha, "--", path).split(" ")[0]; + requireThat( + mode === "100644" || mode === "100755", + `non-regular tracked document: ${path}`, + ); + return Buffer.from(git("show", `${sha}:${path}`)); + } + const file = resolve(subject, path); + const stat = lstatSync(file); + requireThat( + stat.isFile() && !stat.isSymbolicLink(), + `non-regular worktree document: ${path}`, + ); + return readFileSync(file); + } + const basePaths = pathsAt(range.base); + const candidatePaths = pathsAt(range.candidate); + const base = basePaths.includes(recordPath) + ? parseRecord(readAt(range.base, recordPath)) + : null; + const hasCandidateRecord = + range.candidate === null + ? existsSync(resolve(subject, recordPath)) + : candidatePaths.includes(recordPath); + const candidate = hasCandidateRecord + ? parseRecord(readAt(range.candidate, recordPath)) + : null; + const changed = git( + "diff", + "--no-renames", + "--name-only", + "-z", + range.base, + ...(range.candidate === null ? [] : [range.candidate]), + "--", + ) + .split("\0") + .filter(Boolean); + if (range.candidate === null) + changed.push( + ...git("ls-files", "--others", "--exclude-standard", "-z") + .split("\0") + .filter(Boolean), + ); + checkTransition(base, candidate, changed, verifyRelease); + let references = 0; + for (const path of candidatePaths.filter(isMarkdown)) { + if (range.candidate === null && !existsSync(resolve(subject, path))) + continue; + references += scanDocument( + path, + readAt(range.candidate, path).toString("utf8"), + candidate, + ); + } + requireThat( + references > 0, + "documentation gate parsed zero external references", + ); + console.log( + `documentation reference policy passed (${references} external references; ${candidate === null ? "bootstrap" : "released"})`, + ); +} + +if ( + process.argv[1] && + realpathSync(process.argv[1]) === fileURLToPath(import.meta.url) +) { + try { + main(process.argv.slice(2)); + } catch (error) { + console.error(`documentation gate: ${error.message}`); + process.exitCode = 1; + } +} diff --git a/.github/actions/deploy-core/tests/check-doc-action-pins.sh b/.github/actions/deploy-core/tests/check-doc-action-pins.sh new file mode 100755 index 00000000..d4c18292 --- /dev/null +++ b/.github/actions/deploy-core/tests/check-doc-action-pins.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +set -euo pipefail +script_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +exec node "$script_dir/check-doc-action-pins.mjs" "$@" diff --git a/.github/actions/deploy-core/tests/check-doc-action-pins.test.mjs b/.github/actions/deploy-core/tests/check-doc-action-pins.test.mjs new file mode 100644 index 00000000..dcb0c693 --- /dev/null +++ b/.github/actions/deploy-core/tests/check-doc-action-pins.test.mjs @@ -0,0 +1,828 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { execFileSync, spawnSync } from "node:child_process"; +import { + mkdtempSync, + mkdirSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { + checkTransition, + parseRecord, + scanDocument, + selectRange, + verifyRelease, +} from "./check-doc-action-pins.mjs"; + +const revision = "1".repeat(40); +const record = { + "action-revision": revision, + "action-version": "v1.2.3", + "schema-version": 1, +}; +const bytes = JSON.stringify(record); +const path = "docs/guide/deploy-github-actions.md"; +const example = (ref = "", label = "ubuntu-24.04") => + `jobs:\n deploy:\n runs-on: ${label}\n steps:\n - uses: stackpop/edgezero/.github/actions/deploy-fastly@${ref}\n`; +const fence = (yaml) => `\n~~~yaml\n${yaml}~~~\n`; + +test("record requires exact canonical bytes, fields, and values", () => { + assert.deepEqual(parseRecord(Buffer.from(bytes)), record); + for (const value of [ + bytes + "\n", + " " + bytes, + bytes.replace('"schema-version":1', '"schema-version":1.0'), + bytes.replace( + '"schema-version":1', + '"schema-version":1,"schema-version":1', + ), + JSON.stringify({ ...record, extra: 1 }), + JSON.stringify({ ...record, "action-version": "v01.2.3" }), + JSON.stringify({ ...record, "action-revision": "0".repeat(40) }), + ]) { + assert.throws(() => parseRecord(Buffer.from(value))); + } +}); + +test("fences use the Markdown AST including list and quote nesting", () => { + assert.equal(scanDocument(path, fence(example()), null), 1); + const nested = + "- Example\n\n" + + fence(example()) + .split("\n") + .map((line) => ` ${line}`) + .join("\n"); + assert.equal(scanDocument(path, nested, null), 1); + const quoted = fence(example()) + .split("\n") + .map((line) => `> ${line}`) + .join("\n"); + assert.equal(scanDocument(path, quoted, null), 1); + assert.throws(() => scanDocument("README.md", quoted, null)); + assert.throws(() => scanDocument(path, fence(example("v1")), null)); + assert.throws(() => + scanDocument(path, fence(example("v1.2.3", "ubuntu-latest")), record), + ); + assert.throws(() => scanDocument(path, fence(example("v1.2.4")), record)); + assert.equal(scanDocument(path, fence(example("v1.2.3")), record), 1); +}); + +test("bootstrap adoption documents use their existing superpowers locations", () => { + for (const document of [ + "docs/superpowers/specs/edgezero-deploy-github-action.md", + "docs/superpowers/plans/edgezero-deploy-action-implementation-plan.md", + "docs/superpowers/specs/edgezero-deploy-adoption-guide.md", + path, + ]) { + assert.equal(scanDocument(document, fence(example()), null), 1); + } + for (const name of [ + "edgezero-deploy-github-action", + "edgezero-deploy-action-implementation-plan", + "edgezero-deploy-adoption-guide", + ]) { + assert.throws(() => + scanDocument(`docs/specs/${name}.md`, fence(example()), null), + ); + } + assert.throws(() => + scanDocument( + "docs/superpowers/specs/unreviewed.md", + fence(example()), + null, + ), + ); +}); + +test("job-level reusable callers omit steps and runner selection", () => { + const call = + "jobs:\n build:\n uses: stackpop/edgezero/.github/workflows/build-app-cli.yml@v1.2.3\n"; + assert.equal(scanDocument(path, fence(call), record), 1); + assert.throws(() => + scanDocument(path, fence(call + " runs-on: ubuntu-24.04\n"), record), + ); + assert.throws(() => + scanDocument(path, fence(call + " steps: []\n"), record), + ); +}); + +test("annotated YAML fences and root-repository refs cannot bypass policy", () => { + for (const language of [ + "yaml{1}", + "yml:line-numbers", + "yaml [workflow.yml]", + "yaml{1} [workflow.yml]", + ]) { + const valid = `\n\`\`\`${language}\n${example("v1.2.3")}\`\`\`\n`; + assert.equal(scanDocument(path, valid, record), 1); + assert.throws(() => + scanDocument(path, valid.replace("@v1.2.3", "@main"), record), + ); + } + for (const action of [ + "stackpop/edgezero", + "StackPop/EdgeZero", + "StackPop/EdgeZero/.github/actions/deploy-fastly", + ]) { + assert.throws(() => + scanDocument(path, fence(`steps:\n - uses: ${action}@v1.2.3\n`), record), + ); + } + for (const text of [ + "The prepublication placeholder was ``.\n", + "~~~text\n\n~~~\n", + ]) + assert.throws(() => + scanDocument(path, text + fence(example("v1.2.3")), record), + ); + assert.throws(() => scanDocument(path, fence(example()), record)); +}); + +test("fragments cannot hide action refs, nulls, or mixed versions", () => { + for (const yaml of [ + "steps:\n - uses: actions/checkout@v7\n", + "- uses: actions/checkout@null\n", + "uses: actions/checkout@v7\n", + "steps:\n - uses: null\n", + "steps:\n - uses: |\n actions/checkout@v7.0.1\n", + "steps:\n - uses: stackpop/edgezero/.github/actions/deploy-fastly@v1.2.3\n", + example("v1.2.3") + + " - uses: StackPop/EdgeZero/.github/actions/deploy-fastly@v1.2.4\n", + ]) + assert.throws(() => scanDocument(path, fence(yaml), record)); + assert.equal( + scanDocument( + "README.md", + fence("steps:\n - uses: actions/checkout@v7.0.1\n"), + null, + ), + 1, + ); +}); + +test("decoded placeholders and mismatched invocation kinds fail", () => { + const hidden = 'env:\n EXAMPLE: "\\u003cEDGEZERO_ACTION_VERSION>"\n'; + assert.throws(() => + scanDocument(path, fence(hidden + example("v1.2.3")), record), + ); + assert.throws(() => + scanDocument( + path, + fence( + "jobs:\n build:\n uses: stackpop/edgezero/.github/actions/deploy-fastly@v1.2.3\n", + ), + record, + ), + ); + assert.throws(() => + scanDocument( + path, + fence( + example("v1.2.3").replace( + ".github/actions/deploy-fastly", + ".github/workflows/build-app-cli.yml", + ), + ), + record, + ), + ); +}); + +test("workflow filters include every structural gate input family", () => { + const workflow = fileURLToPath( + new URL("../../../workflows/deploy-action.yml", import.meta.url), + ); + const document = JSON.parse( + execFileSync("yq", ["-o=json", ".", workflow], { encoding: "utf8" }), + ); + for (const event of ["pull_request", "push"]) { + const filters = document.on[event].paths; + for (const required of [ + "**/action.yml", + "**/action.yaml", + "docs/.edgezero-action-release.json", + "docs/superpowers/**", + "**/*.[mM][dD]", + "**/*.[mM][aA][rR][kK][dD][oO][wW][nN]", + ]) { + assert.ok(filters.includes(required), `${event} omits ${required}`); + } + } +}); + +test("release transitions are one-way, atomic, and independently verified", () => { + const calls = []; + const verify = (value) => calls.push(value); + checkTransition(null, null, ["code.rs"], verify); + checkTransition( + null, + record, + ["docs/a.md", "docs/.edgezero-action-release.json"], + verify, + ); + assert.deepEqual(calls, [record]); + checkTransition(record, record, ["code.rs"], verify); + assert.equal(calls.length, 1); + const next = { ...record, "action-version": "v1.2.4" }; + checkTransition( + record, + next, + ["docs/a.md", "docs/.edgezero-action-release.json"], + verify, + ); + assert.equal(calls.length, 2); + for (const [base, candidate, changes] of [ + [record, null, []], + [record, { ...record, "action-version": "v1.2.2" }, []], + [ + record, + { ...record, "action-revision": "2".repeat(40) }, + ["docs/.edgezero-action-release.json"], + ], + [null, record, ["code.rs"]], + [ + null, + record, + ["docs/.edgezero-action-release.json", ".github/workflows/a.yml"], + ], + ]) + assert.throws(() => checkTransition(base, candidate, changes, verify)); + assert.throws(() => + checkTransition(null, record, ["docs/a.md"], () => { + throw Error("unverified release"); + }), + ); +}); + +test("event ranges are selected from exact hosted context", () => { + const payloadBase = "2".repeat(40), + prHead = "3".repeat(40), + candidate = "4".repeat(40), + firstParent = "5".repeat(40); + const checkedCommits = []; + const git = { + parents: () => [firstParent, prHead], + ancestor: (base, head) => + (base === payloadBase && head === firstParent) || + (base === firstParent && head === candidate) || + (base === payloadBase && head === candidate), + availableCommit: (sha) => { + checkedCommits.push(sha); + return true; + }, + }; + const env = { + GITHUB_EVENT_NAME: "pull_request", + GITHUB_SHA: candidate, + GITHUB_REF: "refs/pull/9/merge", + }; + const event = { + number: 9, + pull_request: { + base: { + sha: payloadBase, + ref: "main", + repo: { full_name: "stackpop/edgezero" }, + }, + head: { sha: prHead }, + }, + }; + assert.deepEqual(selectRange(env, event, git), { + base: firstParent, + candidate, + }); + assert.deepEqual(checkedCommits, [ + payloadBase, + firstParent, + prHead, + candidate, + ]); + for (const missing of [payloadBase, firstParent, prHead, candidate]) + assert.throws( + () => + selectRange(env, event, { + ...git, + availableCommit: (sha) => sha !== missing, + }), + /pull request commit object is unavailable/, + ); + assert.throws(() => + selectRange({ ...env, GITHUB_REF: "refs/heads/main" }, event, git), + ); + for (const parents of [ + [], + [firstParent], + [prHead, firstParent], + [firstParent, payloadBase], + [firstParent, prHead, payloadBase], + ["0".repeat(40), prHead], + ]) + assert.throws(() => + selectRange(env, event, { ...git, parents: () => parents }), + ); + assert.throws( + () => + selectRange(env, event, { + ...git, + ancestor: (base, head) => base === firstParent && head === candidate, + }), + /pull request base is not an ancestor of its merge parent/, + ); + assert.throws( + () => + selectRange(env, event, { + ...git, + parents: () => { + throw Error("missing merge object"); + }, + }), + /missing merge object/, + ); + assert.throws(() => + selectRange({ ...env, GITHUB_EVENT_NAME: "workflow_dispatch" }, event, git), + ); + assert.deepEqual( + selectRange( + { + GITHUB_EVENT_NAME: "push", + GITHUB_SHA: candidate, + GITHUB_WORKFLOW_SHA: candidate, + GITHUB_REF: "refs/heads/main", + }, + { before: payloadBase, after: candidate }, + git, + ), + { base: payloadBase, candidate }, + ); + const group = { + action: "checks_requested", + merge_group: { + base_sha: payloadBase, + head_sha: candidate, + base_ref: "refs/heads/main", + head_ref: "refs/heads/gh-readonly-queue/main/pr-9", + }, + }; + assert.deepEqual( + selectRange( + { + GITHUB_EVENT_NAME: "merge_group", + GITHUB_SHA: candidate, + GITHUB_REF: group.merge_group.head_ref, + }, + group, + git, + ), + { base: payloadBase, candidate }, + ); + assert.throws(() => + selectRange( + { + GITHUB_EVENT_NAME: "merge_group", + GITHUB_SHA: candidate, + GITHUB_REF: group.merge_group.head_ref, + }, + group, + { ...git, ancestor: () => false }, + ), + ); +}); + +test("release proof rejects API/ref substitution and redirects without leaking a token in argv", (t) => { + const temp = mkdtempSync(resolve(tmpdir(), "edgezero-doc-release-")); + const original = { ...process.env }; + t.after(() => { + process.env = original; + rmSync(temp, { recursive: true, force: true }); + }); + writeFileSync( + resolve(temp, "curl"), + '#!/bin/sh\nif [ -n "${GITHUB_TOKEN+x}${GH_TOKEN+x}${HTTPS_PROXY+x}${https_proxy+x}${HTTP_PROXY+x}${http_proxy+x}${ALL_PROXY+x}${all_proxy+x}${NO_PROXY+x}${no_proxy+x}${CURL_CA_BUNDLE+x}${SSL_CERT_FILE+x}${HOME+x}${XDG_CONFIG_HOME+x}${CURL_HOME+x}${EDGEZERO_AMBIENT_SENTINEL+x}" ]; then exit 20; fi\n[ "$LC_ALL" = C ] || exit 21\nfixture=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) || exit 22\nprintf "%s\\n" "$@" >"$fixture/args"\ncat >"$fixture/headers"\ncat "$fixture/reply"\n', + { mode: 0o755 }, + ); + writeFileSync( + resolve(temp, "git"), + '#!/bin/sh\nif [ "$GIT_CONFIG_NOSYSTEM" != 1 ] || [ "$GIT_CONFIG_GLOBAL" != /dev/null ] || [ "$GIT_TERMINAL_PROMPT" != 0 ]; then exit 9; fi\n[ "$PWD" = "$HOME" ] || exit 10\n[ -z "${GITHUB_TOKEN+x}" ] || exit 11\ncase "$*" in *http.followRedirects=false*) ;; *) exit 12 ;; esac\n[ "$1" = "--no-replace-objects" ] || exit 13\n[ "$GIT_NO_REPLACE_OBJECTS" = 1 ] || exit 14\n[ -z "${GIT_REPLACE_REF_BASE+x}" ] || exit 15\nprintf "%s" ' + + "'" + + `${revision}\trefs/tags/v1.2.3\n` + + "'\n", + { mode: 0o755 }, + ); + process.env.PATH = `${temp}:${original.PATH}`; + process.env.GITHUB_TOKEN = "fixture-token"; + process.env.GH_TOKEN = "ambient-gh-token"; + process.env.HTTPS_PROXY = "https://hostile-proxy.invalid"; + process.env.https_proxy = "https://hostile-proxy.invalid"; + process.env.HTTP_PROXY = "http://hostile-proxy.invalid"; + process.env.http_proxy = "http://hostile-proxy.invalid"; + process.env.ALL_PROXY = "socks5://hostile-proxy.invalid"; + process.env.all_proxy = "socks5://hostile-proxy.invalid"; + process.env.NO_PROXY = "api.github.com"; + process.env.no_proxy = "api.github.com"; + process.env.CURL_CA_BUNDLE = resolve(temp, "hostile-ca.pem"); + process.env.SSL_CERT_FILE = resolve(temp, "hostile-cert.pem"); + process.env.HOME = resolve(temp, "hostile-home"); + process.env.XDG_CONFIG_HOME = resolve(temp, "hostile-xdg"); + process.env.CURL_HOME = resolve(temp, "hostile-curl-home"); + process.env.EDGEZERO_AMBIENT_SENTINEL = "must-not-leak"; + process.env.GIT_REPLACE_REF_BASE = "refs/hostile-replacements"; + const release = { + tag_name: "v1.2.3", + target_commitish: revision, + draft: false, + prerelease: false, + immutable: true, + }; + const reply = ( + value = release, + status = "200", + selectedVersion = "2026-03-10", + contentType = "application/json; charset=utf-8", + ) => + `${JSON.stringify(value)}\n${status}\n${selectedVersion}\n${contentType}`; + const setReply = (value) => writeFileSync(resolve(temp, "reply"), value); + setReply(reply()); + verifyRelease(record); + assert.equal( + readFileSync(resolve(temp, "headers"), "utf8"), + [ + 'header = "Accept: application/vnd.github+json"', + 'header = "X-GitHub-Api-Version: 2026-03-10"', + 'header = "User-Agent: edgezero-build-container-gate/1"', + 'header = "Authorization: Bearer fixture-token"', + "", + ].join("\n"), + ); + assert.ok( + !readFileSync(resolve(temp, "args"), "utf8").includes("fixture-token"), + ); + assert.ok( + readFileSync(resolve(temp, "headers"), "utf8").includes( + "Authorization: Bearer fixture-token", + ), + ); + assert.ok( + !readFileSync(resolve(temp, "args"), "utf8") + .split("\n") + .some((arg) => arg === "-L" || arg === "--location"), + ); + const curlArgs = readFileSync(resolve(temp, "args"), "utf8").split("\n"); + const connectTimeout = curlArgs.indexOf("--connect-timeout"); + assert.notEqual(connectTimeout, -1); + assert.equal(curlArgs[connectTimeout + 1], "10"); + setReply( + reply(release, "200", "2026-03-10", "Application/JSON; Charset=UTF-8"), + ); + verifyRelease(record); + setReply(reply(release, "200", "2026-03-10", "application/json")); + verifyRelease(record); + for (const change of [ + { draft: true }, + { prerelease: true }, + { immutable: false }, + { target_commitish: "main" }, + { tag_name: "v1.2.4" }, + ]) { + setReply(reply({ ...release, ...change })); + assert.throws(() => verifyRelease(record)); + } + for (const response of [ + reply(release, "302"), + reply(release, "201"), + reply(release, "200", ""), + reply(release, "200", "2022-11-28"), + reply(release, "200", "2026-03-10", "text/json"), + reply(release, "200", "2026-03-10", "application/json; charset=ascii"), + ]) { + setReply(response); + assert.throws(() => verifyRelease(record)); + } + setReply(reply()); + writeFileSync( + resolve(temp, "git"), + `#!/bin/sh\nprintf '%s' '${revision}\trefs/tags/v1.2.3\n${revision}\trefs/heads/v1.2.3\n'\n`, + { mode: 0o755 }, + ); + assert.throws(() => verifyRelease(record)); + writeFileSync( + resolve(temp, "git"), + `#!/bin/sh\nprintf '%s' '${"2".repeat(40)}\trefs/tags/v1.2.3\n'\n`, + { mode: 0o755 }, + ); + assert.throws(() => verifyRelease(record)); +}); + +test("hosted scanner reads committed snapshots and fails inconsistent events", (t) => { + const temp = mkdtempSync(resolve(tmpdir(), "edgezero-doc-git-")); + t.after(() => rmSync(temp, { recursive: true, force: true })); + const subject = resolve(temp, "subject"); + mkdirSync(resolve(subject, "docs/guide"), { recursive: true }); + const git = (...args) => + execFileSync( + "git", + [ + "-C", + subject, + "-c", + "core.hooksPath=/dev/null", + "-c", + "commit.gpgsign=false", + "-c", + "user.name=Fixture", + "-c", + "user.email=fixture@example.test", + ...args, + ], + { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }, + ).trim(); + git("init", "-q"); + writeFileSync(resolve(subject, path), fence(example())); + git("add", "."); + git("commit", "-qm", "base"); + const base = git("rev-parse", "HEAD"); + writeFileSync(resolve(subject, "README.md"), "Unrelated documentation\n"); + git("add", "."); + git("commit", "-qm", "candidate"); + const head = git("rev-parse", "HEAD"); + // Dirty subject data must not replace the committed candidate being evaluated. + writeFileSync(resolve(subject, path), fence(example("main"))); + const eventFile = resolve(temp, "event.json"); + writeFileSync(eventFile, JSON.stringify({ before: base, after: head })); + const env = { + ...process.env, + CI: "true", + GITHUB_ACTIONS: "true", + GITHUB_EVENT_NAME: "push", + GITHUB_EVENT_PATH: eventFile, + GITHUB_SHA: head, + GITHUB_WORKFLOW_SHA: head, + GITHUB_REF: "refs/heads/main", + }; + const checker = fileURLToPath( + new URL("./check-doc-action-pins.mjs", import.meta.url), + ); + const run = (changes = {}) => + spawnSync(process.execPath, [checker, "--subject-root", subject], { + env: { ...env, ...changes }, + encoding: "utf8", + }); + const result = run(); + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /1 external references; bootstrap/); + + const explicit = spawnSync( + process.execPath, + [ + checker, + "--subject-root", + subject, + "--base", + base, + "--candidate", + head, + ], + { + env: { + ...process.env, + CI: "false", + GITHUB_ACTIONS: "false", + GITHUB_EVENT_NAME: "workflow_dispatch", + }, + encoding: "utf8", + }, + ); + assert.equal(explicit.status, 0, explicit.stderr); + assert.match(explicit.stdout, /1 external references; bootstrap/); + const wrongExplicit = spawnSync( + process.execPath, + [ + checker, + "--subject-root", + subject, + "--base", + base, + "--candidate", + base, + ], + { env: process.env, encoding: "utf8" }, + ); + assert.notEqual(wrongExplicit.status, 0); + assert.match(wrongExplicit.stderr, /candidate.*HEAD/i); + + const proxyBin = resolve(temp, "bin"); + mkdirSync(proxyBin); + writeFileSync( + resolve(proxyBin, "git"), + '#!/bin/sh\n[ "$1" = "--no-replace-objects" ] || exit 91\nif [ -n "${GIT_DIR+x}${GIT_WORK_TREE+x}${GIT_COMMON_DIR+x}${GIT_NAMESPACE+x}${GIT_INDEX_FILE+x}${GIT_OBJECT_DIRECTORY+x}${GIT_ALTERNATE_OBJECT_DIRECTORIES+x}${GIT_SHALLOW_FILE+x}${GIT_CONFIG_COUNT+x}${GIT_CONFIG_KEY_0+x}${GIT_CONFIG_VALUE_0+x}${GIT_REPLACE_REF_BASE+x}${REAL_GIT+x}${XDG_CONFIG_HOME+x}${EDGEZERO_GIT_AMBIENT+x}" ]; then exit 92; fi\nif [ "$LC_ALL" != C ] || [ "$GIT_NO_REPLACE_OBJECTS" != 1 ] || [ "$GIT_CONFIG_NOSYSTEM" != 1 ] || [ "$GIT_CONFIG_GLOBAL" != /dev/null ] || [ "$GIT_TERMINAL_PROMPT" != 0 ] || [ "$HOME" != /dev/null ] || [ "$TMPDIR" != /tmp ] || [ "$GIT_CEILING_DIRECTORIES" = / ]; then exit 93; fi\ncase "$PATH" in *:*) PATH=${PATH#*:} ;; *) exit 94 ;; esac\nexport PATH\nexec git "$@"\n', + { mode: 0o755 }, + ); + const hardened = run({ + PATH: `${proxyBin}:${process.env.PATH}`, + HOME: "/hostile-home", + GIT_CEILING_DIRECTORIES: "/", + GIT_DIR: "/hostile-git-dir", + GIT_WORK_TREE: "/hostile-work-tree", + GIT_COMMON_DIR: "/hostile-common-dir", + GIT_NAMESPACE: "hostile-namespace", + GIT_INDEX_FILE: "/hostile-index", + GIT_OBJECT_DIRECTORY: "/hostile-objects", + GIT_ALTERNATE_OBJECT_DIRECTORIES: "/hostile-alternates", + GIT_SHALLOW_FILE: "/hostile-shallow", + GIT_CONFIG_COUNT: "1", + GIT_CONFIG_KEY_0: "core.abbrev", + GIT_CONFIG_VALUE_0: "1", + GIT_REPLACE_REF_BASE: "refs/hostile-replacements", + REAL_GIT: "/hostile-git", + XDG_CONFIG_HOME: "/hostile-xdg", + EDGEZERO_GIT_AMBIENT: "must-not-leak", + }); + assert.equal(hardened.status, 0, hardened.stderr); + + const gitDir = resolve(subject, git("rev-parse", "--git-dir")); + const shallow = resolve(gitDir, "shallow"); + writeFileSync(shallow, `${head}\n`); + const shallowResult = run(); + assert.notEqual(shallowResult.status, 0); + assert.match(shallowResult.stderr, /shallow repository/); + rmSync(shallow); + + git("replace", head, base); + const replaceResult = run(); + assert.notEqual(replaceResult.status, 0); + assert.match(replaceResult.stderr, /replace refs/); + git("replace", "-d", head); + + const grafts = resolve(gitDir, "info/grafts"); + writeFileSync(grafts, `${head} ${base}\n`); + const graftsResult = run(); + assert.notEqual(graftsResult.status, 0); + assert.match(graftsResult.stderr, /grafts/); + rmSync(grafts); + + assert.notEqual(run({ GITHUB_WORKFLOW_SHA: base }).status, 0); + assert.notEqual(run({ GITHUB_EVENT_NAME: "workflow_dispatch" }).status, 0); + assert.notEqual(run({ CI: "false", GITHUB_ACTIONS: "false" }).status, 0); +}); + +test("pull request scanning uses the synthetic merge first parent", (t) => { + const temp = mkdtempSync(resolve(tmpdir(), "edgezero-doc-pr-")); + t.after(() => rmSync(temp, { recursive: true, force: true })); + const subject = resolve(temp, "subject"); + mkdirSync(resolve(subject, "docs/guide"), { recursive: true }); + const git = (...args) => + execFileSync( + "git", + [ + "-C", + subject, + "-c", + "core.hooksPath=/dev/null", + "-c", + "commit.gpgsign=false", + "-c", + "user.name=Fixture", + "-c", + "user.email=fixture@example.test", + ...args, + ], + { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }, + ).trim(); + git("init", "-q", "-b", "main"); + writeFileSync(resolve(subject, path), fence(example())); + git("add", "."); + git("commit", "-qm", "payload base"); + const payloadBase = git("rev-parse", "HEAD"); + + git("checkout", "-qb", "feature"); + writeFileSync(resolve(subject, "README.md"), "Pull request documentation\n"); + git("add", "."); + git("commit", "-qm", "pull request head"); + const prHead = git("rev-parse", "HEAD"); + + git("checkout", "-q", "main"); + writeFileSync(resolve(subject, path), fence(example("v1.2.3"))); + writeFileSync(resolve(subject, "docs/.edgezero-action-release.json"), bytes); + writeFileSync(resolve(subject, "release-helper.sh"), "#!/bin/sh\nexit 0\n", { + mode: 0o755, + }); + git("add", "."); + git("commit", "-qm", "advanced base"); + const firstParent = git("rev-parse", "HEAD"); + git("merge", "--no-ff", "-qm", "synthetic merge", prHead); + const candidate = git("rev-parse", "HEAD"); + assert.deepEqual(git("show", "-s", "--format=%P", candidate).split(" "), [ + firstParent, + prHead, + ]); + assert.match( + git("ls-tree", firstParent, "--", "release-helper.sh"), + /^100755 blob /, + ); + const staleChanges = git( + "diff", + "--no-renames", + "--name-only", + payloadBase, + candidate, + "--", + ) + .split("\n") + .filter(Boolean); + let releaseVerified = false; + assert.throws( + () => + checkTransition(null, record, staleChanges, () => { + releaseVerified = true; + }), + /release transition must change only documentation/, + ); + assert.equal(releaseVerified, false); + + const eventFile = resolve(temp, "event.json"); + writeFileSync( + eventFile, + JSON.stringify({ + number: 9, + pull_request: { + base: { + sha: payloadBase, + ref: "main", + repo: { full_name: "stackpop/edgezero" }, + }, + head: { sha: prHead }, + }, + }), + ); + const checker = fileURLToPath( + new URL("./check-doc-action-pins.mjs", import.meta.url), + ); + const result = spawnSync( + process.execPath, + [checker, "--subject-root", subject], + { + env: { + ...process.env, + CI: "true", + GITHUB_ACTIONS: "true", + GITHUB_EVENT_NAME: "pull_request", + GITHUB_EVENT_PATH: eventFile, + GITHUB_SHA: candidate, + GITHUB_REF: "refs/pull/9/merge", + }, + encoding: "utf8", + }, + ); + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /1 external references; released/); +}); + +test("a release transition cannot rename a non-document into documentation", (t) => { + const subject = mkdtempSync(resolve(tmpdir(), "edgezero-doc-rename-")); + t.after(() => rmSync(subject, { recursive: true, force: true })); + mkdirSync(resolve(subject, "docs")); + const git = (...args) => + execFileSync( + "git", + [ + "-C", + subject, + "-c", + "core.hooksPath=/dev/null", + "-c", + "commit.gpgsign=false", + "-c", + "user.name=Fixture", + "-c", + "user.email=fixture@example.test", + ...args, + ], + { stdio: ["ignore", "pipe", "pipe"] }, + ); + git("init", "-q"); + writeFileSync(resolve(subject, "source.rs"), "// A tracked source file\n"); + git("add", "."); + git("commit", "-qm", "base"); + git("mv", "source.rs", "docs/source.md"); + writeFileSync(resolve(subject, "docs/.edgezero-action-release.json"), bytes); + const checker = fileURLToPath( + new URL("./check-doc-action-pins.mjs", import.meta.url), + ); + const result = spawnSync( + process.execPath, + [checker, "--subject-root", subject], + { + env: { ...process.env, CI: "false", GITHUB_ACTIONS: "false" }, + encoding: "utf8", + }, + ); + assert.notEqual(result.status, 0); + assert.match( + result.stderr, + /release transition must change only documentation/, + ); +}); diff --git a/.github/actions/deploy-core/tests/check-image-pin.test.sh b/.github/actions/deploy-core/tests/check-image-pin.test.sh new file mode 100755 index 00000000..b0848baa --- /dev/null +++ b/.github/actions/deploy-core/tests/check-image-pin.test.sh @@ -0,0 +1,168 @@ +#!/usr/bin/env bash +set -euo pipefail + +DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +CHECK="$DIR/../../../docker/build-app-cli/check-image-pin.sh" +WORK=$(mktemp -d) +trap 'rm -rf "$WORK"' EXIT + +REPO="ghcr.io/stackpop/edgezero-build-app-cli" +DIGEST="sha256:$(printf '1%.0s' {1..64})" +SOURCE=$(printf '2%.0s' {1..40}) +CHALLENGE=$(printf '3%.0s' {1..64}) +SCREENSHOT="sha256:$(printf '4%.0s' {1..64})" +TAG="build-container-v7" +NOW=$(date -u '+%Y-%m-%dT%H:%M:%SZ') + +pass=0 +fail=0 +ok() { + printf ' \033[32mok\033[0m %s\n' "$1" + pass=$((pass + 1)) +} +no() { + printf ' \033[31mFAIL\033[0m %s\n' "$1" >&2 + fail=$((fail + 1)) +} +assert_pass() { + local description=$1 + shift + if "$@" >/dev/null 2>&1; then ok "$description"; else no "$description"; fi +} +assert_fail() { + local description=$1 + shift + if "$@" >/dev/null 2>&1; then no "$description"; else ok "$description"; fi +} +assert_eq() { + local description=$1 expected=$2 + shift 2 + local actual + if ! actual=$("$@" 2>/dev/null); then + no "$description" + elif [[ "$actual" == "$expected" ]]; then + ok "$description" + else + no "$description" + printf ' expected: %s\n actual: %s\n' "$expected" "$actual" >&2 + fi +} + +write_image() { + local path=$1 content + if [[ $# -eq 2 ]]; then + content=$2 + else + content="{\"repository\":\"$REPO\",\"tag\":\"$TAG\",\"digest\":\"$DIGEST\",\"image-source-revision\":\"$SOURCE\",\"provenance-protocol\":1}" + fi + printf '%s' "$content" >"$path" +} + +write_evidence() { + local path=$1 reviewed_at=${2:-$NOW} content + if [[ $# -eq 3 ]]; then + content=$3 + else + content="{\"approval-challenge\":\"$CHALLENGE\",\"approver-login\":\"release-reviewer\",\"image-digest\":\"$DIGEST\",\"release-tag\":\"$TAG\",\"reviewed-at\":\"$reviewed_at\",\"run-attempt\":\"2\",\"run-id\":\"9007199254740993\",\"schema-version\":1,\"screenshot-sha256\":\"$SCREENSHOT\",\"source-revision\":\"$SOURCE\"}" + fi + printf '%s' "$content" >"$path" +} + +run_check() { bash "$CHECK" "$@"; } + +echo "== build container image pin validator ==" + +image="$WORK/image.json" +evidence="$WORK/image-release-evidence.json" +write_image "$image" +write_evidence "$evidence" + +assert_pass "the exact five-field image record passes" run_check "$image" +assert_pass "the explicit validate mode passes" run_check validate "$image" +assert_eq "runtime-ref exposes only repository@digest" "$REPO@$DIGEST" \ + run_check runtime-ref "$image" +assert_eq "source-revision exposes the validated source SHA" "$SOURCE" \ + run_check source-revision "$image" +assert_eq "provenance-protocol exposes the validated integer" "1" \ + run_check provenance-protocol "$image" +assert_fail "there is no runtime tag accessor" run_check tag "$image" +assert_pass "the coherent image/evidence pair passes" run_check validate-pair "$image" "$evidence" + +write_evidence "$WORK/stale-evidence.json" '2020-01-01T00:00:00Z' +assert_pass "archived evidence does not expire against a later wall clock" \ + run_check validate-pair "$image" "$WORK/stale-evidence.json" +write_evidence "$WORK/future-evidence.json" '2099-01-01T00:00:00Z' +assert_pass "archival validation leaves freshness to the successful publisher gate" \ + run_check validate-pair "$image" "$WORK/future-evidence.json" +write_evidence "$WORK/invalid-calendar-evidence.json" '2026-02-30T00:00:00Z' +assert_fail "an invalid evidence calendar instant is rejected" \ + run_check validate-pair "$image" "$WORK/invalid-calendar-evidence.json" + +printf 'not json' >"$WORK/bad.json" +assert_fail "malformed JSON fails closed" run_check "$WORK/bad.json" +write_image "$WORK/duplicate.json" \ + "{\"repository\":\"$REPO\",\"repository\":\"$REPO\",\"tag\":\"$TAG\",\"digest\":\"$DIGEST\",\"image-source-revision\":\"$SOURCE\",\"provenance-protocol\":1}" +assert_fail "a duplicate top-level key is rejected" run_check "$WORK/duplicate.json" +write_image "$WORK/escaped-duplicate.json" \ + "{\"\\u0072epository\":\"$REPO\",\"repository\":\"$REPO\",\"tag\":\"$TAG\",\"digest\":\"$DIGEST\",\"image-source-revision\":\"$SOURCE\",\"provenance-protocol\":1}" +assert_fail "an escaped duplicate top-level key is rejected" run_check "$WORK/escaped-duplicate.json" +write_image "$WORK/extra.json" \ + "{\"repository\":\"$REPO\",\"tag\":\"$TAG\",\"digest\":\"$DIGEST\",\"image-source-revision\":\"$SOURCE\",\"provenance-protocol\":1,\"extra\":true}" +assert_fail "an extra field is rejected" run_check "$WORK/extra.json" +printf '%s%s' "$(<"$image")" "$(<"$image")" >"$WORK/multiple.json" +assert_fail "multiple JSON documents are rejected" run_check "$WORK/multiple.json" +printf '[]' >"$WORK/array.json" +assert_fail "a non-object document is rejected" run_check "$WORK/array.json" +printf '%*s' 4097 '' >"$WORK/oversized.json" +assert_fail "an oversized record is rejected before parsing" run_check "$WORK/oversized.json" +ln -s "$image" "$WORK/symlink.json" +assert_fail "a symlink record is rejected" run_check "$WORK/symlink.json" + +for key in repository tag digest image-source-revision provenance-protocol; do + jq "del(.\"$key\")" "$image" >"$WORK/missing-$key.json" + assert_fail "missing $key is rejected" run_check "$WORK/missing-$key.json" +done + +for mutation in \ + '.repository=1' \ + '.tag=false' \ + '.digest=null' \ + '."image-source-revision"=[]' \ + '."provenance-protocol"="1"'; do + jq "$mutation" "$image" >"$WORK/wrong-type.json" + assert_fail "wrong type $mutation is rejected" run_check "$WORK/wrong-type.json" +done + +for value in '' 'ghcr.io/attacker/edgezero-build-app-cli'; do + jq --arg value "$value" '.repository=$value' "$image" >"$WORK/bad-repo.json" + assert_fail "repository '$value' is rejected" run_check "$WORK/bad-repo.json" +done + +for value in latest "sha256:$(printf '0%.0s' {1..64})" \ + "sha256:$(printf 'A%.0s' {1..64})" sha256:deadbeef; do + jq --arg value "$value" '.digest=$value' "$image" >"$WORK/bad-digest.json" + assert_fail "digest '$value' is rejected" run_check "$WORK/bad-digest.json" +done + +for value in "$(printf '0%.0s' {1..40})" "$(printf 'A%.0s' {1..40})" deadbeef; do + jq --arg value "$value" '."image-source-revision"=$value' "$image" >"$WORK/bad-source.json" + assert_fail "source '$value' is rejected" run_check "$WORK/bad-source.json" +done + +for value in 0 2 1.5; do + jq ".\"provenance-protocol\"=$value" "$image" >"$WORK/bad-protocol.json" + assert_fail "protocol $value is rejected" run_check "$WORK/bad-protocol.json" +done + +for value in v1 build-container-v0 build-container-v01 build-container-v1.2 latest; do + jq --arg value "$value" '.tag=$value' "$image" >"$WORK/bad-tag.json" + assert_fail "tag '$value' is rejected" run_check "$WORK/bad-tag.json" +done + +assert_fail "a missing image cannot form a pair" \ + run_check validate-pair "$WORK/missing-image.json" "$evidence" +assert_fail "a missing evidence record cannot form a pair" \ + run_check validate-pair "$image" "$WORK/missing-evidence.json" + +printf 'Passed: %d Failed: %d\n' "$pass" "$fail" +[[ "$fail" -eq 0 ]] diff --git a/.github/actions/deploy-core/tests/classify-build-container-change.test.sh b/.github/actions/deploy-core/tests/classify-build-container-change.test.sh new file mode 100755 index 00000000..bfa3b346 --- /dev/null +++ b/.github/actions/deploy-core/tests/classify-build-container-change.test.sh @@ -0,0 +1,454 @@ +#!/usr/bin/env bash +# shellcheck disable=SC2016 +set -euo pipefail + +DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +CLASSIFIER="$DIR/../../../docker/build-app-cli/classify-build-container-change.sh" +WORK=$(mktemp -d) +WORK=$(cd -- "$WORK" && pwd -P) +trap 'rm -rf "$WORK"' EXIT + +pass=0 +fail=0 +case_number=0 + +ok() { + printf ' \033[32mok\033[0m %s\n' "$1" + pass=$((pass + 1)) +} + +no() { + printf ' \033[31mFAIL\033[0m %s\n' "$1" >&2 + fail=$((fail + 1)) +} + +assert_output() { + local description=$1 expected=$2 + shift 2 + local actual="$CASE_ROOT/stdout" expected_file="$CASE_ROOT/expected" status=0 + printf '%s\n' "$expected" >"$expected_file" + "$@" >"$actual" 2>"$CASE_ROOT/stderr" || status=$? + if [[ "$status" -eq 0 ]] && cmp -s "$expected_file" "$actual" && + [[ ! -s "$CASE_ROOT/stderr" ]]; then + ok "$description" + else + printf 'status: %s\nexpected bytes:\n' "$status" >&2 + od -An -tx1 "$expected_file" >&2 + printf 'actual bytes:\n' >&2 + od -An -tx1 "$actual" >&2 + printf 'stderr:\n' >&2 + cat "$CASE_ROOT/stderr" >&2 + no "$description" + fi +} + +assert_fail() { + local description=$1 + shift + if "$@" >"$CASE_ROOT/stdout" 2>"$CASE_ROOT/stderr"; then + no "$description" + elif [[ -s "$CASE_ROOT/stdout" ]]; then + printf 'unexpected stdout:\n' >&2 + cat "$CASE_ROOT/stdout" >&2 + no "$description" + else + ok "$description" + fi +} + +assert_fail_matching() { + local description=$1 expected_error=$2 + shift 2 + if "$@" >"$CASE_ROOT/stdout" 2>"$CASE_ROOT/stderr"; then + no "$description" + elif [[ -s "$CASE_ROOT/stdout" ]] || ! grep -Fq -- "$expected_error" "$CASE_ROOT/stderr"; then + printf 'expected stderr to contain: %s\nactual stderr:\n' "$expected_error" >&2 + cat "$CASE_ROOT/stderr" >&2 + no "$description" + else + ok "$description" + fi +} + +write_base_gate_files() { + mkdir -p .github/docker/build-app-cli .github + printf 'fixture dockerignore\n' >.dockerignore + printf 'FROM scratch\n' >.github/docker/build-app-cli/Dockerfile + printf '#!/usr/bin/env bash\n' >.github/docker/build-app-cli/old-helper.sh + printf '%s\n' \ + '.dockerignore' \ + '.github/docker/build-app-cli/Dockerfile' \ + '.github/docker/build-app-cli/image-context-paths.txt' \ + >.github/docker/build-app-cli/image-context-paths.txt + printf '%s\n' \ + '.dockerignore' \ + '.github/CODEOWNERS' \ + '.github/docker/build-app-cli/Dockerfile' \ + '.github/docker/build-app-cli/gate-paths.txt' \ + '.github/docker/build-app-cli/image-context-paths.txt' \ + '.github/docker/build-app-cli/old-helper.sh' \ + >.github/docker/build-app-cli/gate-paths.txt + write_codeowners +} + +write_codeowners() { + local manifest=.github/docker/build-app-cli/gate-paths.txt path + : >.github/CODEOWNERS + while IFS= read -r path; do + printf '/%s @stackpop/edgezero-build-container-gate-reviewers\n' "$path" + done <"$manifest" >>.github/CODEOWNERS +} + +new_case() { + case_number=$((case_number + 1)) + CASE_ROOT="$WORK/case-$case_number" + SUBJECT="$CASE_ROOT/subject" + GATE="$CASE_ROOT/gate" + mkdir -p "$SUBJECT" + git -C "$SUBJECT" init -q -b main + git -C "$SUBJECT" config user.name fixture + git -C "$SUBJECT" config user.email fixture@example.invalid + git -C "$SUBJECT" config advice.addEmbeddedRepo false + ( + cd "$SUBJECT" + write_base_gate_files + printf 'base\n' >app.txt + git add . + git commit -q -m base + ) + G=$(git -C "$SUBJECT" rev-parse HEAD) + BASE=$G + git clone -q --no-hardlinks "$SUBJECT" "$GATE" +} + +commit_subject() { + local message=$1 + git -C "$SUBJECT" add -A + git -C "$SUBJECT" commit -q -m "$message" + HEAD=$(git -C "$SUBJECT" rev-parse HEAD) +} + +reset_gate_to_subject_head() { + G=$HEAD + BASE=$G + rm -rf "$GATE" + git clone -q --no-hardlinks "$SUBJECT" "$GATE" +} + +classify() { + local kind=$1 release_state=${2:-enabled} + bash "$CLASSIFIER" \ + --subject-root "$SUBJECT" \ + --gate-root "$GATE" \ + --base "$BASE" \ + --head "$HEAD" \ + --kind "$kind" \ + --gate-sha "$G" \ + --release-state "$release_state" +} + +classify_with_ambient_fsmonitor() { + GIT_CONFIG_COUNT=1 \ + GIT_CONFIG_KEY_0=core.fsmonitor \ + GIT_CONFIG_VALUE_0="$FSMONITOR" \ + classify local || return + [[ ! -e "$FSMONITOR_SENTINEL" ]] +} + +echo '== build-container change classifier ==' + +new_case +printf 'ordinary\n' >>"$SUBJECT/app.txt" +commit_subject ordinary +assert_output 'an unrelated ordinary change is explicit local not-applicable' \ + $'mode=ordinary\nrelevant=false' classify local +assert_output 'an unrelated ordinary change is explicit pin not-applicable' \ + $'mode=ordinary\nrelevant=false' classify pin + +new_case +printf 'ordinary\n' >>"$SUBJECT/app.txt" +commit_subject ordinary +ORIGINAL_SUBJECT=$SUBJECT +mkdir "$SUBJECT/nested-root" +SUBJECT="$SUBJECT/nested-root" +assert_fail_matching 'subject root must be the exact repository top level' \ + 'subject checkout must be the exact repository top level' classify local +SUBJECT=$ORIGINAL_SUBJECT + +new_case +rm -rf "$GATE" +git -C "$SUBJECT" worktree add -q --detach "$GATE" "$G" +printf 'ordinary\n' >>"$SUBJECT/app.txt" +commit_subject ordinary +assert_fail 'gate and subject roots must use separate Git repositories' classify local + +new_case +printf 'ordinary\n' >>"$SUBJECT/app.txt" +commit_subject ordinary +GATE_COMMON_DIRECTORY=$(git -C "$GATE" rev-parse --path-format=absolute --git-common-dir) +printf '%s\n' "$G" >"$GATE_COMMON_DIRECTORY/info/grafts" +assert_fail 'gate common-directory grafts are rejected' classify local + +new_case +printf 'ordinary\n' >>"$SUBJECT/app.txt" +commit_subject ordinary +printf 'original object\n' >"$CASE_ROOT/original-object" +printf 'replacement object\n' >"$CASE_ROOT/replacement-object" +ORIGINAL_OBJECT=$(git -C "$SUBJECT" hash-object -w "$CASE_ROOT/original-object") +REPLACEMENT_OBJECT=$(git -C "$SUBJECT" hash-object -w "$CASE_ROOT/replacement-object") +git -C "$SUBJECT" replace "$ORIGINAL_OBJECT" "$REPLACEMENT_OBJECT" +assert_fail 'subject replacement refs are rejected even when unrelated to the range' classify local + +new_case +printf 'ordinary\n' >>"$SUBJECT/app.txt" +commit_subject ordinary +FSMONITOR="$CASE_ROOT/fsmonitor" +FSMONITOR_SENTINEL="$CASE_ROOT/fsmonitor-ran" +{ + printf '#!/usr/bin/env bash\nset -euo pipefail\n' + printf '%s\n' 'touch "$FAKE_FSMONITOR_SENTINEL"' 'printf "\\n"' +} >"$FSMONITOR" +chmod 0755 "$FSMONITOR" +export FAKE_FSMONITOR_SENTINEL=$FSMONITOR_SENTINEL +assert_output 'ambient Git fsmonitor cannot execute in the classifier' \ + $'mode=ordinary\nrelevant=false' classify_with_ambient_fsmonitor +unset FAKE_FSMONITOR_SENTINEL + +new_case +printf '%s' \ + "{\"gate-sha\":\"$G\",\"provenance-protocol\":1,\"release-tag\":\"build-container-v1\"}" \ + >"$SUBJECT/.github/docker/build-app-cli/release-request.json" +commit_subject release-request +assert_output 'a sole canonical release request is local-image relevant' \ + $'mode=ordinary\nrelevant=true' classify local +assert_output 'a sole canonical release request is pin not-applicable' \ + $'mode=ordinary\nrelevant=false' classify pin + +new_case +printf '%s' \ + "{\"gate-sha\":\"$G\",\"provenance-protocol\":1,\"release-tag\":\"build-container-v1\"}" \ + >"$SUBJECT/.github/docker/build-app-cli/release-request.json" +printf 'mixed\n' >>"$SUBJECT/app.txt" +commit_subject mixed-release +assert_fail 'a release request mixed with another path fails closed' classify local +assert_fail 'mixed release-request failure is independent of job kind' classify pin + +new_case +printf '{}\n' >"$SUBJECT/.github/docker/build-app-cli/image.json" +printf '{}\n' >"$SUBJECT/.github/docker/build-app-cli/image-release-evidence.json" +commit_subject pin-pair +assert_output 'an exact added pin pair is pin relevant' \ + $'mode=ordinary\nrelevant=true' classify pin +assert_output 'an exact added pin pair is local-image not-applicable' \ + $'mode=ordinary\nrelevant=false' classify local + +new_case +printf '{}\n' >"$SUBJECT/.github/docker/build-app-cli/image.json" +commit_subject one-pin +assert_fail 'a one-sided pin addition fails closed' classify pin +assert_fail 'a one-sided pin addition cannot disappear in the local job' classify local + +new_case +printf '{}\n' >"$SUBJECT/.github/docker/build-app-cli/image.json" +printf '{}\n' >"$SUBJECT/.github/docker/build-app-cli/image-release-evidence.json" +commit_subject pin-base +reset_gate_to_subject_head +rm "$SUBJECT/.github/docker/build-app-cli/image.json" +rm "$SUBJECT/.github/docker/build-app-cli/image-release-evidence.json" +commit_subject pin-delete +assert_fail 'paired pin deletion is relevant failure, never not-applicable' classify pin + +new_case +printf '{}\n' >"$SUBJECT/.github/docker/build-app-cli/image.json" +printf '{}\n' >"$SUBJECT/.github/docker/build-app-cli/image-release-evidence.json" +commit_subject pin-base +reset_gate_to_subject_head +printf '{"next":true}\n' >"$SUBJECT/.github/docker/build-app-cli/image.json" +printf '{"next":true}\n' >"$SUBJECT/.github/docker/build-app-cli/image-release-evidence.json" +commit_subject pin-change +assert_output 'both pin records may advance together as one ordinary pin candidate' \ + $'mode=ordinary\nrelevant=true' classify pin + +new_case +printf '# gate update\n' >>"$SUBJECT/.github/docker/build-app-cli/Dockerfile" +commit_subject gate-update +assert_output 'a manifested gate-only change is a relevant gate update' \ + $'mode=gate-update\nrelevant=true' classify local +assert_output 'gate update classification is identical in the pin job' \ + $'mode=gate-update\nrelevant=true' classify pin + +new_case +printf '#!/usr/bin/env bash\n' >"$SUBJECT/.github/docker/build-app-cli/new-helper.sh" +printf '%s\n' \ + '.dockerignore' \ + '.github/CODEOWNERS' \ + '.github/docker/build-app-cli/Dockerfile' \ + '.github/docker/build-app-cli/gate-paths.txt' \ + '.github/docker/build-app-cli/image-context-paths.txt' \ + '.github/docker/build-app-cli/new-helper.sh' \ + '.github/docker/build-app-cli/old-helper.sh' \ + >"$SUBJECT/.github/docker/build-app-cli/gate-paths.txt" +( + cd "$SUBJECT" + write_codeowners +) +commit_subject gate-add +assert_output 'a newly manifested regular gate path is a gate update' \ + $'mode=gate-update\nrelevant=true' classify local + +new_case +rm "$SUBJECT/.github/docker/build-app-cli/old-helper.sh" +printf '%s\n' \ + '.dockerignore' \ + '.github/CODEOWNERS' \ + '.github/docker/build-app-cli/Dockerfile' \ + '.github/docker/build-app-cli/gate-paths.txt' \ + '.github/docker/build-app-cli/image-context-paths.txt' \ + >"$SUBJECT/.github/docker/build-app-cli/gate-paths.txt" +( + cd "$SUBJECT" + write_codeowners +) +commit_subject gate-delete +assert_output 'a removed old-manifest path uses the old/candidate union' \ + $'mode=gate-update\nrelevant=true' classify pin + +new_case +mv "$SUBJECT/.github/docker/build-app-cli/old-helper.sh" \ + "$SUBJECT/.github/docker/build-app-cli/renamed-helper.sh" +printf '%s\n' \ + '.dockerignore' \ + '.github/CODEOWNERS' \ + '.github/docker/build-app-cli/Dockerfile' \ + '.github/docker/build-app-cli/gate-paths.txt' \ + '.github/docker/build-app-cli/image-context-paths.txt' \ + '.github/docker/build-app-cli/renamed-helper.sh' \ + >"$SUBJECT/.github/docker/build-app-cli/gate-paths.txt" +( + cd "$SUBJECT" + write_codeowners +) +commit_subject gate-rename +assert_output 'a gate rename is classified through old-name deletion and new-name addition' \ + $'mode=gate-update\nrelevant=true' classify local + +new_case +printf '# gate update\n' >>"$SUBJECT/.github/docker/build-app-cli/Dockerfile" +printf 'mixed\n' >>"$SUBJECT/app.txt" +commit_subject mixed-gate +assert_fail 'a gate and non-gate change fails closed' classify local + +new_case +printf '%s\n' \ + '.github/CODEOWNERS' \ + '.dockerignore' \ + '.github/docker/build-app-cli/Dockerfile' \ + '.github/docker/build-app-cli/gate-paths.txt' \ + '.github/docker/build-app-cli/image-context-paths.txt' \ + '.github/docker/build-app-cli/old-helper.sh' \ + >"$SUBJECT/.github/docker/build-app-cli/gate-paths.txt" +commit_subject unsorted-manifest +assert_fail 'an unsorted candidate gate manifest fails closed' classify local + +new_case +printf '%s\n' \ + '.dockerignore' \ + '.github/CODEOWNERS' \ + '.github/docker/build-app-cli/Dockerfile' \ + '.github/docker/build-app-cli/gate-paths.txt' \ + '.github/docker/build-app-cli/image-context-paths.txt' \ + '.github/docker/build-app-cli/missing-helper.sh' \ + '.github/docker/build-app-cli/old-helper.sh' \ + >"$SUBJECT/.github/docker/build-app-cli/gate-paths.txt" +( + cd "$SUBJECT" + write_codeowners +) +commit_subject missing-manifested-path +assert_fail 'a candidate manifest cannot claim a missing gate path' classify local + +new_case +rm "$SUBJECT/.github/docker/build-app-cli/Dockerfile" +ln -s ../../../.dockerignore "$SUBJECT/.github/docker/build-app-cli/Dockerfile" +commit_subject symlinked-gate-path +assert_fail 'a manifested symlink is not a gate file' classify local + +new_case +printf 'owner drift\n' >>"$SUBJECT/.github/CODEOWNERS" +commit_subject codeowners-drift +assert_fail 'candidate CODEOWNERS must be the exact manifest expansion' classify local + +new_case +printf 'bad\n' >"$SUBJECT"/$'ambiguous\npath' +commit_subject newline-path +assert_fail 'a changed path containing a newline fails closed' classify local + +new_case +git clone -q --no-hardlinks "$GATE" "$SUBJECT/vendor-dependency" +commit_subject gitlink +assert_fail 'a changed gitlink fails closed' classify local + +new_case +printf '%s\n' \ + "{\"gate-sha\":\"$G\",\"provenance-protocol\":1,\"release-tag\":\"build-container-v1\"}" \ + >"$SUBJECT/.github/docker/build-app-cli/release-request.json" +commit_subject release-with-newline +assert_fail 'release-request bytes with a trailing newline fail closed' classify local + +new_case +printf '# failed gate\n' >>"$SUBJECT/.github/docker/build-app-cli/Dockerfile" +commit_subject failed-gate +FAILED_G=$HEAD +BASE=$FAILED_G +git -C "$SUBJECT" checkout -q "$G" -- .github/docker/build-app-cli/Dockerfile +commit_subject rollback +assert_output 'the exact disabled failed-gate restoration is gate rollback' \ + $'mode=gate-rollback\nrelevant=true' \ + classify local "disabled:123:$G:$FAILED_G" +assert_fail 'the same restoration is forbidden while release is enabled' classify local enabled + +new_case +printf '# failed gate\n' >>"$SUBJECT/.github/docker/build-app-cli/Dockerfile" +printf '#!/usr/bin/env bash\n' >"$SUBJECT/.github/docker/build-app-cli/new-helper.sh" +printf '%s\n' \ + '.dockerignore' \ + '.github/CODEOWNERS' \ + '.github/docker/build-app-cli/Dockerfile' \ + '.github/docker/build-app-cli/gate-paths.txt' \ + '.github/docker/build-app-cli/image-context-paths.txt' \ + '.github/docker/build-app-cli/new-helper.sh' \ + '.github/docker/build-app-cli/old-helper.sh' \ + >"$SUBJECT/.github/docker/build-app-cli/gate-paths.txt" +( + cd "$SUBJECT" + write_codeowners +) +commit_subject failed-gate-with-new-path +FAILED_G=$HEAD +BASE=$FAILED_G +git -C "$SUBJECT" checkout -q "$G" -- \ + .github/CODEOWNERS \ + .github/docker/build-app-cli/Dockerfile \ + .github/docker/build-app-cli/gate-paths.txt +rm "$SUBJECT/.github/docker/build-app-cli/new-helper.sh" +commit_subject rollback-removes-new-path +assert_output 'rollback may remove a path introduced only by failed G prime' \ + $'mode=gate-rollback\nrelevant=true' \ + classify pin "disabled:456:$G:$FAILED_G" +assert_fail 'rollback rejects a zero lock run id' \ + classify pin "disabled:0:$G:$FAILED_G" +assert_fail 'rollback rejects a state bound to a different failed gate' \ + classify pin "disabled:456:$G:1111111111111111111111111111111111111111" +assert_fail 'rollback rejects the interrupted state that lacks failed-G prime' \ + classify pin "disabled:456:$G" + +new_case +printf '# drift\n' >>"$SUBJECT/.github/docker/build-app-cli/Dockerfile" +commit_subject drift +BASE=$HEAD +printf 'ordinary\n' >>"$SUBJECT/app.txt" +commit_subject after-drift +assert_fail 'ordinary work fails when the base gate bytes differ from active G' classify local + +printf 'Passed: %d Failed: %d\n' "$pass" "$fail" +[[ "$fail" -eq 0 ]] diff --git a/.github/actions/deploy-core/tests/install-actionlint.test.sh b/.github/actions/deploy-core/tests/install-actionlint.test.sh new file mode 100755 index 00000000..7592e478 --- /dev/null +++ b/.github/actions/deploy-core/tests/install-actionlint.test.sh @@ -0,0 +1,72 @@ +#!/usr/bin/env bash +set -euo pipefail + +root=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../../../.." && pwd) +tmp=$(mktemp -d) +trap 'rm -rf "$tmp"' EXIT +mkdir -p "$tmp/bin" "$tmp/install" + +# Simulate release transport and extraction, recording whether unverified bytes +# reach tar. Hash results are fixtures, independent of the installer's table. +cat >"$tmp/bin/curl" <<'SH' +#!/bin/sh +while [ "$#" -gt 0 ]; do + if [ "$1" = --output ]; then shift; printf archive >"$1"; exit; fi + shift +done +exit 1 +SH +cat >"$tmp/bin/sha256sum" <<'SH' +#!/bin/sh +printf '%s %s\n' "$TEST_DIGEST" "$1" +SH +cat >"$tmp/bin/tar" <<'SH' +#!/bin/sh +printf extracted >>"$TEST_EXTRACT_LOG" +while [ "$#" -gt 0 ]; do + if [ "$1" = -C ]; then + shift + printf '#!/bin/sh\nprintf "1.7.12\\n"\n' >"$1/actionlint" + exit + fi + shift +done +exit 1 +SH +chmod +x "$tmp/bin/"* +export PATH="$tmp/bin:$PATH" TEST_EXTRACT_LOG="$tmp/extracted" +export INSTALL_DIR="$tmp/install" + +for tuple in \ + linux:amd64:8aca8db96f1b94770f1b0d72b6dddcb1ebb8123cb3712530b08cc387b349a3d8 \ + linux:arm64:325e971b6ba9bfa504672e29be93c24981eeb1c07576d730e9f7c8805afff0c6 \ + darwin:amd64:5b44c3bc2255115c9b69e30efc0fecdf498fdb63c5d58e17084fd5f16324c644 \ + darwin:arm64:aba9ced2dee8d27fecca3dc7feb1a7f9a52caefa1eb46f3271ea66b6e0e6953f; do + OS=${tuple%%:*} + rest=${tuple#*:} + ARCH=${rest%%:*} + TEST_DIGEST=${rest#*:} + export OS ARCH TEST_DIGEST + bash "$root/scripts/install-actionlint.sh" 1.7.12 + [[ "$("$INSTALL_DIR/actionlint" -version)" == 1.7.12 ]] +done +before=$(wc -c <"$TEST_EXTRACT_LOG") +export TEST_DIGEST=bad +if bash "$root/scripts/install-actionlint.sh" 1.7.12 >"$tmp/result" 2>&1; then + echo 'checksum mismatch was accepted' >&2; exit 1 +fi +grep -q 'checksum mismatch' "$tmp/result" +[[ "$(wc -c <"$TEST_EXTRACT_LOG")" == "$before" ]] +for tuple in linux:riscv64 freebsd:amd64; do + export OS=${tuple%%:*} ARCH=${tuple#*:} + if bash "$root/scripts/install-actionlint.sh" 1.7.12 >"$tmp/result" 2>&1; then + echo 'unknown platform was accepted' >&2; exit 1 + fi + grep -q 'no repo-pinned SHA-256' "$tmp/result" +done +export OS=darwin ARCH=arm64 +if bash "$root/scripts/install-actionlint.sh" 1.7.7 >"$tmp/result" 2>&1; then + echo 'obsolete actionlint version was accepted' >&2; exit 1 +fi +grep -q 'no repo-pinned SHA-256' "$tmp/result" +echo 'actionlint installer contract passed' diff --git a/.github/actions/deploy-core/tests/install-yq.test.sh b/.github/actions/deploy-core/tests/install-yq.test.sh new file mode 100755 index 00000000..f9387918 --- /dev/null +++ b/.github/actions/deploy-core/tests/install-yq.test.sh @@ -0,0 +1,68 @@ +#!/usr/bin/env bash +set -euo pipefail + +root=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../../../.." && pwd) +tmp=$(mktemp -d) +trap 'rm -rf "$tmp"' EXIT +mkdir -p "$tmp/bin" "$tmp/install" + +cat >"$tmp/bin/curl" <<'SH' +#!/bin/sh +printf '%s\n' "$*" >>"$TEST_CURL_LOG" +while [ "$#" -gt 0 ]; do + if [ "$1" = --output ]; then + shift + printf '#!/bin/sh\nprintf "yq fixture\\n"\n' >"$1" + exit 0 + fi + shift +done +exit 1 +SH +cat >"$tmp/bin/sha256sum" <<'SH' +#!/bin/sh +printf '%s %s\n' "$TEST_DIGEST" "$1" +SH +chmod +x "$tmp/bin/"* +export PATH="$tmp/bin:$PATH" TEST_CURL_LOG="$tmp/curl.log" INSTALL_DIR="$tmp/install" + +for tuple in \ + linux:amd64:fa52a4e758c63d38299163fbdd1edfb4c4963247918bf9c1c5d31d84789eded4 \ + linux:arm64:578648e463a11c1b6db6010cbf41eafed6bee79466fcffa1bb446672cf7945ea \ + darwin:amd64:b4ba1ecce3c47f00803f4f964de38394326c7a32eb6540616e04fb2935a0f08d \ + darwin:arm64:877de31753a4dd2401aa048937aa9a7fc4d5f6ce858cf31508c5802954297213; do + OS=${tuple%%:*} + rest=${tuple#*:} + ARCH=${rest%%:*} + TEST_DIGEST=${rest#*:} + export OS ARCH TEST_DIGEST + bash "$root/scripts/install-yq.sh" 4.53.3 >/dev/null + grep -Fq "https://github.com/mikefarah/yq/releases/download/v4.53.3/yq_${OS}_${ARCH}" \ + "$TEST_CURL_LOG" + [[ "$("$INSTALL_DIR/yq")" == "yq fixture" ]] +done + +export OS=linux ARCH=amd64 TEST_DIGEST=bad +if bash "$root/scripts/install-yq.sh" 4.53.3 >"$tmp/result" 2>&1; then + echo 'checksum mismatch was accepted' >&2 + exit 1 +fi +grep -Fq 'checksum mismatch' "$tmp/result" + +for tuple in linux:riscv64 freebsd:amd64; do + export OS=${tuple%%:*} ARCH=${tuple#*:} + if bash "$root/scripts/install-yq.sh" 4.53.3 >"$tmp/result" 2>&1; then + echo 'unknown platform was accepted' >&2 + exit 1 + fi + grep -Fq 'no repo-pinned SHA-256' "$tmp/result" +done + +export OS=darwin ARCH=arm64 +if bash "$root/scripts/install-yq.sh" 4.53.2 >"$tmp/result" 2>&1; then + echo 'obsolete yq version was accepted' >&2 + exit 1 +fi +grep -Fq 'no repo-pinned SHA-256' "$tmp/result" + +echo 'yq installer contract passed' diff --git a/.github/actions/deploy-core/tests/release-approval-gate.test.sh b/.github/actions/deploy-core/tests/release-approval-gate.test.sh new file mode 100755 index 00000000..3dda28d9 --- /dev/null +++ b/.github/actions/deploy-core/tests/release-approval-gate.test.sh @@ -0,0 +1,839 @@ +#!/usr/bin/env bash +# shellcheck disable=SC2016,SC2119,SC2120 +set -euo pipefail + +DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +REAL_GATE="$DIR/../../../docker/build-app-cli/release-approval-gate.sh" +WORK=$(mktemp -d) +WORK=$(cd -- "$WORK" && pwd -P) +trap 'rm -rf -- "$WORK"' EXIT + +RUN_ID=9007199254740993 +RUN_ATTEMPT=2 +SOURCE=$(printf '2%.0s' {1..40}) +TAG=build-container-v7 +DIGEST="sha256:$(printf '3%.0s' {1..64})" +CHALLENGE=$(printf '4%.0s' {1..64}) +SCREENSHOT="sha256:$(printf '5%.0s' {1..64})" +REVIEWED_AT=2026-09-10T11:55:00Z +NOW=2026-09-10T12:00:00Z +TOKEN_VALUE='fixture-token-value' + +pass=0 +fail=0 +case_number=0 + +ok() { + printf ' \033[32mok\033[0m %s\n' "$1" + pass=$((pass + 1)) +} + +no() { + printf ' \033[31mFAIL\033[0m %s\n' "$1" >&2 + fail=$((fail + 1)) +} + +GATE_ROOT="$WORK/gate" +FAKE_BIN="$WORK/fake-bin" +OUTPUT_ROOT="$WORK/private-output" +mkdir -p "$GATE_ROOT/.github/docker/build-app-cli" "$FAKE_BIN" "$OUTPUT_ROOT" +chmod 0700 "$OUTPUT_ROOT" +cp "$REAL_GATE" "$GATE_ROOT/.github/docker/build-app-cli/release-approval-gate.sh" +chmod 0755 "$GATE_ROOT/.github/docker/build-app-cli/release-approval-gate.sh" +git -C "$GATE_ROOT" init -q -b main +git -C "$GATE_ROOT" config user.name fixture +git -C "$GATE_ROOT" config user.email fixture@example.invalid +git -C "$GATE_ROOT" add . +git -C "$GATE_ROOT" commit -q -m gate +G=$(git -C "$GATE_ROOT" rev-parse HEAD) +git -C "$GATE_ROOT" checkout -q --detach "$G" +GATE="$GATE_ROOT/.github/docker/build-app-cli/release-approval-gate.sh" + +cat >"$FAKE_BIN/curl" <<'SH' +#!/usr/bin/env bash +set -euo pipefail +fixture=$(cd -- "$(dirname -- "$0")" && pwd) +[[ "${LC_ALL:-}" == C ]] +[[ -z "${GITHUB_TOKEN+x}${GH_TOKEN+x}${HOME+x}${CURL_HOME+x}${XDG_CONFIG_HOME+x}" ]] +[[ -z "${HTTP_PROXY+x}${HTTPS_PROXY+x}${ALL_PROXY+x}${NO_PROXY+x}${AMBIENT_SECRET+x}" ]] +count=0 +[[ ! -f "$fixture/curl-count" ]] || count=$(<"$fixture/curl-count") +count=$((count + 1)) +printf '%s' "$count" >"$fixture/curl-count" +printf '%s\n' "$@" >"$fixture/args-$count" +cat >"$fixture/config-$count" +output= +url= +while (($#)); do + case "$1" in + --output) + output=$2 + shift 2 + ;; + *) + url=$1 + shift + ;; + esac +done +[[ -n "$output" ]] +case "$url" in + */approvals) endpoint=approvals ;; + *) endpoint=run ;; +esac +if [[ -f "$fixture/$endpoint.transport-failure" ]]; then + printf '%s\n' 'curl: transport response detail must not leak' >&2 + exit 28 +fi +if [[ -f "$fixture/$endpoint.block" ]]; then + printf '%s' "$$" >"$fixture/curl-child-pid" + : >"$fixture/curl-blocked" + while [[ ! -f "$fixture/release-curl" ]]; do :; done +fi +cat "$fixture/$endpoint.body" >"$output" +if [[ -f "$fixture/$endpoint.raw-metadata" ]]; then + cat "$fixture/$endpoint.raw-metadata" +else + printf '%s\n%s\n%s' \ + "$(<"$fixture/$endpoint.status")" \ + "$(<"$fixture/$endpoint.version")" \ + "$(<"$fixture/$endpoint.media")" +fi +SH +chmod 0755 "$FAKE_BIN/curl" + +cat >"$FAKE_BIN/date" <<'SH' +#!/usr/bin/env bash +set -euo pipefail +fixture=$(cd -- "$(dirname -- "$0")" && pwd) +[[ "${LC_ALL:-}" == C ]] +[[ -z "${GITHUB_TOKEN+x}${GH_TOKEN+x}${HOME+x}${AMBIENT_SECRET+x}" ]] +[[ "$#" -eq 2 && "$1" == -u && "$2" == +%s ]] +cat "$fixture/now-epoch" +SH +chmod 0755 "$FAKE_BIN/date" +jq -nr --arg value "$NOW" '$value | fromdateiso8601' >"$FAKE_BIN/now-epoch" + +REAL_GIT=$(command -v git) +{ + printf '#!/usr/bin/env bash\nset -euo pipefail\n' + printf 'real_git=%q\n' "$REAL_GIT" + printf '%s\n' \ + 'fixture=$(cd -- "$(dirname -- "$0")" && pwd)' \ + 'if [[ -f "$fixture/fail-git-status" && " $* " == *" status "* ]]; then exit 1; fi' \ + 'exec "$real_git" "$@"' +} >"$FAKE_BIN/git" +chmod 0755 "$FAKE_BIN/git" + +REAL_MKTEMP=$(command -v mktemp) +{ + printf '#!/usr/bin/env bash\nset -euo pipefail\n' + printf 'real_mktemp=%q\n' "$REAL_MKTEMP" + printf '%s\n' \ + 'fixture=$(cd -- "$(dirname -- "$0")" && pwd)' \ + 'count=0' \ + '[[ ! -f "$fixture/mktemp-count" ]] || count=$(<"$fixture/mktemp-count")' \ + 'count=$((count + 1))' \ + 'printf "%s" "$count" >"$fixture/mktemp-count"' \ + 'if [[ -f "$fixture/fail-metadata-mktemp" && "$count" -eq 2 ]]; then exit 1; fi' \ + 'path=$("$real_mktemp" "$@")' \ + 'printf "%s\n" "$path" >>"$fixture/mktemp-created"' \ + 'printf "%s\n" "$path"' +} >"$FAKE_BIN/mktemp" +chmod 0755 "$FAKE_BIN/mktemp" + +expected_comment() { + local reviewed_at=${1:-$REVIEWED_AT} screenshot=${2:-$SCREENSHOT} + printf '%s' "edgezero-release-evidence-v1 {\"challenge\":\"$CHALLENGE\",\"image-digest\":\"$DIGEST\",\"png-sha256\":\"$screenshot\",\"release-tag\":\"$TAG\",\"reviewed-at\":\"$reviewed_at\",\"run-attempt\":\"$RUN_ATTEMPT\",\"run-id\":\"$RUN_ID\",\"source-revision\":\"$SOURCE\"}" +} + +review_json() { + local state=$1 environment=$2 comment=$3 login=${4:-release-reviewer} + jq -cn \ + --arg state "$state" \ + --arg environment "$environment" \ + --arg comment "$comment" \ + --arg login "$login" \ + '{environments:[{name:$environment}],state:$state,user:{login:$login},comment:$comment}' +} + +write_approvals() { + local separator='' item + printf '[' >"$FAKE_BIN/approvals.body" + for item in "$@"; do + printf '%s%s' "$separator" "$item" >>"$FAKE_BIN/approvals.body" + separator=, + done + printf ']' >>"$FAKE_BIN/approvals.body" +} + +write_current_review() { + local state=${1:-approved} environment=${2:-build-container-release} + local comment=${3:-$(expected_comment)} login=${4:-release-reviewer} + write_approvals "$(review_json "$state" "$environment" "$comment" "$login")" +} + +write_run_body() { + local body=${1:-"{\"id\":$RUN_ID,\"run_attempt\":$RUN_ATTEMPT,\"event\":\"workflow_dispatch\",\"status\":\"completed\",\"conclusion\":\"failure\",\"head_sha\":\"wrong\",\"path\":\"wrong\"}"} + printf '%s' "$body" >"$FAKE_BIN/run.body" +} + +set_metadata() { + local endpoint=$1 status=${2:-200} version=${3:-2026-03-10} media=${4:-application/json} + printf '%s' "$status" >"$FAKE_BIN/$endpoint.status" + printf '%s' "$version" >"$FAKE_BIN/$endpoint.version" + printf '%s' "$media" >"$FAKE_BIN/$endpoint.media" +} + +reset_api() { + rm -f -- "$FAKE_BIN"/args-* "$FAKE_BIN"/config-* "$FAKE_BIN/curl-count" \ + "$FAKE_BIN/fail-git-status" "$FAKE_BIN/fail-metadata-mktemp" \ + "$FAKE_BIN/mktemp-count" "$FAKE_BIN/mktemp-created" \ + "$FAKE_BIN/run.raw-metadata" "$FAKE_BIN/approvals.raw-metadata" \ + "$FAKE_BIN/run.transport-failure" "$FAKE_BIN/approvals.transport-failure" \ + "$FAKE_BIN/run.block" "$FAKE_BIN/approvals.block" "$FAKE_BIN/curl-child-pid" \ + "$FAKE_BIN/curl-blocked" "$FAKE_BIN/release-curl" + write_run_body + write_current_review + set_metadata run + set_metadata approvals +} + +new_case() { + case_number=$((case_number + 1)) + CASE_ROOT="$WORK/case-$case_number" + mkdir "$CASE_ROOT" + APPROVAL_OUT="$OUTPUT_ROOT/approval-$case_number.json" + CLI_GATE_SHA=$G + CLI_RUN_ID=$RUN_ID + CLI_RUN_ATTEMPT=$RUN_ATTEMPT + CLI_BUILD_ATTEMPT=$RUN_ATTEMPT + CLI_SOURCE=$SOURCE + CLI_TAG=$TAG + CLI_DIGEST=$DIGEST + CLI_CHALLENGE=$CHALLENGE + TOKEN_VALUE='fixture-token-value' + reset_api +} + +run_gate() { + PATH="$FAKE_BIN:$PATH" \ + GITHUB_TOKEN="$TOKEN_VALUE" \ + AMBIENT_SECRET=must-not-reach-tools \ + bash "$GATE" \ + --gate-root "$GATE_ROOT" \ + --gate-sha "$CLI_GATE_SHA" \ + --run-id "$CLI_RUN_ID" \ + --run-attempt "$CLI_RUN_ATTEMPT" \ + --build-attempt "$CLI_BUILD_ATTEMPT" \ + --source-revision "$CLI_SOURCE" \ + --release-tag "$CLI_TAG" \ + --image-digest "$CLI_DIGEST" \ + --approval-challenge "$CLI_CHALLENGE" \ + --approval-out "$APPROVAL_OUT" \ + "$@" +} + +start_gate() { + local bash_env=$1 + PATH="$FAKE_BIN:$PATH" \ + BASH_ENV="$bash_env" \ + SIGNAL_FIXTURE="$CASE_ROOT" \ + GITHUB_TOKEN="$TOKEN_VALUE" \ + AMBIENT_SECRET=must-not-reach-tools \ + /bin/bash "$GATE" \ + --gate-root "$GATE_ROOT" \ + --gate-sha "$CLI_GATE_SHA" \ + --run-id "$CLI_RUN_ID" \ + --run-attempt "$CLI_RUN_ATTEMPT" \ + --build-attempt "$CLI_BUILD_ATTEMPT" \ + --source-revision "$CLI_SOURCE" \ + --release-tag "$CLI_TAG" \ + --image-digest "$CLI_DIGEST" \ + --approval-challenge "$CLI_CHALLENGE" \ + --approval-out "$APPROVAL_OUT" \ + >"$CASE_ROOT/stdout" 2>"$CASE_ROOT/stderr" & + SIGNAL_GATE_PID=$! +} + +wait_for_marker() { + local marker=$1 pid=$2 deadline=$((SECONDS + 3)) + while [[ ! -e "$marker" && $SECONDS -lt $deadline ]]; do + kill -0 "$pid" 2>/dev/null || return 1 + done + [[ -e "$marker" ]] +} + +wait_for_signaled_gate() { + local gate_pid=$1 child_pid_file=${2:-} watchdog status=0 + ( + deadline=$((SECONDS + 3)) + while ((SECONDS < deadline)); do :; done + : >"$CASE_ROOT/watchdog-fired" + kill -KILL "$gate_pid" 2>/dev/null || true + if [[ -n "$child_pid_file" && -s "$child_pid_file" ]]; then + kill -KILL "$(<"$child_pid_file")" 2>/dev/null || true + fi + ) & + watchdog=$! + wait "$gate_pid" || status=$? + kill -TERM "$watchdog" 2>/dev/null || true + wait "$watchdog" 2>/dev/null || true + SIGNAL_GATE_STATUS=$status +} + +assert_signal_cleanup() { + local expected_status=$1 description=$2 residue + residue=$(find "$OUTPUT_ROOT" -maxdepth 1 -name '.edgezero-*' -print -quit) + if [[ "$SIGNAL_GATE_STATUS" -ne "$expected_status" ]]; then + cat "$CASE_ROOT/stdout" "$CASE_ROOT/stderr" >&2 + no "$description (status $SIGNAL_GATE_STATUS, expected $expected_status)" + elif [[ -e "$CASE_ROOT/watchdog-fired" ]]; then + no "$description required the test watchdog" + elif [[ -s "$CASE_ROOT/stdout" || -e "$APPROVAL_OUT" || -L "$APPROVAL_OUT" || -n "$residue" ]]; then + cat "$CASE_ROOT/stdout" "$CASE_ROOT/stderr" >&2 + no "$description left output or temporary-file residue" + else + ok "$description" + fi +} + +run_gate_missing_challenge() { + PATH="$FAKE_BIN:$PATH" GITHUB_TOKEN="$TOKEN_VALUE" bash "$GATE" \ + --gate-root "$GATE_ROOT" --gate-sha "$G" --run-id "$CLI_RUN_ID" \ + --run-attempt "$CLI_RUN_ATTEMPT" --build-attempt "$CLI_BUILD_ATTEMPT" \ + --source-revision "$CLI_SOURCE" --release-tag "$CLI_TAG" \ + --image-digest "$CLI_DIGEST" --approval-out "$APPROVAL_OUT" +} + +run_bad_flag_without_tools() { + mkdir "$CASE_ROOT/empty-path" + PATH="$CASE_ROOT/empty-path" GITHUB_TOKEN=$'bad\ntoken' /bin/bash "$GATE" \ + --gate-root "$GATE_ROOT" --gate-sha "$G" --run-id "$CLI_RUN_ID" \ + --run-attempt "$CLI_RUN_ATTEMPT" --build-attempt "$CLI_BUILD_ATTEMPT" \ + --source-revision "$CLI_SOURCE" --release-tag "$CLI_TAG" \ + --image-digest "$CLI_DIGEST" --approval-challenge "$CLI_CHALLENGE" \ + --approval-out "$APPROVAL_OUT" --unknown value +} + +run_gate_without_tools() { + mkdir "$CASE_ROOT/empty-path" + PATH="$CASE_ROOT/empty-path" GITHUB_TOKEN=$'bad\ntoken' /bin/bash "$GATE" \ + --gate-root "$GATE_ROOT" --gate-sha "$CLI_GATE_SHA" --run-id "$CLI_RUN_ID" \ + --run-attempt "$CLI_RUN_ATTEMPT" --build-attempt "$CLI_BUILD_ATTEMPT" \ + --source-revision "$CLI_SOURCE" --release-tag "$CLI_TAG" \ + --image-digest "$CLI_DIGEST" --approval-challenge "$CLI_CHALLENGE" \ + --approval-out "$APPROVAL_OUT" +} + +curl_calls() { + if [[ -f "$FAKE_BIN/curl-count" ]]; then cat "$FAKE_BIN/curl-count"; else printf '0'; fi +} + +assert_result() { + local expected_status=$1 description=$2 status=0 + shift 2 + "$@" >"$CASE_ROOT/stdout" 2>"$CASE_ROOT/stderr" || status=$? + if [[ "$status" -ne "$expected_status" ]]; then + cat "$CASE_ROOT/stdout" "$CASE_ROOT/stderr" >&2 + no "$description (status $status, expected $expected_status)" + elif [[ -s "$CASE_ROOT/stdout" ]]; then + cat "$CASE_ROOT/stdout" >&2 + no "$description emitted stdout" + elif [[ "$expected_status" -eq 0 && (! -f "$APPROVAL_OUT" || -L "$APPROVAL_OUT" || -s "$CASE_ROOT/stderr") ]]; then + cat "$CASE_ROOT/stderr" >&2 + no "$description did not produce one silent regular output" + elif [[ "$expected_status" -ne 0 && (-e "$APPROVAL_OUT" || -L "$APPROVAL_OUT") ]]; then + no "$description published output on failure" + elif [[ "$expected_status" -ne 0 && (! -s "$CASE_ROOT/stderr" || $(<"$CASE_ROOT/stderr") == *"$TOKEN_VALUE"*) ]]; then + cat "$CASE_ROOT/stderr" >&2 + no "$description did not emit sanitized stderr" + else + ok "$description" + fi +} + +assert_no_curl() { + local description=$1 + if [[ $(curl_calls) == 0 ]]; then ok "$description"; else no "$description"; fi +} + +assert_curl_contract() { + local good=true call args config expected_config url body index + local -a actual expected + expected_config="$CASE_ROOT/expected-config" + printf '%s\n' \ + 'header = "Accept: application/vnd.github+json"' \ + 'header = "X-GitHub-Api-Version: 2026-03-10"' \ + 'header = "User-Agent: edgezero-build-container-gate/1"' \ + "header = \"Authorization: Bearer $TOKEN_VALUE\"" \ + >"$expected_config" + [[ $(curl_calls) == 2 ]] || good=false + for call in 1 2; do + args="$FAKE_BIN/args-$call" + config="$FAKE_BIN/config-$call" + [[ -f "$args" && -f "$config" ]] || { + good=false + continue + } + cmp -s "$expected_config" "$config" || good=false + actual=() + while IFS= read -r argument || [[ -n "$argument" ]]; do + actual+=("$argument") + done <"$args" + body=${actual[14]:-} + if [[ "$call" -eq 1 ]]; then + url="https://api.github.com/repos/stackpop/edgezero/actions/runs/$RUN_ID" + else + url="https://api.github.com/repos/stackpop/edgezero/actions/runs/$RUN_ID/approvals" + fi + expected=( + --disable + --silent + --show-error + --connect-timeout 10 + --max-time 30 + --max-redirs 0 + --request GET + --config - + --output "$body" + --write-out '%{http_code}\n%header{x-github-api-version-selected}\n%header{content-type}' + "$url" + ) + [[ "$body" == "$OUTPUT_ROOT"/.edgezero-github-body.?????? ]] || good=false + [[ "${#actual[@]}" -eq "${#expected[@]}" ]] || good=false + for index in "${!expected[@]}"; do + [[ "${actual[index]:-}" == "${expected[index]}" ]] || good=false + done + done + if [[ "$good" == true ]]; then + ok 'both API calls use the exact transport, headers, GET routes, and stdin credentials' + else + no 'both API calls use the exact transport, headers, GET routes, and stdin credentials' + fi +} + +echo '== release approval gate ==' + +new_case +assert_result 0 'a current approved review writes evidence silently' run_gate +expected_output="{\"approval-challenge\":\"$CHALLENGE\",\"approver-login\":\"release-reviewer\",\"image-digest\":\"$DIGEST\",\"release-tag\":\"$TAG\",\"reviewed-at\":\"$REVIEWED_AT\",\"run-attempt\":\"$RUN_ATTEMPT\",\"run-id\":\"$RUN_ID\",\"schema-version\":1,\"screenshot-sha256\":\"$SCREENSHOT\",\"source-revision\":\"$SOURCE\"}" +printf '%s' "$expected_output" >"$CASE_ROOT/expected-output" +if cmp -s "$CASE_ROOT/expected-output" "$APPROVAL_OUT"; then + ok 'approval evidence is exact JCS with no trailing LF' +else + no 'approval evidence is exact JCS with no trailing LF' +fi +assert_curl_contract +if ! grep -Fq -- "$TOKEN_VALUE" "$CASE_ROOT/stdout" && + ! grep -Fq -- "$TOKEN_VALUE" "$CASE_ROOT/stderr"; then + ok 'the token is absent from gate stdout and stderr' +else + no 'the token is absent from gate stdout and stderr' +fi + +for mode in unknown duplicate empty missing; do + new_case + case "$mode" in + unknown) assert_result 2 'an unknown flag is rejected as usage' run_gate --unknown value ;; + duplicate) assert_result 2 'a duplicate flag is rejected as usage' run_gate --run-id 7 ;; + empty) + CLI_RUN_ID= + assert_result 2 'an empty flag value is rejected as usage' run_gate + ;; + missing) assert_result 2 'a missing required flag is rejected as usage' run_gate_missing_challenge ;; + esac + assert_no_curl "$mode flag failure occurs before curl" +done + +new_case +assert_result 2 'bad flags take precedence when tools and credentials are invalid' \ + run_bad_flag_without_tools +assert_no_curl 'bad flags with unavailable tools do not call curl' + +new_case +CLI_BUILD_ATTEMPT=1 +assert_result 1 'build attempt must equal the current run attempt' run_gate +assert_no_curl 'build/run attempt mismatch occurs before curl' + +while read -r field value; do + new_case + case "$field" in + run-id) CLI_RUN_ID=$value ;; + run-attempt) CLI_RUN_ATTEMPT=$value ;; + build-attempt) CLI_BUILD_ATTEMPT=$value ;; + esac + assert_result 1 "$field value $value is rejected before curl" run_gate + assert_no_curl "$field value $value does not call curl" +done <<'EOF' +run-id 0 +run-id 01 +run-id 18446744073709551616 +run-attempt 0 +run-attempt 02 +run-attempt 4294967296 +build-attempt 0 +build-attempt 02 +build-attempt 4294967296 +EOF + +new_case +assert_result 2 'an unavailable required tool exits 2 before credential access' \ + run_gate_without_tools +assert_no_curl 'an unavailable required tool does not call curl' + +new_case +malformed='edgezero-release-evidence-v1 {"challenge":' +write_current_review approved build-container-release "$malformed" +assert_result 1 'a malformed protocol comment is rejected' run_gate + +new_case +reordered=$(expected_comment) +reordered=${reordered/\{\"challenge\":\"$CHALLENGE\",\"image-digest\":\"$DIGEST\"/\{\"image-digest\":\"$DIGEST\",\"challenge\":\"$CHALLENGE\"} +write_current_review approved build-container-release "$reordered" +assert_result 1 'a reordered current protocol object is rejected' run_gate + +for duplicate_case in current-earlier current-future challenge image-digest; do + new_case + duplicate=$(expected_comment) + case "$duplicate_case" in + current-earlier) + replacement='"run-attempt":"2","run-attempt":"1"' + duplicate_description='run-attempt keys straddling current and earlier attempts' + ;; + current-future) + replacement='"run-attempt":"2","run-attempt":"3"' + duplicate_description='run-attempt keys straddling current and future attempts' + ;; + challenge) + replacement="\"challenge\":\"$CHALLENGE\",\"challenge\":\"$(printf '6%.0s' {1..64})\"" + duplicate_description='challenge keys' + ;; + image-digest) + replacement="\"image-digest\":\"$DIGEST\",\"image-digest\":\"sha256:$(printf '6%.0s' {1..64})\"" + duplicate_description='image-digest keys' + ;; + esac + case "$duplicate_case" in + current-*) duplicate=${duplicate/"\"run-attempt\":\"$RUN_ATTEMPT\""/"$replacement"} ;; + challenge) duplicate=${duplicate/"\"challenge\":\"$CHALLENGE\""/"$replacement"} ;; + image-digest) duplicate=${duplicate/"\"image-digest\":\"$DIGEST\""/"$replacement"} ;; + esac + write_approvals \ + "$(review_json approved build-container-release "$duplicate")" \ + "$(review_json approved build-container-release "$(expected_comment)")" + assert_result 1 "duplicate $duplicate_description are rejected" run_gate + if grep -Fq 'duplicate or malformed protocol JSON keys' "$CASE_ROOT/stderr"; then + ok "duplicate $duplicate_case keys fail in the raw protocol parser" + else + cat "$CASE_ROOT/stderr" >&2 + no "duplicate $duplicate_case keys fail in the raw protocol parser" + fi +done + +for shape in extra missing-source-revision missing-challenge missing-image-digest; do + new_case + comment=$(expected_comment) + case "$shape" in + extra) comment="${comment%\}},\"extra\":\"value\"}" ;; + missing-source-revision) comment=${comment/,\"source-revision\":\"$SOURCE\"/} ;; + missing-challenge) comment=${comment/\"challenge\":\"$CHALLENGE\",/} ;; + missing-image-digest) comment=${comment/,\"image-digest\":\"$DIGEST\"/} ;; + esac + write_current_review approved build-container-release "$comment" + assert_result 1 "a current protocol record with $shape is rejected" run_gate +done + +new_case +write_current_review approved build-container-release \ + "$(expected_comment "$REVIEWED_AT" 'sha256:not-a-digest')" +assert_result 1 'an invalid PNG digest is rejected' run_gate + +new_case +current=$(review_json approved build-container-release "$(expected_comment)") +write_approvals "$current" "$current" +assert_result 1 'duplicate current protocol records are rejected' run_gate + +for state in rejected bypassed; do + new_case + write_current_review "$state" + assert_result 1 "a $state current review is rejected" run_gate +done + +new_case +write_current_review approved production +assert_result 1 'a review for the wrong environment is rejected' run_gate + +for reviewer_case in malformed-login malformed-user; do + new_case + case "$reviewer_case" in + malformed-login) + write_current_review approved build-container-release "$(expected_comment)" release_reviewer + ;; + malformed-user) + review=$(review_json approved build-container-release "$(expected_comment)") + write_approvals "$(jq -c '.user = ["release-reviewer"]' <<<"$review")" + ;; + esac + assert_result 1 "a current review with $reviewer_case shape is rejected" run_gate +done + +for field in challenge image-digest release-tag run-id source-revision; do + new_case + comment=$(expected_comment) + replacement64=$(printf '6%.0s' {1..64}) + case "$field" in + challenge) comment=${comment/"$CHALLENGE"/"$replacement64"} ;; + image-digest) comment=${comment/"$DIGEST"/"sha256:$replacement64"} ;; + release-tag) comment=${comment/"$TAG"/build-container-v8} ;; + run-id) comment=${comment/"\"run-id\":\"$RUN_ID\""/"\"run-id\":\"7\""} ;; + source-revision) + replacement40=$(printf '6%.0s' {1..40}) + comment=${comment/"$SOURCE"/"$replacement40"} + ;; + esac + write_current_review approved build-container-release "$comment" + assert_result 1 "a mismatched current $field is rejected" run_gate +done + +new_case +write_run_body "{\"id\":$RUN_ID,\"run_attempt\":1}" +assert_result 1 'a mismatched current API run attempt is rejected' run_gate + +new_case +earlier=$(expected_comment 2000-01-01T00:00:00Z) +earlier=${earlier/"\"run-attempt\":\"$RUN_ATTEMPT\""/"\"run-attempt\":\"1\""} +earlier_challenge=$(printf '7%.0s' {1..64}) +earlier=${earlier/"$CHALLENGE"/"$earlier_challenge"} +write_approvals \ + "$(review_json rejected old-environment "$earlier" old-reviewer)" \ + "$(review_json approved build-container-release "$(expected_comment)")" +assert_result 0 'an earlier-attempt protocol record is inert' run_gate + +new_case +malformed_earlier='edgezero-release-evidence-v1 {"run-attempt":"1","irrelevant":true}' +write_approvals \ + "$(review_json bypassed wrong-environment "$malformed_earlier" invalid_reviewer)" \ + "$(review_json approved build-container-release "$(expected_comment)")" +assert_result 0 'malformed details in a classifiable earlier attempt are inert' run_gate + +new_case +future=$(expected_comment) +future=${future/"\"run-attempt\":\"$RUN_ATTEMPT\""/"\"run-attempt\":\"3\""} +write_approvals \ + "$(review_json approved build-container-release "$(expected_comment)")" \ + "$(review_json approved build-container-release "$future")" +assert_result 1 'a future-attempt protocol predeclaration is rejected' run_gate + +new_case +write_approvals "$(review_json approved build-container-release 'ordinary approval')" +assert_result 1 'missing current protocol review is rejected' run_gate + +new_case +earlier_only=$(expected_comment) +earlier_only=${earlier_only/"\"run-attempt\":\"$RUN_ATTEMPT\""/"\"run-attempt\":\"1\""} +write_approvals "$(review_json approved build-container-release "$earlier_only")" +assert_result 1 'earlier-attempt-only evidence cannot satisfy the current attempt' run_gate + +for field in id run_attempt; do + if [[ "$field" == id ]]; then + values=('"2"' 1.5 0 18446744073709551616) + else + values=('"2"' 1.5 0 4294967296) + fi + for value in "${values[@]}"; do + new_case + if [[ "$field" == id ]]; then + write_run_body "{\"id\":$value,\"run_attempt\":$RUN_ATTEMPT}" + else + write_run_body "{\"id\":$RUN_ID,\"run_attempt\":$value}" + fi + assert_result 1 "API $field rejects non-integer, zero, or out-of-range value $value" run_gate + done +done + +for endpoint_case in run-status run-version run-media run-charset run-malformed run-multiple approvals-status approvals-version approvals-media; do + new_case + case "$endpoint_case" in + run-status) set_metadata run 302 ;; + run-version) set_metadata run 200 2022-11-28 ;; + run-media) set_metadata run 200 2026-03-10 text/json ;; + run-charset) set_metadata run 200 2026-03-10 'application/json; charset=iso-8859-1' ;; + run-malformed) printf '{' >"$FAKE_BIN/run.body" ;; + run-multiple) printf '{} {}' >"$FAKE_BIN/run.body" ;; + approvals-status) set_metadata approvals 500 ;; + approvals-version) set_metadata approvals 200 2022-11-28 ;; + approvals-media) set_metadata approvals 200 2026-03-10 text/json ;; + esac + assert_result 1 "$endpoint_case response is rejected" run_gate +done + +for approvals_shape in malformed multiple; do + new_case + if [[ "$approvals_shape" == malformed ]]; then + printf '[' >"$FAKE_BIN/approvals.body" + else + printf '[] []' >"$FAKE_BIN/approvals.body" + fi + assert_result 1 "$approvals_shape approvals JSON is rejected" run_gate +done + +new_case +touch "$FAKE_BIN/run.transport-failure" +assert_result 1 'a curl transport failure is terminal without output' run_gate +if [[ $(curl_calls) == 1 ]]; then + ok 'a curl transport failure is not retried' +else + no 'a curl transport failure is not retried' +fi +if [[ $(<"$CASE_ROOT/stderr") == '::error::current run request failed' ]]; then + ok 'a curl transport failure emits only sanitized stderr' +else + cat "$CASE_ROOT/stderr" >&2 + no 'a curl transport failure emits only sanitized stderr' +fi + +new_case +response_secret='server-response-must-not-leak' +write_run_body "{\"id\":\"$response_secret\",\"run_attempt\":$RUN_ATTEMPT}" +assert_result 1 'a malformed response body is rejected without output' run_gate +if ! grep -Fq "$response_secret" "$CASE_ROOT/stdout" && + ! grep -Fq "$response_secret" "$CASE_ROOT/stderr"; then + ok 'response body content is absent from diagnostics' +else + no 'response body content is absent from diagnostics' +fi + +new_case +printf '200\n2026-03-10\napplication/json\nextra' >"$FAKE_BIN/run.raw-metadata" +assert_result 1 'malformed response metadata cardinality is rejected' run_gate + +new_case +touch "$FAKE_BIN/fail-metadata-mktemp" +assert_result 1 'metadata tempfile creation failure is rejected' run_gate +body_temp=$(sed -n '1p' "$FAKE_BIN/mktemp-created") +if [[ -n "$body_temp" && ! -e "$body_temp" ]]; then + ok 'body tempfile is registered and cleaned before metadata tempfile creation' +else + [[ -z "$body_temp" ]] || rm -f -- "$body_temp" + no 'body tempfile is registered and cleaned before metadata tempfile creation' +fi +assert_no_curl 'metadata tempfile creation failure occurs before curl' + +for reviewed_at in 2026-09-10T11:44:59Z 2026-09-10T12:00:01Z \ + 2026-02-30T12:00:00Z 2026-09-10T11:55:00+00:00 2026-09-10T11:55:00.000Z; do + new_case + write_current_review approved build-container-release "$(expected_comment "$reviewed_at")" + assert_result 1 "review timestamp $reviewed_at is rejected" run_gate +done + +new_case +write_current_review approved build-container-release "$(expected_comment 2026-09-10T11:45:00Z)" +assert_result 0 'a review exactly 900 seconds old is accepted' run_gate + +new_case +assert_result 0 'initial publication for no-replace test succeeds' run_gate +printf '%s' "$expected_output" >"$CASE_ROOT/preserved" +status=0 +run_gate >"$CASE_ROOT/second-stdout" 2>"$CASE_ROOT/second-stderr" || status=$? +if [[ "$status" -eq 1 && ! -s "$CASE_ROOT/second-stdout" ]] && + cmp -s "$CASE_ROOT/preserved" "$APPROVAL_OUT" && [[ $(curl_calls) == 2 ]]; then + ok 'an existing output is never replaced and blocks before curl' +else + cat "$CASE_ROOT/second-stdout" "$CASE_ROOT/second-stderr" >&2 + no 'an existing output is never replaced and blocks before curl' +fi + +new_case +APPROVAL_OUT="$GATE_ROOT/approval.json" +assert_result 1 'an output path inside a repository is rejected' run_gate +assert_no_curl 'repository-contained output is rejected before curl' + +new_case +chmod 0755 "$OUTPUT_ROOT" +assert_result 1 'a non-private output directory is rejected' run_gate +assert_no_curl 'non-private output directory is rejected before curl' +chmod 0700 "$OUTPUT_ROOT" + +for bad_token in $'line\nbreak' 'double"quote' 'back\slash'; do + new_case + TOKEN_VALUE=$bad_token + assert_result 1 'an unsafe token encoding is rejected' run_gate + assert_no_curl 'unsafe token encoding is rejected before curl' +done + +new_case +TOKEN_VALUE=$'bad\ntoken' +printf 'dirty\n' >"$GATE_ROOT/dirty" +assert_result 1 'checkout validation precedes credential validation' run_gate +assert_no_curl 'a dirty checkout with a bad token does not call curl' +rm "$GATE_ROOT/dirty" + +new_case +TOKEN_VALUE=$'bad\ntoken' +git -C "$GATE_ROOT" switch -q main +assert_result 1 'an attached gate checkout is rejected before credential access' run_gate +assert_no_curl 'an attached gate checkout does not call curl' +git -C "$GATE_ROOT" checkout -q --detach "$G" + +new_case +TOKEN_VALUE=$'bad\ntoken' +CLI_GATE_SHA=$(printf 'f%.0s' {1..40}) +assert_result 1 'a supplied gate SHA different from HEAD is rejected before credential access' run_gate +assert_no_curl 'a wrong supplied gate SHA does not call curl' + +new_case +TOKEN_VALUE=$'bad\ntoken' +touch "$FAKE_BIN/fail-git-status" +assert_result 1 'a failed gate cleanliness probe is rejected before credential use' run_gate +if grep -Fq 'cannot inspect gate checkout status' "$CASE_ROOT/stderr"; then + ok 'a failed gate cleanliness probe cannot masquerade as a clean checkout' +else + cat "$CASE_ROOT/stderr" >&2 + no 'a failed gate cleanliness probe cannot masquerade as a clean checkout' +fi +assert_no_curl 'a failed gate cleanliness probe does not call curl' + +new_case +cat >"$CASE_ROOT/shell-signal-hook" <<'SH' +set -T +trap ' + if [[ ${BASH_COMMAND:-} == GATE_ROOT= && ! -e "$SIGNAL_FIXTURE/shell-blocked" ]]; then + : >"$SIGNAL_FIXTURE/shell-blocked" + while [[ ! -e "$SIGNAL_FIXTURE/release-shell" ]]; do :; done + fi +' DEBUG +SH +start_gate "$CASE_ROOT/shell-signal-hook" +if wait_for_marker "$CASE_ROOT/shell-blocked" "$SIGNAL_GATE_PID"; then + kill -HUP "$SIGNAL_GATE_PID" +fi +wait_for_signaled_gate "$SIGNAL_GATE_PID" +assert_signal_cleanup 129 'HUP during shell-only processing exits nonzero and cleans all output' + +new_case +touch "$FAKE_BIN/run.block" +start_gate /dev/null +if wait_for_marker "$FAKE_BIN/curl-blocked" "$SIGNAL_GATE_PID"; then + CURL_CHILD_PID=$(<"$FAKE_BIN/curl-child-pid") + kill -TERM "$SIGNAL_GATE_PID" + # Bash defers its trap while waiting for the foreground child. Terminating + # the fake transport releases that wait so the queued TERM trap can run. + kill -TERM "$CURL_CHILD_PID" +else + CURL_CHILD_PID= +fi +wait_for_signaled_gate "$SIGNAL_GATE_PID" "$FAKE_BIN/curl-child-pid" +assert_signal_cleanup 143 'TERM while curl is blocked exits nonzero and cleans all output' +if [[ -n "$CURL_CHILD_PID" ]] && ! kill -0 "$CURL_CHILD_PID" 2>/dev/null; then + ok 'the blocked curl child does not survive gate termination' +else + [[ -z "$CURL_CHILD_PID" ]] || kill -KILL "$CURL_CHILD_PID" 2>/dev/null || true + no 'the blocked curl child does not survive gate termination' +fi + +if ((fail)); then + printf '\n%d passed, %d failed\n' "$pass" "$fail" >&2 + exit 1 +fi +printf '\n%d passed, %d failed\n' "$pass" "$fail" diff --git a/.github/actions/deploy-core/tests/run-actionlint.test.sh b/.github/actions/deploy-core/tests/run-actionlint.test.sh new file mode 100755 index 00000000..0c57675c --- /dev/null +++ b/.github/actions/deploy-core/tests/run-actionlint.test.sh @@ -0,0 +1,113 @@ +#!/usr/bin/env bash +# GitHub expressions are deliberately literal fixture data. +# shellcheck disable=SC2016 +set -euo pipefail +root=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../../../.." && pwd) +tmp=$(mktemp -d) +trap 'rm -rf "$tmp"' EXIT +mkdir -p "$tmp/scripts" "$tmp/.github/workflows" +cp "$root/scripts/run-actionlint.sh" "$tmp/scripts/run-actionlint.sh" +workflow="$tmp/.github/workflows/publish-build-container.yml" +cat >"$workflow" <<'YAML' +name: Test +on: push +concurrency: + group: edgezero-build-container-publication + cancel-in-progress: false + queue: max +jobs: + build: + runs-on: ubuntu-24.04 + environment: + name: build-container-release + deployment: false + steps: + - run: echo hello +YAML +if actionlint -oneline "$workflow" >"$tmp/raw" 2>&1; then + echo 'raw actionlint unexpectedly accepts queue; review compatibility rewrite' >&2; exit 1 +fi +[[ "$(wc -l <"$tmp/raw" | tr -d ' ')" == 1 ]] +grep -q '"queue"' "$tmp/raw" +bash "$tmp/scripts/run-actionlint.sh" "$workflow" +cp "$workflow" "$tmp/valid" + +reject() { + if bash "$tmp/scripts/run-actionlint.sh" "$workflow" >"$tmp/result" 2>&1; then + echo "accepted invalid compatibility fixture: $1" >&2; exit 1 + fi +} +for expr in '.concurrency.queue = "min"' '.concurrency.group = "different"' \ + '.concurrency.cancel-in-progress = true' '.concurrency.queue = "${{ vars.QUEUE }}"' \ + '.jobs.build.concurrency.queue = "max"'; do + yq "$expr" "$tmp/valid" >"$workflow" + reject "$expr" +done +cp "$tmp/valid" "$workflow" +printf ' queue: max\n' >>"$workflow" +reject misplaced +cp "$tmp/valid" "$workflow" +cp "$workflow" "$tmp/.github/workflows/unapproved.yml" +if bash "$tmp/scripts/run-actionlint.sh" "$tmp/.github/workflows/unapproved.yml" >/dev/null 2>&1; then + echo 'queue accepted in unapproved workflow' >&2; exit 1 +fi +rm "$tmp/.github/workflows/unapproved.yml" +yq '.jobs.build.steps[0].run = "echo ${{ needs.absent.outputs.x }}"' "$tmp/valid" >"$workflow" +reject unrelated-expression +grep -q "$workflow:14:" "$tmp/result" || { cat "$tmp/result" >&2; exit 1; } + +workflow="$tmp/.github/workflows/build-app-cli.yml" +cat >"$workflow" <<'YAML' +name: Build +on: workflow_call +jobs: + build: + runs-on: ubuntu-24.04 + steps: + - name: Bootstrap + env: + EDGEZERO_WORKFLOW_REPOSITORY: ${{ job.workflow_repository }} + EDGEZERO_WORKFLOW_FILE_PATH: ${{ job.workflow_file_path }} + EDGEZERO_WORKFLOW_REF: ${{ job.workflow_ref }} + EDGEZERO_WORKFLOW_SHA: ${{ job.workflow_sha }} + run: echo bootstrap + - uses: actions/checkout@v7.0.1 + with: + repository: stackpop/edgezero + ref: ${{ job.workflow_sha }} + persist-credentials: false +YAML +if actionlint -oneline "$workflow" >"$tmp/raw" 2>&1; then + echo 'raw actionlint unexpectedly accepts job identity; review compatibility rewrite' >&2; exit 1 +fi +[[ "$(wc -l <"$tmp/raw" | tr -d ' ')" == 5 ]] +[[ "$(grep -c 'property "workflow_' "$tmp/raw")" == 5 ]] +bash "$tmp/scripts/run-actionlint.sh" "$workflow" +cp "$workflow" "$tmp/producer" +for expr in '.jobs.build.steps[0].env.EDGEZERO_WORKFLOW_SHA = "${{ job.workflow_shaa }}"' \ + '.jobs.build.steps[0].env.OTHER = "${{ job.workflow_sha }}"' \ + '.jobs.build.steps[0].env.EDGEZERO_WORKFLOW_SHA = "${{ job.workflow_sha || github.sha }}"' \ + '.jobs.build.steps[1].with.repository = "attacker/repo"' \ + '.jobs.build.steps[0].run = "echo ${{ job.workflow_sha }}"'; do + yq "$expr" "$tmp/producer" >"$workflow" + reject "$expr" +done +cp "$tmp/producer" "$workflow" +printf 'name: Duplicate\n' >>"$workflow" +reject duplicate +cp "$tmp/producer" "$workflow" +printf 'env:\n ONE: &replay value\n TWO: *replay\n' >>"$workflow" +reject alias +cp "$tmp/producer" "$workflow" +printf 'env:\n &key ONE: value\n *key: another\n' >>"$workflow" +reject alias-key +sed 's/ env:/ env: {/; s/ EDGEZERO_WORKFLOW_SHA:.*/ EDGEZERO_WORKFLOW_SHA: "${{ job.workflow_sha }}", HIDDEN: "${{ needs.absent.outputs.value }}" }/; s/\(EDGEZERO_WORKFLOW_REPOSITORY: \)\(.*\)/\1"\2",/; s/\(EDGEZERO_WORKFLOW_FILE_PATH: \)\(.*\)/\1"\2",/; s/\(EDGEZERO_WORKFLOW_REF: \)\(.*\)/\1"\2",/' "$tmp/producer" >"$workflow" +reject flow-map-ancestor +cp "$tmp/producer" "$workflow" +cp "$tmp/valid" "$tmp/.github/workflows/publish-build-container.yml" +ln -s "$tmp/producer" "$tmp/.github/workflows/linked.yml" +if bash "$tmp/scripts/run-actionlint.sh" >"$tmp/result" 2>&1; then + echo 'default workflow discovery skipped a symlink' >&2; exit 1 +fi +grep -q 'symlink workflow' "$tmp/result" +echo 'actionlint compatibility contract passed' diff --git a/.github/actions/deploy-core/tests/run-build-container-gate.test.sh b/.github/actions/deploy-core/tests/run-build-container-gate.test.sh new file mode 100755 index 00000000..45e55a6d --- /dev/null +++ b/.github/actions/deploy-core/tests/run-build-container-gate.test.sh @@ -0,0 +1,388 @@ +#!/usr/bin/env bash +# shellcheck disable=SC2016 +set -euo pipefail + +DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +REAL_DRIVER="$DIR/../../../docker/build-app-cli/run-build-container-gate.sh" +WORK=$(mktemp -d) +WORK=$(cd -- "$WORK" && pwd -P) +trap 'rm -rf "$WORK"' EXIT + +pass=0 +fail=0 +case_number=0 + +ok() { + printf ' \033[32mok\033[0m %s\n' "$1" + pass=$((pass + 1)) +} + +no() { + printf ' \033[31mFAIL\033[0m %s\n' "$1" >&2 + fail=$((fail + 1)) +} + +assert_pass() { + local description=$1 + shift + if "$@" >"$CASE_ROOT/stdout" 2>"$CASE_ROOT/stderr"; then + ok "$description" + else + cat "$CASE_ROOT/stderr" >&2 + no "$description" + fi +} + +assert_fail() { + local description=$1 + shift + if "$@" >"$CASE_ROOT/stdout" 2>"$CASE_ROOT/stderr"; then + cat "$CASE_ROOT/stdout" "$CASE_ROOT/stderr" "$LOG" >&2 + no "$description" + elif [[ -e "$COMPLETION" || -L "$COMPLETION" ]]; then + printf 'failed driver published a completion marker\n' >&2 + no "$description" + else + ok "$description" + fi +} + +write_executable() { + local path=$1 + shift + mkdir -p "$(dirname -- "$path")" + { + printf '#!/usr/bin/env bash\nset -euo pipefail\n' + printf '%s\n' "$@" + } >"$path" + chmod 0755 "$path" +} + +new_case() { + unset CLASSIFIER_OUTPUT CLASSIFIER_STATUS CLASSIFIER_NUL FAIL_HELPER KIND ZERO_ACTION_REFS + case_number=$((case_number + 1)) + CASE_ROOT="$WORK/case-$case_number" + GATE="$CASE_ROOT/gate" + SUBJECT="$CASE_ROOT/subject" + RUN_WORK="$CASE_ROOT/run-work" + COMPLETION="$RUN_WORK/completion" + LOG="$CASE_ROOT/gate.log" + FAKE_BIN="$CASE_ROOT/fake-bin" + mkdir -p "$GATE/.github/docker/build-app-cli" "$FAKE_BIN" "$RUN_WORK" + + cp "$REAL_DRIVER" "$GATE/.github/docker/build-app-cli/run-build-container-gate.sh" + chmod 0755 "$GATE/.github/docker/build-app-cli/run-build-container-gate.sh" + printf 'FROM scratch\n' >"$GATE/.github/docker/build-app-cli/Dockerfile" + printf '%s\n' \ + '.github/docker/build-app-cli/Dockerfile' \ + '.github/docker/build-app-cli/image-context-paths.txt' \ + >"$GATE/.github/docker/build-app-cli/image-context-paths.txt" + + write_executable "$GATE/.github/docker/build-app-cli/classify-build-container-change.sh" \ + 'if [[ "${FAKE_CLASSIFIER_NUL:-false}" == true ]]; then printf "mode=ordinary\000\nrelevant=true\n"; exit 0; fi' \ + 'printf "%s" "$FAKE_CLASSIFIER_OUTPUT"' \ + 'exit "${FAKE_CLASSIFIER_STATUS:-0}"' + write_executable "$GATE/.github/actions/deploy-core/tests/check-action-pins.sh" \ + 'printf "action-pins:%s\n" "$*" >>"$FAKE_LOG"' \ + 'if [[ "${FAKE_FAIL_HELPER:-}" == action-pins ]]; then exit 1; fi' \ + 'for path in "$@"; do [[ "$path" == "$FAKE_RUN_WORK/"* && -f "$path" && ! -L "$path" ]]; done' \ + 'if [[ "${FAKE_ZERO_ACTION_REFS:-false}" == true ]]; then count=0; else count=2; fi' \ + 'printf "action reference policy passed (%s external references)\n" "$count"' + write_executable "$GATE/.github/docker/build-app-cli/stage-build-context.sh" \ + 'printf "stage:%s\n" "$*" >>"$FAKE_LOG"' \ + '[[ "${FAKE_FAIL_HELPER:-}" != stage ]] || exit 1' \ + 'while (($#)); do if [[ "$1" == --output ]]; then output=$2; fi; shift; done' \ + 'mkdir "$output"' \ + 'mkdir -p "$output/.github/docker/build-app-cli"' \ + 'cp "$FAKE_GATE_ROOT/.github/docker/build-app-cli/Dockerfile" "$output/.github/docker/build-app-cli/Dockerfile"' + write_executable "$GATE/.github/docker/build-app-cli/assert-build-container-context.sh" \ + 'printf "assert:%s\n" "$*" >>"$FAKE_LOG"' \ + '[[ "${FAKE_FAIL_HELPER:-}" != assert ]]' + write_executable "$GATE/.github/docker/build-app-cli/verify-published-image.sh" \ + 'printf "verify:%s\n" "$*" >>"$FAKE_LOG"' \ + '[[ "${FAKE_FAIL_HELPER:-}" != verify ]]' + write_executable "$GATE/.github/docker/build-app-cli/check-build-container-publisher.sh" \ + 'printf "publisher-structure:%s\n" "$*" >>"$FAKE_LOG"' \ + '[[ "${FAKE_FAIL_HELPER:-}" != publisher-structure ]]' + write_executable "$GATE/.github/docker/build-app-cli/verify-build-container-publication.sh" \ + 'printf "publisher-evidence:%s\n" "$*" >>"$FAKE_LOG"' \ + '[[ "${FAKE_FAIL_HELPER:-}" != publisher-evidence ]]' + write_executable "$GATE/.github/docker/build-app-cli/check-image-pin.sh" \ + 'printf "pin:%s\n" "$*" >>"$FAKE_LOG"' \ + '[[ "${FAKE_FAIL_HELPER:-}" != pin ]] || exit 1' \ + 'case "$1" in' \ + ' runtime-ref) printf "%s\n" "ghcr.io/stackpop/edgezero-build-app-cli@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" ;;' \ + ' source-revision) printf "%s\n" "$FAKE_SOURCE_SHA" ;;' \ + ' provenance-protocol) printf "1\n" ;;' \ + 'esac' + write_executable "$FAKE_BIN/docker" \ + 'printf "docker:%s\n" "$*" >>"$FAKE_LOG"' \ + '[[ "${FAKE_FAIL_HELPER:-}" != docker ]] || exit 1' \ + 'while (($#)); do if [[ "$1" == --iidfile ]]; then iidfile=$2; fi; shift; done' \ + 'printf "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\n" >"$iidfile"' + mkdir -p "$GATE/.github/workflows" "$GATE/tools/example" + printf 'name: fixture\n' >"$GATE/.github/workflows/fixture.yml" + printf 'runs:\n using: composite\n steps: []\n' >"$GATE/tools/example/action.yaml" + + git -C "$GATE" init -q -b main + git -C "$GATE" config user.name fixture + git -C "$GATE" config user.email fixture@example.invalid + git -C "$GATE" add . + git -C "$GATE" commit -q -m gate + G=$(git -C "$GATE" rev-parse HEAD) + + git clone -q --no-hardlinks "$GATE" "$SUBJECT" + git -C "$SUBJECT" config user.name fixture + git -C "$SUBJECT" config user.email fixture@example.invalid + printf 'candidate\n' >"$SUBJECT/app.txt" + git -C "$SUBJECT" add app.txt + git -C "$SUBJECT" commit -q -m candidate + BASE=$G + HEAD=$(git -C "$SUBJECT" rev-parse HEAD) + DRIVER="$GATE/.github/docker/build-app-cli/run-build-container-gate.sh" + : >"$LOG" +} + +run_driver() { + PATH="$FAKE_BIN:$PATH" \ + FAKE_LOG="$LOG" \ + FAKE_GATE_ROOT="$GATE" \ + FAKE_RUN_WORK="$RUN_WORK" \ + FAKE_SOURCE_SHA="$HEAD" \ + FAKE_CLASSIFIER_OUTPUT="${CLASSIFIER_OUTPUT:-$'mode=ordinary\nrelevant=true\n'}" \ + FAKE_CLASSIFIER_STATUS="${CLASSIFIER_STATUS:-0}" \ + FAKE_CLASSIFIER_NUL="${CLASSIFIER_NUL:-false}" \ + FAKE_FAIL_HELPER="${FAIL_HELPER:-}" \ + FAKE_ZERO_ACTION_REFS="${ZERO_ACTION_REFS:-false}" \ + bash "$DRIVER" \ + --subject-root "$SUBJECT" \ + --gate-root "$GATE" \ + --base "$BASE" \ + --head "$HEAD" \ + --kind "${KIND:-local}" \ + --gate-sha "$G" \ + --release-state enabled \ + --work-root "$RUN_WORK" \ + --completion-file "$COMPLETION" +} + +run_driver_with_ambient_fsmonitor() { + GIT_CONFIG_COUNT=1 \ + GIT_CONFIG_KEY_0=core.fsmonitor \ + GIT_CONFIG_VALUE_0="$FSMONITOR" \ + run_driver + [[ ! -e "$FSMONITOR_SENTINEL" ]] +} + +assert_completion() { + local expected=$1 description=$2 + if [[ -f "$COMPLETION" && ! -L "$COMPLETION" ]] && + cmp -s <(printf '%s' "$expected") "$COMPLETION"; then + ok "$description" + else + no "$description" + fi +} + +echo '== trusted build-container gate driver ==' + +new_case +assert_pass 'ordinary relevant local mode completes' run_driver +assert_completion $'kind=local\nmode=ordinary\nbranch=relevant\n' \ + 'local completion marker is exact' +if grep -q '^publisher-structure:' "$LOG" && grep -q '^stage:' "$LOG" && grep -q '^docker:' "$LOG" && + grep -q '^verify:--local-image-id sha256:b\{64\} --source-sha ' "$LOG"; then + ok 'local mode stages, builds by immutable ID, and verifies exact source' +else + cat "$LOG" >&2 + no 'local mode stages, builds by immutable ID, and verifies exact source' +fi + +new_case +CLASSIFIER_OUTPUT=$'mode=ordinary\nrelevant=false\n' +assert_pass 'ordinary not-applicable mode completes explicitly' run_driver +assert_completion $'kind=local\nmode=ordinary\nbranch=not-applicable\n' \ + 'not-applicable completion marker is exact' +if grep -q '^action-pins:' "$LOG" && grep -q '^publisher-structure:' "$LOG" && + ! grep -Eq '^(stage|assert|docker|verify|pin|publisher-evidence):' "$LOG"; then + ok 'not-applicable still runs both repository-wide static policy scans' +else + cat "$LOG" >&2 + no 'not-applicable still runs both repository-wide static policy scans' +fi + +new_case +CLASSIFIER_OUTPUT=$'mode=gate-update\nrelevant=true\n' +assert_pass 'gate update runs trusted static subject-data checks' run_driver +assert_completion $'kind=local\nmode=gate-update\nbranch=gate-update\n' \ + 'gate-update completion marker is exact' +if grep -q '^assert:' "$LOG" && + grep -q "^publisher-structure:--gate-root $GATE --subject-root $SUBJECT --gate-sha $G --candidate-sha $HEAD$" "$LOG" && + ! grep -Eq '^(stage|docker|verify|publisher-evidence):' "$LOG"; then + ok 'gate update runs only trusted static subject-data checks' +else + cat "$LOG" >&2 + no 'gate update runs only trusted static subject-data checks' +fi + +new_case +CLASSIFIER_OUTPUT=$'mode=gate-rollback\nrelevant=true\n' +assert_pass 'gate rollback runs trusted static restoration checks' run_driver +assert_completion $'kind=local\nmode=gate-rollback\nbranch=gate-rollback\n' \ + 'gate-rollback completion marker is exact' +if grep -q '^publisher-structure:' "$LOG" && ! grep -q '^publisher-evidence:' "$LOG"; then + ok 'gate rollback runs the structural publisher checker only' +else + cat "$LOG" >&2 + no 'gate rollback runs the structural publisher checker only' +fi + +new_case +KIND=pin +printf '{}\n' >"$SUBJECT/.github/docker/build-app-cli/image.json" +printf '{}\n' >"$SUBJECT/.github/docker/build-app-cli/image-release-evidence.json" +git -C "$SUBJECT" add . +git -C "$SUBJECT" commit -q -m pin +HEAD=$(git -C "$SUBJECT" rev-parse HEAD) +assert_pass 'ordinary relevant pin mode validates publisher and image' run_driver +assert_completion $'kind=pin\nmode=ordinary\nbranch=relevant\n' \ + 'pin completion marker is exact' +if grep -q '^pin:validate-pair ' "$LOG" && grep -q '^publisher-evidence:' "$LOG" && + grep -q '^publisher-structure:' "$LOG" && + grep -q '^verify:--ref ghcr.io/stackpop/edgezero-build-app-cli@sha256:a\{64\}' "$LOG"; then + ok 'pin mode validates the pair, publisher evidence, and digest reference' +else + cat "$LOG" >&2 + no 'pin mode validates the pair, publisher evidence, and digest reference' +fi + +for malformed in \ + $'mode=ordinary\n' \ + $'relevant=true\nmode=ordinary\n' \ + $'mode=ordinary\nrelevant=true\nrelevant=true\n' \ + $'mode=gate-update\nrelevant=false\n'; do + new_case + CLASSIFIER_OUTPUT=$malformed + assert_fail 'malformed or contradictory classifier output fails closed' run_driver +done + +new_case +CLASSIFIER_NUL=true +assert_fail 'NUL-bearing classifier output fails closed' run_driver + +new_case +CLASSIFIER_STATUS=1 +assert_fail 'classifier failure propagates without completion' run_driver + +for helper in stage docker verify; do + new_case + FAIL_HELPER=$helper + assert_fail "$helper failure propagates without completion" run_driver +done + +new_case +FAIL_HELPER=action-pins +assert_fail 'action-reference policy failure propagates before classification' run_driver + +new_case +ZERO_ACTION_REFS=true +assert_fail 'a vacuous action-reference scan fails before classification' run_driver + +for helper in pin publisher-evidence verify; do + new_case + KIND=pin + printf '{}\n' >"$SUBJECT/.github/docker/build-app-cli/image.json" + printf '{}\n' >"$SUBJECT/.github/docker/build-app-cli/image-release-evidence.json" + git -C "$SUBJECT" add . + git -C "$SUBJECT" commit -q -m pin + HEAD=$(git -C "$SUBJECT" rev-parse HEAD) + FAIL_HELPER=$helper + assert_fail "$helper pin-path failure propagates without completion" run_driver +done + +for mode in gate-update gate-rollback; do + new_case + CLASSIFIER_OUTPUT=$(printf 'mode=%s\nrelevant=true\n' "$mode") + FAIL_HELPER=publisher-structure + assert_fail "$mode structural publisher failure propagates without completion" run_driver +done + +new_case +CLASSIFIER_OUTPUT=$'mode=ordinary\nrelevant=false\n' +FAIL_HELPER=publisher-structure +assert_fail 'ordinary structural publisher failure propagates without completion' run_driver + +new_case +printf 'occupied\n' >"$COMPLETION" +if run_driver >"$CASE_ROOT/stdout" 2>"$CASE_ROOT/stderr"; then + no 'a pre-existing completion marker is rejected' +elif cmp -s <(printf 'occupied\n') "$COMPLETION"; then + ok 'a pre-existing completion marker is rejected without replacement' +else + no 'a pre-existing completion marker is rejected without replacement' +fi + +new_case +git -C "$SUBJECT" checkout -q "$BASE" +assert_fail 'subject checkout must be at the supplied full head SHA' run_driver + +new_case +mkdir "$SUBJECT/nested-root" +SUBJECT="$SUBJECT/nested-root" +assert_fail 'subject root must be the exact repository top level' run_driver + +new_case +ORIGINAL_SUBJECT=$SUBJECT +SUBJECT="$CASE_ROOT/linked-subject" +git -C "$GATE" worktree add -q -b candidate "$SUBJECT" "$G" +git -C "$SUBJECT" config user.name fixture +git -C "$SUBJECT" config user.email fixture@example.invalid +printf 'candidate\n' >"$SUBJECT/app.txt" +git -C "$SUBJECT" add app.txt +git -C "$SUBJECT" commit -q -m candidate +HEAD=$(git -C "$SUBJECT" rev-parse HEAD) +assert_fail 'gate and subject roots must use separate Git repositories' run_driver +SUBJECT=$ORIGINAL_SUBJECT + +new_case +COMMON_GIT=$(git -C "$GATE" rev-parse --path-format=absolute --git-common-dir) +printf '%s\n' "$G" >"$COMMON_GIT/info/grafts" +assert_fail 'gate common-directory grafts are rejected' run_driver +rm "$COMMON_GIT/info/grafts" + +new_case +FSMONITOR="$CASE_ROOT/fsmonitor" +FSMONITOR_SENTINEL="$CASE_ROOT/fsmonitor-ran" +write_executable "$FSMONITOR" \ + 'touch "$FAKE_FSMONITOR_SENTINEL"' \ + 'printf "\n"' +export FAKE_FSMONITOR_SENTINEL=$FSMONITOR_SENTINEL +assert_pass 'ambient Git fsmonitor cannot execute in the driver' run_driver_with_ambient_fsmonitor +unset FAKE_FSMONITOR_SENTINEL + +new_case +printf 'candidate replacement\n' >"$SUBJECT/.github/docker/build-app-cli/verify-published-image.sh" +printf 'candidate scanner replacement\n' >"$SUBJECT/.github/actions/deploy-core/tests/check-action-pins.sh" +git -C "$SUBJECT" add . +git -C "$SUBJECT" commit -q -m candidate-helper +HEAD=$(git -C "$SUBJECT" rev-parse HEAD) +CLASSIFIER_OUTPUT=$'mode=gate-update\nrelevant=true\n' +assert_pass 'candidate helper substitution remains inert subject data' run_driver +if ! grep -q 'candidate replacement' "$LOG" && ! grep -q 'candidate scanner replacement' "$LOG" && + grep -q '^action-pins:' "$LOG" && grep -q '^assert:' "$LOG" && + grep -q '^publisher-structure:' "$LOG"; then + ok 'only the separately checked-out gate helper executed' +else + no 'only the separately checked-out gate helper executed' +fi + +new_case +mkdir -p "$SUBJECT/tools/linked" +ln -s ../../app.txt "$SUBJECT/tools/linked/action.yml" +git -C "$SUBJECT" add tools/linked/action.yml +git -C "$SUBJECT" commit -q -m linked-action +HEAD=$(git -C "$SUBJECT" rev-parse HEAD) +assert_fail 'action-reference inventory rejects a symlink action metadata entry' run_driver + +printf 'Passed: %d Failed: %d\n' "$pass" "$fail" +[[ "$fail" -eq 0 ]] diff --git a/.github/actions/deploy-core/tests/run.sh b/.github/actions/deploy-core/tests/run.sh index afd3aea4..489cbc72 100755 --- a/.github/actions/deploy-core/tests/run.sh +++ b/.github/actions/deploy-core/tests/run.sh @@ -1909,22 +1909,43 @@ test_action_pin_gate() { # jobs/steps scaffolding so the tests exercise the same paths the gate selects. wrap() { printf 'jobs:\n a:\n steps:\n%s\n' "$1" >"$2"; } - # Valid pins in block, quoted, and flow forms — a version tag and a full SHA — - # plus a commented-out branch ref that must be IGNORED (not a real `uses`). A bare - # major tag (`@v4`) is a deliberately-accepted VERSION TAG under the repo's policy: - # the gate enforces a concrete, reviewable ref, NOT immutability (a publisher can - # repoint `@v4`). See check-action-pins.sh's header. + # Only canonical exact stable patch tags are public action refs. wrap ' - uses: actions/checkout@v4.3.0 - - "uses": actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 - - { uses: actions/setup-node@v4 } + - "uses": actions/cache@v5.1.0 + - { uses: actions/setup-node@v6.0.0 } # uses: actions/checkout@main' "$dir/ok.yml" - assert_succeeds "valid pins (block/quoted/flow, tag+SHA, major tag) pass; a commented branch is ignored" \ + assert_succeeds "exact versions in block/quoted/flow forms pass; comments are ignored" \ bash "$checker" "$dir/ok.yml" - # A full semver tag with BOTH a prerelease and build-metadata suffix is valid. - wrap ' - uses: some/action@v1.2.3-rc.1+build.5' "$dir/semver.yml" - assert_succeeds "a prerelease+build semver tag (@v1.2.3-rc.1+build.5) is accepted" \ - bash "$checker" "$dir/semver.yml" + local invalid + for invalid in v1 v1.2 v1.2.3-rc.1 v1.2.3+build.5 v1.2.3-rc.1+build.5 \ + 1.2.3 v01.2.3 v1.02.3 v1.2.03 v1.2.3.4 \ + 0057852bfaa89a56745cba8c7296529d2fc39830 0057852 '' main latest; do + wrap " - uses: some/action@$invalid" "$dir/invalid.yml" + assert_fails "noncanonical external ref @$invalid is rejected" bash "$checker" "$dir/invalid.yml" + done + for invalid in 'null' '~' '""' 'true' '123' '[actions/checkout@v4.3.0]' '{ref: actions/checkout@v4.3.0}'; do + wrap " - uses: $invalid" "$dir/invalid.yml" + assert_fails "empty or non-string uses $invalid is rejected" bash "$checker" "$dir/invalid.yml" + done + wrap ' - uses:' "$dir/empty.yml" + assert_fails "an empty uses is rejected" bash "$checker" "$dir/empty.yml" + wrap ' - uses: |- + actions/checkout@v4.3.0 + actions/cache@v5.1.0' "$dir/multiple-lines.yml" + assert_fails "one multiline value cannot become two valid refs" bash "$checker" "$dir/multiple-lines.yml" + wrap ' - uses: | + actions/checkout@v4.3.0' "$dir/trailing-newline.yml" + assert_fails "a ref with a trailing newline is rejected" bash "$checker" "$dir/trailing-newline.yml" + printf 'jobs:\n build:\n uses: owner/repo/.github/workflows/build.yml@v1.2.3\n' >"$dir/reusable.yml" + assert_succeeds "job-level exact reusable workflow ref is accepted" bash "$checker" "$dir/reusable.yml" + printf 'jobs:\n build:\n uses: owner/repo/.github/workflows/build.yml@v1\n' >"$dir/reusable.yml" + assert_fails "job-level major reusable workflow ref is rejected" bash "$checker" "$dir/reusable.yml" + printf 'runs:\n using: composite\n steps:\n - uses: owner/repo@v1\n' >"$dir/action.yml" + assert_fails "composite step major ref is rejected" bash "$checker" "$dir/action.yml" + wrap ' - uses: ./tools/local-action' "$dir/local.yml" + assert_succeeds "local actions remain accepted" bash "$checker" "$dir/local.yml" + assert_fails "a missing explicit input is rejected" bash "$checker" "$dir/absent.yml" # Every mutable-ref form must be REJECTED — including the ones NO text regex can # catch: a unicode-ESCAPED key, a `!!str`-TAGGED value, and a MULTILINE folded @@ -1955,8 +1976,7 @@ test_action_pin_gate() { wrap ' - uses: actions/checkout' "$dir/unpinned.yml" assert_fails "an unpinned ref (no @) is rejected" bash "$checker" "$dir/unpinned.yml" - # A docker ref must itself be pinned: a floating `:latest` or a bare image is - # rejected, while an @: or a version tag passes. + # Docker actions require an exact lowercase sha256 digest. wrap ' - uses: docker://ghcr.io/x/y:latest' "$dir/docker-latest.yml" assert_fails "a floating docker ':latest' is rejected" bash "$checker" "$dir/docker-latest.yml" @@ -1967,7 +1987,11 @@ test_action_pin_gate() { assert_succeeds "a docker @sha256 digest is accepted" bash "$checker" "$dir/docker-digest.yml" wrap ' - uses: docker://alpine:3.18.4' "$dir/docker-tag.yml" - assert_succeeds "a docker version tag is accepted" bash "$checker" "$dir/docker-tag.yml" + assert_fails "a docker version tag is rejected" bash "$checker" "$dir/docker-tag.yml" + for invalid in sha256:abc sha512:0123456789abcdef 'sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdeF'; do + wrap " - uses: docker://alpine@$invalid" "$dir/docker-invalid.yml" + assert_fails "invalid docker digest $invalid is rejected" bash "$checker" "$dir/docker-invalid.yml" + done # A non-action `uses` field (here an env var literally named `uses`) is NOT an # action reference and must NOT be flagged — only job/step/composite `uses` count. @@ -1988,6 +2012,70 @@ YML >"$dir/malformed.yml" assert_fails "malformed YAML is rejected (fail-closed, not passed unchecked)" \ bash "$checker" "$dir/malformed.yml" + + cat >"$dir/duplicate.yml" <<'YML' +jobs: + a: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@main + uses: actions/checkout@v4.3.0 +YML + assert_fails "duplicate action-reference keys are rejected before policy evaluation" \ + bash "$checker" "$dir/duplicate.yml" + + cat >"$dir/alias.yml" <<'YML' +jobs: + a: + runs-on: ubuntu-latest + steps: + - &checkout + uses: actions/checkout@v4.3.0 + - *checkout +YML + assert_fails "YAML aliases are rejected before policy evaluation" \ + bash "$checker" "$dir/alias.yml" + + wrap ' - uses: >- + actions/checkout@v4.3.0' "$dir/folded-valid.yml" + assert_succeeds "a folded scalar resolving to one exact ref passes" bash "$checker" "$dir/folded-valid.yml" + local sandbox="$dir/repository" sandbox_checker pruned + mkdir -p "$sandbox/.github/actions/deploy-core/tests" "$sandbox/.github/workflows" "$sandbox/tools/local" + sandbox_checker="$sandbox/.github/actions/deploy-core/tests/check-action-pins.sh" + cp "$checker" "$sandbox_checker" + assert_fails "an empty default scan fails" bash "$sandbox_checker" + cp "$dir/local.yml" "$sandbox/.github/workflows/local.yaml" + assert_fails "a local-only default scan fails" bash "$sandbox_checker" + cp "$dir/env-uses.yml" "$sandbox/.github/workflows/valid.yml" + assert_equals "only external refs count in a default scan" \ + 'action reference policy passed (1 external references)' "$(bash "$sandbox_checker")" + cp "$dir/action.yml" "$sandbox/tools/local/action.yaml" + assert_fails "default scan includes action metadata outside .github" bash "$sandbox_checker" + printf 'runs:\n using: composite\n steps:\n - uses: owner/repo@v1.2.3\n' >"$sandbox/tools/local/action.yaml" + assert_succeeds "default scan accepts an exact composite ref" bash "$sandbox_checker" + mkdir -p "$sandbox/.github/workflows/nested" + cp "$dir/block.yml" "$sandbox/.github/workflows/nested/ignored.yml" + for pruned in .git target node_modules; do + mkdir -p "$sandbox/$pruned" + cp "$dir/action.yml" "$sandbox/$pruned/action.yml" + done + cp "$dir/block.yml" "$sandbox/fixture.yml" + assert_succeeds "default scan prunes Git/build/dependencies and ignores non-workflow YAML" bash "$sandbox_checker" + mkdir -p "$dir/parser-bin" + # The fake parser expands its own argument, not this test shell's. + # shellcheck disable=SC2016 + printf '#!/bin/sh\ncase "$1" in --version) echo "yq (https://github.com/mikefarah/yq/) version v4.53.3";; esac\n' >"$dir/parser-bin/yq" + chmod +x "$dir/parser-bin/yq" + assert_fails "a broken parser emitting no records cannot pass" env PATH="$dir/parser-bin:$PATH" bash "$sandbox_checker" + local wrong_version_bin="$dir/wrong-version-bin" real_yq + real_yq=$(command -v yq) + mkdir -p "$wrong_version_bin" + # shellcheck disable=SC2016 # The fake parser expands its own arguments. + printf '#!/bin/sh\nif [ "$1" = --version ]; then echo "yq (https://github.com/mikefarah/yq/) version v4.99.0"; exit 0; fi\nexec %q "$@"\n' \ + "$real_yq" >"$wrong_version_bin/yq" + chmod +x "$wrong_version_bin/yq" + assert_fails "the action pin gate rejects a non-pinned yq v4 release" \ + env PATH="$wrong_version_bin:$PATH" bash "$checker" "$dir/valid.yml" } # --------------------------------------------------------------------------- @@ -2486,6 +2574,36 @@ main() { test_action_output_contracts test_action_public_surface test_action_pin_gate + assert_succeeds "build-container image pin contract" bash "$ACTIONS_DIR/deploy-core/tests/check-image-pin.test.sh" + assert_succeeds "build-container release record contract" bash "$ACTIONS_DIR/deploy-core/tests/write-image-release-record.test.sh" + assert_succeeds "build-container release approval contract" bash "$ACTIONS_DIR/deploy-core/tests/release-approval-gate.test.sh" + assert_succeeds "build-container release prerequisite audit contract" bash "$ACTIONS_DIR/deploy-core/tests/verify-release-prerequisites.test.sh" + assert_succeeds "build-container publisher prerequisite writer contract" bash "$ACTIONS_DIR/deploy-core/tests/write-publisher-prerequisite.test.sh" + assert_succeeds "build-container pin updater contract" bash "$ACTIONS_DIR/deploy-core/tests/update-image-pin-pr.test.sh" + assert_succeeds "build-container staged context contract" bash "$ACTIONS_DIR/deploy-core/tests/stage-build-context.test.sh" + assert_succeeds "build-container input contract" bash "$ACTIONS_DIR/deploy-core/tests/assert-build-container-context.test.sh" + assert_succeeds "build-container change classifier contract" bash "$ACTIONS_DIR/deploy-core/tests/classify-build-container-change.test.sh" + assert_succeeds "build-container event range contract" bash "$ACTIONS_DIR/deploy-core/tests/select-build-container-range.test.sh" + assert_succeeds "build-container gate driver contract" bash "$ACTIONS_DIR/deploy-core/tests/run-build-container-gate.test.sh" + assert_succeeds "build-container completion marker contract" bash "$ACTIONS_DIR/deploy-core/tests/assert-build-container-completion.test.sh" + assert_succeeds "build-container dispatch context contract" bash "$ACTIONS_DIR/deploy-core/tests/assert-build-container-dispatch-context.test.sh" + assert_succeeds "build-container App-token boundary contract" bash "$ACTIONS_DIR/deploy-core/tests/assert-build-container-app-token.test.sh" + assert_succeeds "build-container workflow structure contract" bash "$ACTIONS_DIR/deploy-core/tests/build-container-workflows.test.sh" + assert_succeeds "build-container publisher topology contract" bash "$ACTIONS_DIR/deploy-core/tests/check-build-container-publisher.test.sh" + assert_succeeds "build-container publication evidence contract" bash "$ACTIONS_DIR/deploy-core/tests/verify-build-container-publication.test.sh" + assert_succeeds "build-container rotation lock contract" bash "$ACTIONS_DIR/deploy-core/tests/verify-gate-rotation-lock.test.sh" + assert_succeeds "build-container toolchain contract" bash "$ACTIONS_DIR/deploy-core/tests/verify-toolchain.test.sh" + assert_succeeds "published build-container verification contract" bash "$ACTIONS_DIR/deploy-core/tests/verify-published-image.test.sh" + assert_succeeds "actionlint installer contract" bash "$ACTIONS_DIR/deploy-core/tests/install-actionlint.test.sh" + assert_succeeds "yq installer contract" bash "$ACTIONS_DIR/deploy-core/tests/install-yq.test.sh" + assert_succeeds "documentation reference and release-state contracts" node --test "$ACTIONS_DIR/deploy-core/tests/check-doc-action-pins.test.mjs" + if command -v actionlint >/dev/null 2>&1 && [[ "$(actionlint -version | sed -n '1p')" == 1.7.12 ]]; then + assert_succeeds "actionlint compatibility contract" bash "$ACTIONS_DIR/deploy-core/tests/run-actionlint.test.sh" + elif [[ "${CI:-}" == true ]]; then + fail "actionlint 1.7.12 is required in CI" + else + skip "actionlint compatibility contract (requires actionlint 1.7.12)" + fi printf '\nPassed: %d Failed: %d Skipped: %d\n' "$tests_passed" "$tests_failed" "$tests_skipped" [[ "$tests_failed" -eq 0 ]] diff --git a/.github/actions/deploy-core/tests/select-build-container-range.test.sh b/.github/actions/deploy-core/tests/select-build-container-range.test.sh new file mode 100755 index 00000000..53562cea --- /dev/null +++ b/.github/actions/deploy-core/tests/select-build-container-range.test.sh @@ -0,0 +1,441 @@ +#!/usr/bin/env bash +set -euo pipefail + +DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +SELECTOR="$DIR/../../../docker/build-app-cli/select-build-container-range.sh" +WORK=$(mktemp -d) +WORK=$(cd -- "$WORK" && pwd -P) +trap 'rm -rf "$WORK"' EXIT + +pass=0 +fail=0 +case_number=0 + +ok() { + printf ' \033[32mok\033[0m %s\n' "$1" + pass=$((pass + 1)) +} + +no() { + printf ' \033[31mFAIL\033[0m %s\n' "$1" >&2 + fail=$((fail + 1)) +} + +assert_output() { + local description=$1 expected=$2 status=0 + shift 2 + CASE_ROOT="$WORK/case-$((case_number += 1))" + mkdir -p "$CASE_ROOT" + printf '%s\n' "$expected" >"$CASE_ROOT/expected" + "$@" >"$CASE_ROOT/stdout" 2>"$CASE_ROOT/stderr" || status=$? + if [[ "$status" -eq 0 && ! -s "$CASE_ROOT/stderr" ]] && + cmp -s "$CASE_ROOT/expected" "$CASE_ROOT/stdout"; then + ok "$description" + else + printf 'status: %s\nexpected bytes:\n' "$status" >&2 + od -An -tx1 "$CASE_ROOT/expected" >&2 + printf 'actual bytes:\n' >&2 + od -An -tx1 "$CASE_ROOT/stdout" >&2 + cat "$CASE_ROOT/stderr" >&2 + no "$description" + fi +} + +assert_fail() { + local description=$1 + shift + CASE_ROOT="$WORK/case-$((case_number += 1))" + mkdir -p "$CASE_ROOT" + if "$@" >"$CASE_ROOT/stdout" 2>"$CASE_ROOT/stderr"; then + no "$description" + elif [[ -s "$CASE_ROOT/stdout" ]]; then + printf 'unexpected stdout:\n' >&2 + cat "$CASE_ROOT/stdout" >&2 + no "$description" + else + ok "$description" + fi +} + +REPO="$WORK/repository" +EVENT="$WORK/event.json" +mkdir -p "$REPO" +git -C "$REPO" init -q -b main +git -C "$REPO" config user.name fixture +git -C "$REPO" config user.email fixture@example.invalid +printf 'base\n' >"$REPO/base.txt" +git -C "$REPO" add base.txt +git -C "$REPO" commit -q -m base +A=$(git -C "$REPO" rev-parse HEAD) + +git -C "$REPO" switch -q -c feature +printf 'feature\n' >"$REPO/feature.txt" +git -C "$REPO" add feature.txt +git -C "$REPO" commit -q -m feature +J=$(git -C "$REPO" rev-parse HEAD) + +git -C "$REPO" switch -q main +printf 'first parent\n' >"$REPO/main.txt" +git -C "$REPO" add main.txt +git -C "$REPO" commit -q -m first-parent +F=$(git -C "$REPO" rev-parse HEAD) +git -C "$REPO" merge -q --no-ff feature -m synthetic-merge +M=$(git -C "$REPO" rev-parse HEAD) +G=$A + +git -C "$REPO" switch -q --orphan unrelated +git -C "$REPO" rm -q -rf --ignore-unmatch . +printf 'unrelated\n' >"$REPO/unrelated.txt" +git -C "$REPO" add unrelated.txt +git -C "$REPO" commit -q -m unrelated +U=$(git -C "$REPO" rev-parse HEAD) +git -C "$REPO" switch -q main + +GATE_REPO="$WORK/gate" +git clone -q --no-hardlinks "$REPO" "$GATE_REPO" +git -C "$GATE_REPO" checkout -q --detach "$G" + +write_pr_event() { + local base_sha=${1:-$A} head_sha=${2:-$J} number=${3:-17} + local base_repo=${4:-stackpop/edgezero} base_ref=${5:-main} + jq -cn \ + --arg base_sha "$base_sha" \ + --arg head_sha "$head_sha" \ + --arg base_repo "$base_repo" \ + --arg base_ref "$base_ref" \ + --argjson number "$number" \ + '{number:$number,pull_request:{base:{sha:$base_sha,ref:$base_ref,repo:{full_name:$base_repo}},head:{sha:$head_sha}}}' \ + >"$EVENT" +} + +write_merge_group_event() { + local base_sha=${1:-$F} head_sha=${2:-$M} + local base_ref=${3:-refs/heads/main} + local head_ref=${4:-refs/heads/gh-readonly-queue/main/pr-17-deadbeef} + local action=${5:-checks_requested} + jq -cn \ + --arg action "$action" \ + --arg base_sha "$base_sha" \ + --arg head_sha "$head_sha" \ + --arg base_ref "$base_ref" \ + --arg head_ref "$head_ref" \ + '{action:$action,merge_group:{base_sha:$base_sha,head_sha:$head_sha,base_ref:$base_ref,head_ref:$head_ref}}' \ + >"$EVENT" +} + +write_push_event() { + local before=${1:-$F} after=${2:-$M} repository=${3:-stackpop/edgezero} + jq -cn \ + --arg before "$before" \ + --arg after "$after" \ + --arg repository "$repository" \ + '{before:$before,after:$after,repository:{full_name:$repository}}' \ + >"$EVENT" +} + +reset_subject() { + git -C "$REPO" switch -q main + git -C "$REPO" reset -q --hard "$M" + git -C "$REPO" clean -q -fd +} + +run_selector() { + bash "$SELECTOR" \ + --event-name "${EVENT_NAME:-pull_request}" \ + --github-repository "${GITHUB_REPOSITORY_VALUE:-stackpop/edgezero}" \ + --github-sha "${GITHUB_SHA_VALUE:-$M}" \ + --github-workflow-sha "${GITHUB_WORKFLOW_SHA_VALUE:-$G}" \ + --github-ref "${GITHUB_REF_VALUE:-refs/pull/17/merge}" \ + --github-ref-protected "${GITHUB_REF_PROTECTED_VALUE:-false}" \ + --gate-sha "${GATE_SHA_VALUE:-$G}" \ + --gate-root "${GATE_ROOT_VALUE:-$GATE_REPO}" \ + --event-json "${EVENT_VALUE:-$EVENT}" \ + --subject-root "${SUBJECT_ROOT_VALUE:-$REPO}" +} + +clear_overrides() { + unset EVENT_NAME GITHUB_REPOSITORY_VALUE GITHUB_SHA_VALUE GITHUB_WORKFLOW_SHA_VALUE + unset GITHUB_REF_VALUE GITHUB_REF_PROTECTED_VALUE GATE_SHA_VALUE GATE_ROOT_VALUE + unset EVENT_VALUE SUBJECT_ROOT_VALUE +} + +echo '== trusted build-container event range selector ==' + +write_pr_event +assert_output 'pull request selects authenticated synthetic first-parent range' \ + "$(printf 'base=%s\nhead=%s' "$F" "$M")" run_selector + +write_pr_event "$A" "$J" +GITHUB_WORKFLOW_SHA_VALUE=$F +assert_fail 'pull request workflow revision must be active gate G' run_selector +clear_overrides + +write_pr_event "$A" "$J" 17 other/repository +assert_fail 'pull request base repository must be exact' run_selector +write_pr_event "$A" "$J" 17 stackpop/edgezero develop +assert_fail 'pull request base ref must be main' run_selector +write_pr_event "$A" "$J" 17 +GITHUB_REF_VALUE=refs/pull/18/merge +assert_fail 'pull request merge ref must match canonical event number' run_selector +clear_overrides + +write_pr_event "$A" "$F" +assert_fail 'pull request second parent must equal payload head' run_selector +write_pr_event "$U" "$J" +assert_fail 'pull request payload base must be an ancestor of first parent' run_selector +write_pr_event "$A" "$J" 0 +assert_fail 'pull request number must be a positive integer' run_selector + +WRONG_PARENT=$(git -C "$REPO" commit-tree "${M}^{tree}" -p "$F" -p "$U" <<<'wrong second parent') +git -C "$REPO" reset -q --hard "$WRONG_PARENT" +write_pr_event "$A" "$J" +GITHUB_SHA_VALUE=$WRONG_PARENT +assert_fail 'synthetic merge with wrong second parent is rejected' run_selector +clear_overrides +reset_subject + +git -C "$REPO" reset -q --hard "$J" +write_pr_event "$A" "$J" +GITHUB_SHA_VALUE=$J +assert_fail 'pull request candidate must have exactly two parents' run_selector +clear_overrides +reset_subject + +REVERSED=$(git -C "$REPO" commit-tree "${M}^{tree}" -p "$J" -p "$F" <<<'reversed parents') +git -C "$REPO" reset -q --hard "$REVERSED" +write_pr_event "$A" "$J" +GITHUB_SHA_VALUE=$REVERSED +assert_fail 'synthetic merge parent order is authenticated' run_selector +clear_overrides +reset_subject + +write_pr_event "1111111111111111111111111111111111111111" "$J" +assert_fail 'missing pull request base object is rejected' run_selector +write_pr_event "$A" "2222222222222222222222222222222222222222" +assert_fail 'missing pull request head object is rejected' run_selector + +write_merge_group_event +EVENT_NAME=merge_group +GITHUB_REF_VALUE=refs/heads/gh-readonly-queue/main/pr-17-deadbeef +assert_output 'merge group selects payload base and candidate' \ + "$(printf 'base=%s\nhead=%s' "$F" "$M")" run_selector +clear_overrides + +write_merge_group_event "$F" "$M" refs/heads/main \ + refs/heads/gh-readonly-queue/main/pr-17-deadbeef completed +EVENT_NAME=merge_group +GITHUB_REF_VALUE=refs/heads/gh-readonly-queue/main/pr-17-deadbeef +assert_fail 'merge group action must be checks_requested' run_selector +clear_overrides + +write_merge_group_event "$F" "$M" refs/heads/develop +EVENT_NAME=merge_group +GITHUB_REF_VALUE=refs/heads/gh-readonly-queue/main/pr-17-deadbeef +assert_fail 'merge group base ref must be exact main ref' run_selector +clear_overrides + +write_merge_group_event "$F" "$M" refs/heads/main refs/heads/queue/main/pr-17 +EVENT_NAME=merge_group +GITHUB_REF_VALUE=refs/heads/queue/main/pr-17 +assert_fail 'merge group head ref must use exact queue prefix' run_selector +clear_overrides + +write_merge_group_event +EVENT_NAME=merge_group +GITHUB_REF_VALUE=refs/heads/gh-readonly-queue/main/other +assert_fail 'merge group context ref must equal payload head ref' run_selector +clear_overrides + +write_merge_group_event "$U" "$M" +EVENT_NAME=merge_group +GITHUB_REF_VALUE=refs/heads/gh-readonly-queue/main/pr-17-deadbeef +assert_fail 'merge group base must be an ancestor of candidate' run_selector +clear_overrides + +write_merge_group_event "$F" "$M" +EVENT_NAME=merge_group +GITHUB_REF_VALUE=refs/heads/gh-readonly-queue/main/pr-17-deadbeef +GITHUB_WORKFLOW_SHA_VALUE=$F +assert_fail 'merge group workflow revision must be active gate G' run_selector +clear_overrides + +write_push_event +EVENT_NAME=push +GITHUB_REF_VALUE=refs/heads/main +GITHUB_REF_PROTECTED_VALUE=true +GITHUB_WORKFLOW_SHA_VALUE=$M +assert_output 'protected main push selects exact before and after range' \ + "$(printf 'base=%s\nhead=%s' "$F" "$M")" run_selector +clear_overrides + +write_push_event "$F" "$M" other/repository +EVENT_NAME=push +GITHUB_REF_VALUE=refs/heads/main +GITHUB_REF_PROTECTED_VALUE=true +GITHUB_WORKFLOW_SHA_VALUE=$M +assert_fail 'push payload repository must be exact' run_selector +clear_overrides + +write_push_event 0000000000000000000000000000000000000000 "$M" +EVENT_NAME=push +GITHUB_REF_VALUE=refs/heads/main +GITHUB_REF_PROTECTED_VALUE=true +GITHUB_WORKFLOW_SHA_VALUE=$M +assert_fail 'all-zero first-push base is rejected' run_selector +clear_overrides + +write_push_event "$F" "$M" +EVENT_NAME=push +GITHUB_REF_VALUE=refs/heads/develop +GITHUB_REF_PROTECTED_VALUE=true +GITHUB_WORKFLOW_SHA_VALUE=$M +assert_fail 'push ref must be exact protected main' run_selector +clear_overrides + +write_push_event "$F" "$M" +EVENT_NAME=push +GITHUB_REF_VALUE=refs/heads/main +GITHUB_REF_PROTECTED_VALUE=false +GITHUB_WORKFLOW_SHA_VALUE=$M +assert_fail 'unprotected main push is rejected' run_selector +clear_overrides + +write_push_event "$F" "$M" +EVENT_NAME=push +GITHUB_REF_VALUE=refs/heads/main +GITHUB_REF_PROTECTED_VALUE=true +GITHUB_WORKFLOW_SHA_VALUE=$F +assert_fail 'push workflow revision must equal candidate revision' run_selector +clear_overrides + +write_push_event "$F" "$F" +EVENT_NAME=push +GITHUB_REF_VALUE=refs/heads/main +GITHUB_REF_PROTECTED_VALUE=true +GITHUB_WORKFLOW_SHA_VALUE=$M +assert_fail 'push payload after must equal context SHA' run_selector +clear_overrides + +write_push_event "$U" "$M" +EVENT_NAME=push +GITHUB_REF_VALUE=refs/heads/main +GITHUB_REF_PROTECTED_VALUE=true +GITHUB_WORKFLOW_SHA_VALUE=$M +assert_fail 'push before must be an ancestor of after' run_selector +clear_overrides + +write_pr_event +EVENT_NAME=workflow_dispatch +assert_fail 'workflow dispatch is never a range-consumer event' run_selector +clear_overrides +EVENT_NAME=pull_request_target +assert_fail 'every other event is rejected before classification' run_selector +clear_overrides + +printf '{not-json\n' >"$EVENT" +assert_fail 'malformed event JSON is rejected' run_selector +write_pr_event + +git -C "$REPO" status --porcelain >/dev/null +printf 'dirty\n' >>"$REPO/base.txt" +assert_fail 'dirty subject checkout is rejected' run_selector +git -C "$REPO" reset -q --hard "$M" + +SHALLOW="$WORK/shallow" +git clone -q --depth 1 "file://$REPO" "$SHALLOW" +SUBJECT_ROOT_VALUE=$SHALLOW +assert_fail 'shallow subject checkout is rejected' run_selector +clear_overrides + +git -C "$REPO" replace "$F" "$A" +assert_fail 'replacement refs are rejected' run_selector +git -C "$REPO" replace -d "$F" + +GRAFTS="$(git -C "$REPO" rev-parse --absolute-git-dir)/info/grafts" +printf '%s %s\n' "$M" "$F" >"$GRAFTS" +assert_fail 'legacy grafts are rejected' run_selector +rm -f "$GRAFTS" + +GITHUB_REPOSITORY_VALUE=other/repository +assert_fail 'workflow repository must be exact' run_selector +clear_overrides + +GITHUB_SHA_VALUE=$F +assert_fail 'subject HEAD must equal the supplied context SHA' run_selector +clear_overrides + +git -C "$GATE_REPO" checkout -q --detach "$F" +assert_fail 'gate checkout HEAD must equal active gate G' run_selector +git -C "$GATE_REPO" checkout -q --detach "$G" + +mkdir "$REPO/nested-root" +SUBJECT_ROOT_VALUE="$REPO/nested-root" +assert_fail 'subject input must be the exact repository top level' run_selector +clear_overrides +rmdir "$REPO/nested-root" + +SAME_GATE="$WORK/same-common-gate" +git -C "$REPO" worktree add -q --detach "$SAME_GATE" "$G" +GATE_ROOT_VALUE="$SAME_GATE" +assert_fail 'gate and subject roots must have separate Git common directories' run_selector +clear_overrides +git -C "$REPO" worktree remove -f "$SAME_GATE" + +LINKED_SUBJECT="$WORK/linked-subject" +git -C "$REPO" worktree add -q --detach "$LINKED_SUBJECT" "$M" +COMMON_GIT=$(git -C "$LINKED_SUBJECT" rev-parse --path-format=absolute --git-common-dir) +printf '%s %s\n' "$M" "$U" >"$COMMON_GIT/info/grafts" +write_push_event "$U" "$M" +EVENT_NAME=push +GITHUB_REF_VALUE=refs/heads/main +GITHUB_REF_PROTECTED_VALUE=true +GITHUB_WORKFLOW_SHA_VALUE=$M +SUBJECT_ROOT_VALUE=$LINKED_SUBJECT +assert_fail 'linked-worktree common-directory grafts are rejected' run_selector +clear_overrides +rm "$COMMON_GIT/info/grafts" +git -C "$REPO" worktree remove -f "$LINKED_SUBJECT" + +printf '{"before":"%s","after":"%s","repository":{"full_name":"stackpop/edgezero\\n"}}\n' \ + "$F" "$M" >"$EVENT" +EVENT_NAME=push +GITHUB_REF_VALUE=refs/heads/main +GITHUB_REF_PROTECTED_VALUE=true +GITHUB_WORKFLOW_SHA_VALUE=$M +assert_fail 'line-breaking event strings cannot normalize to trusted values' run_selector +clear_overrides + +printf '{"before":"%s","after":"%s","repository":{"full_name":"stackpop/edgezero\\u0000"}}\n' \ + "$F" "$M" >"$EVENT" +EVENT_NAME=push +GITHUB_REF_VALUE=refs/heads/main +GITHUB_REF_PROTECTED_VALUE=true +GITHUB_WORKFLOW_SHA_VALUE=$M +assert_fail 'NUL-bearing event strings cannot normalize to trusted values' run_selector +clear_overrides + +write_pr_event +FSMONITOR_SENTINEL="$WORK/fsmonitor-ran" +cat >"$WORK/fsmonitor" <"$FSMONITOR_SENTINEL" +printf '\\n' +EOF +chmod 0755 "$WORK/fsmonitor" +run_with_ambient_fsmonitor() { + rm -f "$FSMONITOR_SENTINEL" + GIT_CONFIG_COUNT=1 \ + GIT_CONFIG_KEY_0=core.fsmonitor \ + GIT_CONFIG_VALUE_0="$WORK/fsmonitor" \ + run_selector + [[ ! -e "$FSMONITOR_SENTINEL" ]] +} +assert_output 'ambient Git config cannot execute a filesystem monitor' \ + "$(printf 'base=%s\nhead=%s' "$F" "$M")" run_with_ambient_fsmonitor + +if ((fail)); then + printf '\n%d passed, %d failed\n' "$pass" "$fail" >&2 + exit 1 +fi + +printf '\n%d passed, %d failed\n' "$pass" "$fail" diff --git a/.github/actions/deploy-core/tests/stage-build-context.test.sh b/.github/actions/deploy-core/tests/stage-build-context.test.sh new file mode 100755 index 00000000..f3a15774 --- /dev/null +++ b/.github/actions/deploy-core/tests/stage-build-context.test.sh @@ -0,0 +1,246 @@ +#!/usr/bin/env bash +set -euo pipefail + +DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +STAGE="$DIR/../../../docker/build-app-cli/stage-build-context.sh" +WORK=$(mktemp -d) +trap 'rm -rf "$WORK"' EXIT + +pass=0 +fail=0 +ok() { + printf ' \033[32mok\033[0m %s\n' "$1" + pass=$((pass + 1)) +} +no() { + printf ' \033[31mFAIL\033[0m %s\n' "$1" >&2 + fail=$((fail + 1)) +} +assert_pass() { + local description=$1 + shift + if "$@" >/dev/null 2>&1; then ok "$description"; else no "$description"; fi +} +assert_fail() { + local description=$1 + shift + if "$@" >/dev/null 2>&1; then no "$description"; else ok "$description"; fi +} + +context_paths() { + cat <<'EOF' +.dockerignore +.github/actions/deploy-fastly/versions.json +.github/docker/build-app-cli/Dockerfile +.github/docker/build-app-cli/fixtures/gnu-smoke.rs +.github/docker/build-app-cli/fixtures/provenance/valid/archive.tar +.github/docker/build-app-cli/fixtures/wasm-smoke.rs +.github/docker/build-app-cli/image-context-paths.txt +.github/docker/build-app-cli/provenance.schema.json +.github/docker/build-app-cli/verify-toolchain.sh +.github/tools/edgezero-provenance-validator/Cargo.lock +.github/tools/edgezero-provenance-validator/Cargo.toml +.github/tools/edgezero-provenance-validator/src/lib.rs +.tool-versions +EOF +} + +make_repo() { + local root=$1 + mkdir -p \ + "$root/.github/actions/deploy-fastly" \ + "$root/.github/docker/build-app-cli/fixtures/provenance/valid" \ + "$root/.github/docker/build-app-cli/fixtures" \ + "$root/.github/tools/edgezero-provenance-validator/src" + printf '**\n!.github/**\n!.tool-versions\n' >"$root/.dockerignore" + printf 'rust 1.95.0\nfastly 15.1.0\n' >"$root/.tool-versions" + printf '{"fastly":{"version":"15.1.0"}}\n' >"$root/.github/actions/deploy-fastly/versions.json" + printf 'FROM scratch\nCOPY .tool-versions /image/.tool-versions\n' >"$root/.github/docker/build-app-cli/Dockerfile" + printf 'fn main() {}\n' >"$root/.github/docker/build-app-cli/fixtures/gnu-smoke.rs" + printf 'fixture' >"$root/.github/docker/build-app-cli/fixtures/provenance/valid/archive.tar" + printf 'pub fn smoke() {}\n' >"$root/.github/docker/build-app-cli/fixtures/wasm-smoke.rs" + printf '{}\n' >"$root/.github/docker/build-app-cli/provenance.schema.json" + printf '#!/usr/bin/env bash\nexit 0\n' >"$root/.github/docker/build-app-cli/verify-toolchain.sh" + chmod 0755 "$root/.github/docker/build-app-cli/verify-toolchain.sh" + printf '[workspace]\n[package]\nname="probe"\nversion="0.0.0"\nedition="2024"\n' \ + >"$root/.github/tools/edgezero-provenance-validator/Cargo.toml" + printf '# lock\n' >"$root/.github/tools/edgezero-provenance-validator/Cargo.lock" + printf 'pub fn probe() {}\n' >"$root/.github/tools/edgezero-provenance-validator/src/lib.rs" + context_paths >"$root/.github/docker/build-app-cli/image-context-paths.txt" + context_paths >"$root/.github/docker/build-app-cli/gate-paths.txt" + + git -C "$root" init -q + git -C "$root" config user.email test@example.com + git -C "$root" config user.name Test + git -C "$root" add . + git -C "$root" commit -qm base +} + +clone_pair() { + local name=$1 + GATE="$WORK/$name-gate" + SOURCE="$WORK/$name-source" + OUTPUT="$WORK/$name-output" + make_repo "$GATE" + git clone -q --no-hardlinks "$GATE" "$SOURCE" + printf 'ordinary source change\n' >"$SOURCE/app.txt" + git -C "$SOURCE" add app.txt + git -C "$SOURCE" commit -qm source + GATE_SHA=$(git -C "$GATE" rev-parse HEAD) + SOURCE_SHA=$(git -C "$SOURCE" rev-parse HEAD) +} + +run_stage() { + bash "$STAGE" \ + --gate-root "$GATE" \ + --source-root "$SOURCE" \ + --gate-sha "$GATE_SHA" \ + --source-sha "$SOURCE_SHA" \ + --output "$OUTPUT" +} + +commit_gate_change() { + git -C "$GATE" add -A + git -C "$GATE" commit -qm fixture + GATE_SHA=$(git -C "$GATE" rev-parse HEAD) + git -C "$SOURCE" fetch -q "$GATE" "$GATE_SHA" + git -C "$SOURCE" rebase -q "$GATE_SHA" + SOURCE_SHA=$(git -C "$SOURCE" rev-parse HEAD) +} + +commit_source_change() { + git -C "$SOURCE" add -A + git -C "$SOURCE" commit -qm fixture + SOURCE_SHA=$(git -C "$SOURCE" rev-parse HEAD) +} + +echo "== isolated build-container context staging ==" + +clone_pair valid +assert_pass "a clean exact G stages its closed context" run_stage +if [[ -f "$OUTPUT/.tool-versions" && ! -e "$OUTPUT/app.txt" && ! -e "$OUTPUT/.git" ]]; then + ok "the output contains manifested files only" +else + no "the output contains manifested files only" +fi +if [[ -x "$OUTPUT/.github/docker/build-app-cli/verify-toolchain.sh" ]]; then + ok "executable mode is preserved" +else + no "executable mode is preserved" +fi + +clone_pair output-exists +mkdir "$OUTPUT" +assert_fail "an existing output path is rejected" run_stage + +clone_pair inside-gate +OUTPUT="$GATE/context" +assert_fail "output beneath G is rejected" run_stage + +clone_pair inside-source +OUTPUT="$SOURCE/context" +assert_fail "output beneath S is rejected" run_stage + +clone_pair wrong-gate-sha +GATE_SHA=$(printf 'f%.0s' {1..40}) +assert_fail "a non-HEAD gate SHA is rejected" run_stage + +clone_pair wrong-source-sha +SOURCE_SHA=$(printf 'e%.0s' {1..40}) +assert_fail "a non-HEAD source SHA is rejected" run_stage + +clone_pair dirty-gate +printf dirty >>"$GATE/.tool-versions" +assert_fail "a dirty G checkout is rejected" run_stage + +clone_pair dirty-source +printf dirty >>"$SOURCE/.tool-versions" +assert_fail "a dirty S checkout is rejected" run_stage + +clone_pair missing-manifest +sed '/Cargo.lock/d' "$GATE/.github/docker/build-app-cli/image-context-paths.txt" \ + >"$GATE/.github/docker/build-app-cli/image-context-paths.txt.new" +mv "$GATE/.github/docker/build-app-cli/image-context-paths.txt.new" \ + "$GATE/.github/docker/build-app-cli/image-context-paths.txt" +commit_gate_change +assert_fail "a missing manifest entry is rejected" run_stage + +clone_pair extra-manifest +printf 'unrelated.txt\n' >>"$GATE/.github/docker/build-app-cli/image-context-paths.txt" +printf unrelated >"$GATE/unrelated.txt" +commit_gate_change +assert_fail "an extra manifest entry is rejected" run_stage + +clone_pair duplicate-manifest +printf '.tool-versions\n' >>"$GATE/.github/docker/build-app-cli/image-context-paths.txt" +commit_gate_change +assert_fail "a duplicate manifest entry is rejected" run_stage + +clone_pair unsorted-manifest +sort -r "$GATE/.github/docker/build-app-cli/image-context-paths.txt" \ + >"$GATE/.github/docker/build-app-cli/image-context-paths.txt.new" +mv "$GATE/.github/docker/build-app-cli/image-context-paths.txt.new" \ + "$GATE/.github/docker/build-app-cli/image-context-paths.txt" +commit_gate_change +assert_fail "an unsorted manifest is rejected" run_stage + +clone_pair path-escape +printf '../escape\n' >>"$GATE/.github/docker/build-app-cli/image-context-paths.txt" +commit_gate_change +assert_fail "a path-escape manifest entry is rejected" run_stage + +clone_pair symlink-input +rm "$GATE/.tool-versions" +ln -s /etc/passwd "$GATE/.tool-versions" +commit_gate_change +assert_fail "a symlink input is rejected" run_stage + +clone_pair hardlink-input +ln "$GATE/.tool-versions" "$GATE/tool-versions-hardlink" +commit_gate_change +assert_fail "a multiply linked input is rejected" run_stage + +clone_pair fifo-input +rm "$GATE/.tool-versions" +mkfifo "$GATE/.tool-versions" +assert_fail "a FIFO input is rejected" run_stage + +clone_pair device-input +rm "$GATE/.tool-versions" +if mknod "$GATE/.tool-versions" c 1 3 >/dev/null 2>&1; then + assert_fail "a device input is rejected" run_stage +else + ok "a device input is rejected (host forbids unprivileged device creation)" +fi + +mutation_index=0 +for mutation in \ + 'ADD https://attacker.invalid/tool /usr/local/bin/tool' \ + 'RUN --mount=type=bind,source=.,target=/src true' \ + 'COPY . /src' \ + 'COPY .tool-versions /usr/local/bin/edgezero-provenance-validator'; do + mutation_index=$((mutation_index + 1)) + clone_pair "dockerfile-mutation-$mutation_index" + printf '%s\n' "$mutation" >>"$SOURCE/.github/docker/build-app-cli/Dockerfile" + commit_source_change + assert_fail "candidate Dockerfile mutation is rejected: $mutation" run_stage +done + +clone_pair changed-source-byte +printf changed >>"$SOURCE/.tool-versions" +commit_source_change +assert_fail "a changed manifested S byte is rejected" run_stage + +clone_pair changed-source-mode +chmod 0755 "$SOURCE/.tool-versions" +commit_source_change +assert_fail "a changed manifested S mode is rejected" run_stage + +clone_pair unmanifested-source +printf 'COPY unlisted /image/unlisted\n' >>"$SOURCE/.github/docker/build-app-cli/Dockerfile" +printf unlisted >"$SOURCE/unlisted" +commit_source_change +assert_fail "an unmanifested candidate source is rejected" run_stage + +printf 'Passed: %d Failed: %d\n' "$pass" "$fail" +[[ "$fail" -eq 0 ]] diff --git a/.github/actions/deploy-core/tests/update-image-pin-pr.test.sh b/.github/actions/deploy-core/tests/update-image-pin-pr.test.sh new file mode 100755 index 00000000..b3702ccc --- /dev/null +++ b/.github/actions/deploy-core/tests/update-image-pin-pr.test.sh @@ -0,0 +1,1379 @@ +#!/usr/bin/env bash +# shellcheck disable=SC2016 +set -euo pipefail + +DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +SOURCE_UPDATER="$DIR/../../../docker/build-app-cli/update-image-pin-pr.sh" + +pass=0 +fail=0 +ok() { printf ' \033[32mok\033[0m %s\n' "$1"; pass=$((pass + 1)); } +no() { printf ' \033[31mFAIL\033[0m %s\n' "$1" >&2; fail=$((fail + 1)); } + +echo '== image pin pull-request updater ==' +if [[ ! -f "$SOURCE_UPDATER" ]]; then + no 'the gate-owned updater helper exists' + printf 'Passed: %d Failed: %d\n' "$pass" "$fail" + exit 1 +fi + +WORK=$(mktemp -d) +WORK=$(cd -- "$WORK" && pwd -P) +trap 'rm -rf -- "$WORK"' EXIT HUP INT TERM +FIXTURE_REPO="$WORK/fixture" +REMOTE="$WORK/remote.git" +GATE_ROOT="$WORK/gate" +REPOSITORY_ROOT="$WORK/source" +CASE_ROOT="$WORK/case" +FAKE_BIN="$WORK/bin" +API_ROOT="$CASE_ROOT/api" +LOG_ROOT="$CASE_ROOT/log" +TOKEN='updater-secret-token-value' +BOT_ID=4242 +BOT_LOGIN='edgezero-publisher[bot]' +SOURCE_PR=77 +COMMENT_ID=88 +TAG=build-container-v7 +DIGEST="sha256:$(printf '1%.0s' {1..64})" +OLD_DIGEST="sha256:$(printf '2%.0s' {1..64})" +CHALLENGE=$(printf '3%.0s' {1..64}) +SCREENSHOT="sha256:$(printf '4%.0s' {1..64})" +REVIEWED_AT=2026-09-10T12:00:00Z +REAL_GIT=$(command -v git) +REAL_JQ=$(command -v jq) +REAL_BASH=$(command -v bash) +REAL_ENV=$(command -v env) +REAL_MKTEMP=$(command -v mktemp) +export FIXTURE_REPO REMOTE CASE_ROOT API_ROOT LOG_ROOT REAL_GIT REAL_JQ BOT_ID BOT_LOGIN TOKEN +printf '%s' "$REAL_GIT" >"$WORK/real-git" +printf '%s' "$REAL_JQ" >"$WORK/real-jq" +printf '%s' "$REAL_MKTEMP" >"$WORK/real-mktemp" +printf '%s' "$TOKEN" >"$WORK/token" +printf '%s' "$BOT_ID" >"$WORK/bot-id" +printf '%s' "$BOT_LOGIN" >"$WORK/bot-login" + +git_fixture() { + env PATH="$PATH" HOME="$WORK/home" LC_ALL=C \ + GIT_CONFIG_NOSYSTEM=1 GIT_CONFIG_GLOBAL=/dev/null \ + "$REAL_GIT" -C "$FIXTURE_REPO" "$@" +} + +write_pair() { + local root=$1 source=$2 digest=$3 + mkdir -p "$root/.github/docker/build-app-cli" + printf '%s' "{\"digest\":\"$digest\",\"image-source-revision\":\"$source\",\"provenance-protocol\":1,\"repository\":\"ghcr.io/stackpop/edgezero-build-app-cli\",\"tag\":\"$TAG\"}" \ + >"$root/.github/docker/build-app-cli/image.json" + printf '%s' "{\"approval-challenge\":\"$CHALLENGE\",\"approver-login\":\"release-reviewer\",\"image-digest\":\"$digest\",\"release-tag\":\"$TAG\",\"reviewed-at\":\"$REVIEWED_AT\",\"run-attempt\":\"2\",\"run-id\":\"9007199254740993\",\"schema-version\":1,\"screenshot-sha256\":\"$SCREENSHOT\",\"source-revision\":\"$source\"}" \ + >"$root/.github/docker/build-app-cli/image-release-evidence.json" +} + +mkdir -p "$WORK/home" "$FIXTURE_REPO/.github/docker/build-app-cli" "$FAKE_BIN" +"$REAL_GIT" init -q "$FIXTURE_REPO" +cp "$SOURCE_UPDATER" "$FIXTURE_REPO/.github/docker/build-app-cli/update-image-pin-pr.sh" +cp "$DIR/../../../docker/build-app-cli/check-image-pin.sh" \ + "$FIXTURE_REPO/.github/docker/build-app-cli/check-image-pin.sh" +cp "$DIR/../../../docker/build-app-cli/write-image-release-record.sh" \ + "$FIXTURE_REPO/.github/docker/build-app-cli/write-image-release-record.sh" +chmod 0755 "$FIXTURE_REPO/.github/docker/build-app-cli/"*.sh +git_fixture add .github/docker/build-app-cli +git_fixture -c user.name=fixture -c user.email=fixture@example.invalid commit -q -m gate +G=$(git_fixture rev-parse HEAD) +git_fixture -c user.name=fixture -c user.email=fixture@example.invalid commit -q --allow-empty -m previous +P=$(git_fixture rev-parse HEAD) +write_pair "$FIXTURE_REPO" "$P" "$OLD_DIGEST" +git_fixture add .github/docker/build-app-cli/image.json \ + .github/docker/build-app-cli/image-release-evidence.json +git_fixture -c user.name=fixture -c user.email=fixture@example.invalid commit -q -m base-pin +I=$(git_fixture rev-parse HEAD) +git_fixture -c user.name=fixture -c user.email=fixture@example.invalid commit -q --allow-empty -m source +S=$(git_fixture rev-parse HEAD) +git_fixture -c user.name=fixture -c user.email=fixture@example.invalid commit -q --allow-empty -m newer +N=$(git_fixture rev-parse HEAD) +git_fixture checkout -q --detach "$S" +write_pair "$FIXTURE_REPO" "$S" "$DIGEST" +git_fixture add .github/docker/build-app-cli/image.json \ + .github/docker/build-app-cli/image-release-evidence.json +git_fixture -c user.name=fixture -c user.email=fixture@example.invalid commit -q -m equal-main +B_EQUAL=$(git_fixture rev-parse HEAD) +git_fixture checkout -q --detach "$N" +write_pair "$FIXTURE_REPO" "$N" "$DIGEST" +git_fixture add .github/docker/build-app-cli/image.json \ + .github/docker/build-app-cli/image-release-evidence.json +git_fixture -c user.name=fixture -c user.email=fixture@example.invalid commit -q -m newer-main +B_NEWER=$(git_fixture rev-parse HEAD) +git_fixture checkout -q --detach "$G" +git_fixture -c user.name=fixture -c user.email=fixture@example.invalid commit -q --allow-empty -m side +Q=$(git_fixture rev-parse HEAD) +write_pair "$FIXTURE_REPO" "$Q" "$DIGEST" +git_fixture add .github/docker/build-app-cli/image.json \ + .github/docker/build-app-cli/image-release-evidence.json +git_fixture -c user.name=fixture -c user.email=fixture@example.invalid commit -q -m side-main +B_SIDE=$(git_fixture rev-parse HEAD) + +"$REAL_GIT" clone -q --bare "$FIXTURE_REPO" "$REMOTE" >/dev/null 2>&1 +"$REAL_GIT" clone -q "$FIXTURE_REPO" "$GATE_ROOT" >/dev/null 2>&1 +"$REAL_GIT" -C "$GATE_ROOT" checkout -q --detach "$G" >/dev/null 2>&1 +"$REAL_GIT" clone -q "$FIXTURE_REPO" "$REPOSITORY_ROOT" >/dev/null 2>&1 +"$REAL_GIT" -C "$REPOSITORY_ROOT" checkout -q --detach "$S" >/dev/null 2>&1 + +cat >"$FAKE_BIN/git" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +ROOT=${0%/bin/git} +REMOTE="$ROOT/remote.git" +CASE_ROOT="$ROOT/case" +LOG_ROOT="$CASE_ROOT/log" +REAL_GIT=$(<"$ROOT/real-git") +audit_lazy_fetch=true +for transport_argument in "$@"; do + case "$transport_argument" in + upload-pack | receive-pack | */records) audit_lazy_fetch=false ;; + esac +done +for variable in EDGEZERO_BUILD_CONTAINER_APP_TOKEN GITHUB_TOKEN GH_TOKEN TOKEN APP_TOKEN APP_TOKEN_LOCAL; do + eval "present=\${$variable+x}" + [[ -z "$present" ]] || printf '%s\n' "$variable" >>"$LOG_ROOT/environment-leaks" +done +[[ "$-" != *x* && "$-" != *a* ]] || printf '%s\n' shell-options >>"$LOG_ROOT/environment-leaks" +if [[ "$audit_lazy_fetch" == true ]]; then + [[ "${GIT_NO_LAZY_FETCH:-}" == 1 ]] || printf '%s\n' git-lazy-fetch >>"$LOG_ROOT/environment-leaks" +fi +printf '%s\0' "$@" >>"$LOG_ROOT/git.argv" +printf '\n' >>"$LOG_ROOT/git.argv" +args=("$@") +operation= +for argument in "$@"; do + case "$argument" in ls-remote|fetch|push) operation=$argument; break ;; esac +done +if [[ "$operation" == ls-remote ]]; then + for index in "${!args[@]}"; do + [[ "${args[index]}" == https://github.com/stackpop/edgezero.git ]] && args[index]=$REMOTE + done +elif [[ "$operation" == fetch || "$operation" == push ]]; then + for index in "${!args[@]}"; do + [[ "${args[index]}" == origin ]] && args[index]=$REMOTE + done +fi +if [[ -n "$operation" ]]; then + mode= + if mode=$(stat -f '%OLp' -- "${GIT_ASKPASS:-}" 2>/dev/null); then :; else mode=$(stat -c '%a' -- "${GIT_ASKPASS:-}" 2>/dev/null || true); fi + printf '%s\t%s\n' "${GIT_TERMINAL_PROMPT:-}" "$mode" >>"$LOG_ROOT/git-auth" +fi +if [[ "$operation" == push && -f "$CASE_ROOT/race-create" ]]; then + branch=$(<"$CASE_ROOT/race-create") + "$REAL_GIT" --git-dir="$REMOTE" update-ref "refs/heads/$branch" "$S" + rm -f -- "$CASE_ROOT/race-create" +fi +if [[ "$operation" == push && -f "$CASE_ROOT/race-update-existing" ]]; then + branch=$(sed -n '1p' "$CASE_ROOT/race-update-existing") + winner=$(sed -n '2p' "$CASE_ROOT/race-update-existing") + "$REAL_GIT" --git-dir="$REMOTE" update-ref "refs/heads/$branch" "$winner" + rm -f -- "$CASE_ROOT/race-update-existing" +fi +if [[ "$operation" == push && -f "$CASE_ROOT/race-readback" ]]; then + "$REAL_GIT" "${args[@]}" + status=$? + branch=$(<"$CASE_ROOT/race-readback") + "$REAL_GIT" --git-dir="$REMOTE" update-ref "refs/heads/$branch" "$S" + rm -f -- "$CASE_ROOT/race-readback" + exit "$status" +fi +if [[ "$operation" == ls-remote && -f "$CASE_ROOT/race-final-main" ]]; then + reads_main=false + reads_target=false + for argument in "${args[@]}"; do + [[ "$argument" != refs/heads/main ]] || reads_main=true + [[ "$argument" != refs/heads/edgezero-build-container-pin/* ]] || reads_target=true + done + if [[ "$reads_main" == true && "$reads_target" == true ]]; then + combined_count=0 + [[ ! -f "$CASE_ROOT/combined-ref-count" ]] || combined_count=$(<"$CASE_ROOT/combined-ref-count") + combined_count=$((combined_count + 1)) + printf '%s' "$combined_count" >"$CASE_ROOT/combined-ref-count" + if ((combined_count >= 2)); then + "$REAL_GIT" --git-dir="$REMOTE" update-ref refs/heads/main "$(<"$CASE_ROOT/race-final-main")" + rm -f -- "$CASE_ROOT/race-final-main" + fi + fi +fi +exec "$REAL_GIT" "${args[@]}" +EOF +chmod 0755 "$FAKE_BIN/git" + +cat >"$FAKE_BIN/mktemp" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +ROOT=${0%/bin/mktemp} +if [[ -f "$ROOT/case/fail-mktemp" ]]; then + exit 1 +fi +exec "$(<"$ROOT/real-mktemp")" "$@" +EOF +chmod 0755 "$FAKE_BIN/mktemp" + +cat >"$FAKE_BIN/curl" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +ROOT=${0%/bin/curl} +REMOTE="$ROOT/remote.git" +CASE_ROOT="$ROOT/case" +API_ROOT="$CASE_ROOT/api" +LOG_ROOT="$CASE_ROOT/log" +REAL_GIT=$(<"$ROOT/real-git") +REAL_JQ=$(<"$ROOT/real-jq") +BOT_ID=$(<"$ROOT/bot-id") +BOT_LOGIN=$(<"$ROOT/bot-login") +for variable in EDGEZERO_BUILD_CONTAINER_APP_TOKEN GITHUB_TOKEN GH_TOKEN TOKEN APP_TOKEN APP_TOKEN_LOCAL; do + eval "present=\${$variable+x}" + [[ -z "$present" ]] || printf '%s\n' "$variable" >>"$LOG_ROOT/environment-leaks" +done +[[ "$-" != *x* && "$-" != *a* ]] || printf '%s\n' shell-options >>"$LOG_ROOT/environment-leaks" +count=0 +[[ ! -f "$LOG_ROOT/curl-count" ]] || count=$(<"$LOG_ROOT/curl-count") +count=$((count + 1)) +printf '%s' "$count" >"$LOG_ROOT/curl-count" +call="$LOG_ROOT/curl-$count" +mkdir -p "$call" +printf '%s\n' "$@" >"$call/args" +cat >"$call/config" +method= output= data= url= +while (($#)); do + case "$1" in + --request) method=$2; shift 2 ;; + --output) output=$2; shift 2 ;; + --data-binary) + data=$2 + shift 2 + ;; + --connect-timeout|--max-time|--max-redirs|--config|--write-out) shift 2 ;; + --disable|--silent|--show-error) shift ;; + https://*) url=$1; shift ;; + *) shift ;; + esac +done +printf '%s' "$method" >"$call/method" +printf '%s' "$url" >"$call/url" +if [[ -n "$data" ]]; then + [[ "$data" == @* ]] || exit 91 + cp "${data#@}" "$call/body" +else + : >"$call/body" +fi +path=${url#https://api.github.com} +if [[ -f "$CASE_ROOT/fail-next" ]]; then + failure=$(<"$CASE_ROOT/fail-next") + if [[ "$method $path" == *"$failure"* ]]; then + rm -f -- "$CASE_ROOT/fail-next" + printf '{}'>"$output" + printf '500\n2026-03-10\napplication/json\n\n' + exit 0 + fi +fi +if [[ -f "$CASE_ROOT/signal-next" ]]; then + temp_root=${output%/api/*} + printf '%s' "$temp_root" >"$CASE_ROOT/signal-temp-root" + signal=TERM + [[ ! -s "$CASE_ROOT/signal-next" ]] || signal=$(<"$CASE_ROOT/signal-next") + kill "-$signal" "$PPID" + sleep 2 + exit 94 +fi +status=200 +link= +case "$method $path" in + "GET /users/${BOT_LOGIN%\[bot\]}%5Bbot%5D") cp "$API_ROOT/user.json" "$output" ;; + 'GET /repos/stackpop/edgezero') cp "$API_ROOT/repo.json" "$output" ;; + GET\ /repos/stackpop/edgezero/pulls\?*) + page=${path##*page=} + [[ "$page" =~ ^([1-9]|[1-9][0-9]|100)$ ]] || exit 92 + expected="/repos/stackpop/edgezero/pulls?state=all&base=main&sort=created&direction=asc&per_page=100&page=$page" + [[ "$path" == "$expected" ]] || exit 92 + if [[ -f "$API_ROOT/full-pages" ]]; then + jq -cn --argjson page "$page" '[range(0;100) | {id:(($page-1)*100+.+1),number:(($page-1)*100+.+1),title:"ordinary",head:{ref:"ordinary"}}]' >"$output" + elif [[ -f "$API_ROOT/pulls-page-$page.json" ]]; then + cp "$API_ROOT/pulls-page-$page.json" "$output" + else + printf '[]' >"$output" + fi + [[ ! -f "$API_ROOT/link-page-$page" ]] || link=$(<"$API_ROOT/link-page-$page") + ;; + GET\ /repos/stackpop/edgezero/pulls/*) + number=${path##*/} + [[ "$number" =~ ^[1-9][0-9]*$ ]] || exit 93 + pull_get_count=0 + [[ ! -f "$API_ROOT/pull-$number-get-count" ]] || \ + pull_get_count=$(<"$API_ROOT/pull-$number-get-count") + pull_get_count=$((pull_get_count + 1)) + printf '%s' "$pull_get_count" >"$API_ROOT/pull-$number-get-count" + if [[ -f "$CASE_ROOT/race-final-pull" && $(<"$CASE_ROOT/race-final-pull") == "$number" && + "$pull_get_count" -ge 2 ]]; then + jq '.user.id='"$((BOT_ID + 1))" "$API_ROOT/pull-$number.json" \ + >"$API_ROOT/pull-$number.next" + mv "$API_ROOT/pull-$number.next" "$API_ROOT/pull-$number.json" + rm -f -- "$CASE_ROOT/race-final-pull" + fi + head_ref=$(jq -er '.head.ref' "$API_ROOT/pull-$number.json") + if head_sha=$("$REAL_GIT" --git-dir="$REMOTE" rev-parse "refs/heads/$head_ref" 2>/dev/null); then + jq --arg head_sha "$head_sha" '.head.sha=$head_sha' \ + "$API_ROOT/pull-$number.json" >"$API_ROOT/pull-$number.next" + mv "$API_ROOT/pull-$number.next" "$API_ROOT/pull-$number.json" + fi + cp "$API_ROOT/pull-$number.json" "$output" + ;; + POST\ /repos/stackpop/edgezero/pulls) + status=201 + number=900 + body=$(<"$call/body") + ref=$(jq -er '.head' "$call/body") + sha=$("$REAL_GIT" --git-dir="$REMOTE" rev-parse "refs/heads/$ref") + jq -cn --argjson number "$number" --argjson id 9900 --argjson bot "$BOT_ID" \ + --arg login "$BOT_LOGIN" --arg sha "$sha" --argjson request "$body" \ + '{id:$id,number:$number,state:"open",merged:false,merged_at:null,merge_commit_sha:null, + user:{id:$bot,login:$login,type:"Bot"},title:$request.title,body:$request.body, + base:{ref:$request.base,repo:{full_name:"stackpop/edgezero"}}, + head:{ref:$request.head,sha:$sha,repo:{full_name:"stackpop/edgezero"}}}' \ + >"$API_ROOT/pull-$number.json" + if [[ -f "$CASE_ROOT/created-merge-sha" ]]; then + "$REAL_JQ" --arg merge_sha "$(<"$CASE_ROOT/created-merge-sha")" \ + '.merge_commit_sha=$merge_sha' "$API_ROOT/pull-$number.json" \ + >"$API_ROOT/pull-$number.next" + mv "$API_ROOT/pull-$number.next" "$API_ROOT/pull-$number.json" + fi + "$REAL_JQ" --slurpfile pull "$API_ROOT/pull-$number.json" \ + '. + [($pull[0] | {id,number,title,head:{ref:.head.ref}})]' \ + "$API_ROOT/pulls-page-1.json" >"$API_ROOT/pulls-page-1.next" + mv "$API_ROOT/pulls-page-1.next" "$API_ROOT/pulls-page-1.json" + if [[ -f "$CASE_ROOT/race-post-head" ]]; then + "$REAL_GIT" --git-dir="$REMOTE" update-ref "refs/heads/$ref" "$(<"$CASE_ROOT/race-post-head")" + rm -f -- "$CASE_ROOT/race-post-head" + fi + cp "$API_ROOT/pull-$number.json" "$output" + ;; + PATCH\ /repos/stackpop/edgezero/pulls/*) + number=${path##*/} + [[ "$number" =~ ^[1-9][0-9]*$ ]] || exit 93 + request=$(<"$call/body") + head_ref=$(jq -er '.head.ref' "$API_ROOT/pull-$number.json") + head_sha=$("$REAL_GIT" --git-dir="$REMOTE" rev-parse "refs/heads/$head_ref") + jq --argjson request "$request" --arg head_sha "$head_sha" \ + '.head.sha=$head_sha | .state=($request.state // .state) | .title=($request.title // .title) | + .body=($request.body // .body) | .base.ref=($request.base // .base.ref)' \ + "$API_ROOT/pull-$number.json" >"$API_ROOT/pull-$number.next" + mv "$API_ROOT/pull-$number.next" "$API_ROOT/pull-$number.json" + cp "$API_ROOT/pull-$number.json" "$output" + ;; + *) exit 93 ;; +esac +content_type=application/json +version=2026-03-10 +[[ ! -f "$API_ROOT/content-type" ]] || content_type=$(<"$API_ROOT/content-type") +[[ ! -f "$API_ROOT/version" ]] || version=$(<"$API_ROOT/version") +if [[ -f "$CASE_ROOT/response-status" ]]; then + read -r override_method override_status <"$CASE_ROOT/response-status" + if [[ "$method" == "$override_method" ]]; then + status=$override_status + rm -f -- "$CASE_ROOT/response-status" + fi +fi +printf '%s\n%s\n%s\n%s' "$status" "$version" "$content_type" "$link" +EOF +chmod 0755 "$FAKE_BIN/curl" + +cat >"$FAKE_BIN/jq" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +ROOT=${0%/bin/jq} +LOG_ROOT="$ROOT/case/log" +REAL_JQ=$(<"$ROOT/real-jq") +for variable in EDGEZERO_BUILD_CONTAINER_APP_TOKEN GITHUB_TOKEN GH_TOKEN TOKEN APP_TOKEN APP_TOKEN_LOCAL; do + eval "present=\${$variable+x}" + [[ -z "$present" ]] || printf '%s\n' "$variable" >>"$LOG_ROOT/environment-leaks" +done +[[ "$-" != *x* && "$-" != *a* ]] || printf '%s\n' shell-options >>"$LOG_ROOT/environment-leaks" +exec "$REAL_JQ" "$@" +EOF +chmod 0755 "$FAKE_BIN/jq" + +remote_ref() { + "$REAL_GIT" --git-dir="$REMOTE" rev-parse --verify "$1" 2>/dev/null || true +} + +reset_remote() { + local main=$1 ref + "$REAL_GIT" --git-dir="$REMOTE" update-ref refs/heads/main "$main" + while IFS= read -r ref; do + [[ -z "$ref" ]] || "$REAL_GIT" --git-dir="$REMOTE" update-ref -d "$ref" + done < <("$REAL_GIT" --git-dir="$REMOTE" for-each-ref --format='%(refname)' \ + refs/heads/edgezero-build-container-pin/) +} + +set_pin_branch_mode() { + local source=$1 path=$2 work="$WORK/mode-work" + rm -rf -- "$work" + "$REAL_GIT" clone -q "$REMOTE" "$work" >/dev/null 2>&1 + "$REAL_GIT" -C "$work" checkout -q "edgezero-build-container-pin/$source" + "$REAL_GIT" -C "$work" update-index --chmod=+x -- "$path" + "$REAL_GIT" -C "$work" -c user.name="$BOT_LOGIN" \ + -c user.email="$BOT_ID+$BOT_LOGIN@users.noreply.github.com" \ + -c commit.gpgsign=false commit -q --amend --no-edit --no-gpg-sign + TARGET_OID=$("$REAL_GIT" -C "$work" rev-parse HEAD) + "$REAL_GIT" -C "$work" push -q --force "$REMOTE" \ + "HEAD:refs/heads/edgezero-build-container-pin/$source" +} + +write_approval() { + local source=$1 digest=$2 + APPROVAL="$CASE_ROOT/approval.json" + printf '%s' "{\"approval-challenge\":\"$CHALLENGE\",\"approver-login\":\"release-reviewer\",\"image-digest\":\"$digest\",\"release-tag\":\"$TAG\",\"reviewed-at\":\"$REVIEWED_AT\",\"run-attempt\":\"2\",\"run-id\":\"9007199254740993\",\"schema-version\":1,\"screenshot-sha256\":\"$SCREENSHOT\",\"source-revision\":\"$source\"}" >"$APPROVAL" +} + +new_case() { + local main=${1:-$G} source=${2:-$S} digest=${3:-$DIGEST} + rm -rf -- "$CASE_ROOT" + mkdir -m 0700 "$CASE_ROOT" + mkdir -p "$API_ROOT" "$LOG_ROOT" + reset_remote "$main" + printf '%s' "{\"id\":$BOT_ID,\"login\":\"$BOT_LOGIN\",\"type\":\"Bot\"}" >"$API_ROOT/user.json" + printf '%s' '{"id":101,"full_name":"stackpop/edgezero","private":false,"visibility":"public","default_branch":"main","owner":{"login":"stackpop"}}' >"$API_ROOT/repo.json" + printf '[]' >"$API_ROOT/pulls-page-1.json" + RUN_SOURCE=$source + RUN_DIGEST=$digest + write_approval "$source" "$digest" +} + +expected_title() { printf 'chore(actions): pin build container for %s' "$1"; } +expected_body() { + local source=$1 digest=$2 + printf 'edgezero-build-container-pin-v1 {"evidence-url":"https://github.com/stackpop/edgezero/pull/%s#issuecomment-%s","image-digest":"%s","release-tag":"%s","source-pr":"%s","source-revision":"%s"}' \ + "$SOURCE_PR" "$COMMENT_ID" "$digest" "$TAG" "$SOURCE_PR" "$source" +} + +create_pin_branch() { + local source=$1 digest=$2 parent=${3:-$G} branch work + branch="edgezero-build-container-pin/$source" + work="$WORK/branch-work" + rm -rf -- "$work" + "$REAL_GIT" clone -q "$REMOTE" "$work" >/dev/null 2>&1 + "$REAL_GIT" -C "$work" checkout -q --detach "$parent" + write_pair "$work" "$source" "$digest" + "$REAL_GIT" -C "$work" add .github/docker/build-app-cli/image.json \ + .github/docker/build-app-cli/image-release-evidence.json + if "$REAL_GIT" -C "$work" diff --cached --quiet; then + TARGET_OID=$parent + "$REAL_GIT" --git-dir="$REMOTE" update-ref "refs/heads/$branch" "$TARGET_OID" + return + fi + "$REAL_GIT" -C "$work" -c user.name="$BOT_LOGIN" \ + -c user.email="$BOT_ID+$BOT_LOGIN@users.noreply.github.com" \ + -c commit.gpgsign=false commit -q --no-gpg-sign -m "$(expected_title "$source")" + TARGET_OID=$("$REAL_GIT" -C "$work" rev-parse HEAD) + "$REAL_GIT" -C "$work" push -q "$REMOTE" "HEAD:refs/heads/$branch" +} + +create_mismatched_pin_branch() { + local branch_source=$1 record_source=$2 digest=$3 parent=${4:-$G} branch work + branch="edgezero-build-container-pin/$branch_source" + work="$WORK/branch-work" + rm -rf -- "$work" + "$REAL_GIT" clone -q "$REMOTE" "$work" >/dev/null 2>&1 + "$REAL_GIT" -C "$work" checkout -q --detach "$parent" + write_pair "$work" "$record_source" "$digest" + "$REAL_GIT" -C "$work" add .github/docker/build-app-cli/image.json \ + .github/docker/build-app-cli/image-release-evidence.json + "$REAL_GIT" -C "$work" -c user.name="$BOT_LOGIN" \ + -c user.email="$BOT_ID+$BOT_LOGIN@users.noreply.github.com" \ + -c commit.gpgsign=false commit -q --no-gpg-sign -m "$(expected_title "$branch_source")" + TARGET_OID=$("$REAL_GIT" -C "$work" rev-parse HEAD) + "$REAL_GIT" -C "$work" push -q "$REMOTE" "HEAD:refs/heads/$branch" +} + +set_pin_pr() { + local number=$1 source=$2 digest=$3 state=$4 merged=$5 + local author_id=${6:-$BOT_ID} author_login=${7:-$BOT_LOGIN} head_repo=${8:-stackpop/edgezero} + local branch sha body title merged_at merge_sha + branch="edgezero-build-container-pin/$source" + sha=$(remote_ref "refs/heads/$branch") + [[ -n "$sha" ]] || sha=$source + body=$(expected_body "$source" "$digest") + title=$(expected_title "$source") + if [[ "$merged" == true ]]; then merged_at='"2026-09-10T13:00:00Z"'; merge_sha="\"$(remote_ref refs/heads/main)\""; else merged_at=null; merge_sha=null; fi + jq -cn --argjson number "$number" --argjson id "$((number + 1000))" \ + --argjson author_id "$author_id" --arg author_login "$author_login" \ + --arg head_repo "$head_repo" --arg branch "$branch" --arg sha "$sha" \ + --arg title "$title" --arg body "$body" --arg state "$state" \ + --argjson merged "$merged" --argjson merged_at "$merged_at" --argjson merge_sha "$merge_sha" \ + '{id:$id,number:$number,state:$state,merged:$merged,merged_at:$merged_at, + merge_commit_sha:$merge_sha,user:{id:$author_id,login:$author_login,type:"Bot"}, + title:$title,body:$body,base:{ref:"main",repo:{full_name:"stackpop/edgezero"}}, + head:{ref:$branch,sha:$sha,repo:{full_name:$head_repo}}}' >"$API_ROOT/pull-$number.json" + jq -cn --argjson number "$number" --argjson id "$((number + 1000))" \ + --arg title "$title" --arg branch "$branch" '[{id:$id,number:$number,title:$title,head:{ref:$branch}}]' \ + >"$API_ROOT/pulls-page-1.json" +} + +append_pin_pr() { + local number=$1 source=$2 digest=$3 state=$4 merged=$5 item + set_pin_pr "$number" "$source" "$digest" "$state" "$merged" + item=$(<"$API_ROOT/pulls-page-1.json") + if [[ -f "$CASE_ROOT/saved-list" ]]; then + jq -cn --argjson old "$(<"$CASE_ROOT/saved-list")" --argjson new "$item" '$old + $new' >"$API_ROOT/pulls-page-1.json" + fi + cp "$API_ROOT/pulls-page-1.json" "$CASE_ROOT/saved-list" +} + +run_updater() { + local supplied_token=${1:-$TOKEN} + env -i PATH="$FAKE_BIN:$PATH" LC_ALL=C EDGEZERO_BUILD_CONTAINER_APP_TOKEN="$supplied_token" \ + bash "$GATE_ROOT/.github/docker/build-app-cli/update-image-pin-pr.sh" \ + --gate-root "$GATE_ROOT" \ + --gate-sha "$G" \ + --repository-root "$REPOSITORY_ROOT" \ + --source-revision "$RUN_SOURCE" \ + --release-tag "$TAG" \ + --image-digest "$RUN_DIGEST" \ + --provenance-protocol 1 \ + --approval-json "$APPROVAL" \ + --source-pr "$SOURCE_PR" \ + --evidence-url "https://github.com/stackpop/edgezero/pull/$SOURCE_PR#issuecomment-$COMMENT_ID" \ + --expected-bot-id "$BOT_ID" \ + --expected-bot-login "$BOT_LOGIN" +} + +run_updater_hostile_environment() { + env -i PATH="$FAKE_BIN:$PATH" LC_ALL=C SHELLOPTS=xtrace:allexport \ + EDGEZERO_BUILD_CONTAINER_APP_TOKEN="$TOKEN" \ + GITHUB_TOKEN=ambient-github-token GH_TOKEN=ambient-gh-token TOKEN=ambient-token-alias \ + APP_TOKEN=ambient-app-token-alias APP_TOKEN_LOCAL=ambient-local-token-alias \ + bash "$GATE_ROOT/.github/docker/build-app-cli/update-image-pin-pr.sh" \ + --gate-root "$GATE_ROOT" \ + --gate-sha "$G" \ + --repository-root "$REPOSITORY_ROOT" \ + --source-revision "$RUN_SOURCE" \ + --release-tag "$TAG" \ + --image-digest "$RUN_DIGEST" \ + --provenance-protocol 1 \ + --approval-json "$APPROVAL" \ + --source-pr "$SOURCE_PR" \ + --evidence-url "https://github.com/stackpop/edgezero/pull/$SOURCE_PR#issuecomment-$COMMENT_ID" \ + --expected-bot-id "$BOT_ID" \ + --expected-bot-login "$BOT_LOGIN" +} + +run_updater_with_environment_alternate() { + env -i PATH="$FAKE_BIN:$PATH" LC_ALL=C \ + GIT_ALTERNATE_OBJECT_DIRECTORIES="$GATE_ROOT/.git/objects" \ + EDGEZERO_BUILD_CONTAINER_APP_TOKEN="$TOKEN" \ + bash "$GATE_ROOT/.github/docker/build-app-cli/update-image-pin-pr.sh" \ + --gate-root "$GATE_ROOT" \ + --gate-sha "$G" \ + --repository-root "$REPOSITORY_ROOT" \ + --source-revision "$RUN_SOURCE" \ + --release-tag "$TAG" \ + --image-digest "$RUN_DIGEST" \ + --provenance-protocol 1 \ + --approval-json "$APPROVAL" \ + --source-pr "$SOURCE_PR" \ + --evidence-url "https://github.com/stackpop/edgezero/pull/$SOURCE_PR#issuecomment-$COMMENT_ID" \ + --expected-bot-id "$BOT_ID" \ + --expected-bot-login "$BOT_LOGIN" +} + +run_updater_without_tool() { + local missing=$1 closed_path="$WORK/path-without-$1" tool resolved + rm -rf -- "$closed_path" + mkdir -p "$closed_path" + for tool in bash env git jq curl mktemp stat chmod rm cmp install wc tr sed awk sort uniq mkdir ln \ + cat cp dirname basename; do + [[ "$tool" != "$missing" ]] || continue + case "$tool" in + git | jq | curl) resolved="$FAKE_BIN/$tool" ;; + *) resolved=$(command -v "$tool") ;; + esac + ln -s "$resolved" "$closed_path/$tool" + done + "$REAL_ENV" -i PATH="$closed_path" LC_ALL=C EDGEZERO_BUILD_CONTAINER_APP_TOKEN="$TOKEN" \ + "$REAL_BASH" "$GATE_ROOT/.github/docker/build-app-cli/update-image-pin-pr.sh" \ + --gate-root "$GATE_ROOT" \ + --gate-sha "$G" \ + --repository-root "$REPOSITORY_ROOT" \ + --source-revision "$RUN_SOURCE" \ + --release-tag "$TAG" \ + --image-digest "$RUN_DIGEST" \ + --provenance-protocol 1 \ + --approval-json "$APPROVAL" \ + --source-pr "$SOURCE_PR" \ + --evidence-url "https://github.com/stackpop/edgezero/pull/$SOURCE_PR#issuecomment-$COMMENT_ID" \ + --expected-bot-id "$BOT_ID" \ + --expected-bot-login "$BOT_LOGIN" +} + +capture() { + local status=0 + "$@" >"$CASE_ROOT/stdout" 2>"$CASE_ROOT/stderr" || status=$? + printf '%s' "$status" +} + +assert_status() { + local expected=$1 description=$2 + shift 2 + local status + status=$(capture "$@") + if [[ "$status" == "$expected" ]]; then ok "$description"; else + cat "$CASE_ROOT/stdout" "$CASE_ROOT/stderr" >&2 + no "$description (status $status, expected $expected)" + fi +} + +assert_silent_sanitized() { + local description=$1 + if [[ ! -s "$CASE_ROOT/stdout" && $(<"$CASE_ROOT/stderr") != *"$TOKEN"* ]]; then + ok "$description" + else + cat "$CASE_ROOT/stdout" "$CASE_ROOT/stderr" >&2 + no "$description" + fi +} + +mutation_count() { + local count=0 call method + for call in "$LOG_ROOT"/curl-*; do + [[ -d "$call" ]] || continue + method=$(<"$call/method") + [[ "$method" == GET ]] || count=$((count + 1)) + done + printf '%s' "$count" +} + +assert_no_mutation() { + local description=$1 + if [[ $(mutation_count) == 0 ]]; then ok "$description"; else no "$description"; fi +} + +assert_no_network() { + local description=$1 + if [[ ! -f "$LOG_ROOT/curl-count" ]]; then ok "$description"; else no "$description"; fi +} + +assert_api_transport() { + local good=true call method url output request actual expected expected_config + for call in "$LOG_ROOT"/curl-*; do + [[ -d "$call" ]] || continue + method=$(<"$call/method") + expected_config="$CASE_ROOT/expected-config" + printf '%s\n' \ + 'header = "Accept: application/vnd.github+json"' \ + 'header = "X-GitHub-Api-Version: 2026-03-10"' \ + 'header = "User-Agent: edgezero-build-container-gate/1"' \ + "header = \"Authorization: Bearer $TOKEN\"" >"$expected_config" + [[ "$method" == GET ]] || printf '%s\n' 'header = "Content-Type: application/json"' >>"$expected_config" + if ! cmp -s "$expected_config" "$call/config"; then + printf 'curl config mismatch for %s %s\n' "$method" "$(<"$call/url")" >&2 + diff -u "$expected_config" "$call/config" >&2 || true + good=false + fi + url=$(<"$call/url") + if [[ "$method" == GET ]]; then + output=$(sed -n '15p' "$call/args") + actual=$(<"$call/args") + expected=$(printf '%s\n' \ + --disable --silent --show-error --connect-timeout 10 --max-time 30 --max-redirs 0 \ + --request "$method" --config - --output "$output" --write-out \ + '%{http_code}\n%header{x-github-api-version-selected}\n%header{content-type}\n%header{link}' "$url") + else + request=$(sed -n '15p' "$call/args") + output=$(sed -n '17p' "$call/args") + actual=$(<"$call/args") + expected=$(printf '%s\n' \ + --disable --silent --show-error --connect-timeout 10 --max-time 30 --max-redirs 0 \ + --request "$method" --config - --data-binary "$request" --output "$output" --write-out \ + '%{http_code}\n%header{x-github-api-version-selected}\n%header{content-type}\n%header{link}' "$url") + [[ "$request" == @/private/tmp/edgezero-image-pin.??????/api/*.json || + "$request" == @/tmp/edgezero-image-pin.??????/api/*.json ]] || good=false + fi + [[ "$output" == /private/tmp/edgezero-image-pin.??????/api/body.?????? || + "$output" == /tmp/edgezero-image-pin.??????/api/body.?????? ]] || good=false + if [[ "$actual" != "$expected" ]]; then + printf 'curl argv mismatch for %s %s\nexpected:\n%s\nactual:\n%s\n' \ + "$method" "$url" "$expected" "$actual" >&2 + good=false + fi + done + if [[ "$good" == true ]]; then ok 'every REST call uses the complete ordered curl argv and exact config'; else no 'every REST call uses the complete ordered curl argv and exact config'; fi +} + +assert_request_order() { + local expected=$1 description=$2 actual='' call + for call in "$LOG_ROOT"/curl-*; do + [[ -d "$call" ]] || continue + actual+="$(<"$call/method") $(<"$call/url")" + actual+=$'\n' + done + if [[ "${actual%$'\n'}" == "$expected" ]]; then ok "$description"; else + printf 'expected:\n%s\nactual:\n%b' "$expected" "$actual" >&2 + no "$description" + fi +} + +new_case "$G" +assert_status 0 'an absent target branch creates the exact pin proposal' run_updater +assert_silent_sanitized 'successful creation is silent and keeps the token out of output' +CREATED_OID=$(remote_ref "refs/heads/edgezero-build-container-pin/$S") +if [[ -n "$CREATED_OID" && $("$REAL_GIT" --git-dir="$REMOTE" show -s --format=%P "$CREATED_OID") == "$G" ]]; then + ok 'the created commit has the recorded remote main as its sole parent' +else + no 'the created commit has the recorded remote main as its sole parent' +fi +if [[ $("$REAL_GIT" --git-dir="$REMOTE" show -s --format='%s%n%an%n%ae' "$CREATED_OID") == "$(expected_title "$S")"$'\n'"$BOT_LOGIN"$'\n'"$BOT_ID+$BOT_LOGIN@users.noreply.github.com" ]]; then + ok 'the commit is unsigned, hook-free, and uses the fixed title and bot identity' +else + no 'the commit is unsigned, hook-free, and uses the fixed title and bot identity' +fi +if [[ $("$REAL_GIT" --git-dir="$REMOTE" show "$CREATED_OID:.github/docker/build-app-cli/image.json") == "{\"digest\":\"$DIGEST\",\"image-source-revision\":\"$S\",\"provenance-protocol\":1,\"repository\":\"ghcr.io/stackpop/edgezero-build-app-cli\",\"tag\":\"$TAG\"}" ]]; then + ok 'the typed writer installs the exact image record on the branch' +else + no 'the typed writer installs the exact image record on the branch' +fi +if [[ $("$REAL_GIT" --git-dir="$REMOTE" show "$CREATED_OID:.github/docker/build-app-cli/image-release-evidence.json") == "$(<"$APPROVAL")" ]]; then + ok 'the typed writer installs the exact ten-field evidence record on the branch' +else + no 'the typed writer installs the exact ten-field evidence record on the branch' +fi +created_tree=$("$REAL_GIT" --git-dir="$REMOTE" diff-tree --no-commit-id --raw -r "$CREATED_OID") +if [[ $(printf '%s\n' "$created_tree" | awk '$1 == ":000000" && $2 == "100644" && $3 ~ /^0+$/ && $5 == "A" {count++} END {print count+0}') == 2 && + $(printf '%s\n' "$created_tree" | wc -l | tr -d '[:space:]') == 2 ]]; then + ok 'the commit mutates exactly two regular mode-100644 blobs' +else + no 'the commit mutates exactly two regular mode-100644 blobs' +fi +expected_create=$(jq -cnS --arg base main --arg body "$(expected_body "$S" "$DIGEST")" \ + --arg head "edgezero-build-container-pin/$S" --arg title "$(expected_title "$S")" \ + '{base:$base,body:$body,draft:false,head:$head,title:$title}') +if [[ $(<"$LOG_ROOT/curl-4/body") == "$expected_create" ]]; then + ok 'POST uses the exact closed request body bytes' +else + no 'POST uses the exact closed request body bytes' +fi +assert_request_order "$(printf '%s\n' \ + "GET https://api.github.com/users/${BOT_LOGIN%\[bot\]}%5Bbot%5D" \ + 'GET https://api.github.com/repos/stackpop/edgezero' \ + 'GET https://api.github.com/repos/stackpop/edgezero/pulls?state=all&base=main&sort=created&direction=asc&per_page=100&page=1' \ + 'POST https://api.github.com/repos/stackpop/edgezero/pulls' \ + 'GET https://api.github.com/repos/stackpop/edgezero/pulls/900')" \ + 'creation performs the exact REST request sequence' +assert_api_transport +if [[ ! -e "$LOG_ROOT/environment-leaks" ]]; then + ok 'ordinary execution exports no credential alias and disables lazy Git fetching' +else + cat "$LOG_ROOT/environment-leaks" >&2 + no 'ordinary execution exports no credential alias and disables lazy Git fetching' +fi +if tr '\0' '\n' <"$LOG_ROOT/git.argv" | rg -q -- "--force-with-lease=refs/heads/edgezero-build-container-pin/$S:" && + tr '\0' '\n' <"$LOG_ROOT/git.argv" | rg -q -x -- '--porcelain' && + tr '\0' '\n' <"$LOG_ROOT/git.argv" | rg -q -x -- '--no-verify' && + ! tr '\0' '\n' <"$LOG_ROOT/git.argv" | rg -Fq "$TOKEN"; then + ok 'creation uses the empty exact lease, porcelain/no-verify, and no token argv' +else + no 'creation uses the empty exact lease, porcelain/no-verify, and no token argv' +fi +if [[ $(sort -u "$LOG_ROOT/git-auth") == $'0\t700' ]]; then + ok 'every remote Git command disables prompts and uses only the mode-0700 askpass helper' +else + no 'every remote Git command disables prompts and uses only the mode-0700 askpass helper' +fi +if ! tr '\0' '\n' <"$LOG_ROOT/git.argv" | rg -q -F "refs/heads/edgezero-build-container-pin/$S:refs/remotes/origin/pin-target"; then + ok 'an absent target branch omits the conditional target fetch' +else + no 'an absent target branch omits the conditional target fetch' +fi +set_pin_pr 900 "$S" "$DIGEST" open false +before=$CREATED_OID +rm -rf -- "$LOG_ROOT"; mkdir -p "$LOG_ROOT" +assert_status 0 'the same source and digest rerun is idempotent' run_updater +if [[ $(remote_ref "refs/heads/edgezero-build-container-pin/$S") == "$before" ]]; then + ok 'idempotence leaves the exact branch OID unchanged' +else + no 'idempotence leaves the exact branch OID unchanged' +fi +assert_no_mutation 'idempotence performs no API mutation' + +new_case "$G" +printf '%s' "$G" >"$CASE_ROOT/created-merge-sha" +assert_status 0 'a created open PR accepts its valid final server merge-commit identity' run_updater + +new_case "$G" +printf '%s' "edgezero-build-container-pin/$S" >"$CASE_ROOT/race-create" +assert_status 1 'a branch created after the absence recheck loses the empty lease race' run_updater +assert_no_mutation 'a lease race performs no pull-request mutation' +assert_silent_sanitized 'a lease-race diagnostic is sanitized' + +new_case "$G" "$S" "$DIGEST" +create_pin_branch "$S" "$OLD_DIGEST" +printf '%s\n%s\n' "edgezero-build-container-pin/$S" "$S" >"$CASE_ROOT/race-update-existing" +assert_status 1 'an existing target update loses an exact-OID lease race' run_updater +if [[ $(remote_ref "refs/heads/edgezero-build-container-pin/$S") == "$S" ]]; then + ok 'an existing-target lease race preserves the winning remote OID' +else + no 'an existing-target lease race preserves the winning remote OID' +fi +assert_no_mutation 'an existing-target lease race performs no pull-request mutation' + +new_case "$G" +printf '%s' "edgezero-build-container-pin/$S" >"$CASE_ROOT/race-readback" +assert_status 1 'a branch moved after push is rejected by immediate remote readback' run_updater +assert_no_mutation 'a post-push readback race performs no pull-request mutation' + +new_case "$G" +printf '%s' "$S" >"$CASE_ROOT/race-post-head" +assert_status 1 'a branch moved during PR creation fails final remote/API proof' run_updater +assert_silent_sanitized 'a post-create branch race diagnostic is sanitized' + +new_case "$G" +printf '%s' "$S" >"$CASE_ROOT/race-final-main" +assert_status 1 'remote main moving at the final combined read is rejected' run_updater +if [[ $(remote_ref refs/heads/main) == "$S" ]]; then + ok 'the final-main race fixture moved the protected ref' +else + no 'the final-main race fixture moved the protected ref' +fi + +new_case "$G" +create_pin_branch "$S" "$DIGEST" +set_pin_pr 41 "$S" "$DIGEST" closed false +assert_status 0 'one closed unmerged exact proposal is reopened and reconciled' run_updater +if [[ $(jq -r .state "$API_ROOT/pull-41.json") == open ]]; then + ok 'closed-unmerged reconciliation leaves the PR open' +else + no 'closed-unmerged reconciliation leaves the PR open' +fi +if tr '\0' '\n' <"$LOG_ROOT/git.argv" | rg -q -F \ + "refs/heads/edgezero-build-container-pin/$S:refs/remotes/origin/pin-target"; then + ok 'an existing target fetch records and verifies its exact remote OID' +else + no 'an existing target fetch records and verifies its exact remote OID' +fi +expected_reopen=$(jq -cnS --arg base main --arg body "$(expected_body "$S" "$DIGEST")" --arg state open --arg title "$(expected_title "$S")" '{base:$base,body:$body,state:$state,title:$title}') +if [[ $(<"$LOG_ROOT/curl-5/body") == "$expected_reopen" ]]; then ok 'reopen uses the exact reconciliation JSON bytes'; else no 'reopen uses the exact reconciliation JSON bytes'; fi + +new_case "$B_EQUAL" +set_pin_pr 42 "$S" "$DIGEST" closed true +assert_status 0 'an already merged exact pin is an idempotent success' run_updater +assert_no_mutation 'an already merged exact pin performs no mutation' + +new_case "$B_EQUAL" +set_pin_pr 42 "$S" "$DIGEST" closed true +printf '%s' 42 >"$CASE_ROOT/race-final-pull" +assert_status 1 'an already merged pin changed after classification is rejected' run_updater +assert_no_mutation 'an already merged final-state race is mutation-free' + +new_case "$B_EQUAL" +set_pin_pr 42 "$S" "$DIGEST" closed true +jq '.merge_commit_sha="ffffffffffffffffffffffffffffffffffffffff"' "$API_ROOT/pull-42.json" >"$API_ROOT/pull-42.next" +mv "$API_ROOT/pull-42.next" "$API_ROOT/pull-42.json" +assert_status 1 'an already-merged pull with an unknown merge commit is rejected' run_updater +assert_no_mutation 'malformed merged commit identity is mutation-free' + +new_case "$G" "$S" "$DIGEST" +create_pin_branch "$S" "$OLD_DIGEST" +old_recorded=$TARGET_OID +set_pin_pr 43 "$S" "$OLD_DIGEST" open false +assert_status 0 'the same source with a new digest closes and replaces the open PR' run_updater +if [[ $(jq -r .state "$API_ROOT/pull-43.json") == closed && -f "$API_ROOT/pull-900.json" ]]; then + ok 'same-source replacement closes old before creating new' +else + no 'same-source replacement closes old before creating new' +fi +if [[ $(<"$LOG_ROOT/curl-5/method") == PATCH && $(<"$LOG_ROOT/curl-5/body") == '{"state":"closed"}' ]]; then + ok 'close uses the exact JSON body' +else + no 'close uses the exact JSON body' +fi +if tr '\0' '\n' <"$LOG_ROOT/git.argv" | rg -q -- \ + "--force-with-lease=refs/heads/edgezero-build-container-pin/$S:$old_recorded"; then + ok 'an existing target update uses its exact recorded OID lease' +else + no 'an existing target update uses its exact recorded OID lease' +fi +replacement_oid=$(remote_ref "refs/heads/edgezero-build-container-pin/$S") +if [[ $(jq -r '.head.sha' "$API_ROOT/pull-43.json") == "$replacement_oid" ]]; then + ok 'same-source replacement verifies the closed old PR at the post-push branch OID' +else + no 'same-source replacement verifies the closed old PR at the post-push branch OID' +fi +rm -rf -- "$LOG_ROOT"; mkdir -p "$LOG_ROOT" +assert_status 0 'a completed same-source replacement reruns idempotently' run_updater +assert_no_mutation 'a completed same-source replacement rerun performs no mutation' + +new_case "$G" "$S" "$DIGEST" +create_pin_branch "$S" "$OLD_DIGEST" +set_pin_pr 47 "$S" "$OLD_DIGEST" closed false +assert_status 0 'a closed same-source old-digest proposal is verified and replaced' run_updater +replacement_oid=$(remote_ref "refs/heads/edgezero-build-container-pin/$S") +if [[ $(jq -r '.state + " " + .head.sha' "$API_ROOT/pull-47.json") == "closed $replacement_oid" && + -f "$API_ROOT/pull-900.json" ]]; then + ok 'closed same-source replacement verifies the old PR at the post-push OID' +else + no 'closed same-source replacement verifies the old PR at the post-push OID' +fi + +new_case "$N" +create_pin_branch "$S" "$DIGEST" "$G" +ancestor_target=$TARGET_OID +set_pin_pr 47 "$S" "$DIGEST" open false +assert_status 0 'an existing proposal based on an ancestor of current main is rebuilt on current main' run_updater +rebuilt_target=$(remote_ref "refs/heads/edgezero-build-container-pin/$S") +if [[ "$rebuilt_target" != "$ancestor_target" && + $("$REAL_GIT" --git-dir="$REMOTE" show -s --format=%P "$rebuilt_target") == "$N" && + $(jq -r '.state + " " + .head.sha' "$API_ROOT/pull-47.json") == "open $rebuilt_target" && + ! -f "$API_ROOT/pull-900.json" ]]; then + ok 'ancestor-parent reconciliation preserves the PR and binds it to the rebuilt OID' +else + no 'ancestor-parent reconciliation preserves the PR and binds it to the rebuilt OID' +fi + +new_case "$N" +create_pin_branch "$S" "$DIGEST" "$Q" +set_pin_pr 48 "$S" "$DIGEST" open false +assert_status 1 'an existing proposal with a parent incomparable to current main is rejected' run_updater +assert_no_mutation 'incomparable target-parent rejection is API-mutation-free' + +for mode_path in .github/docker/build-app-cli/image.json \ + .github/docker/build-app-cli/image-release-evidence.json; do + new_case "$G" + create_pin_branch "$S" "$DIGEST" + set_pin_branch_mode "$S" "$mode_path" + set_pin_pr 49 "$S" "$DIGEST" open false + assert_status 1 "an existing executable $mode_path entry is rejected" run_updater + assert_no_mutation "an invalid $mode_path mode is API-mutation-free" +done + +new_case "$I" +create_pin_branch "$I" "$OLD_DIGEST" "$I" +set_pin_pr 44 "$I" "$OLD_DIGEST" open false +assert_status 0 'a forward source closes an older open proposal and creates the new one' run_updater +if [[ $(jq -r .state "$API_ROOT/pull-44.json") == closed && -f "$API_ROOT/pull-900.json" ]]; then + ok 'older proposals close only in the successful superseding transition' +else + no 'older proposals close only in the successful superseding transition' +fi + +new_case "$I" +create_pin_branch "$I" "$OLD_DIGEST" "$I" +set_pin_pr 56 "$I" "$OLD_DIGEST" closed false +printf '%s' 56 >"$CASE_ROOT/race-final-pull" +assert_status 1 'a selected closed older proposal is re-read before success' run_updater + +new_case "$B_NEWER" +assert_status 1 'a source older than the protected base pin is rejected' run_updater +assert_no_mutation 'base-pin regression is mutation-free' + +new_case "$B_SIDE" +assert_status 1 'a source incomparable with the protected base pin is rejected' run_updater +assert_no_mutation 'incomparable base ancestry is mutation-free' + +new_case "$N" +create_pin_branch "$N" "$DIGEST" "$N" +set_pin_pr 45 "$N" "$DIGEST" open false +before=$(remote_ref "refs/heads/edgezero-build-container-pin/$N") +assert_status 0 'a newer existing proposal supersedes an older run without mutation' run_updater +if [[ $(remote_ref "refs/heads/edgezero-build-container-pin/$N") == "$before" ]]; then + ok 'superseded success leaves the newer branch unchanged' +else + no 'superseded success leaves the newer branch unchanged' +fi +assert_no_mutation 'superseded success performs no API mutation' + +new_case "$N" +create_pin_branch "$N" "$DIGEST" "$N" +set_pin_pr 45 "$N" "$DIGEST" open false +printf '%s' 45 >"$CASE_ROOT/race-final-pull" +assert_status 1 'a newer proposal changed after classification is rejected' run_updater +assert_no_mutation 'a superseded final-state race is mutation-free' + +new_case "$B_NEWER" +create_pin_branch "$N" "$DIGEST" "$B_NEWER" +set_pin_pr 46 "$N" "$DIGEST" closed true +assert_status 1 'a stale run after a newer merge is rejected before mutation' run_updater +assert_no_mutation 'stale-after-merge rejection is mutation-free' + +for collision in missing-head-repo wrong-author wrong-head-repo; do + new_case "$G" + create_pin_branch "$S" "$DIGEST" + case "$collision" in + missing-head-repo) + set_pin_pr 50 "$S" "$DIGEST" open false + jq '.head.repo=null' "$API_ROOT/pull-50.json" >"$API_ROOT/pull-50.next" + mv "$API_ROOT/pull-50.next" "$API_ROOT/pull-50.json" + ;; + wrong-author) set_pin_pr 50 "$S" "$DIGEST" open false 999 attacker stackpop/edgezero ;; + wrong-head-repo) set_pin_pr 50 "$S" "$DIGEST" open false "$BOT_ID" "$BOT_LOGIN" attacker/edgezero ;; + esac + assert_status 1 "$collision collision is rejected" run_updater + assert_no_mutation "$collision collision is mutation-free" +done + +for collision in wrong-title wrong-branch; do + new_case "$G" + create_pin_branch "$S" "$DIGEST" + set_pin_pr 52 "$S" "$DIGEST" open false + if [[ "$collision" == wrong-title ]]; then + jq '.title="chore(actions): pin build container for attacker"' "$API_ROOT/pull-52.json" >"$API_ROOT/pull-52.next" + else + jq '.head.ref="attacker-branch"' "$API_ROOT/pull-52.json" >"$API_ROOT/pull-52.next" + fi + mv "$API_ROOT/pull-52.next" "$API_ROOT/pull-52.json" + assert_status 1 "$collision pin identity collision is rejected" run_updater + assert_no_mutation "$collision pin identity collision is mutation-free" +done + +new_case "$G" +create_pin_branch "$S" "$DIGEST" +"$REAL_GIT" -C "$WORK/branch-work" -c user.name=attacker -c user.email=attacker@example.invalid \ + -c commit.gpgsign=false commit -q --amend --no-edit --reset-author --no-gpg-sign +TARGET_OID=$("$REAL_GIT" -C "$WORK/branch-work" rev-parse HEAD) +"$REAL_GIT" -C "$WORK/branch-work" push -q --force "$REMOTE" "HEAD:refs/heads/edgezero-build-container-pin/$S" +set_pin_pr 55 "$S" "$DIGEST" open false +assert_status 1 'a target branch commit with the wrong fixed bot identity is rejected' run_updater +assert_no_mutation 'malformed target commit identity is mutation-free' + +new_case "$G" +create_mismatched_pin_branch "$S" "$P" "$OLD_DIGEST" +mismatched_target=$TARGET_OID +assert_status 1 'a target branch whose record source differs from its suffix is rejected' run_updater +if [[ $(remote_ref "refs/heads/edgezero-build-container-pin/$S") == "$mismatched_target" ]]; then + ok 'target source/suffix mismatch is rejected before branch mutation' +else + no 'target source/suffix mismatch is rejected before branch mutation' +fi +assert_no_mutation 'target source/suffix mismatch is rejected before API mutation' + +new_case "$G" +create_pin_branch "$S" "$DIGEST" +append_pin_pr 53 "$S" "$DIGEST" open false +append_pin_pr 54 "$S" "$DIGEST" closed false +assert_status 1 'multiple pull requests for one source are rejected as ambiguous' run_updater +assert_no_mutation 'multiple same-source ambiguity is mutation-free' + +new_case "$G" +printf '%s' '[{"id":1,"number":1,"title":"ordinary","head":{"ref":"ordinary"}},{"id":1,"number":2,"title":"ordinary-2","head":{"ref":"ordinary-2"}}]' >"$API_ROOT/pulls-page-1.json" +assert_status 1 'duplicate pull identities are rejected' run_updater +assert_no_mutation 'duplicate pagination is mutation-free' + +new_case "$G" +printf '%s' '; rel="next"' >"$API_ROOT/link-page-1" +assert_status 1 'a response-supplied cross-origin pagination URL is rejected' run_updater +assert_no_mutation 'bad response pagination URLs are mutation-free' + +new_case "$G" +jq -cn '[range(0;100) | {id:(.+1),number:(.+1),title:"ordinary",head:{ref:"ordinary"}}]' \ + >"$API_ROOT/pulls-page-1.json" +printf '%s' \ + '; rel="next"' \ + >"$API_ROOT/link-page-1" +assert_status 1 'an overflowing pagination target page is rejected' run_updater +assert_no_mutation 'overflowing pagination is mutation-free' + +new_case "$G" +jq -cn '[range(0;100) | {id:(.+1),number:(.+1),title:"ordinary",head:{ref:"ordinary"}}]' \ + >"$API_ROOT/pulls-page-1.json" +printf '%s' '[{"id":101,"number":101,"title":"ordinary","head":{"ref":"ordinary"}}]' \ + >"$API_ROOT/pulls-page-2.json" +printf '%s' \ + '; rel="next", ; rel="last"' \ + >"$API_ROOT/link-page-1" +assert_status 0 'an exact two-page pull inventory is completely enumerated' run_updater +assert_request_order "$(printf '%s\n' \ + "GET https://api.github.com/users/${BOT_LOGIN%\[bot\]}%5Bbot%5D" \ + 'GET https://api.github.com/repos/stackpop/edgezero' \ + 'GET https://api.github.com/repos/stackpop/edgezero/pulls?state=all&base=main&sort=created&direction=asc&per_page=100&page=1' \ + 'GET https://api.github.com/repos/stackpop/edgezero/pulls?state=all&base=main&sort=created&direction=asc&per_page=100&page=2' \ + 'POST https://api.github.com/repos/stackpop/edgezero/pulls' \ + 'GET https://api.github.com/repos/stackpop/edgezero/pulls/900')" \ + 'pagination synthesizes only the exact ordered page URLs' +assert_api_transport + +new_case "$G" +jq -cn '[range(0;100) | {id:(.+1),number:(.+1),title:"ordinary",head:{ref:"ordinary"}}]' \ + >"$API_ROOT/pulls-page-1.json" +printf '%s' \ + '; rel="next", ; rel="next"' \ + >"$API_ROOT/link-page-1" +assert_status 1 'duplicate pagination relations are rejected' run_updater +assert_no_mutation 'duplicate pagination relations are mutation-free' + +new_case "$G" +touch "$API_ROOT/full-pages" +assert_status 1 'one hundred full synthesized pages fail as 10k truncation' run_updater +assert_no_mutation 'pagination truncation is mutation-free' + +new_case "$G" +printf '%s' "GET /users/${BOT_LOGIN%\[bot\]}%5Bbot%5D" >"$CASE_ROOT/fail-next" +assert_status 1 'an API status failure is terminal' run_updater +assert_no_mutation 'an API status failure cannot mutate state' + +new_case "$G" +printf '%s' 'POST 200' >"$CASE_ROOT/response-status" +assert_status 1 'pull creation rejects a non-201 success status' run_updater + +new_case "$G" +create_pin_branch "$S" "$DIGEST" +set_pin_pr 57 "$S" "$DIGEST" closed false +printf '%s' 'PATCH 201' >"$CASE_ROOT/response-status" +assert_status 1 'pull reconciliation rejects a non-200 success status' run_updater + +new_case "$G" +printf '%s' 2025-01-01 >"$API_ROOT/version" +assert_status 1 'an unexpected selected API version is rejected' run_updater +assert_no_mutation 'an API-version failure is mutation-free' + +new_case "$G" +printf '%s' text/plain >"$API_ROOT/content-type" +assert_status 1 'a non-JSON response content type is rejected' run_updater +assert_no_mutation 'a response content-type failure is mutation-free' + +new_case "$G" +printf '%s' 'application/json; charset=UTF-8' >"$API_ROOT/content-type" +assert_status 0 'the exact UTF-8 JSON media type is accepted' run_updater + +for bad_media_type in 'application/problem+json' 'application/json; charset=utf-16' \ + 'application/json; profile=unexpected'; do + new_case "$G" + printf '%s' "$bad_media_type" >"$API_ROOT/content-type" + assert_status 1 "unsupported response media type $bad_media_type is rejected" run_updater + assert_no_mutation "unsupported response media type $bad_media_type is mutation-free" +done + +new_case "$G" +printf '{' >"$API_ROOT/user.json" +assert_status 1 'a malformed API JSON response is rejected' run_updater +assert_no_mutation 'malformed API JSON is mutation-free' + +new_case "$G" +printf '%s' "{\"id\":$BOT_ID,\"login\":\"wrong-bot\",\"type\":\"Bot\",\"secret\":\"$TOKEN\"}" >"$API_ROOT/user.json" +assert_status 1 'a wrong authenticated bot identity is rejected' run_updater +assert_no_mutation 'wrong bot identity is mutation-free' +assert_silent_sanitized 'identity failure does not echo a token-bearing API body' + +for bot_identity_mutation in wrong-id wrong-type; do + new_case "$G" + case "$bot_identity_mutation" in + wrong-id) printf '%s' "{\"id\":9999,\"login\":\"$BOT_LOGIN\",\"type\":\"Bot\"}" >"$API_ROOT/user.json" ;; + wrong-type) printf '%s' "{\"id\":$BOT_ID,\"login\":\"$BOT_LOGIN\",\"type\":\"User\"}" >"$API_ROOT/user.json" ;; + esac + assert_status 1 "$bot_identity_mutation public bot identity is rejected" run_updater + assert_no_mutation "$bot_identity_mutation public bot identity is mutation-free" +done + +new_case "$G" +printf '%s' '{"id":101,"full_name":"attacker/edgezero","private":false,"visibility":"public","default_branch":"main","owner":{"loginlogin":"stackpop"}}' >"$API_ROOT/repo.json" +assert_status 1 'a wrong authenticated repository identity is rejected' run_updater +assert_no_mutation 'wrong repository identity is mutation-free' + +new_case "$G" +unsafe_token='unsafe"token' +assert_status 1 'a token that cannot be encoded in curl config is rejected before REST access' run_updater "$unsafe_token" +assert_no_network 'unsafe token bytes never reach curl' +if ! rg -Fq "$unsafe_token" "$CASE_ROOT/stdout" "$CASE_ROOT/stderr"; then ok 'unsafe token diagnostics are redacted'; else no 'unsafe token diagnostics are redacted'; fi + +new_case "$G" +create_pin_branch "$S" "$DIGEST" +set_pin_pr 51 "$S" "$DIGEST" closed false +printf '%s' 'PATCH /repos/stackpop/edgezero/pulls/51' >"$CASE_ROOT/fail-next" +assert_status 1 'a reopen API failure is terminal' run_updater +assert_silent_sanitized 'API failure diagnostics redact the token' + +for signal_case in 'HUP 129' 'INT 130' 'TERM 143'; do + signal_name=${signal_case% *} + signal_status=${signal_case#* } + new_case "$G" + printf '%s' "$signal_name" >"$CASE_ROOT/signal-next" + assert_status "$signal_status" "$signal_name during a REST call exits with the signal status" run_updater + if [[ -f "$CASE_ROOT/signal-temp-root" && ! -e "$(<"$CASE_ROOT/signal-temp-root")" ]]; then + ok "$signal_name cleanup removes the private clone, worktree, askpass, token, and response files" + else + no "$signal_name cleanup removes the private clone, worktree, askpass, token, and response files" + fi + assert_silent_sanitized "$signal_name cleanup emits no token" +done + +new_case "$G" +bad_approval="$APPROVAL" +printf '\n' >>"$bad_approval" +assert_status 1 'non-JCS approval bytes fail before App-token REST access' run_updater +assert_no_network 'approval validation precedes credentialed REST access' + +new_case "$G" +RUN_DIGEST="$OLD_DIGEST" +assert_status 1 'approval source/tag/digest/protocol must match the invocation' run_updater +assert_no_network 'approval mismatch is rejected before token use' + +new_case "$G" +status=$(env -i PATH="$FAKE_BIN:$PATH" LC_ALL=C bash "$GATE_ROOT/.github/docker/build-app-cli/update-image-pin-pr.sh" --unknown value >"$CASE_ROOT/stdout" 2>"$CASE_ROOT/stderr"; printf '%s' "$?") || true +if [[ "$status" == 2 && ! -f "$LOG_ROOT/curl-count" ]]; then ok 'strict CLI errors take precedence over missing credentials'; else no 'strict CLI errors take precedence over missing credentials'; fi + +token_read_match=$(rg -n -m1 -F '${EDGEZERO_BUILD_CONTAINER_APP_TOKEN:-}' "$SOURCE_UPDATER" || true) +approval_compare_match=$(rg -n -m1 -F 'cmp -s "$EVIDENCE_OUTPUT" "$APPROVAL_JSON"' "$SOURCE_UPDATER" || true) +token_read_line=${token_read_match%%:*} +approval_compare_line=${approval_compare_match%%:*} +if [[ "$token_read_line" =~ ^[1-9][0-9]*$ && "$approval_compare_line" =~ ^[1-9][0-9]*$ && + "$token_read_line" -gt "$approval_compare_line" ]]; then + ok 'the installation token is not read until CLI and precredential validation finish' +else + no 'the installation token is not read until CLI and precredential validation finish' +fi + +new_case "$G" +touch "$CASE_ROOT/fail-mktemp" +assert_status 1 'a runtime temporary-directory failure uses the runtime exit' run_updater +assert_no_network 'a runtime temporary-directory failure is pre-network' + +new_case "$G" +printf dirty >"$REPOSITORY_ROOT/untracked" +assert_status 1 'a dirty source checkout is rejected before token access' run_updater +rm -f -- "$REPOSITORY_ROOT/untracked" +assert_no_network 'checkout validation occurs before token access' + +new_case "$G" +assert_status 1 'an evidence URL with a mismatched source PR is rejected' \ + env -i PATH="$FAKE_BIN:$PATH" LC_ALL=C EDGEZERO_BUILD_CONTAINER_APP_TOKEN="$TOKEN" \ + bash "$GATE_ROOT/.github/docker/build-app-cli/update-image-pin-pr.sh" \ + --gate-root "$GATE_ROOT" --gate-sha "$G" --repository-root "$REPOSITORY_ROOT" \ + --source-revision "$S" --release-tag "$TAG" --image-digest "$DIGEST" \ + --provenance-protocol 1 --approval-json "$APPROVAL" --source-pr "$SOURCE_PR" \ + --evidence-url "https://github.com/stackpop/edgezero/pull/78#issuecomment-$COMMENT_ID" \ + --expected-bot-id "$BOT_ID" --expected-bot-login "$BOT_LOGIN" +assert_no_network 'evidence URL grammar is checked before token access' + +new_case "$G" +assert_status 0 'realistic App bot identity succeeds under inherited xtrace and allexport' \ + run_updater_hostile_environment +if [[ ! -e "$LOG_ROOT/environment-leaks" && + $(<"$CASE_ROOT/stderr") != *"$TOKEN"* && + $(<"$CASE_ROOT/stderr") != *ambient-github-token* && + $(<"$CASE_ROOT/stderr") != *ambient-gh-token* && + $(<"$CASE_ROOT/stderr") != *ambient-token-alias* && + $(<"$CASE_ROOT/stderr") != *ambient-app-token-alias* && + $(<"$CASE_ROOT/stderr") != *ambient-local-token-alias* ]]; then + ok 'xtrace, allexport, GITHUB_TOKEN, GH_TOKEN, and exported TOKEN cannot expose credentials' +else + [[ ! -e "$LOG_ROOT/environment-leaks" ]] || cat "$LOG_ROOT/environment-leaks" >&2 + cat "$CASE_ROOT/stderr" >&2 + no 'xtrace, allexport, GITHUB_TOKEN, GH_TOKEN, and exported TOKEN cannot expose credentials' +fi + +new_case "$G" +"$REAL_GIT" -C "$REPOSITORY_ROOT" config remote.origin.promisor true +assert_status 1 'a promisor source repository is rejected before network access' run_updater +"$REAL_GIT" -C "$REPOSITORY_ROOT" config --unset remote.origin.promisor +assert_no_network 'promisor rejection is pre-network' + +new_case "$G" +"$REAL_GIT" -C "$GATE_ROOT" config extensions.partialClone origin +assert_status 1 'a partial-clone gate repository is rejected before network access' run_updater +"$REAL_GIT" -C "$GATE_ROOT" config --unset extensions.partialClone +assert_no_network 'partial-clone gate rejection is pre-network' + +new_case "$G" +"$REAL_GIT" -C "$REPOSITORY_ROOT" config remote.origin.partialCloneFilter blob:none +assert_status 1 'a partial-clone filter in the source repository is rejected before network access' run_updater +"$REAL_GIT" -C "$REPOSITORY_ROOT" config --unset remote.origin.partialCloneFilter +assert_no_network 'partial-clone filter rejection is pre-network' + +new_case "$G" +"$REAL_GIT" -C "$REPOSITORY_ROOT" config extensions.worktreeConfig true +"$REAL_GIT" -C "$REPOSITORY_ROOT" config --worktree remote.origin.promisor true +assert_status 1 'worktree-scoped promisor configuration is rejected before network access' run_updater +"$REAL_GIT" -C "$REPOSITORY_ROOT" config --worktree --unset remote.origin.promisor +"$REAL_GIT" -C "$REPOSITORY_ROOT" config --unset extensions.worktreeConfig +assert_no_network 'worktree-scoped promisor rejection is pre-network' + +hidden_path=.github/docker/build-app-cli/check-image-pin.sh +for hidden_mode in assume-unchanged skip-worktree; do + new_case "$G" + case "$hidden_mode" in + assume-unchanged) + "$REAL_GIT" -C "$REPOSITORY_ROOT" update-index --assume-unchanged -- "$hidden_path" + clear_hidden=--no-assume-unchanged + ;; + skip-worktree) + "$REAL_GIT" -C "$REPOSITORY_ROOT" update-index --skip-worktree -- "$hidden_path" + clear_hidden=--no-skip-worktree + ;; + esac + printf '\n# hidden checkout mutation\n' >>"$REPOSITORY_ROOT/$hidden_path" + assert_status 1 "$hidden_mode index state cannot hide a dirty source checkout" run_updater + "$REAL_GIT" -C "$REPOSITORY_ROOT" update-index "$clear_hidden" -- "$hidden_path" + "$REAL_GIT" -C "$REPOSITORY_ROOT" checkout -q HEAD -- "$hidden_path" + assert_no_network "$hidden_mode rejection is pre-network" +done + +new_case "$G" +printf '%s\n' "$S" >"$REPOSITORY_ROOT/.git/shallow" +assert_status 1 'a shallow source repository is rejected before network access' run_updater +rm -f -- "$REPOSITORY_ROOT/.git/shallow" +assert_no_network 'shallow source rejection is pre-network' + +new_case "$G" +"$REAL_GIT" -C "$REPOSITORY_ROOT" config core.sparseCheckout true +assert_status 1 'a sparse source repository is rejected before network access' run_updater +"$REAL_GIT" -C "$REPOSITORY_ROOT" config --unset core.sparseCheckout +assert_no_network 'sparse source rejection is pre-network' + +new_case "$G" +"$REAL_GIT" -C "$REPOSITORY_ROOT" replace "$G" "$P" +assert_status 1 'a source replacement ref is rejected before network access' run_updater +"$REAL_GIT" -C "$REPOSITORY_ROOT" replace -d "$G" >/dev/null +assert_no_network 'replacement-ref rejection is pre-network' + +new_case "$G" +mkdir -p "$REPOSITORY_ROOT/.git/info" +printf '%s %s\n' "$G" "$P" >"$REPOSITORY_ROOT/.git/info/grafts" +assert_status 1 'a source graft is rejected before network access' run_updater +rm -f -- "$REPOSITORY_ROOT/.git/info/grafts" +assert_no_network 'graft rejection is pre-network' + +new_case "$G" +mkdir -p "$REPOSITORY_ROOT/.git/objects/info" +printf '%s\n' "$GATE_ROOT/.git/objects" >"$REPOSITORY_ROOT/.git/objects/info/alternates" +assert_status 1 'an on-disk object alternate is rejected before network access' run_updater +rm -f -- "$REPOSITORY_ROOT/.git/objects/info/alternates" +assert_no_network 'on-disk alternate rejection is pre-network' + +new_case "$G" +assert_status 1 'an environment-provided object alternate is rejected before network access' \ + run_updater_with_environment_alternate +assert_no_network 'environment alternate rejection is pre-network' + +new_case "$G" +mkdir -p "$GATE_ROOT/.git/objects/info" +ln -s "$WORK/nonexistent-alternate" "$GATE_ROOT/.git/objects/info/alternates" +assert_status 1 'a dangling on-disk alternate in the gate repository is rejected' run_updater +rm -f -- "$GATE_ROOT/.git/objects/info/alternates" +assert_no_network 'dangling gate alternate rejection is pre-network' + +for invalid_bot_login in edgezero-publisher 'Edgezero-publisher[bot]' 'edgezero--publisher[bot]'; do + new_case "$G" + status=$(capture env -i PATH="$FAKE_BIN:$PATH" LC_ALL=C \ + EDGEZERO_BUILD_CONTAINER_APP_TOKEN="$TOKEN" \ + bash "$GATE_ROOT/.github/docker/build-app-cli/update-image-pin-pr.sh" \ + --gate-root "$GATE_ROOT" --gate-sha "$G" --repository-root "$REPOSITORY_ROOT" \ + --source-revision "$RUN_SOURCE" --release-tag "$TAG" --image-digest "$RUN_DIGEST" \ + --provenance-protocol 1 --approval-json "$APPROVAL" --source-pr "$SOURCE_PR" \ + --evidence-url "https://github.com/stackpop/edgezero/pull/$SOURCE_PR#issuecomment-$COMMENT_ID" \ + --expected-bot-id "$BOT_ID" --expected-bot-login "$invalid_bot_login") + if [[ "$status" == 1 && ! -f "$LOG_ROOT/curl-count" ]]; then + ok "invalid App bot login $invalid_bot_login is rejected before network access" + else + no "invalid App bot login $invalid_bot_login is rejected before network access" + fi +done + +for missing_tool in bash env git jq curl mktemp stat chmod rm cmp install wc tr sed awk sort uniq mkdir ln \ + cat cp dirname basename; do + new_case "$G" + assert_status 2 "missing required tool $missing_tool preserves the tool-error exit" \ + run_updater_without_tool "$missing_tool" + assert_no_network "missing required tool $missing_tool fails before network access" +done + +printf 'Passed: %d Failed: %d\n' "$pass" "$fail" +[[ "$fail" -eq 0 ]] diff --git a/.github/actions/deploy-core/tests/verify-build-container-publication.test.sh b/.github/actions/deploy-core/tests/verify-build-container-publication.test.sh new file mode 100755 index 00000000..5cbfd9e7 --- /dev/null +++ b/.github/actions/deploy-core/tests/verify-build-container-publication.test.sh @@ -0,0 +1,963 @@ +#!/usr/bin/env bash +# shellcheck disable=SC2016 +set -euo pipefail + +DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +REAL_VERIFY="$DIR/../../../docker/build-app-cli/verify-build-container-publication.sh" +REAL_CHECK="$DIR/../../../docker/build-app-cli/check-image-pin.sh" +REAL_BASH=$(command -v bash) +WORK=$(mktemp -d) +WORK=$(cd -- "$WORK" && pwd -P) +trap 'rm -rf -- "$WORK"' EXIT + +RUN_ID=9007199254740993 +RUN_ATTEMPT=2 +SOURCE=$(printf '2%.0s' {1..40}) +TAG=build-container-v7 +DIGEST="sha256:$(printf '3%.0s' {1..64})" +CHALLENGE=$(printf '4%.0s' {1..64}) +SCREENSHOT="sha256:$(printf '5%.0s' {1..64})" +REVIEWED_AT=2020-01-02T03:04:05Z +TOKEN_VALUE='fixture-token-value' +API_ROOT=https://api.github.com/repos/stackpop/edgezero/actions/runs + +pass=0 +fail=0 +case_number=0 + +ok() { + printf ' \033[32mok\033[0m %s\n' "$1" + pass=$((pass + 1)) +} + +no() { + printf ' \033[31mFAIL\033[0m %s\n' "$1" >&2 + fail=$((fail + 1)) +} + +GATE_ROOT="$WORK/gate" +SUBJECT_ROOT="$WORK/subject" +RECORD_ROOT="$WORK/records" +FAKE_BIN="$WORK/fake-bin" +mkdir -p "$GATE_ROOT/.github/docker/build-app-cli" \ + "$SUBJECT_ROOT/.github/docker/build-app-cli" "$RECORD_ROOT" "$FAKE_BIN" +cp "$REAL_VERIFY" "$GATE_ROOT/.github/docker/build-app-cli/verify-build-container-publication.sh" +cp "$REAL_CHECK" "$GATE_ROOT/.github/docker/build-app-cli/check-image-pin.sh" +chmod 0755 "$GATE_ROOT/.github/docker/build-app-cli/verify-build-container-publication.sh" \ + "$GATE_ROOT/.github/docker/build-app-cli/check-image-pin.sh" + +git -C "$GATE_ROOT" init -q -b main +git -C "$GATE_ROOT" config user.name fixture +git -C "$GATE_ROOT" config user.email fixture@example.invalid +git -C "$GATE_ROOT" add . +git -C "$GATE_ROOT" commit -q -m gate-base +printf '%s\n' gate >"$GATE_ROOT/gate-marker" +git -C "$GATE_ROOT" add gate-marker +git -C "$GATE_ROOT" commit -q -m gate +G=$(git -C "$GATE_ROOT" rev-parse HEAD) +git -C "$GATE_ROOT" checkout -q --detach "$G" + +IMAGE_CONTENT="{\"digest\":\"$DIGEST\",\"image-source-revision\":\"$SOURCE\",\"provenance-protocol\":1,\"repository\":\"ghcr.io/stackpop/edgezero-build-app-cli\",\"tag\":\"$TAG\"}" +EVIDENCE_CONTENT="{\"approval-challenge\":\"$CHALLENGE\",\"approver-login\":\"release-reviewer\",\"image-digest\":\"$DIGEST\",\"release-tag\":\"$TAG\",\"reviewed-at\":\"$REVIEWED_AT\",\"run-attempt\":\"$RUN_ATTEMPT\",\"run-id\":\"$RUN_ID\",\"schema-version\":1,\"screenshot-sha256\":\"$SCREENSHOT\",\"source-revision\":\"$SOURCE\"}" +printf '%s' "$IMAGE_CONTENT" >"$SUBJECT_ROOT/.github/docker/build-app-cli/image.json" +printf '%s' "$EVIDENCE_CONTENT" >"$SUBJECT_ROOT/.github/docker/build-app-cli/image-release-evidence.json" +git -C "$SUBJECT_ROOT" init -q -b main +git -C "$SUBJECT_ROOT" config user.name fixture +git -C "$SUBJECT_ROOT" config user.email fixture@example.invalid +git -C "$SUBJECT_ROOT" add . +git -C "$SUBJECT_ROOT" commit -q -m subject +T=$(git -C "$SUBJECT_ROOT" rev-parse HEAD) +git -C "$SUBJECT_ROOT" checkout -q --detach "$T" + +IMAGE_JSON="$RECORD_ROOT/image.json" +EVIDENCE_JSON="$RECORD_ROOT/image-release-evidence.json" +VERIFY="$GATE_ROOT/.github/docker/build-app-cli/verify-build-container-publication.sh" + +cat >"$FAKE_BIN/curl" <<'SH' +#!/usr/bin/env bash +set -euo pipefail +fixture=${0%/*} +[[ "$fixture" != "$0" ]] || fixture=. +fixture=$(cd -- "$fixture" && pwd) +[[ "${LC_ALL:-}" == C ]] +[[ -z "${GITHUB_TOKEN+x}${GH_TOKEN+x}${HOME+x}${CURL_HOME+x}${XDG_CONFIG_HOME+x}" ]] +[[ -z "${HTTP_PROXY+x}${HTTPS_PROXY+x}${ALL_PROXY+x}${NO_PROXY+x}${AMBIENT_SECRET+x}" ]] +count=0 +[[ ! -f "$fixture/curl-count" ]] || count=$(<"$fixture/curl-count") +count=$((count + 1)) +printf '%s' "$count" >"$fixture/curl-count" +printf '%s\n' "$@" >"$fixture/args-$count" +cat >"$fixture/config-$count" +output= +url= +while (($#)); do + case "$1" in + --output) output=$2; shift 2 ;; + http*) url=$1; shift ;; + *) shift ;; + esac +done +[[ -n "$output" && -n "$url" ]] +case "$url" in + */approvals) endpoint=approvals ;; + */jobs\?per_page=100\&page=1) endpoint=jobs ;; + *) endpoint=run ;; +esac +endpoint_count=0 +[[ ! -f "$fixture/$endpoint-count" ]] || endpoint_count=$(<"$fixture/$endpoint-count") +endpoint_count=$((endpoint_count + 1)) +printf '%s' "$endpoint_count" >"$fixture/$endpoint-count" +printf 'curl %s\n' "$url" >>"$fixture/events" +if [[ -f "$fixture/$endpoint.transport-failure" ]]; then + printf '%s\n' 'curl: fixture-token-value secret-response-body' >&2 + exit 28 +fi +body="$fixture/$endpoint.body" +[[ ! -f "$fixture/$endpoint.$endpoint_count.body" ]] || body="$fixture/$endpoint.$endpoint_count.body" +cat "$body" >"$output" +if [[ -f "$fixture/$endpoint.raw-metadata" ]]; then + cat "$fixture/$endpoint.raw-metadata" +else + printf '%s\n%s\n%s\nlink=%s' \ + "$(<"$fixture/$endpoint.status")" \ + "$(<"$fixture/$endpoint.version")" \ + "$(<"$fixture/$endpoint.media")" \ + "$(<"$fixture/$endpoint.link")" +fi +SH +chmod 0755 "$FAKE_BIN/curl" + +cat >"$FAKE_BIN/sleep" <<'SH' +#!/usr/bin/env bash +set -euo pipefail +fixture=$(cd -- "$(dirname -- "$0")" && pwd) +[[ -z "${GITHUB_TOKEN+x}${GH_TOKEN+x}${HOME+x}${AMBIENT_SECRET+x}" ]] +printf '%s\n' "$*" >>"$fixture/sleeps" +printf 'sleep %s\n' "$*" >>"$fixture/events" +SH +chmod 0755 "$FAKE_BIN/sleep" + +cat >"$FAKE_BIN/tool-probe" <<'SH' +#!/usr/bin/env bash +set -euo pipefail +fixture=${0%/*} +[[ "$fixture" != "$0" ]] || fixture=. +fixture=$(cd -- "$fixture" && pwd) +tool=${0##*/} +while IFS='=' read -r _ value; do + if [[ "$value" == fixture-token-value ]]; then + : >"$fixture/credential-env-leak" + break + fi +done < <(/usr/bin/env) +if [[ "$tool" == git && "${GIT_NO_LAZY_FETCH:-}" != 1 ]]; then + : >"$fixture/lazy-fetch-enabled" +fi +PATH=${PATH#*:} +export PATH +hash -r +real_tool=$(command -v "$tool") +[[ "$real_tool" == /* ]] +exec "$real_tool" "$@" +SH +chmod 0755 "$FAKE_BIN/tool-probe" +for tool in git jq awk cmp dirname mktemp rm; do + ln -s tool-probe "$FAKE_BIN/$tool" +done + +write_run() { + local path=$1 status=${2:-completed} conclusion=${3:-'"success"'} + local id=${4:-$RUN_ID} attempt=${5:-$RUN_ATTEMPT} event=${6:-push} + local workflow_path=${7:-.github/workflows/publish-build-container.yml@$TAG} + local head_sha=${8:-$SOURCE} head_branch=${9:-$TAG} + printf '%s' "{\"id\":$id,\"run_attempt\":$attempt,\"event\":\"$event\",\"path\":\"$workflow_path\",\"head_sha\":\"$head_sha\",\"head_branch\":\"$head_branch\",\"status\":\"$status\",\"conclusion\":$conclusion}" >"$path" +} + +write_jobs() { + local path=$1 + printf '%s' "{\"total_count\":2,\"jobs\":[{\"name\":\"build-and-verify\",\"status\":\"completed\",\"conclusion\":\"success\",\"head_sha\":\"$SOURCE\",\"run_attempt\":$RUN_ATTEMPT,\"steps\":[{\"name\":\"checkout\",\"conclusion\":\"success\"},{\"name\":\"assert-exact-publisher-context\",\"conclusion\":\"success\"}]},{\"name\":\"update-pin\",\"status\":\"completed\",\"conclusion\":\"success\",\"head_sha\":\"$SOURCE\",\"run_attempt\":$RUN_ATTEMPT,\"steps\":[{\"name\":\"assert-exact-publisher-context\",\"conclusion\":\"success\"},{\"name\":\"update\",\"conclusion\":\"success\"}]}]}" >"$path" +} + +protocol_comment() { + local attempt=${1:-$RUN_ATTEMPT} run_id=${2:-$RUN_ID} challenge=${3:-$CHALLENGE} + local digest=${4:-$DIGEST} tag=${5:-$TAG} reviewed=${6:-$REVIEWED_AT} + local screenshot=${7:-$SCREENSHOT} source=${8:-$SOURCE} + printf '%s' "edgezero-release-evidence-v1 {\"challenge\":\"$challenge\",\"image-digest\":\"$digest\",\"png-sha256\":\"$screenshot\",\"release-tag\":\"$tag\",\"reviewed-at\":\"$reviewed\",\"run-attempt\":\"$attempt\",\"run-id\":\"$run_id\",\"source-revision\":\"$source\"}" +} + +review_json() { + local state=$1 environment=$2 comment=$3 login=${4:-release-reviewer} + jq -cn --arg state "$state" --arg environment "$environment" \ + --arg comment "$comment" --arg login "$login" \ + '{state:$state,environments:[{name:$environment}],user:{login:$login},comment:$comment}' +} + +reviewer_only_json() { + local state=$1 environment=$2 comment=$3 login=${4:-release-reviewer} + jq -cn --arg state "$state" --arg environment "$environment" \ + --arg comment "$comment" --arg login "$login" \ + '{state:$state,environments:[{name:$environment}],reviewer:{login:$login},comment:$comment}' +} + +write_approvals() { + local separator='' item + printf '[' >"$FAKE_BIN/approvals.body" + for item in "$@"; do + printf '%s%s' "$separator" "$item" >>"$FAKE_BIN/approvals.body" + separator=, + done + printf ']' >>"$FAKE_BIN/approvals.body" +} + +write_current_review() { + local state=${1:-approved} environment=${2:-build-container-release} + local comment=${3:-$(protocol_comment)} login=${4:-release-reviewer} + write_approvals "$(review_json "$state" "$environment" "$comment" "$login")" +} + +reset_repositories() { + git -C "$GATE_ROOT" config --unset core.sparseCheckout 2>/dev/null || true + git -C "$SUBJECT_ROOT" config --unset core.sparseCheckout 2>/dev/null || true + git -C "$GATE_ROOT" config --unset-all extensions.partialClone 2>/dev/null || true + git -C "$SUBJECT_ROOT" config --unset-all extensions.partialClone 2>/dev/null || true + git -C "$GATE_ROOT" config --unset-all remote.origin.promisor 2>/dev/null || true + git -C "$SUBJECT_ROOT" config --unset-all remote.origin.promisor 2>/dev/null || true + git -C "$GATE_ROOT" config --unset-all remote.origin.partialCloneFilter 2>/dev/null || true + git -C "$SUBJECT_ROOT" config --unset-all remote.origin.partialCloneFilter 2>/dev/null || true + rm -f "$GATE_ROOT/.git/objects/info/alternates" "$SUBJECT_ROOT/.git/objects/info/alternates" + git -C "$GATE_ROOT" replace -d "$G" >/dev/null 2>&1 || true + rm -f "$GATE_ROOT/.git/info/grafts" "$SUBJECT_ROOT/.git/info/grafts" + git -C "$GATE_ROOT" reset --hard -q "$G" + git -C "$GATE_ROOT" clean -fdq + git -C "$GATE_ROOT" checkout -q --detach "$G" + git -C "$SUBJECT_ROOT" reset --hard -q "$T" + git -C "$SUBJECT_ROOT" clean -fdq + git -C "$SUBJECT_ROOT" checkout -q --detach "$T" + git -C "$SUBJECT_ROOT" show "$T:.github/docker/build-app-cli/image.json" >"$IMAGE_JSON" + git -C "$SUBJECT_ROOT" show "$T:.github/docker/build-app-cli/image-release-evidence.json" >"$EVIDENCE_JSON" +} + +reset_api() { + rm -f "$FAKE_BIN"/{args-*,config-*,curl-count,run-count,jobs-count,approvals-count,events,sleeps} \ + "$FAKE_BIN"/{run,jobs,approvals}.{transport-failure,raw-metadata} \ + "$FAKE_BIN"/run.*.body "$FAKE_BIN"/{credential-env-leak,lazy-fetch-enabled} + write_run "$FAKE_BIN/run.body" + write_jobs "$FAKE_BIN/jobs.body" + write_current_review + local endpoint + for endpoint in run jobs approvals; do + printf '200' >"$FAKE_BIN/$endpoint.status" + printf '2026-03-10' >"$FAKE_BIN/$endpoint.version" + printf 'application/json; charset=utf-8' >"$FAKE_BIN/$endpoint.media" + : >"$FAKE_BIN/$endpoint.link" + done +} + +reset_case() { + case_number=$((case_number + 1)) + reset_repositories + reset_api + : >"$WORK/stdout" + : >"$WORK/stderr" +} + +default_args() { + printf '%s\0' \ + --gate-root "$GATE_ROOT" --subject-root "$SUBJECT_ROOT" \ + --gate-sha "$G" --candidate-sha "$T" \ + --image-json "$IMAGE_JSON" --evidence-json "$EVIDENCE_JSON" +} + +invoke() { + local verifier=$1 + shift + if env PATH="$FAKE_BIN:$PATH" GITHUB_TOKEN="$TOKEN_VALUE" TOKEN=exported-token-slot \ + AMBIENT_SECRET=must-not-propagate bash "$verifier" "$@" \ + >"$WORK/stdout" 2>"$WORK/stderr"; then + CAPTURE_STATUS=0 + else + CAPTURE_STATUS=$? + fi +} + +invoke_with_hostile_shell_options() { + local -a args=() + while IFS= read -r -d '' arg; do args+=("$arg"); done < <(default_args) + if env PATH="$FAKE_BIN:$PATH" GITHUB_TOKEN="$TOKEN_VALUE" TOKEN=exported-token-slot \ + AMBIENT_SECRET=must-not-propagate bash -x -a "$VERIFY" "${args[@]}" \ + >"$WORK/stdout" 2>"$WORK/stderr"; then + CAPTURE_STATUS=0 + else + CAPTURE_STATUS=$? + fi +} + +invoke_with_missing_tool() { + local missing=$1 tool path missing_bin + local -a args=() + missing_bin="$WORK/missing-$missing" + mkdir -p "$missing_bin" + for tool in env git jq curl sleep mktemp cmp awk rm dirname bash; do + [[ "$tool" == "$missing" ]] && continue + path=$(command -v "$tool") + ln -s "$path" "$missing_bin/$tool" + done + while IFS= read -r -d '' arg; do args+=("$arg"); done < <(default_args) + if env PATH="$missing_bin" GITHUB_TOKEN="$TOKEN_VALUE" TOKEN=exported-token-slot \ + "$REAL_BASH" "$VERIFY" "${args[@]}" >"$WORK/stdout" 2>"$WORK/stderr"; then + CAPTURE_STATUS=0 + else + CAPTURE_STATUS=$? + fi +} + +invoke_default() { + local -a args=() + while IFS= read -r -d '' arg; do args+=("$arg"); done < <(default_args) + invoke "$VERIFY" "${args[@]}" +} + +assert_status() { + local description=$1 expected=$2 + if [[ "$CAPTURE_STATUS" -eq "$expected" ]]; then + ok "$description" + else + no "$description" + printf ' expected status: %s, actual status: %s\n' "$expected" "$CAPTURE_STATUS" >&2 + sed -n '1p' "$WORK/stderr" >&2 + fi +} + +assert_silent_success() { + local description=$1 + if [[ "$CAPTURE_STATUS" -eq 0 && ! -s "$WORK/stdout" && ! -s "$WORK/stderr" ]]; then + ok "$description" + else + no "$description" + printf ' actual status: %s\n' "$CAPTURE_STATUS" >&2 + sed -n '1p' "$WORK/stderr" >&2 + fi +} + +assert_no_network() { + local description=$1 + if [[ ! -e "$FAKE_BIN/curl-count" ]]; then ok "$description"; else no "$description"; fi +} + +assert_eq() { + local description=$1 expected=$2 actual=$3 + if [[ "$actual" == "$expected" ]]; then ok "$description"; else no "$description"; fi +} + +file_value_or_zero() { + local path=$1 + if [[ -f "$path" ]]; then cat "$path"; else printf 0; fi +} + +line_count_or_zero() { + local path=$1 + if [[ -f "$path" ]]; then wc -l <"$path" | tr -d '[:space:]'; else printf 0; fi +} + +assert_complete_curl_request() { + local description=$1 ordinal=$2 expected_url=$3 line normalized expected config + local -a args=() + if [[ ! -f "$FAKE_BIN/args-$ordinal" || ! -f "$FAKE_BIN/config-$ordinal" ]]; then + no "$description" + return + fi + while IFS= read -r line || [[ -n "$line" ]]; do args+=("$line"); done <"$FAKE_BIN/args-$ordinal" + if [[ "${#args[@]}" -ne 18 ]]; then + no "$description" + return + fi + [[ "${args[14]}" == "$RECORD_ROOT/.edgezero-publication-body."?????? ]] || { + no "$description" + return + } + args[14]='' + normalized=$(printf '%s\n' "${args[@]}") + expected="--disable +--silent +--show-error +--connect-timeout +10 +--max-time +30 +--max-redirs +0 +--request +GET +--config +- +--output + +--write-out +%{http_code}\\n%header{x-github-api-version-selected}\\n%header{content-type}\\nlink=%header{link} +$expected_url" + config="header = \"Accept: application/vnd.github+json\" +header = \"X-GitHub-Api-Version: 2026-03-10\" +header = \"User-Agent: edgezero-build-container-gate/1\" +header = \"Authorization: Bearer $TOKEN_VALUE\"" + if [[ "$normalized" == "$expected" && "$(<"$FAKE_BIN/config-$ordinal")" == "$config" ]]; then + ok "$description" + else + no "$description" + fi +} + +invoke_default_without_token() { + local -a args=() + while IFS= read -r -d '' arg; do args+=("$arg"); done < <(default_args) + if env -u GITHUB_TOKEN PATH="$FAKE_BIN:$PATH" bash "$VERIFY" "${args[@]}" \ + >"$WORK/stdout" 2>"$WORK/stderr"; then + CAPTURE_STATUS=0 + else + CAPTURE_STATUS=$? + fi +} + +echo "== build container publication verifier ==" + +reset_case +invoke_default +assert_silent_success "a valid archived publication is accepted silently" +assert_eq "success performs exactly four API reads" 4 "$(file_value_or_zero "$FAKE_BIN/curl-count")" +if [[ ! -e "$FAKE_BIN/credential-env-leak" ]]; then + ok "the token is absent from every verifier child environment" +else + no "the token is absent from every verifier child environment" +fi +if [[ ! -e "$FAKE_BIN/lazy-fetch-enabled" ]]; then + ok "every Git read disables lazy object fetching" +else + no "every Git read disables lazy object fetching" +fi + +reset_case +invoke_with_hostile_shell_options +assert_status "inherited xtrace and allexport do not change success" 0 +if ! rg -F -q "$TOKEN_VALUE" "$WORK/stdout" "$WORK/stderr" && + [[ ! -e "$FAKE_BIN/credential-env-leak" ]]; then + ok "inherited xtrace and allexport cannot expose the token" +else + no "inherited xtrace and allexport cannot expose the token" +fi + +reset_case +invoke "$VERIFY" --gate-root "$GATE_ROOT" +assert_status "missing flags return usage status 2" 2 +assert_no_network "missing flags fail before token or network" + +reset_case +invoke "$VERIFY" --unknown value +assert_status "unknown flags return usage status 2" 2 +assert_no_network "unknown flags fail before token or network" + +reset_case +invoke "$VERIFY" --gate-root "$GATE_ROOT" --gate-root "$GATE_ROOT" \ + --subject-root "$SUBJECT_ROOT" --gate-sha "$G" --candidate-sha "$T" \ + --image-json "$IMAGE_JSON" --evidence-json "$EVIDENCE_JSON" +assert_status "duplicate flags return usage status 2" 2 +assert_no_network "duplicate flags fail before token or network" + +reset_case +invoke "$VERIFY" --gate-root '' --subject-root "$SUBJECT_ROOT" --gate-sha "$G" \ + --candidate-sha "$T" --image-json "$IMAGE_JSON" --evidence-json "$EVIDENCE_JSON" +assert_status "empty flag values return usage status 2" 2 +assert_no_network "empty values fail before token or network" + +for missing_tool in dirname bash; do + reset_case + invoke_with_missing_tool "$missing_tool" + assert_status "missing $missing_tool returns tooling status 2" 2 + assert_no_network "missing $missing_tool fails before network" +done + +reset_case +invoke_default_without_token +assert_status "an absent GITHUB_TOKEN is rejected" 1 +assert_no_network "an absent token causes no request" + +reset_case +printf '\n' >>"$IMAGE_JSON" +invoke_default +assert_status "an extracted image differing from its exact blob is rejected" 1 +assert_no_network "image blob mismatch is pre-network" + +reset_case +printf '\n' >>"$EVIDENCE_JSON" +invoke_default +assert_status "extracted evidence differing from its exact blob is rejected" 1 +assert_no_network "evidence blob mismatch is pre-network" + +reset_case +chmod 0755 "$SUBJECT_ROOT/.github/docker/build-app-cli/image.json" +git -C "$SUBJECT_ROOT" add .github/docker/build-app-cli/image.json +git -C "$SUBJECT_ROOT" commit -q -m executable-image-record +EXECUTABLE_IMAGE_T=$(git -C "$SUBJECT_ROOT" rev-parse HEAD) +git -C "$SUBJECT_ROOT" checkout -q --detach "$EXECUTABLE_IMAGE_T" +git -C "$SUBJECT_ROOT" show \ + "$EXECUTABLE_IMAGE_T:.github/docker/build-app-cli/image.json" >"$IMAGE_JSON" +invoke "$VERIFY" --gate-root "$GATE_ROOT" --subject-root "$SUBJECT_ROOT" \ + --gate-sha "$G" --candidate-sha "$EXECUTABLE_IMAGE_T" --image-json "$IMAGE_JSON" \ + --evidence-json "$EVIDENCE_JSON" +assert_status "an executable candidate image record is rejected" 1 +assert_no_network "executable image record rejection is pre-network" + +reset_case +chmod 0755 "$SUBJECT_ROOT/.github/docker/build-app-cli/image-release-evidence.json" +git -C "$SUBJECT_ROOT" add .github/docker/build-app-cli/image-release-evidence.json +git -C "$SUBJECT_ROOT" commit -q -m executable-evidence-record +EXECUTABLE_EVIDENCE_T=$(git -C "$SUBJECT_ROOT" rev-parse HEAD) +git -C "$SUBJECT_ROOT" checkout -q --detach "$EXECUTABLE_EVIDENCE_T" +git -C "$SUBJECT_ROOT" show \ + "$EXECUTABLE_EVIDENCE_T:.github/docker/build-app-cli/image-release-evidence.json" \ + >"$EVIDENCE_JSON" +invoke "$VERIFY" --gate-root "$GATE_ROOT" --subject-root "$SUBJECT_ROOT" \ + --gate-sha "$G" --candidate-sha "$EXECUTABLE_EVIDENCE_T" --image-json "$IMAGE_JSON" \ + --evidence-json "$EVIDENCE_JSON" +assert_status "an executable candidate evidence record is rejected" 1 +assert_no_network "executable evidence record rejection is pre-network" + +reset_case +ln -s "$IMAGE_JSON" "$RECORD_ROOT/image-link.json" +invoke "$VERIFY" --gate-root "$GATE_ROOT" --subject-root "$SUBJECT_ROOT" \ + --gate-sha "$G" --candidate-sha "$T" --image-json "$RECORD_ROOT/image-link.json" \ + --evidence-json "$EVIDENCE_JSON" +assert_status "a symlink input record is rejected" 1 +assert_no_network "symlink input rejection is pre-network" +rm -f "$RECORD_ROOT/image-link.json" + +reset_case +invoke "$VERIFY" --gate-root "$GATE_ROOT" --subject-root "$SUBJECT_ROOT" \ + --gate-sha "$G" --candidate-sha "$T" --image-json records/image.json \ + --evidence-json "$EVIDENCE_JSON" +assert_status "a non-absolute input record is rejected" 1 +assert_no_network "non-absolute input rejection is pre-network" + +reset_case +invoke "$VERIFY" --gate-root "$GATE_ROOT" --subject-root "$SUBJECT_ROOT" \ + --gate-sha "$G" --candidate-sha "$T" \ + --image-json "$SUBJECT_ROOT/.github/docker/build-app-cli/image.json" \ + --evidence-json "$EVIDENCE_JSON" +assert_status "an input record inside a repository is rejected" 1 +assert_no_network "inside-repository input rejection is pre-network" + +reset_case +printf '%s' '{"bad":true}' >"$SUBJECT_ROOT/.github/docker/build-app-cli/image.json" +git -C "$SUBJECT_ROOT" add .github/docker/build-app-cli/image.json +git -C "$SUBJECT_ROOT" commit -q -m invalid-record +BAD_T=$(git -C "$SUBJECT_ROOT" rev-parse HEAD) +git -C "$SUBJECT_ROOT" checkout -q --detach "$BAD_T" +git -C "$SUBJECT_ROOT" show "$BAD_T:.github/docker/build-app-cli/image.json" >"$IMAGE_JSON" +invoke "$VERIFY" --gate-root "$GATE_ROOT" --subject-root "$SUBJECT_ROOT" \ + --gate-sha "$G" --candidate-sha "$BAD_T" --image-json "$IMAGE_JSON" \ + --evidence-json "$EVIDENCE_JSON" +assert_status "an invalid exact candidate record pair is rejected" 1 +assert_no_network "record validation runs before network" + +reset_case +printf x >"$GATE_ROOT/dirty" +invoke_default +assert_status "untracked gate state is rejected" 1 +assert_no_network "dirty gate rejection is pre-network" + +reset_case +printf x >>"$SUBJECT_ROOT/.github/docker/build-app-cli/image.json" +invoke_default +assert_status "dirty tracked subject state is rejected" 1 +assert_no_network "dirty subject rejection is pre-network" + +reset_case +printf x >>"$SUBJECT_ROOT/.github/docker/build-app-cli/image.json" +git -C "$SUBJECT_ROOT" add .github/docker/build-app-cli/image.json +invoke_default +assert_status "dirty subject index state is rejected" 1 +assert_no_network "dirty index rejection is pre-network" + +reset_case +git -C "$GATE_ROOT" checkout -q main +invoke_default +assert_status "an attached gate checkout is rejected" 1 +assert_no_network "attached checkout rejection is pre-network" + +reset_case +git -C "$SUBJECT_ROOT" config core.sparseCheckout true +invoke_default +assert_status "a sparse subject checkout is rejected" 1 +assert_no_network "sparse checkout rejection is pre-network" + +reset_case +git -C "$GATE_ROOT" replace "$G" "$G^" >/dev/null 2>&1 || true +invoke_default +assert_status "gate replacement refs are rejected" 1 +assert_no_network "replacement-ref rejection is pre-network" + +reset_case +: >"$GATE_ROOT/.git/info/grafts" +invoke_default +assert_status "legacy gate grafts are rejected" 1 +assert_no_network "graft rejection is pre-network" + +reset_case +invoke "$VERIFY" --gate-root "$GATE_ROOT/.github" --subject-root "$SUBJECT_ROOT" \ + --gate-sha "$G" --candidate-sha "$T" --image-json "$IMAGE_JSON" \ + --evidence-json "$EVIDENCE_JSON" +assert_status "a non-top-level gate root is rejected" 1 +assert_no_network "top-level rejection is pre-network" + +reset_case +ln -s "$GATE_ROOT" "$WORK/gate-link" +invoke "$WORK/gate-link/.github/docker/build-app-cli/verify-build-container-publication.sh" \ + --gate-root "$WORK/gate-link" --subject-root "$SUBJECT_ROOT" --gate-sha "$G" \ + --candidate-sha "$T" --image-json "$IMAGE_JSON" --evidence-json "$EVIDENCE_JSON" +assert_status "a non-canonical gate root is rejected" 1 +assert_no_network "canonical-root rejection is pre-network" +rm -f "$WORK/gate-link" + +reset_case +invoke "$VERIFY" --gate-root "$GATE_ROOT" --subject-root "$GATE_ROOT" \ + --gate-sha "$G" --candidate-sha "$G" --image-json "$IMAGE_JSON" \ + --evidence-json "$EVIDENCE_JSON" +assert_status "gate and subject roots must be separate repositories" 1 +assert_no_network "same-repository rejection is pre-network" + +reset_case +git -C "$SUBJECT_ROOT" update-index --add --cacheinfo "160000,$G,fixture-submodule" +git -C "$SUBJECT_ROOT" commit -q -m gitlink +SUBMODULE_T=$(git -C "$SUBJECT_ROOT" rev-parse HEAD) +git -C "$SUBJECT_ROOT" checkout -q --detach "$SUBMODULE_T" +invoke "$VERIFY" --gate-root "$GATE_ROOT" --subject-root "$SUBJECT_ROOT" \ + --gate-sha "$G" --candidate-sha "$SUBMODULE_T" --image-json "$IMAGE_JSON" \ + --evidence-json "$EVIDENCE_JSON" +assert_status "committed submodule state is rejected" 1 +assert_no_network "submodule rejection is pre-network" + +reset_case +SHALLOW_GATE="$WORK/shallow-gate-$case_number" +git clone -q --depth 1 "file://$GATE_ROOT" "$SHALLOW_GATE" +git -C "$SHALLOW_GATE" checkout -q --detach "$G" +invoke "$SHALLOW_GATE/.github/docker/build-app-cli/verify-build-container-publication.sh" \ + --gate-root "$SHALLOW_GATE" --subject-root "$SUBJECT_ROOT" --gate-sha "$G" \ + --candidate-sha "$T" --image-json "$IMAGE_JSON" --evidence-json "$EVIDENCE_JSON" +assert_status "a shallow gate repository is rejected" 1 +assert_no_network "shallow repository rejection is pre-network" + +reset_case +git -C "$SUBJECT_ROOT" config extensions.partialClone origin +invoke_default +assert_status "partial-clone configuration is rejected" 1 +assert_no_network "partial-clone rejection is pre-network" + +reset_case +git -C "$SUBJECT_ROOT" config remote.origin.promisor true +invoke_default +assert_status "promisor configuration is rejected" 1 +assert_no_network "promisor rejection is pre-network" + +reset_case +printf '%s\n' "$GATE_ROOT/.git/objects" >"$SUBJECT_ROOT/.git/objects/info/alternates" +invoke_default +assert_status "an on-disk object alternate is rejected" 1 +assert_no_network "on-disk alternate rejection is pre-network" + +reset_case +GIT_ALTERNATE_OBJECT_DIRECTORIES="$GATE_ROOT/.git/objects" invoke_default +assert_status "an environment-provided object alternate is rejected" 1 +assert_no_network "environment alternate rejection is pre-network" + +reset_case +SHARED_OBJECT_BACKUP="$WORK/subject-objects-$case_number" +cp -R "$SUBJECT_ROOT/.git/objects/." "$GATE_ROOT/.git/objects/" +mv "$SUBJECT_ROOT/.git/objects" "$SHARED_OBJECT_BACKUP" +ln -s "$GATE_ROOT/.git/objects" "$SUBJECT_ROOT/.git/objects" +invoke_default +rm "$SUBJECT_ROOT/.git/objects" +mv "$SHARED_OBJECT_BACKUP" "$SUBJECT_ROOT/.git/objects" +assert_status "repositories with the same canonical object store are rejected" 1 +assert_no_network "shared object-store rejection is pre-network" + +reset_case +write_run "$FAKE_BIN/run.1.body" queued null +write_run "$FAKE_BIN/run.2.body" +invoke_default +assert_silent_success "an incomplete run is polled to successful completion" +assert_eq "one incomplete poll sleeps exactly once" 10 "$(file_value_or_zero "$FAKE_BIN/sleeps")" +assert_eq "polling is immediate and ordered" \ + "curl $API_ROOT/$RUN_ID +sleep 10 +curl $API_ROOT/$RUN_ID +curl $API_ROOT/$RUN_ID/attempts/$RUN_ATTEMPT/jobs?per_page=100&page=1 +curl $API_ROOT/$RUN_ID/approvals +curl $API_ROOT/$RUN_ID" "$(if [[ -f "$FAKE_BIN/events" ]]; then cat "$FAKE_BIN/events"; fi)" + +reset_case +write_run "$FAKE_BIN/run.1.body" +write_run "$FAKE_BIN/run.2.body" queued null "$RUN_ID" 3 +invoke_default +assert_status "a rerun beginning before the final detail read is rejected" 1 +assert_eq "rerun drift is detected by the final linearization read" 2 \ + "$(file_value_or_zero "$FAKE_BIN/run-count")" + +reset_case +write_run "$FAKE_BIN/run.body" queued null +invoke_default +assert_status "thirty incomplete polls fail closed" 1 +assert_eq "polling stops after exactly thirty reads" 30 "$(file_value_or_zero "$FAKE_BIN/run-count")" +assert_eq "there is no sleep after the final poll" 29 "$(line_count_or_zero "$FAKE_BIN/sleeps")" + +for endpoint in run jobs approvals; do + reset_case + printf '500' >"$FAKE_BIN/$endpoint.status" + invoke_default + assert_status "$endpoint HTTP status failure is terminal" 1 + + reset_case + printf '2022-11-28' >"$FAKE_BIN/$endpoint.version" + invoke_default + assert_status "$endpoint API version mismatch is terminal" 1 + + reset_case + printf 'application/json; charset=us-ascii' >"$FAKE_BIN/$endpoint.media" + invoke_default + assert_status "$endpoint content type mismatch is terminal" 1 + + reset_case + : >"$FAKE_BIN/$endpoint.raw-metadata" + invoke_default + assert_status "$endpoint malformed metadata is terminal" 1 + + reset_case + : >"$FAKE_BIN/$endpoint.transport-failure" + invoke_default + assert_status "$endpoint curl failure is terminal" 1 +done + +for endpoint in run jobs approvals; do + reset_case + printf 'application/json' >"$FAKE_BIN/$endpoint.media" + invoke_default + assert_silent_success "$endpoint accepts bare application/json" +done + +reset_case +invoke_default +expected_urls="$API_ROOT/$RUN_ID +$API_ROOT/$RUN_ID/attempts/$RUN_ATTEMPT/jobs?per_page=100&page=1 +$API_ROOT/$RUN_ID/approvals +$API_ROOT/$RUN_ID" +actual_urls=$(sed -n 's/^curl //p' "$FAKE_BIN/events" 2>/dev/null || true) +assert_eq "only the four exact read endpoints and query are used" "$expected_urls" "$actual_urls" +assert_complete_curl_request "initial run GET has exact closed curl argv" 1 "$API_ROOT/$RUN_ID" +assert_complete_curl_request "jobs GET has exact closed curl argv" 2 \ + "$API_ROOT/$RUN_ID/attempts/$RUN_ATTEMPT/jobs?per_page=100&page=1" +assert_complete_curl_request "approvals GET has exact closed curl argv" 3 \ + "$API_ROOT/$RUN_ID/approvals" +assert_complete_curl_request "final run GET has exact closed curl argv" 4 "$API_ROOT/$RUN_ID" + +for mutation in \ + '"9007199254740993" 2' '9007199254740993.0 2' '0 2' \ + '18446744073709551616 2' '9007199254740992 2' \ + '9007199254740993 "2"' '9007199254740993 2.0' \ + '9007199254740993 0' '9007199254740993 4294967296'; do + read -r bad_id bad_attempt <<<"$mutation" + reset_case + write_run "$FAKE_BIN/run.body" completed '"success"' "$bad_id" "$bad_attempt" + invoke_default + assert_status "run identifiers reject $mutation without lossy conversion" 1 +done + +for field_value in \ + 'event workflow_dispatch' \ + 'path .github/workflows/publish-build-container.yml' \ + 'path .github/workflows/publish-build-container.yml@build-container-v8' \ + "head_sha $(printf '6%.0s' {1..40})" \ + 'head_branch build-container-v8'; do + read -r field value <<<"$field_value" + reset_case + event=push + workflow_path=.github/workflows/publish-build-container.yml@$TAG + head_sha=$SOURCE + head_branch=$TAG + case "$field" in + event) event=$value ;; + path) workflow_path=$value ;; + head_sha) head_sha=$value ;; + head_branch) head_branch=$value ;; + esac + write_run "$FAKE_BIN/run.body" completed '"success"' "$RUN_ID" "$RUN_ATTEMPT" \ + "$event" "$workflow_path" "$head_sha" "$head_branch" + invoke_default + assert_status "run identity rejects $field substitution" 1 +done + +reset_case +write_run "$FAKE_BIN/run.1.body" queued null +write_run "$FAKE_BIN/run.2.body" completed '"success"' "$RUN_ID" "$RUN_ATTEMPT" \ + push ".github/workflows/publish-build-container.yml@$TAG" "$SOURCE" build-container-v8 +invoke_default +assert_status "immutable run identity drift between polls is rejected" 1 +assert_eq "identity drift is not retried" 2 "$(file_value_or_zero "$FAKE_BIN/run-count")" + +reset_case +write_run "$FAKE_BIN/run.body" completed '"failure"' +invoke_default +assert_status "a completed unsuccessful run is rejected" 1 + +reset_case +printf '%s' '[]' >"$FAKE_BIN/run.body" +invoke_default +assert_status "a non-object run response is rejected" 1 + +reset_case +jq '.total_count=3' "$FAKE_BIN/jobs.body" >"$WORK/jobs" && mv "$WORK/jobs" "$FAKE_BIN/jobs.body" +invoke_default +assert_status "jobs total_count must be exactly two" 1 + +reset_case +sed 's/"total_count":2/"total_count":2.0/' "$FAKE_BIN/jobs.body" >"$WORK/jobs" +mv "$WORK/jobs" "$FAKE_BIN/jobs.body" +invoke_default +assert_status "jobs total_count must use JSON integer spelling" 1 + +reset_case +jq '.jobs |= .[0:1]' "$FAKE_BIN/jobs.body" >"$WORK/jobs" && mv "$WORK/jobs" "$FAKE_BIN/jobs.body" +invoke_default +assert_status "jobs array length must be exactly two" 1 + +reset_case +jq '.jobs[1].name="build-and-verify"' "$FAKE_BIN/jobs.body" >"$WORK/jobs" && mv "$WORK/jobs" "$FAKE_BIN/jobs.body" +invoke_default +assert_status "publisher job names must be unique and exact" 1 + +for mutation in \ + '.jobs[0].conclusion="failure"' '.jobs[0].status="in_progress"' \ + '.jobs[0].head_sha="0000000000000000000000000000000000000000"' \ + '.jobs[0].run_attempt=1' '.jobs[0].run_attempt="2"' \ + 'del(.jobs[0].steps[1])' '.jobs[0].steps[1].conclusion="failure"' \ + '.jobs[0].steps += [{"name":"assert-exact-publisher-context","conclusion":"success"}]'; do + reset_case + jq "$mutation" "$FAKE_BIN/jobs.body" >"$WORK/jobs" && mv "$WORK/jobs" "$FAKE_BIN/jobs.body" + invoke_default + assert_status "jobs reject $mutation" 1 +done + +reset_case +sed 's/"run_attempt":2/"run_attempt":2.0/' "$FAKE_BIN/jobs.body" >"$WORK/jobs" +mv "$WORK/jobs" "$FAKE_BIN/jobs.body" +invoke_default +assert_status "job run_attempt must use JSON integer spelling when present" 1 + +reset_case +printf '; rel="next"' >"$FAKE_BIN/jobs.link" +invoke_default +assert_status "a jobs Link continuation is rejected" 1 + +reset_case +printf '{}' >"$FAKE_BIN/approvals.body" +invoke_default +assert_status "the approvals endpoint must return exactly one array" 1 + +reset_case +write_approvals +invoke_default +assert_status "a missing current approval is rejected" 1 + +reset_case +current=$(review_json approved build-container-release "$(protocol_comment)") +write_approvals "$current" "$current" +invoke_default +assert_status "duplicate current approval records are rejected" 1 + +for state in rejected pending bypassed; do + reset_case + write_current_review "$state" + invoke_default + assert_status "a $state current review is rejected" 1 +done + +reset_case +write_current_review approved wrong-environment +invoke_default +assert_status "a current review for the wrong environment is rejected" 1 + +reset_case +write_current_review approved build-container-release "$(protocol_comment)" another-reviewer +invoke_default +assert_status "the API reviewer must equal the evidence approver" 1 + +reset_case +write_approvals "$(reviewer_only_json approved build-container-release "$(protocol_comment)")" +invoke_default +assert_status "a reviewer-only approval identity is rejected" 1 + +reset_case +write_current_review approved build-container-release \ + "$(protocol_comment "$RUN_ATTEMPT" "$RUN_ID" "$(printf '6%.0s' {1..64})")" +invoke_default +assert_status "a mismatched current protocol field is rejected" 1 + +reset_case +duplicate_comment="edgezero-release-evidence-v1 {\"challenge\":\"$CHALLENGE\",\"challenge\":\"$CHALLENGE\",\"image-digest\":\"$DIGEST\",\"png-sha256\":\"$SCREENSHOT\",\"release-tag\":\"$TAG\",\"reviewed-at\":\"$REVIEWED_AT\",\"run-attempt\":\"$RUN_ATTEMPT\",\"run-id\":\"$RUN_ID\",\"source-revision\":\"$SOURCE\"}" +write_current_review approved build-container-release "$duplicate_comment" +invoke_default +assert_status "raw duplicate protocol keys are rejected" 1 + +reset_case +malformed='edgezero-release-evidence-v1 {"challenge":' +write_current_review approved build-container-release "$malformed" +invoke_default +assert_status "malformed current protocol JSON is rejected" 1 + +reset_case +earlier=$(review_json rejected wrong-environment \ + "$(protocol_comment 1 "$RUN_ID" "$(printf '8%.0s' {1..64})")" nobody) +current=$(review_json approved build-container-release "$(protocol_comment)") +write_approvals "$earlier" "$current" +invoke_default +assert_silent_success "well-formed earlier attempts are inert" + +reset_case +earlier_bad=$(review_json approved build-container-release \ + 'edgezero-release-evidence-v1 {"run-attempt":"1"}') +current=$(review_json approved build-container-release "$(protocol_comment)") +write_approvals "$earlier_bad" "$current" +invoke_default +assert_status "malformed earlier protocol comments still fail" 1 + +reset_case +future=$(review_json approved build-container-release "$(protocol_comment 3)") +current=$(review_json approved build-container-release "$(protocol_comment)") +write_approvals "$current" "$future" +invoke_default +assert_status "future-attempt protocol comments are rejected" 1 + +reset_case +wrong_run=$(review_json approved build-container-release \ + "$(protocol_comment "$RUN_ATTEMPT" 9007199254740994)") +write_approvals "$wrong_run" +invoke_default +assert_status "a protocol comment for a different run is rejected" 1 + +reset_case +: >"$FAKE_BIN/run.transport-failure" +invoke_default +if ! rg -F -q -e "$TOKEN_VALUE" -e secret-response-body -e "$IMAGE_CONTENT" \ + -e "$EVIDENCE_CONTENT" "$WORK/stdout" "$WORK/stderr"; then + ok "curl errors, response bodies, records, and tokens are redacted" +else + no "curl errors, response bodies, records, and tokens are redacted" +fi + +reset_case +invoke_default +if [[ -f "$FAKE_BIN/args-1" ]] && + ! rg -q '^POST$|^PATCH$|^PUT$|^DELETE$|--data|--upload-file' "$FAKE_BIN"/args-*; then + ok "the verifier performs no mutation requests" +else + no "the verifier performs no mutation requests" +fi + +printf 'Passed: %d Failed: %d\n' "$pass" "$fail" +[[ "$fail" -eq 0 ]] diff --git a/.github/actions/deploy-core/tests/verify-gate-rotation-lock.test.sh b/.github/actions/deploy-core/tests/verify-gate-rotation-lock.test.sh new file mode 100755 index 00000000..a342fe62 --- /dev/null +++ b/.github/actions/deploy-core/tests/verify-gate-rotation-lock.test.sh @@ -0,0 +1,1112 @@ +#!/usr/bin/env bash +# shellcheck disable=SC2016 +set -euo pipefail + +DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +REAL_VERIFY="$DIR/../../../docker/build-app-cli/verify-gate-rotation-lock.sh" +REAL_JQ=$(command -v jq) +REAL_BASH=$(command -v bash) +WORK=$(mktemp -d) +WORK=$(cd -- "$WORK" && pwd -P) +trap 'rm -rf -- "$WORK"' EXIT + +TOKEN_VALUE='fixture-github-token' +RUN_ID=9007199254740993 +RUN_ATTEMPT=2 +RUN_NUMBER=10 +OLD_RUN_ID=18446744073709551614 +OLD_RUN_ATTEMPT=1 +OLD_RUN_NUMBER=9 +POLICY_DIGEST="sha256:$(printf '7%.0s' {1..64})" +OUTER_DIGEST="sha256:$(printf '8%.0s' {1..64})" +PREVIOUS_DIGEST="sha256:$(printf '9%.0s' {1..64})" +SOURCE_PR=42 +EVIDENCE_COMMENT_ID=9007199254740997 +EVIDENCE_URL="https://github.com/stackpop/edgezero/pull/$SOURCE_PR#issuecomment-$EVIDENCE_COMMENT_ID" +ROTATION_PATH='.github/workflows/rotate-build-container-gate.yml@main' +API_BASE=https://api.github.com/repos/stackpop/edgezero +HISTORY_URL="$API_BASE/actions/workflows/rotate-build-container-gate.yml/runs?event=workflow_dispatch&per_page=100&page=1" + +format_epoch() { + local epoch=$1 + if date -u -r "$epoch" '+%Y-%m-%dT%H:%M:%SZ' 2>/dev/null; then + return + fi + date -u -d "@$epoch" '+%Y-%m-%dT%H:%M:%SZ' +} + +NOW_EPOCH=$(date -u '+%s') +OLD_CREATED=$(format_epoch $((NOW_EPOCH - 720))) +RUN_CREATED=$(format_epoch $((NOW_EPOCH - 600))) +AUDITED_AT=$(format_epoch $((NOW_EPOCH - 300))) +REVIEWED_AT=$(format_epoch $((NOW_EPOCH - 240))) +STEP_COMPLETED=$(format_epoch $((NOW_EPOCH - 180))) +STALE_AUDITED=$(format_epoch $((NOW_EPOCH - 1200))) + +pass=0 +fail=0 + +ok() { + printf ' \033[32mok\033[0m %s\n' "$1" + pass=$((pass + 1)) +} + +no() { + printf ' \033[31mFAIL\033[0m %s\n' "$1" >&2 + fail=$((fail + 1)) +} + +GATE_ROOT="$WORK/gate" +INPUT_ROOT="$WORK/input" +FAKE_BIN="$WORK/fake-bin" +mkdir -p "$GATE_ROOT/.github/docker/build-app-cli" "$INPUT_ROOT" "$FAKE_BIN" +cp "$REAL_VERIFY" "$GATE_ROOT/.github/docker/build-app-cli/verify-gate-rotation-lock.sh" +chmod 0755 "$GATE_ROOT/.github/docker/build-app-cli/verify-gate-rotation-lock.sh" +printf 'old-gate\n' >"$GATE_ROOT/.github/docker/build-app-cli/gate-marker" +printf '%s\n' \ + '.github/docker/build-app-cli/gate-marker' \ + '.github/docker/build-app-cli/gate-paths.txt' \ + '.github/docker/build-app-cli/verify-gate-rotation-lock.sh' \ + >"$GATE_ROOT/.github/docker/build-app-cli/gate-paths.txt" + +git -C "$GATE_ROOT" init -q -b main +git -C "$GATE_ROOT" config user.name fixture +git -C "$GATE_ROOT" config user.email fixture@example.invalid +git -C "$GATE_ROOT" add . +git -C "$GATE_ROOT" commit -q -m old-gate +G=$(git -C "$GATE_ROOT" rev-parse HEAD) + +printf 'dispatch\n' >"$GATE_ROOT/dispatch-marker" +git -C "$GATE_ROOT" add dispatch-marker +git -C "$GATE_ROOT" commit -q -m dispatch-snapshot +QD=$(git -C "$GATE_ROOT" rev-parse HEAD) + +printf 'new-gate\n' >"$GATE_ROOT/.github/docker/build-app-cli/gate-marker" +git -C "$GATE_ROOT" add .github/docker/build-app-cli/gate-marker +git -C "$GATE_ROOT" commit -q -m new-gate +G_PRIME=$(git -C "$GATE_ROOT" rev-parse HEAD) + +printf 'final\n' >"$GATE_ROOT/final-marker" +git -C "$GATE_ROOT" add final-marker +git -C "$GATE_ROOT" commit -q -m final-head +QF=$(git -C "$GATE_ROOT" rev-parse HEAD) + +printf 'source\n' >"$GATE_ROOT/source-marker" +git -C "$GATE_ROOT" add source-marker +git -C "$GATE_ROOT" commit -q -m release-source +S=$(git -C "$GATE_ROOT" rev-parse HEAD) + +git -C "$GATE_ROOT" checkout -q -b main-content-drift "$S" +printf 'drifted-gate\n' >"$GATE_ROOT/.github/docker/build-app-cli/gate-marker" +git -C "$GATE_ROOT" add .github/docker/build-app-cli/gate-marker +git -C "$GATE_ROOT" commit -q -m main-content-drift +MAIN_CONTENT_DRIFT=$(git -C "$GATE_ROOT" rev-parse HEAD) + +git -C "$GATE_ROOT" checkout -q -b main-mode-drift "$S" +chmod 0755 "$GATE_ROOT/.github/docker/build-app-cli/gate-marker" +git -C "$GATE_ROOT" add .github/docker/build-app-cli/gate-marker +git -C "$GATE_ROOT" commit -q -m main-mode-drift +MAIN_MODE_DRIFT=$(git -C "$GATE_ROOT" rev-parse HEAD) + +git -C "$GATE_ROOT" checkout -q -b main-type-drift "$S" +git -C "$GATE_ROOT" rm -q .github/docker/build-app-cli/gate-marker +mkdir "$GATE_ROOT/.github/docker/build-app-cli/gate-marker" +printf 'nested\n' >"$GATE_ROOT/.github/docker/build-app-cli/gate-marker/value" +git -C "$GATE_ROOT" add .github/docker/build-app-cli/gate-marker/value +git -C "$GATE_ROOT" commit -q -m main-type-drift +MAIN_TYPE_DRIFT=$(git -C "$GATE_ROOT" rev-parse HEAD) + +git -C "$GATE_ROOT" checkout -q -b main-absence-drift "$S" +git -C "$GATE_ROOT" rm -q .github/docker/build-app-cli/gate-marker +git -C "$GATE_ROOT" commit -q -m main-absence-drift +MAIN_ABSENCE_DRIFT=$(git -C "$GATE_ROOT" rev-parse HEAD) + +git -C "$GATE_ROOT" checkout -q -b rollback "$G_PRIME" +git -C "$GATE_ROOT" show "$G:.github/docker/build-app-cli/gate-marker" \ + >"$GATE_ROOT/.github/docker/build-app-cli/gate-marker" +git -C "$GATE_ROOT" add .github/docker/build-app-cli/gate-marker +git -C "$GATE_ROOT" commit -q -m restore-old-gate +printf 'rollback-final\n' >"$GATE_ROOT/rollback-final-marker" +git -C "$GATE_ROOT" add rollback-final-marker +git -C "$GATE_ROOT" commit -q -m rollback-final-head +QF_ROLLBACK=$(git -C "$GATE_ROOT" rev-parse HEAD) +git -C "$GATE_ROOT" branch source "$S" +git -C "$GATE_ROOT" checkout -q --detach "$G" + +VERIFY="$GATE_ROOT/.github/docker/build-app-cli/verify-gate-rotation-lock.sh" +PREREQUISITE="$INPUT_ROOT/publisher-prerequisite.json" + +cat >"$FAKE_BIN/jq" <"\$fixture/jq-count" +exec "$REAL_JQ" "\$@" +SH +chmod 0755 "$FAKE_BIN/jq" + +cat >"$FAKE_BIN/curl" <<'SH' +#!/usr/bin/env bash +set -euo pipefail +fixture=$(cd -- "$(dirname -- "$0")" && pwd) +[[ "${LC_ALL:-}" == C ]] +[[ -z "${GITHUB_TOKEN+x}${GH_TOKEN+x}${TOKEN+x}${HOME+x}${CURL_HOME+x}${XDG_CONFIG_HOME+x}" ]] +[[ -z "${HTTP_PROXY+x}${HTTPS_PROXY+x}${ALL_PROXY+x}${NO_PROXY+x}${AMBIENT_SECRET+x}" ]] +[[ -z "${BASH_ENV+x}${ENV+x}${GIT_DIR+x}${GIT_WORK_TREE+x}" ]] +count=0 +[[ ! -f "$fixture/curl-count" ]] || count=$(<"$fixture/curl-count") +count=$((count + 1)) +printf '%s' "$count" >"$fixture/curl-count" +printf '%s\n' "$@" >"$fixture/args-$count" +cat >"$fixture/config-$count" +output= +url= +while (($#)); do + case "$1" in + --output) output=$2; shift 2 ;; + https://*) url=$1; shift ;; + *) shift ;; + esac +done +[[ -n "$output" && -n "$url" ]] +case "$url" in + */actions/workflows/rotate-build-container-gate.yml/runs\?event=workflow_dispatch\&per_page=100\&page=*) + page=${url##*page=}; endpoint="history-$page" ;; + */actions/runs/*/attempts/*/jobs\?per_page=100\&page=*) + page=${url##*page=}; endpoint="jobs-$page" ;; + */actions/runs/*/approvals) endpoint=approvals ;; + */actions/runs/*) endpoint=run ;; + */git/ref/heads/main) endpoint=main ;; + *) printf 'unexpected URL: %s\n' "$url" >&2; exit 97 ;; +esac +endpoint_count=0 +[[ ! -f "$fixture/$endpoint-count" ]] || endpoint_count=$(<"$fixture/$endpoint-count") +endpoint_count=$((endpoint_count + 1)) +printf '%s' "$endpoint_count" >"$fixture/$endpoint-count" +printf 'curl %s\n' "$url" >>"$fixture/events" +if [[ -f "$fixture/$endpoint.transport-failure" ]]; then + printf '%s\n' 'curl: fixture-github-token secret-response-body' >&2 + exit 28 +fi +body="$fixture/$endpoint.body" +metadata_suffix= +[[ ! -f "$fixture/$endpoint.$endpoint_count.body" ]] || body="$fixture/$endpoint.$endpoint_count.body" +[[ ! -f "$fixture/$endpoint.$endpoint_count.status" ]] || metadata_suffix=.$endpoint_count +[[ -f "$body" ]] || { printf 'missing fixture body: %s\n' "$body" >&2; exit 98; } +cat "$body" >"$output" +if [[ -f "$fixture/$endpoint.raw-metadata" ]]; then + cat "$fixture/$endpoint.raw-metadata" +else + printf '%s\n%s\n%s\nlink=%s' \ + "$(<"$fixture/$endpoint$metadata_suffix.status")" \ + "$(<"$fixture/$endpoint$metadata_suffix.version")" \ + "$(<"$fixture/$endpoint$metadata_suffix.media")" \ + "$(<"$fixture/$endpoint$metadata_suffix.link")" +fi +SH +chmod 0755 "$FAKE_BIN/curl" + +hash_bytes() { + local value=$1 output + output=$(printf '%s' "$value" | sha256sum) + printf '%s' "${output%% *}" +} + +write_run() { + local status=${1:-completed} conclusion=${2:-'"success"'} id=${3:-$RUN_ID} + local attempt=${4:-$RUN_ATTEMPT} number=${5:-$RUN_NUMBER} head=${6:-$QD} + local actor=${7:-rotation-operator} event=${8:-workflow_dispatch} + local path=${9:-$ROTATION_PATH} repository=${10:-stackpop/edgezero} + local created=${11:-$RUN_CREATED} head_branch=${12:-main} + printf '%s' "{\"id\":$id,\"run_number\":$number,\"run_attempt\":$attempt,\"created_at\":\"$created\",\"status\":\"$status\",\"conclusion\":$conclusion,\"event\":\"$event\",\"path\":\"$path\",\"head_sha\":\"$head\",\"head_branch\":\"$head_branch\",\"actor\":{\"login\":\"$actor\"},\"repository\":{\"full_name\":\"$repository\"},\"head_repository\":{\"full_name\":\"$repository\"}}" >"$FAKE_BIN/run.body" +} + +history_entry() { + printf '{"id":%s,"run_number":%s,"run_attempt":%s,"created_at":"%s"}' "$2" "$1" "$3" "$4" +} + +write_default_history() { + local old selected + old=$(history_entry "$OLD_RUN_NUMBER" "$OLD_RUN_ID" "$OLD_RUN_ATTEMPT" "$OLD_CREATED") + selected=$(history_entry "$RUN_NUMBER" "$RUN_ID" "$RUN_ATTEMPT" "$RUN_CREATED") + printf '%s' "{\"total_count\":2,\"workflow_runs\":[$selected,$old]}" >"$FAKE_BIN/history-1.body" + HISTORY_SNAPSHOT="[{\"run-attempt\":\"$OLD_RUN_ATTEMPT\",\"run-id\":\"$OLD_RUN_ID\",\"run-number\":\"$OLD_RUN_NUMBER\"},{\"run-attempt\":\"$RUN_ATTEMPT\",\"run-id\":\"$RUN_ID\",\"run-number\":\"$RUN_NUMBER\"}]" + HISTORY_DIGEST="sha256:$(hash_bytes "$HISTORY_SNAPSHOT")" +} + +write_jobs() { + local step_conclusion=${1:-success} step_completed=${2:-$STEP_COMPLETED} + local attempt=${3:-$RUN_ATTEMPT} head=${4:-$QD} + printf '%s' "{\"total_count\":2,\"jobs\":[{\"id\":7001,\"name\":\"acquire-publication-lock\",\"status\":\"completed\",\"conclusion\":\"success\",\"head_sha\":\"$head\",\"run_attempt\":$attempt,\"steps\":[{\"name\":\"acquire\",\"conclusion\":\"success\",\"completed_at\":\"$step_completed\"}]},{\"id\":7002,\"name\":\"wait-for-rotation-approval\",\"status\":\"completed\",\"conclusion\":\"success\",\"head_sha\":\"$head\",\"run_attempt\":$attempt,\"steps\":[{\"name\":\"assert-exact-rotation-context\",\"status\":\"completed\",\"conclusion\":\"$step_conclusion\",\"completed_at\":\"$step_completed\"}]}]}" >"$FAKE_BIN/jobs-1.body" +} + +write_main() { + local sha=${1:-$S} ref=${2:-refs/heads/main} type=${3:-commit} + printf '%s' "{\"ref\":\"$ref\",\"object\":{\"sha\":\"$sha\",\"type\":\"$type\"}}" >"$FAKE_BIN/main.body" +} + +rotation_comment() { + local result=${1:-activated} final_head=${2:-$QF} new_gate=${3:-$G_PRIME} + local old_gate=${4:-$G} attempt=${5:-$RUN_ATTEMPT} id=${6:-$RUN_ID} + local dispatch=${7:-$QD} audit=${8:-$AUDITED_AT} review=${9:-$REVIEWED_AT} + local final_gate=$new_gate + [[ "$result" == activated ]] || final_gate=$old_gate + local policy="{\"audited-at\":\"$audit\",\"dispatch-sha\":\"$dispatch\",\"gate-sha\":\"$final_gate\",\"head-sha\":\"$final_head\",\"lock-run-attempt\":\"$attempt\",\"lock-run-id\":\"$id\",\"policy-sha256\":\"$POLICY_DIGEST\",\"release-state\":\"enabled\",\"required-workflow-sha\":\"$final_gate\"}" + local evidence + evidence="sha256:$(hash_bytes "$policy")" + printf '%s\n%s' \ + "edgezero-gate-rotation-v1 {\"evidence-sha256\":\"$evidence\",\"head-sha\":\"$final_head\",\"lock-run-id\":\"$id\",\"new-gate-sha\":\"$new_gate\",\"old-gate-sha\":\"$old_gate\",\"result\":\"$result\",\"reviewed-at\":\"$review\"}" \ + "edgezero-gate-rotation-policy-v1 $policy" +} + +review_json() { + local state=$1 environment=$2 comment=$3 reviewer=${4:-rotation-reviewer} + "$REAL_JQ" -cn --arg state "$state" --arg environment "$environment" \ + --arg comment "$comment" --arg reviewer "$reviewer" \ + '{state:$state,environments:[{name:$environment}],user:{login:$reviewer},comment:$comment}' +} + +write_approval() { + local comment=${1:-$(rotation_comment)} state=${2:-approved} + local environment=${3:-build-container-gate-rotation-lock} reviewer=${4:-rotation-reviewer} + printf '[%s]' "$(review_json "$state" "$environment" "$comment" "$reviewer")" >"$FAKE_BIN/approvals.body" +} + +write_verified_prerequisite() { + local gate=${1:-$G_PRIME} source=${2:-$S} history_digest=${3:-$HISTORY_DIGEST} + local evidence + evidence=${4:-$(rotation_comment | sed -n '1s/^edgezero-gate-rotation-v1 {"evidence-sha256":"\([^"]*\)".*/\1/p')} + local attempt=${5:-$RUN_ATTEMPT} id=${6:-$RUN_ID} number=${7:-$RUN_NUMBER} + local created=${8:-$RUN_CREATED} + printf '%s' "{\"evidence-sha256\":\"$OUTER_DIGEST\",\"evidence-url\":\"$EVIDENCE_URL\",\"gate-sha\":\"$gate\",\"previous-value-sha256\":\"$PREVIOUS_DIGEST\",\"rotation-history\":{\"created-at\":\"$created\",\"evidence-sha256\":\"$evidence\",\"history-sha256\":\"$history_digest\",\"run-attempt\":\"$attempt\",\"run-id\":\"$id\",\"run-number\":\"$number\",\"state\":\"verified\"},\"schema-version\":2,\"source-pr\":\"$SOURCE_PR\",\"source-revision\":\"$source\"}" >"$PREREQUISITE" +} + +write_bootstrap_prerequisite() { + printf '%s' "{\"evidence-sha256\":\"$OUTER_DIGEST\",\"evidence-url\":\"$EVIDENCE_URL\",\"gate-sha\":\"$G_PRIME\",\"previous-value-sha256\":\"$PREVIOUS_DIGEST\",\"rotation-history\":{\"state\":\"bootstrap-no-rotation\"},\"schema-version\":2,\"source-pr\":\"$SOURCE_PR\",\"source-revision\":\"$S\"}" >"$PREREQUISITE" +} + +write_inert_bootstrap_prerequisite() { + printf '%s' "{\"evidence-sha256\":\"$OUTER_DIGEST\",\"evidence-url\":null,\"gate-sha\":\"$G_PRIME\",\"previous-value-sha256\":\"$PREVIOUS_DIGEST\",\"rotation-history\":{\"state\":\"bootstrap-no-rotation\"},\"schema-version\":2,\"source-pr\":null,\"source-revision\":null}" >"$PREREQUISITE" +} + +set_metadata_defaults() { + local endpoint=$1 + printf '200' >"$FAKE_BIN/$endpoint.status" + printf '2026-03-10' >"$FAKE_BIN/$endpoint.version" + printf 'application/json; charset=utf-8' >"$FAKE_BIN/$endpoint.media" + : >"$FAKE_BIN/$endpoint.link" +} + +reset_api() { + rm -f "$FAKE_BIN"/{args-*,config-*,curl-count,jq-count,*-count,events} \ + "$FAKE_BIN"/*.transport-failure "$FAKE_BIN"/*.raw-metadata \ + "$FAKE_BIN"/*.body "$FAKE_BIN"/*.status "$FAKE_BIN"/*.version \ + "$FAKE_BIN"/*.media "$FAKE_BIN"/*.link + local endpoint + for endpoint in history-1 run jobs-1 approvals main; do set_metadata_defaults "$endpoint"; done +} + +prepare_waiting() { + local result=${1:-activated} + git -C "$GATE_ROOT" checkout -q --detach "$G" + reset_api + write_run in_progress null + if [[ "$result" == activated ]]; then + write_approval "$(rotation_comment activated "$QF")" + write_main "$QF" + else + write_approval "$(rotation_comment rolled-back "$QF_ROLLBACK")" + write_main "$QF_ROLLBACK" + fi +} + +prepare_publisher() { + git -C "$GATE_ROOT" checkout -q --detach "$G_PRIME" + reset_api + write_default_history + write_run + write_jobs + write_approval + write_main "$S" + write_verified_prerequisite +} + +prepare_publisher_rollback() { + local comment evidence + git -C "$GATE_ROOT" checkout -q --detach "$G" + reset_api + write_default_history + write_run + write_jobs + comment=$(rotation_comment rolled-back "$QF_ROLLBACK" "$G_PRIME" "$G") + write_approval "$comment" + write_main "$QF_ROLLBACK" + evidence=$(sed -n '1s/^edgezero-gate-rotation-v1 {"evidence-sha256":"\([^"]*\)".*/\1/p' <<<"$comment") + write_verified_prerequisite "$G" "$QF_ROLLBACK" "$HISTORY_DIGEST" "$evidence" +} + +waiting_args() { + printf '%s\0' waiting --gate-root "$GATE_ROOT" --old-gate-sha "$G" \ + --dispatch-sha "$QD" --run-id "$RUN_ID" --run-attempt "$RUN_ATTEMPT" \ + --run-actor-login rotation-operator +} + +publisher_args() { + printf '%s\0' publisher --gate-root "$GATE_ROOT" --gate-sha "$G_PRIME" \ + --source-revision "$S" --publisher-prerequisite-json "$PREREQUISITE" +} + +invoke() { + local -a args=("$@") + if env PATH="$FAKE_BIN:$PATH" GITHUB_TOKEN="$TOKEN_VALUE" \ + HOME="$WORK/hostile-home" CURL_HOME="$WORK/hostile-curl" \ + XDG_CONFIG_HOME="$WORK/hostile-xdg" HTTP_PROXY=http://proxy.invalid \ + HTTPS_PROXY=http://proxy.invalid ALL_PROXY=http://proxy.invalid NO_PROXY=invalid \ + GH_TOKEN=ambient-gh-token AMBIENT_SECRET=must-not-propagate \ + BASH_ENV= ENV= GIT_DIR="$WORK/hostile-git" GIT_WORK_TREE="$WORK/hostile-tree" \ + bash "$VERIFY" "${args[@]}" >"$WORK/stdout" 2>"$WORK/stderr"; then + CAPTURE_STATUS=0 + else + CAPTURE_STATUS=$? + fi +} + +invoke_waiting() { + local -a args=() + while IFS= read -r -d '' arg; do args+=("$arg"); done < <(waiting_args) + invoke "${args[@]}" +} + +invoke_publisher() { + local -a args=() + while IFS= read -r -d '' arg; do args+=("$arg"); done < <(publisher_args) + invoke "${args[@]}" +} + +invoke_publisher_at() { + local gate=$1 source=$2 + invoke publisher --gate-root "$GATE_ROOT" --gate-sha "$gate" \ + --source-revision "$source" --publisher-prerequisite-json "$PREREQUISITE" +} + +invoke_publisher_with_inherited_options() { + local -a args=() + while IFS= read -r -d '' arg; do args+=("$arg"); done < <(publisher_args) + if env PATH="$FAKE_BIN:$PATH" GITHUB_TOKEN="$TOKEN_VALUE" \ + GH_TOKEN=ambient-gh-token TOKEN=ambient-exported-token AMBIENT_SECRET=must-not-propagate \ + "$REAL_BASH" -xa "$VERIFY" "${args[@]}" >"$WORK/stdout" 2>"$WORK/stderr"; then + CAPTURE_STATUS=0 + else + CAPTURE_STATUS=$? + fi +} + +assert_status() { + local expected=$1 description=$2 + if [[ "$CAPTURE_STATUS" -eq "$expected" ]]; then + ok "$description" + else + no "$description" + printf ' expected status %s, got %s: ' "$expected" "$CAPTURE_STATUS" >&2 + sed -n '1p' "$WORK/stderr" >&2 + fi +} + +assert_silent_success() { + local description=$1 + if [[ "$CAPTURE_STATUS" -eq 0 && ! -s "$WORK/stdout" && ! -s "$WORK/stderr" ]]; then + ok "$description" + else + no "$description" + printf ' status %s: ' "$CAPTURE_STATUS" >&2 + sed -n '1p' "$WORK/stderr" >&2 + fi +} + +assert_no_network() { + local description=$1 + if [[ ! -e "$FAKE_BIN/curl-count" ]]; then ok "$description"; else no "$description"; fi +} + +assert_eq() { + local expected=$1 actual=$2 description=$3 + if [[ "$actual" == "$expected" ]]; then ok "$description"; else no "$description"; fi +} + +assert_error_contains() { + local expected=$1 description=$2 + if grep -Fq -e "$expected" "$WORK/stderr"; then ok "$description"; else no "$description"; fi +} + +file_or_zero() { + if [[ -f "$1" ]]; then cat "$1"; else printf 0; fi +} + +mutate_json() { + local filter=$1 file=$2 temporary="$WORK/mutated.json" + "$REAL_JQ" -c "$filter" "$file" >"$temporary" && mv "$temporary" "$file" +} + +echo '== gate rotation lock verifier ==' + +if env -i PATH="$WORK/no-tools" GITHUB_TOKEN="$TOKEN_VALUE" \ + "$REAL_BASH" "$VERIFY" invalid >"$WORK/stdout" 2>"$WORK/stderr"; then + CAPTURE_STATUS=0 +else + CAPTURE_STATUS=$? +fi +assert_status 2 'usage is builtin-only when no external tools are available' +assert_error_contains 'usage: verify-gate-rotation-lock.sh waiting' \ + 'builtin-only usage emits the documented interface' + +NO_DIRNAME_BIN="$WORK/no-dirname-bin" +mkdir "$NO_DIRNAME_BIN" +for tool in env git jq curl awk sed sort mktemp cmp sha256sum date wc tail od grep paste tr rm cat; do + ln -s "$(command -v "$tool")" "$NO_DIRNAME_BIN/$tool" +done +prepare_waiting +missing_tool_args=() +while IFS= read -r -d '' arg; do missing_tool_args+=("$arg"); done < <(waiting_args) +if env -i PATH="$NO_DIRNAME_BIN" GITHUB_TOKEN="$TOKEN_VALUE" \ + "$REAL_BASH" "$VERIFY" "${missing_tool_args[@]}" >"$WORK/stdout" 2>"$WORK/stderr"; then + CAPTURE_STATUS=0 +else + CAPTURE_STATUS=$? +fi +assert_status 2 'a missing dirname is a tooling error' +assert_error_contains 'rotation verifier requires dirname' \ + 'dirname is included in the closed tool preflight' + +prepare_waiting +invoke_waiting +assert_silent_success 'waiting mode accepts a valid activation receipt' +assert_eq 3 "$(file_or_zero "$FAKE_BIN/curl-count")" 'waiting performs exactly run, approval, and main reads' + +prepare_waiting rolled-back +invoke_waiting +assert_silent_success 'waiting mode accepts an exact rollback restoration' + +prepare_publisher +invoke_publisher +assert_silent_success 'publisher mode accepts a stable verified rotation history' +assert_eq 7 "$(file_or_zero "$FAKE_BIN/curl-count")" 'publisher performs two history reads and repeats selected detail' + +prepare_publisher_rollback +invoke_publisher_at "$G" "$QF_ROLLBACK" +assert_silent_success 'publisher mode accepts a valid rollback receipt' + +prepare_publisher +invoke_publisher_with_inherited_options +assert_status 0 'publisher disables inherited xtrace and allexport' +if [[ ! -s "$WORK/stdout" ]] && + ! grep -Fq -e "$TOKEN_VALUE" -e ambient-exported-token "$WORK/stderr" && + [[ "$(wc -l <"$WORK/stderr" | tr -d '[:space:]')" -le 1 ]]; then + ok 'inherited shell options do not expose tokens or propagate tracing to child tools' +else + no 'inherited shell options do not expose tokens or propagate tracing to child tools' +fi + +for mode in waiting publisher; do + prepare_waiting + if [[ "$mode" == publisher ]]; then prepare_publisher; fi + + invoke "$mode" --gate-root "$GATE_ROOT" + assert_status 2 "$mode rejects missing flags as usage" + assert_no_network "$mode missing flags fail before network" + + invoke "$mode" --unknown value + assert_status 2 "$mode rejects unknown flags as usage" + assert_no_network "$mode unknown flags fail before network" + + if [[ "$mode" == waiting ]]; then + invoke waiting --gate-root "$GATE_ROOT" --gate-root "$GATE_ROOT" \ + --old-gate-sha "$G" --dispatch-sha "$QD" --run-id "$RUN_ID" \ + --run-attempt "$RUN_ATTEMPT" --run-actor-login rotation-operator + else + invoke publisher --gate-root "$GATE_ROOT" --gate-root "$GATE_ROOT" \ + --gate-sha "$G_PRIME" --source-revision "$S" \ + --publisher-prerequisite-json "$PREREQUISITE" + fi + assert_status 2 "$mode rejects duplicate flags as usage" + assert_no_network "$mode duplicate flags fail before network" +done + +prepare_waiting +invoke waiting --gate-root "$GATE_ROOT" --old-gate-sha "$G" --dispatch-sha "$QD" \ + --run-id "$RUN_ID" --run-attempt "$RUN_ATTEMPT" --run-actor-login rotation-operator \ + --final-head-sha "$QF" +assert_status 2 'caller-supplied derived waiting receipt fields are rejected' +assert_no_network 'derived waiting fields fail before network' + +prepare_publisher +invoke publisher --gate-root "$GATE_ROOT" --gate-sha "$G_PRIME" \ + --source-revision "$S" --publisher-prerequisite-json "$PREREQUISITE" \ + --run-id "$RUN_ID" +assert_status 2 'caller-supplied derived publisher receipt fields are rejected' +assert_no_network 'derived publisher fields fail before network' + +prepare_waiting +if env -u GITHUB_TOKEN PATH="$FAKE_BIN:$PATH" bash "$VERIFY" waiting \ + --gate-root "$GATE_ROOT" --old-gate-sha "$G" --dispatch-sha "$QD" \ + --run-id "$RUN_ID" --run-attempt "$RUN_ATTEMPT" --run-actor-login rotation-operator \ + >"$WORK/stdout" 2>"$WORK/stderr"; then CAPTURE_STATUS=0; else CAPTURE_STATUS=$?; fi +assert_status 1 'an absent GitHub token is rejected' +assert_no_network 'an absent token fails before network' + +for field in id attempt number event path head actor repository branch status conclusion empty-conclusion; do + prepare_waiting + case "$field" in + id) write_run in_progress null 9007199254740992 ;; + attempt) write_run in_progress null "$RUN_ID" 1 ;; + number) write_run in_progress null "$RUN_ID" "$RUN_ATTEMPT" 0 ;; + event) write_run in_progress null "$RUN_ID" "$RUN_ATTEMPT" "$RUN_NUMBER" "$QD" rotation-operator push ;; + path) write_run in_progress null "$RUN_ID" "$RUN_ATTEMPT" "$RUN_NUMBER" "$QD" rotation-operator workflow_dispatch '.github/workflows/other.yml@main' ;; + head) write_run in_progress null "$RUN_ID" "$RUN_ATTEMPT" "$RUN_NUMBER" "$QF" ;; + actor) write_run in_progress null "$RUN_ID" "$RUN_ATTEMPT" "$RUN_NUMBER" "$QD" another-actor ;; + repository) write_run in_progress null "$RUN_ID" "$RUN_ATTEMPT" "$RUN_NUMBER" "$QD" rotation-operator workflow_dispatch "$ROTATION_PATH" fork/edgezero ;; + branch) write_run in_progress null "$RUN_ID" "$RUN_ATTEMPT" "$RUN_NUMBER" "$QD" rotation-operator workflow_dispatch "$ROTATION_PATH" stackpop/edgezero "$RUN_CREATED" feature ;; + status) write_run queued null ;; + conclusion) write_run in_progress '"success"' ;; + empty-conclusion) write_run in_progress '""' ;; + esac + invoke_waiting + assert_status 1 "waiting rejects a mismatched run $field" +done + +for number_case in \ + '"9007199254740993" 2 10' '9007199254740993.0 2 10' \ + '0 2 10' '18446744073709551616 2 10' \ + '9007199254740993 "2" 10' '9007199254740993 2.0 10' \ + '9007199254740993 0 10' '9007199254740993 4294967296 10' \ + '9007199254740993 2 "10"' '9007199254740993 2 10.0' \ + '9007199254740993 2 18446744073709551616'; do + read -r id attempt number <<<"$number_case" + prepare_waiting + write_run in_progress null "$id" "$attempt" "$number" + invoke_waiting + assert_status 1 "waiting rejects noncanonical or out-of-range numbers: $number_case" +done + +prepare_waiting +write_approval "$(rotation_comment)" approved build-container-gate-rotation-lock rotation-operator +invoke_waiting +assert_status 1 'waiting requires reviewer and run actor separation' + +prepare_waiting +mutate_json '.[0].reviewer=.[0].user | del(.[0].user)' "$FAKE_BIN/approvals.body" +invoke_waiting +assert_status 1 'waiting rejects reviewer-only workflow review history' + +for approval_case in duplicate rejected wrong-environment malformed digest head old-gate final-gate stale future-attempt; do + prepare_waiting + case "$approval_case" in + duplicate) + item=$(review_json approved build-container-gate-rotation-lock "$(rotation_comment)") + printf '[%s,%s]' "$item" "$item" >"$FAKE_BIN/approvals.body" ;; + rejected) write_approval "$(rotation_comment)" rejected ;; + wrong-environment) write_approval "$(rotation_comment)" approved other ;; + malformed) write_approval 'edgezero-gate-rotation-v1 {"broken":true}' ;; + digest) + comment=$(rotation_comment) + comment=${comment/sha256:/sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa} + write_approval "$comment" ;; + head) write_approval "$(rotation_comment activated "$S")" ;; + old-gate) write_approval "$(rotation_comment activated "$QF" "$G_PRIME" "$G_PRIME")" ;; + final-gate) + comment=$(rotation_comment) + comment=${comment/\"gate-sha\":\"$G_PRIME\"/\"gate-sha\":\"$G\"} + write_approval "$comment" ;; + stale) write_approval "$(rotation_comment activated "$QF" "$G_PRIME" "$G" "$RUN_ATTEMPT" "$RUN_ID" "$QD" "$STALE_AUDITED")" ;; + future-attempt) write_approval "$(rotation_comment activated "$QF" "$G_PRIME" "$G" 3)" ;; + esac + invoke_waiting + assert_status 1 "waiting rejects $approval_case approval evidence" +done + +prepare_waiting +write_main "$S" +invoke_waiting +assert_status 1 'waiting requires both receipt heads to equal the current main ref' + +prepare_waiting +write_main "$QF" refs/heads/other +invoke_waiting +assert_status 1 'waiting requires the exact main ref identity' + +prepare_waiting +printf 'tampered\n' >"$GATE_ROOT/.github/docker/build-app-cli/gate-marker" +git -C "$GATE_ROOT" add .github/docker/build-app-cli/gate-marker +git -C "$GATE_ROOT" commit -q -m tampered-final +TAMPERED=$(git -C "$GATE_ROOT" rev-parse HEAD) +git -C "$GATE_ROOT" checkout -q --detach "$G" +write_approval "$(rotation_comment activated "$TAMPERED")" +write_main "$TAMPERED" +invoke_waiting +assert_status 1 'waiting rejects a final head with different manifested gate bytes' + +for endpoint in run approvals main; do + prepare_waiting + : >"$FAKE_BIN/$endpoint.transport-failure" + invoke_waiting + assert_status 1 "waiting fails closed on $endpoint transport failure" + if ! rg -q "$TOKEN_VALUE|secret-response-body" "$WORK/stderr"; then + ok "waiting redacts $endpoint transport diagnostics" + else + no "waiting redacts $endpoint transport diagnostics" + fi + + prepare_waiting + printf '500' >"$FAKE_BIN/$endpoint.status" + invoke_waiting + assert_status 1 "waiting rejects $endpoint HTTP failure" + + prepare_waiting + printf '2022-11-28' >"$FAKE_BIN/$endpoint.version" + invoke_waiting + assert_status 1 "waiting rejects $endpoint selected-version drift" + + prepare_waiting + printf 'application/json; charset=iso-8859-1' >"$FAKE_BIN/$endpoint.media" + invoke_waiting + assert_status 1 "waiting rejects $endpoint media-type drift" +done + +prepare_waiting +invoke_waiting +expected_waiting="$API_BASE/actions/runs/$RUN_ID +$API_BASE/actions/runs/$RUN_ID/approvals +$API_BASE/git/ref/heads/main" +actual_waiting=$(sed -n 's/^curl //p' "$FAKE_BIN/events") +assert_eq "$expected_waiting" "$actual_waiting" 'waiting uses only its exact three-route allowlist' + +expected_transport='--disable +--silent +--show-error +--connect-timeout +10 +--max-time +30 +--max-redirs +0 +--request +GET +--config +-' +expected_config="header = \"Accept: application/vnd.github+json\" +header = \"X-GitHub-Api-Version: 2026-03-10\" +header = \"User-Agent: edgezero-build-container-gate/1\" +header = \"Authorization: Bearer $TOKEN_VALUE\"" +if [[ "$(sed -n '1,13p' "$FAKE_BIN/args-1")" == "$expected_transport" && + "$(<"$FAKE_BIN/config-1")" == "$expected_config" ]]; then + ok 'every API request uses the exact transport and ordered header configuration' +else + no 'every API request uses the exact transport and ordered header configuration' +fi + +prepare_publisher +printf '\n' >>"$PREREQUISITE" +invoke_publisher +assert_status 1 'publisher rejects a prerequisite trailing newline' +assert_no_network 'noncanonical prerequisite bytes fail before network' + +for mutation in \ + '."gate-sha"="0000000000000000000000000000000000000001"' \ + '."source-revision"="0000000000000000000000000000000000000001"' \ + '."schema-version"=1' \ + '.extra=true' \ + '."source-pr"="43"' \ + '."source-pr"="0"' \ + '."source-pr"="18446744073709551616"' \ + '."source-pr"=42' \ + '."evidence-url"="https://github.com/stackpop/edgezero/pull/43#issuecomment-9007199254740997"' \ + '."evidence-url"="https://github.com/stackpop/edgezero/pull/42#issuecomment-0"' \ + '."evidence-url"="https://github.com/stackpop/edgezero/pull/42#issuecomment-01"' \ + '."evidence-url"="https://github.com/stackpop/edgezero/pull/42#issuecomment-18446744073709551616"' \ + '."evidence-url"="https://github.com/stackpop/edgezero/pull/42#issuecomment-9007199254740997?view=1"' \ + '."rotation-history"."history-sha256"="sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"' \ + '."rotation-history"."run-id"="9007199254740992"' \ + '."rotation-history"."run-attempt"="1"' \ + '."rotation-history"."run-number"="9"' \ + '."rotation-history"."created-at"="2020-01-01T00:00:00Z"'; do + prepare_publisher + mutate_json "$mutation" "$PREREQUISITE" + invoke_publisher + assert_status 1 "publisher rejects prerequisite mutation $mutation" + assert_no_network "prerequisite mutation is rejected before network: $mutation" +done + +for tuple_mutation in \ + '."source-revision"=null' \ + '."source-pr"=null' \ + '."evidence-url"=null'; do + prepare_publisher + mutate_json "$tuple_mutation" "$PREREQUISITE" + invoke_publisher + assert_status 1 "publisher rejects a partial release-bound tuple: $tuple_mutation" + assert_no_network "partial release tuple fails before network: $tuple_mutation" +done + +prepare_publisher +write_inert_bootstrap_prerequisite +invoke_publisher +assert_status 1 'publisher rejects an inert all-null source tuple' +assert_no_network 'inert publisher prerequisite fails before network' + +prepare_publisher +printf '%s' "{\"evidence-sha256\":\"$OUTER_DIGEST\",\"gate-sha\":\"$G_PRIME\",\"previous-value-sha256\":\"$PREVIOUS_DIGEST\",\"rotation-history\":{\"state\":\"bootstrap-no-rotation\"},\"schema-version\":1,\"source-revision\":\"$S\"}" >"$PREREQUISITE" +invoke_publisher +assert_status 1 'publisher rejects the former schema-version-1 exact JCS shape' +assert_no_network 'schema-version-1 prerequisite fails before network' + +prepare_publisher +sed 's/"source-pr":"42"/"source-pr":"42","source-pr":"42"/' "$PREREQUISITE" >"$WORK/duplicate-record" +mv "$WORK/duplicate-record" "$PREREQUISITE" +invoke_publisher +assert_status 1 'publisher rejects duplicate prerequisite JSON keys' +assert_no_network 'duplicate prerequisite keys fail before network' + +prepare_publisher +printf '%s' '{"total_count":0,"workflow_runs":[]}' >"$FAKE_BIN/history-1.body" +write_bootstrap_prerequisite +invoke_publisher +assert_silent_success 'publisher accepts reviewed bootstrap history only when both snapshots are empty' +assert_eq 3 "$(file_or_zero "$FAKE_BIN/curl-count")" \ + 'bootstrap performs two stable history enumerations and an exact main read' + +prepare_publisher +printf '%s' '{"total_count":0,"workflow_runs":[]}' >"$FAKE_BIN/history-1.body" +write_bootstrap_prerequisite +write_main "$MAIN_CONTENT_DRIFT" +invoke_publisher +assert_status 1 'bootstrap rejects active-gate content drift on protected main' + +prepare_publisher +write_bootstrap_prerequisite +invoke_publisher +assert_status 1 'bootstrap history is rejected once any rotation exists' + +prepare_publisher +printf '%s' '{"total_count":0,"workflow_runs":[]}' >"$FAKE_BIN/history-1.body" +invoke_publisher +assert_status 1 'verified history is rejected when current history is empty' + +for history_case in duplicate-id duplicate-number id-string id-fraction id-zero id-overflow \ + attempt-string attempt-fraction attempt-zero attempt-overflow number-string number-fraction \ + number-zero number-overflow future-created malformed-created count-string count-mismatch; do + prepare_publisher + old=$(history_entry "$OLD_RUN_NUMBER" "$OLD_RUN_ID" "$OLD_RUN_ATTEMPT" "$OLD_CREATED") + selected=$(history_entry "$RUN_NUMBER" "$RUN_ID" "$RUN_ATTEMPT" "$RUN_CREATED") + case "$history_case" in + duplicate-id) selected=$(history_entry "$RUN_NUMBER" "$OLD_RUN_ID" "$RUN_ATTEMPT" "$RUN_CREATED") ;; + duplicate-number) selected=$(history_entry "$OLD_RUN_NUMBER" "$RUN_ID" "$RUN_ATTEMPT" "$RUN_CREATED") ;; + id-string) selected=${selected/\"id\":$RUN_ID/\"id\":\"$RUN_ID\"} ;; + id-fraction) selected=${selected/\"id\":$RUN_ID/\"id\":$RUN_ID.0} ;; + id-zero) selected=${selected/\"id\":$RUN_ID/\"id\":0} ;; + id-overflow) selected=${selected/\"id\":$RUN_ID/\"id\":18446744073709551616} ;; + attempt-string) selected=${selected/\"run_attempt\":$RUN_ATTEMPT/\"run_attempt\":\"$RUN_ATTEMPT\"} ;; + attempt-fraction) selected=${selected/\"run_attempt\":$RUN_ATTEMPT/\"run_attempt\":2.0} ;; + attempt-zero) selected=${selected/\"run_attempt\":$RUN_ATTEMPT/\"run_attempt\":0} ;; + attempt-overflow) selected=${selected/\"run_attempt\":$RUN_ATTEMPT/\"run_attempt\":4294967296} ;; + number-string) selected=${selected/\"run_number\":$RUN_NUMBER/\"run_number\":\"$RUN_NUMBER\"} ;; + number-fraction) selected=${selected/\"run_number\":$RUN_NUMBER/\"run_number\":10.0} ;; + number-zero) selected=${selected/\"run_number\":$RUN_NUMBER/\"run_number\":0} ;; + number-overflow) selected=${selected/\"run_number\":$RUN_NUMBER/\"run_number\":18446744073709551616} ;; + future-created) selected=${selected/$RUN_CREATED/$(format_epoch $((NOW_EPOCH + 60)))} ;; + malformed-created) selected=${selected/$RUN_CREATED/2026-02-30T00:00:00Z} ;; + count-string) printf '%s' "{\"total_count\":\"2\",\"workflow_runs\":[$selected,$old]}" >"$FAKE_BIN/history-1.body" ;; + count-mismatch) printf '%s' "{\"total_count\":3,\"workflow_runs\":[$selected,$old]}" >"$FAKE_BIN/history-1.body" ;; + esac + if [[ "$history_case" != count-string && "$history_case" != count-mismatch ]]; then + printf '%s' "{\"total_count\":2,\"workflow_runs\":[$selected,$old]}" >"$FAKE_BIN/history-1.body" + fi + invoke_publisher + assert_status 1 "publisher rejects malformed history: $history_case" +done + +prepare_publisher +sed 's/"id":9007199254740993/"id":9007199254740993,"id":9007199254740993/' \ + "$FAKE_BIN/history-1.body" >"$WORK/duplicate-number-key" +mv "$WORK/duplicate-number-key" "$FAKE_BIN/history-1.body" +invoke_publisher +assert_status 1 'publisher rejects duplicate raw numeric fields in history JSON' + +prepare_publisher +sed 's/"run_attempt":1/"run_attempt":2/' \ + "$FAKE_BIN/history-1.body" >"$WORK/mutated.json" +mv "$WORK/mutated.json" "$FAKE_BIN/history-1.body" +invoke_publisher +assert_status 1 'an old-run rerun changes the complete history digest and blocks' + +prepare_publisher +printf '{"total_count":1,"workflow_runs":[%s]}' \ + "$(history_entry "$RUN_NUMBER" "$RUN_ID" "$RUN_ATTEMPT" "$RUN_CREATED")" \ + >"$FAKE_BIN/history-1.body" +invoke_publisher +assert_status 1 'deletion or retention pruning of recorded history blocks' + +prepare_publisher +later=$(history_entry 11 8 1 "$RUN_CREATED") +current=$(<"$FAKE_BIN/history-1.body") +current=${current/\"total_count\":2/\"total_count\":3} +current=${current/\"workflow_runs\":[/\"workflow_runs\":[$later,} +printf '%s' "$current" >"$FAKE_BIN/history-1.body" +invoke_publisher +assert_status 1 'a later-numbered rotation supersedes an older successful record even with a smaller id' + +prepare_publisher +cp "$FAKE_BIN/history-1.body" "$FAKE_BIN/history-1.1.body" +sed 's/"run_attempt":1/"run_attempt":2/' \ + "$FAKE_BIN/history-1.body" >"$WORK/mutated.json" +mv "$WORK/mutated.json" "$FAKE_BIN/history-1.body" +invoke_publisher +assert_status 1 'history changes between complete enumerations fail closed' + +prepare_publisher +cp "$FAKE_BIN/run.body" "$FAKE_BIN/run.1.body" +sed 's/"status":"completed","conclusion":"success"/"status":"in_progress","conclusion":null/' \ + "$FAKE_BIN/run.body" >"$WORK/mutated.json" +mv "$WORK/mutated.json" "$FAKE_BIN/run.body" +invoke_publisher +assert_status 1 'selected-run detail drift after the second snapshot fails closed' + +for detail_case in list-id list-attempt list-number list-created status conclusion event path head repository branch; do + prepare_publisher + case "$detail_case" in + list-id) write_run completed '"success"' 9007199254740992 ;; + list-attempt) write_run completed '"success"' "$RUN_ID" 1 ;; + list-number) write_run completed '"success"' "$RUN_ID" "$RUN_ATTEMPT" 9 ;; + list-created) write_run completed '"success"' "$RUN_ID" "$RUN_ATTEMPT" "$RUN_NUMBER" "$QD" rotation-operator workflow_dispatch "$ROTATION_PATH" stackpop/edgezero "$OLD_CREATED" ;; + status) write_run in_progress null ;; + conclusion) write_run completed '"failure"' ;; + event) write_run completed '"success"' "$RUN_ID" "$RUN_ATTEMPT" "$RUN_NUMBER" "$QD" rotation-operator push ;; + path) write_run completed '"success"' "$RUN_ID" "$RUN_ATTEMPT" "$RUN_NUMBER" "$QD" rotation-operator workflow_dispatch '.github/workflows/other.yml@main' ;; + head) write_run completed '"success"' "$RUN_ID" "$RUN_ATTEMPT" "$RUN_NUMBER" "$S" ;; + repository) write_run completed '"success"' "$RUN_ID" "$RUN_ATTEMPT" "$RUN_NUMBER" "$QD" rotation-operator workflow_dispatch "$ROTATION_PATH" fork/edgezero ;; + branch) write_run completed '"success"' "$RUN_ID" "$RUN_ATTEMPT" "$RUN_NUMBER" "$QD" rotation-operator workflow_dispatch "$ROTATION_PATH" stackpop/edgezero "$RUN_CREATED" feature ;; + esac + invoke_publisher + assert_status 1 "publisher rejects selected-run detail mismatch: $detail_case" +done + +for jobs_case in total duplicate-id attempt head missing-step duplicate-step failed-step incomplete-step stale-completion link; do + prepare_publisher + case "$jobs_case" in + total) mutate_json '.total_count=3' "$FAKE_BIN/jobs-1.body" ;; + duplicate-id) mutate_json '.jobs[1].id=.jobs[0].id' "$FAKE_BIN/jobs-1.body" ;; + attempt) mutate_json '.jobs[1].run_attempt=1' "$FAKE_BIN/jobs-1.body" ;; + head) mutate_json '.jobs[1].head_sha="0000000000000000000000000000000000000001"' "$FAKE_BIN/jobs-1.body" ;; + missing-step) mutate_json '.jobs[1].steps=[]' "$FAKE_BIN/jobs-1.body" ;; + duplicate-step) mutate_json '.jobs[0].steps += [{"name":"assert-exact-rotation-context","status":"completed","conclusion":"success","completed_at":"'"$STEP_COMPLETED"'"}]' "$FAKE_BIN/jobs-1.body" ;; + failed-step) write_jobs failure ;; + incomplete-step) mutate_json '.jobs[1].steps[0].status="in_progress"' "$FAKE_BIN/jobs-1.body" ;; + stale-completion) write_jobs success "$AUDITED_AT" ;; + link) printf '<%s>; rel="next"' "$API_BASE/actions/runs/$RUN_ID/attempts/$RUN_ATTEMPT/jobs?per_page=100&page=2" >"$FAKE_BIN/jobs-1.link" ;; + esac + invoke_publisher + assert_status 1 "publisher rejects exact-attempt jobs mismatch: $jobs_case" +done + +prepare_publisher +write_run completed '"failure"' +write_verified_prerequisite +invoke_publisher +assert_status 1 'a failed rerun blocks despite a previously successful run identity' + +prepare_publisher +write_main "$QF" +invoke_publisher +assert_status 1 'publisher requires source revision to remain on protected main' + +for main_drift in \ + "$MAIN_CONTENT_DRIFT:content" \ + "$MAIN_MODE_DRIFT:mode" \ + "$MAIN_TYPE_DRIFT:type" \ + "$MAIN_ABSENCE_DRIFT:absence"; do + prepare_publisher + write_main "${main_drift%%:*}" + invoke_publisher + assert_status 1 "publisher rejects active-gate ${main_drift#*:} drift on protected main" +done + +prepare_publisher +write_main "$S" refs/heads/other +invoke_publisher +assert_status 1 'publisher binds the exact protected main ref response' + +prepare_publisher +item=$(review_json approved build-container-gate-rotation-lock "$(rotation_comment)") +printf '[%s,%s]' "$item" "$item" >"$FAKE_BIN/approvals.body" +invoke_publisher +assert_status 1 'publisher rejects duplicate authenticated rotation receipts' + +prepare_publisher +write_approval "$(rotation_comment)" approved build-container-gate-rotation-lock rotation-operator +invoke_publisher +assert_status 1 'publisher enforces reviewer and run actor separation' + +prepare_publisher +cp "$FAKE_BIN/history-1.body" "$FAKE_BIN/history-1.1.body" +cp "$FAKE_BIN/history-1.body" "$FAKE_BIN/history-1.2.body" +cp "$FAKE_BIN/run.body" "$FAKE_BIN/run.1.body" +cp "$FAKE_BIN/run.body" "$FAKE_BIN/run.2.body" +later=$(history_entry 11 8 1 "$RUN_CREATED") +printf '%s' "{\"total_count\":3,\"workflow_runs\":[$later]}" >"$FAKE_BIN/history-1.3.body" +invoke_publisher +assert_silent_success 'work appearing only after the second snapshot is later FIFO work' +assert_eq 2 "$(file_or_zero "$FAKE_BIN/history-1-count")" 'publisher takes exactly two history snapshots at its linearization point' + +prepare_publisher +entries="$WORK/history.tsv" +: >"$entries" +page1='{"total_count":101,"workflow_runs":[' +separator= +for ((number = 100; number >= 1; number--)); do + id=$((1000 + number)) + page1+="$separator$(history_entry "$number" "$id" 1 "$OLD_CREATED")" + separator=, + printf '%s\t%s\t%s\t%s\n' "$number" "$id" 1 "$OLD_CREATED" >>"$entries" +done +page1+=']}' +printf '%s' "$page1" >"$FAKE_BIN/history-1.body" +printf '<%s>; rel="next"' \ + "$API_BASE/actions/workflows/rotate-build-container-gate.yml/runs?event=workflow_dispatch&per_page=100&page=2" \ + >"$FAKE_BIN/history-1.link" +set_metadata_defaults history-2 +selected=$(history_entry 101 "$RUN_ID" "$RUN_ATTEMPT" "$RUN_CREATED") +printf '%s' "{\"total_count\":101,\"workflow_runs\":[$selected]}" >"$FAKE_BIN/history-2.body" +printf '%s\t%s\t%s\t%s\n' 101 "$RUN_ID" "$RUN_ATTEMPT" "$RUN_CREATED" >>"$entries" +snapshot='[' +separator= +while IFS=$'\t' read -r number id attempt _; do + snapshot+="$separator{\"run-attempt\":\"$attempt\",\"run-id\":\"$id\",\"run-number\":\"$number\"}" + separator=, +done < <(sort -t $'\t' -k1,1n "$entries") +snapshot+=']' +HISTORY_DIGEST="sha256:$(hash_bytes "$snapshot")" +RUN_NUMBER=101 +write_run completed '"success"' "$RUN_ID" "$RUN_ATTEMPT" "$RUN_NUMBER" +write_verified_prerequisite "$G_PRIME" "$S" "$HISTORY_DIGEST" \ + "$(rotation_comment | sed -n '1s/^edgezero-gate-rotation-v1 {"evidence-sha256":"\([^"]*\)".*/\1/p')" \ + "$RUN_ATTEMPT" "$RUN_ID" "$RUN_NUMBER" +invoke_publisher +assert_silent_success 'publisher consumes a complete 101-run paginated history twice' +assert_eq 2 "$(file_or_zero "$FAKE_BIN/history-2-count")" 'both history passes reach the synthesized second page' +RUN_NUMBER=10 + +for pagination_case in missing-next wrong-query wrong-page extra-relation repeated-page inconsistent-count skipped-page; do + prepare_publisher + selected=$(history_entry "$RUN_NUMBER" "$RUN_ID" "$RUN_ATTEMPT" "$RUN_CREATED") + printf '%s' "{\"total_count\":101,\"workflow_runs\":[" >"$FAKE_BIN/history-1.body" + separator= + for ((i = 100; i >= 1; i--)); do + printf '%s%s' "$separator" "$(history_entry "$i" "$((1000 + i))" 1 "$OLD_CREATED")" \ + >>"$FAKE_BIN/history-1.body" + separator=, + done + printf ']}' >>"$FAKE_BIN/history-1.body" + set_metadata_defaults history-2 + printf '%s' "{\"total_count\":101,\"workflow_runs\":[$selected]}" >"$FAKE_BIN/history-2.body" + next="$API_BASE/actions/workflows/rotate-build-container-gate.yml/runs?event=workflow_dispatch&per_page=100&page=2" + printf '<%s>; rel="next"' "$next" >"$FAKE_BIN/history-1.link" + case "$pagination_case" in + missing-next) : >"$FAKE_BIN/history-1.link" ;; + wrong-query) printf '<%s>; rel="next"' "${next/event=workflow_dispatch/per_page=100}" >"$FAKE_BIN/history-1.link" ;; + wrong-page) printf '<%s>; rel="next"' "${next/page=2/page=3}" >"$FAKE_BIN/history-1.link" ;; + extra-relation) printf '<%s>; rel="next", <%s>; rel="next"' "$next" "$next" >"$FAKE_BIN/history-1.link" ;; + repeated-page) cp "$FAKE_BIN/history-1.body" "$FAKE_BIN/history-2.body" ;; + inconsistent-count) mutate_json '.total_count=102' "$FAKE_BIN/history-2.body" ;; + skipped-page) + printf '<%s>; rel="next"' "${next/page=2/page=100}" >"$FAKE_BIN/history-1.link" ;; + esac + invoke_publisher + assert_status 1 "publisher rejects malformed or incomplete pagination: $pagination_case" +done + +if grep -Fqx 'readonly MAX_PAGES=100' "$REAL_VERIFY"; then + ok 'publisher production page bound remains exactly 100' +else + no 'publisher production page bound remains exactly 100' +fi + +# Reduce the committed fixture bound so both terminal-page branches run without 100 API pages. +git -C "$GATE_ROOT" checkout -q --detach "$G_PRIME" +sed 's/^readonly MAX_PAGES=100$/readonly MAX_PAGES=1/' \ + "$VERIFY" >"$WORK/limited-helper" +mv "$WORK/limited-helper" "$VERIFY" +chmod 0755 "$VERIFY" +git -C "$GATE_ROOT" add .github/docker/build-app-cli/verify-gate-rotation-lock.sh +git -C "$GATE_ROOT" commit -q -m reduced-page-bound-fixture +LIMIT_GATE=$(git -C "$GATE_ROOT" rev-parse HEAD) +printf 'limit-source\n' >"$GATE_ROOT/limit-source-marker" +git -C "$GATE_ROOT" add limit-source-marker +git -C "$GATE_ROOT" commit -q -m reduced-page-bound-source +LIMIT_SOURCE=$(git -C "$GATE_ROOT" rev-parse HEAD) +git -C "$GATE_ROOT" checkout -q --detach "$LIMIT_GATE" + +full_page= +separator= +for ((i = 0; i < 100; i++)); do + full_page+="${separator}null" + separator=, +done + +reset_api +write_default_history +printf '%s' "{\"total_count\":100,\"workflow_runs\":[$full_page]}" \ + >"$FAKE_BIN/history-1.body" +write_verified_prerequisite "$LIMIT_GATE" "$LIMIT_SOURCE" +invoke_publisher_at "$LIMIT_GATE" "$LIMIT_SOURCE" +assert_status 1 'history rejects a full terminal page at the page-100 bound' +assert_error_contains 'rotation history page bound ended on a full page' \ + 'history terminal-page rejection occurs before entry parsing' + +reset_api +write_default_history +write_run +printf '%s' "{\"total_count\":100,\"jobs\":[$full_page]}" >"$FAKE_BIN/jobs-1.body" +write_verified_prerequisite "$LIMIT_GATE" "$LIMIT_SOURCE" +invoke_publisher_at "$LIMIT_GATE" "$LIMIT_SOURCE" +assert_status 1 'jobs reject a full terminal page at the page-100 bound' +assert_error_contains 'rotation jobs page bound ended on a full page' \ + 'jobs terminal-page rejection occurs before entry parsing' + +git -C "$GATE_ROOT" checkout -q --detach "$G_PRIME" + +for endpoint in history-1 run jobs-1 approvals main; do + prepare_publisher + : >"$FAKE_BIN/$endpoint.transport-failure" + invoke_publisher + assert_status 1 "publisher fails closed on $endpoint transport failure" +done + +prepare_publisher +invoke_publisher +publisher_urls=$(sed -n 's/^curl //p' "$FAKE_BIN/events") +expected_publisher="$HISTORY_URL +$API_BASE/actions/runs/$RUN_ID +$API_BASE/actions/runs/$RUN_ID/attempts/$RUN_ATTEMPT/jobs?per_page=100&page=1 +$API_BASE/actions/runs/$RUN_ID/approvals +$API_BASE/git/ref/heads/main +$HISTORY_URL +$API_BASE/actions/runs/$RUN_ID" +assert_eq "$expected_publisher" "$publisher_urls" 'publisher uses the exact ordered read-only route allowlist' +if rg -q '^GET$' "$FAKE_BIN"/args-* && ! rg -q '^POST$|^PATCH$|^PUT$|^DELETE$' "$FAKE_BIN"/args-*; then + ok 'both modes issue explicit GET requests only' +else + no 'both modes issue explicit GET requests only' +fi + +prepare_publisher +printf '#!/usr/bin/env bash\nexit 1\n' >"$FAKE_BIN/jq" +chmod 0755 "$FAKE_BIN/jq" +invoke_publisher +assert_status 2 'an unusable required jq is a tooling error' +assert_no_network 'tooling failure occurs before network' + +printf '\nPassed: %d Failed: %d\n' "$pass" "$fail" +((fail == 0)) diff --git a/.github/actions/deploy-core/tests/verify-published-image.test.sh b/.github/actions/deploy-core/tests/verify-published-image.test.sh new file mode 100755 index 00000000..8d1755ed --- /dev/null +++ b/.github/actions/deploy-core/tests/verify-published-image.test.sh @@ -0,0 +1,629 @@ +#!/usr/bin/env bash +set -euo pipefail + +DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +VERIFY="$DIR/../../../docker/build-app-cli/verify-published-image.sh" +FIXTURES=$(cd -- "$DIR/../../../docker/build-app-cli/fixtures/provenance" && pwd -P) +WORK=$(mktemp -d) +trap 'rm -rf "$WORK"' EXIT +BIN="$WORK/bin" +STATE="$WORK/state" +mkdir "$BIN" "$STATE" + +pass=0 +fail=0 +ok() { + printf ' \033[32mok\033[0m %s\n' "$1" + pass=$((pass + 1)) +} +no() { + printf ' \033[31mFAIL\033[0m %s\n' "$1" >&2 + fail=$((fail + 1)) +} +assert_pass() { + local description=$1 + shift + if "$@" >/dev/null 2>&1; then ok "$description"; else no "$description"; fi +} +assert_fail() { + local description=$1 + shift + if "$@" >/dev/null 2>&1; then no "$description"; else ok "$description"; fi +} + +REF=ghcr.io/stackpop/edgezero-build-app-cli@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa +LOCAL_ID=sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd +SOURCE=bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb + +cat >"$BIN/timeout" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +count=$(cat "$FAKE_STATE/timeout-count") +count=$((count + 1)) +printf '%s\n' "$count" >"$FAKE_STATE/timeout-count" +printf '%s\n' "$@" >"$FAKE_STATE/timeout-$count.args" +while (($#)) && [[ "$1" == --* ]]; do + shift +done +(($# >= 2)) || exit 125 +shift +exec "$@" +EOF +chmod 0755 "$BIN/timeout" + +cat >"$BIN/chmod" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +/bin/chmod "$@" +path=${!#} +[[ "${FAKE_ENV_FILE_MUTATION:-}" != "" && "$path" == */probe.env ]] || exit 0 +case "$FAKE_ENV_FILE_MUTATION" in + bare) printf 'BARE_NAME\n' >>"$path" ;; + blank) printf '\n' >>"$path" ;; + comment) printf '#COMMENT=value\n' >>"$path" ;; + duplicate) printf 'HOME=/attacker\n' >>"$path" ;; + extra) printf 'EXTRA=value\n' >>"$path" ;; + hardlink) ln "$path" "$path.link" ;; + missing) sed '/^HOME=/d' "$path" >"$path.new"; mv "$path.new" "$path" ;; + missing-final-newline) bytes=$(cat "$path"); printf '%s' "$bytes" >"$path" ;; + mode) /bin/chmod 0644 "$path" ;; + nul) printf '\0' >>"$path" ;; + symlink) mv "$path" "$path.target"; ln -s "$path.target" "$path" ;; + *) exit 94 ;; +esac +EOF +chmod 0755 "$BIN/chmod" + +cat >"$BIN/docker" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +printf '%s\0' "$@" >>"$FAKE_STATE/docker.log" +printf '\n' >>"$FAKE_STATE/docker.log" +[[ -n "${DOCKER_CONFIG:-}" && -d "$DOCKER_CONFIG" && ! -e "$DOCKER_CONFIG/config.json" ]] || exit 96 + +if [[ "$1 ${2:-} ${3:-}" == "buildx imagetools inspect" ]]; then + [[ "${FAKE_INSPECT_STATUS:-0}" == 0 ]] || exit "$FAKE_INSPECT_STATUS" + if [[ "$5" == "--raw" ]]; then + cat "$FAKE_STATE/raw.json" + else + [[ "$5" == "--format" && "$6" == "{{json .Image}}" ]] + cat "$FAKE_STATE/image.json" + fi + exit 0 +fi + +case "$1" in + image) + [[ "$2" == inspect && "$3" == "$FAKE_LOCAL_ID" ]] + cat "$FAKE_STATE/local-image.json" + ;; + pull) + [[ "${FAKE_PULL_STATUS:-0}" == 0 ]] || exit "$FAKE_PULL_STATUS" + ;; + create) + count=$(cat "$FAKE_STATE/create-count") + count=$((count + 1)) + printf '%s\n' "$count" >"$FAKE_STATE/create-count" + args="$FAKE_STATE/create-$count.args" + printf '%s\n' "${@:2}" >"$args" + create_argv=("${@:2}") + process_path= + process_start= + for ((index = 0; index < ${#create_argv[@]}; index++)); do + if [[ "${create_argv[$index]}" == --entrypoint ]]; then + process_path=${create_argv[$((index + 1))]} + process_start=$((index + 3)) + break + fi + done + [[ -n "$process_path" && -n "$process_start" ]] + process_args=("${create_argv[@]:$process_start}") + if [[ "${FAKE_PROCESS_MUTATE_ID:-}" == "$count" ]]; then + [[ -z "${FAKE_PROCESS_PATH:-}" ]] || process_path=$FAKE_PROCESS_PATH + [[ -z "${FAKE_PROCESS_EXTRA_ARG:-}" ]] || process_args+=("$FAKE_PROCESS_EXTRA_ARG") + fi + jq -cn --arg path "$process_path" --args \ + '[{Path: $path, Args: $ARGS.positional}]' -- "${process_args[@]}" \ + >"$FAKE_STATE/create-$count.inspect.json" + previous= + for argument in "${@:2}"; do + if [[ "$previous" == --env-file ]]; then + cp "$argument" "$FAKE_STATE/create-$count.env" + printf '%s\n' "$argument" >"$FAKE_STATE/create-$count.env-path" + fi + case "$argument" in + type=bind,src=*,dst=/work/expected) + value=${argument#type=bind,src=} + printf '%s\n' "${value%,dst=/work/expected}" >"$FAKE_STATE/create-$count.mount-expected" + ;; + type=bind,src=*,dst=/work/input/app-cli,readonly) + value=${argument#type=bind,src=} + printf '%s\n' "${value%,dst=/work/input/app-cli,readonly}" >"$FAKE_STATE/create-$count.mount-app-cli" + ;; + type=bind,src=*,dst=/work/input/expected.json,readonly) + value=${argument#type=bind,src=} + printf '%s\n' "${value%,dst=/work/input/expected.json,readonly}" >"$FAKE_STATE/create-$count.mount-expected-json" + ;; + type=bind,src=*,dst=/work/packaged) + value=${argument#type=bind,src=} + printf '%s\n' "${value%,dst=/work/packaged}" >"$FAKE_STATE/create-$count.mount-packaged" + ;; + type=bind,src=*,dst=/work/input/artifact.tar,readonly) + value=${argument#type=bind,src=} + printf '%s\n' "${value%,dst=/work/input/artifact.tar,readonly}" >"$FAKE_STATE/create-$count.mount-artifact" + ;; + type=bind,src=*,dst=/work/validated) + value=${argument#type=bind,src=} + printf '%s\n' "${value%,dst=/work/validated}" >"$FAKE_STATE/create-$count.mount-validated" + ;; + type=bind,src=*,dst=/work/compiled) + value=${argument#type=bind,src=} + printf '%s\n' "${value%,dst=/work/compiled}" >"$FAKE_STATE/create-$count.mount-compiled" + ;; + type=bind,src=*,dst=/work/bin/app-cli,readonly) + value=${argument#type=bind,src=} + printf '%s\n' "${value%,dst=/work/bin/app-cli,readonly}" >"$FAKE_STATE/create-$count.mount-smoke-binary" + ;; + esac + previous=$argument + done + printf '%s\n' "${FAKE_CREATE_OUTPUT:-container-$count}" + ;; + start) + id=${3##*-} + env_path=$(cat "$FAKE_STATE/create-$id.env-path") + [[ ! -e "$env_path" && ! -L "$env_path" ]] || exit 97 + case "$id" in + 1) + cat "$FAKE_STATE/env-output" + [[ "${FAKE_ENV_STATUS:-0}" == 0 ]] || exit "$FAKE_ENV_STATUS" + ;; + 2) [[ "${FAKE_TOOLCHAIN_STATUS:-0}" == 0 ]] || exit "$FAKE_TOOLCHAIN_STATUS" ;; + 3) [[ "${FAKE_SELF_TEST_STATUS:-0}" == 0 ]] || exit "$FAKE_SELF_TEST_STATUS" ;; + *) + if grep -Fxq /usr/local/rustup/toolchains/1.95.0-x86_64-unknown-linux-gnu/bin/rustc \ + "$FAKE_STATE/create-$id.args"; then + output="$(cat "$FAKE_STATE/create-$id.mount-compiled")/app-cli" + printf '#!/usr/bin/env bash\nprintf "edgezero image runtime smoke\\n"\n' >"$output" + chmod 0755 "$output" + elif grep -Fxq write-expected "$FAKE_STATE/create-$id.args"; then + cp "$FAKE_FIXTURES/valid/expected.json" \ + "$(cat "$FAKE_STATE/create-$id.mount-expected")/expected.json" + elif grep -Fxq package "$FAKE_STATE/create-$id.args"; then + binary=$(cat "$FAKE_STATE/create-$id.mount-app-cli") + if [[ "$binary" == "$FAKE_FIXTURES/valid/elf-static/app-cli" ]]; then + cp "$FAKE_FIXTURES/valid/archive.tar" \ + "$(cat "$FAKE_STATE/create-$id.mount-packaged")/artifact.tar" + else + cp "$binary" "$(cat "$FAKE_STATE/create-$id.mount-packaged")/artifact.tar" + chmod 0644 "$(cat "$FAKE_STATE/create-$id.mount-packaged")/artifact.tar" + fi + elif grep -Fxq validate "$FAKE_STATE/create-$id.args"; then + archive=$(cat "$FAKE_STATE/create-$id.mount-artifact") + [[ "$archive" != */invalid/* ]] || exit 1 + if [[ "$archive" == */package-real/artifact.tar ]]; then + cp "$archive" "$(cat "$FAKE_STATE/create-$id.mount-validated")/app-cli" + else + cp "$FAKE_FIXTURES/valid/elf-static/app-cli" \ + "$(cat "$FAKE_STATE/create-$id.mount-validated")/app-cli" + fi + chmod 0755 "$(cat "$FAKE_STATE/create-$id.mount-validated")/app-cli" + elif grep -Fxq /lib64/ld-linux-x86-64.so.2 "$FAKE_STATE/create-$id.args"; then + printf 'edgezero image runtime smoke\n' + else + exit 98 + fi + ;; + esac + ;; + container) + [[ "$2" == inspect ]] + id=${3##*-} + cat "$FAKE_STATE/create-$id.inspect.json" + ;; + rm) ;; + *) exit 99 ;; +esac +EOF +chmod 0755 "$BIN/docker" + +write_oci_manifest() { + cat >"$STATE/raw.json" <<'EOF' +{"schemaVersion":2,"mediaType":"application/vnd.oci.image.manifest.v1+json","config":{"mediaType":"application/vnd.oci.image.config.v1+json","size":123,"digest":"sha256:1111111111111111111111111111111111111111111111111111111111111111"},"layers":[{"mediaType":"application/vnd.oci.image.layer.v1.tar+gzip","size":456,"digest":"sha256:2222222222222222222222222222222222222222222222222222222222222222"}]} +EOF +} + +write_docker_manifest() { + cat >"$STATE/raw.json" <<'EOF' +{"schemaVersion":2,"mediaType":"application/vnd.docker.distribution.manifest.v2+json","config":{"mediaType":"application/vnd.docker.container.image.v1+json","size":123,"digest":"sha256:1111111111111111111111111111111111111111111111111111111111111111"},"layers":[{"mediaType":"application/vnd.docker.image.rootfs.diff.tar.gzip","size":456,"digest":"sha256:2222222222222222222222222222222222222222222222222222222222222222"}]} +EOF +} + +write_image() { + cat >"$STATE/image.json" <"$STATE/local-image.json" <"$STATE/docker.log" + mkdir -p "$STATE/hostile-docker-config" + printf '{"auths":{"ghcr.io":{"auth":"hostile"}}}\n' >"$STATE/hostile-docker-config/config.json" + printf '0\n' >"$STATE/create-count" + printf '0\n' >"$STATE/timeout-count" + write_oci_manifest + write_image + write_local_image + cat >"$STATE/env-output" <<'EOF' +EDGEZERO_ENV_BACKSLASH=back\slash +EDGEZERO_ENV_DOLLAR=dollar$value +EDGEZERO_ENV_EMPTY= +EDGEZERO_ENV_EQUALS=left=right +EDGEZERO_ENV_HASH=hash#value +EDGEZERO_ENV_LITERAL=${EDGEZERO_ENV_DOLLAR} +EDGEZERO_ENV_NONASCII=café +EDGEZERO_ENV_QUOTE=quote"value +EDGEZERO_ENV_SPACE=two words +HOME=/work/home +PATH=/usr/local/bin:/usr/local/cargo/bin:/usr/bin:/bin +TMPDIR=/work/tmp +EOF + printf '{"containerimage.digest":"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}\n' \ + >"$STATE/metadata.json" +} + +run_verify() { + PATH="$BIN:$PATH" \ + FAKE_STATE="$STATE" \ + FAKE_INSPECT_STATUS="${FAKE_INSPECT_STATUS:-0}" \ + FAKE_PULL_STATUS="${FAKE_PULL_STATUS:-0}" \ + FAKE_ENV_STATUS="${FAKE_ENV_STATUS:-0}" \ + FAKE_ENV_FILE_MUTATION="${FAKE_ENV_FILE_MUTATION:-}" \ + FAKE_TOOLCHAIN_STATUS="${FAKE_TOOLCHAIN_STATUS:-0}" \ + FAKE_SELF_TEST_STATUS="${FAKE_SELF_TEST_STATUS:-0}" \ + FAKE_CREATE_OUTPUT="${FAKE_CREATE_OUTPUT:-}" \ + FAKE_PROCESS_MUTATE_ID="${FAKE_PROCESS_MUTATE_ID:-}" \ + FAKE_PROCESS_PATH="${FAKE_PROCESS_PATH:-}" \ + FAKE_PROCESS_EXTRA_ARG="${FAKE_PROCESS_EXTRA_ARG:-}" \ + FAKE_LOCAL_ID="$LOCAL_ID" \ + FAKE_FIXTURES="$FIXTURES" \ + DOCKER_CONFIG="$STATE/hostile-docker-config" \ + bash "$VERIFY" \ + --ref "$REF" \ + --source-sha "$SOURCE" \ + --protocol 1 \ + --build-metadata "$STATE/metadata.json" +} + + +run_verify_local() { + PATH="$BIN:$PATH" \ + FAKE_STATE="$STATE" \ + FAKE_ENV_STATUS="${FAKE_ENV_STATUS:-0}" \ + FAKE_ENV_FILE_MUTATION="${FAKE_ENV_FILE_MUTATION:-}" \ + FAKE_TOOLCHAIN_STATUS="${FAKE_TOOLCHAIN_STATUS:-0}" \ + FAKE_SELF_TEST_STATUS="${FAKE_SELF_TEST_STATUS:-0}" \ + FAKE_CREATE_OUTPUT="${FAKE_CREATE_OUTPUT:-}" \ + FAKE_PROCESS_MUTATE_ID="${FAKE_PROCESS_MUTATE_ID:-}" \ + FAKE_PROCESS_PATH="${FAKE_PROCESS_PATH:-}" \ + FAKE_PROCESS_EXTRA_ARG="${FAKE_PROCESS_EXTRA_ARG:-}" \ + FAKE_LOCAL_ID="$LOCAL_ID" \ + FAKE_FIXTURES="$FIXTURES" \ + DOCKER_CONFIG="$STATE/hostile-docker-config" \ + bash "$VERIFY" \ + --local-image-id "$LOCAL_ID" \ + --source-sha "$SOURCE" \ + --protocol 1 +} + +echo "== published build-container verification ==" + +reset_state +assert_pass "an OCI leaf with exact platform, labels, and runtime passes" run_verify + +reset_state +assert_pass "an immutable local BuildKit image ID passes without registry access" run_verify_local +tr '\0' '\n' <"$STATE/docker.log" >"$STATE/docker.lines" +if grep -Eq '^(buildx|pull)$' "$STATE/docker.lines"; then + no "local image verification avoids registry inspection and pull" +else + ok "local image verification avoids registry inspection and pull" +fi + +reset_state +LOCAL_ID=edgezero-build-app-cli:bootstrap +assert_fail "a tagged local image reference is rejected" run_verify_local +LOCAL_ID=sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd + +reset_state +assert_fail "local and published image identities are mutually exclusive" \ + bash "$VERIFY" --ref "$REF" --local-image-id "$LOCAL_ID" --source-sha "$SOURCE" --protocol 1 + +reset_state +write_docker_manifest +assert_pass "a Docker v2 leaf is accepted" run_verify + +reset_state +cat >"$STATE/raw.json" <<'EOF' +{"schemaVersion":2,"mediaType":"application/vnd.oci.image.index.v1+json","manifests":[{"digest":"sha256:1111111111111111111111111111111111111111111111111111111111111111","mediaType":"application/vnd.oci.image.manifest.v1+json","size":1,"platform":{"architecture":"amd64","os":"linux"}}]} +EOF +assert_fail "a one-entry OCI index is rejected" run_verify + +reset_state +cat >"$STATE/raw.json" <<'EOF' +{"schemaVersion":2,"mediaType":"application/vnd.docker.distribution.manifest.list.v2+json","manifests":[{},{}]} +EOF +assert_fail "a multi-entry Docker manifest list is rejected" run_verify + +reset_state +jq 'del(.config)' "$STATE/raw.json" >"$STATE/raw.new" +mv "$STATE/raw.new" "$STATE/raw.json" +assert_fail "a leaf without config is rejected" run_verify + +reset_state +jq 'del(.layers)' "$STATE/raw.json" >"$STATE/raw.new" +mv "$STATE/raw.new" "$STATE/raw.json" +assert_fail "a leaf without layers is rejected" run_verify + +for mutation in \ + '.architecture = "arm64"' \ + '.os = "windows"' \ + '.config.Labels["org.opencontainers.image.source"] = "https://attacker.invalid/repo"' \ + '.config.Labels["org.opencontainers.image.revision"] = "cccccccccccccccccccccccccccccccccccccccc"' \ + '.config.Labels["io.edgezero.provenance-protocol"] = "2"' \ + '.config.Entrypoint = ["/bin/sh"]' \ + '.config.User = "0:0"'; do + reset_state + jq "$mutation" "$STATE/image.json" >"$STATE/image.new" + mv "$STATE/image.new" "$STATE/image.json" + assert_fail "image config mutation is rejected: $mutation" run_verify +done + +reset_state +REF=ghcr.io/stackpop/edgezero-build-app-cli:build-container-v1 +assert_fail "a mutable tag lookup is rejected" run_verify +REF=ghcr.io/stackpop/edgezero-build-app-cli@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + +reset_state +FAKE_INSPECT_STATUS=1 +assert_fail "an unavailable or private registry response is rejected" run_verify +unset FAKE_INSPECT_STATUS + +for metadata in \ + 'not-json' \ + '{}' \ + '{"containerimage.digest":1}' \ + '{"containerimage.digest":"sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"}' \ + '{"containerimage.digest":"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","containerimage.digest":"sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}'; do + reset_state + printf '%s\n' "$metadata" >"$STATE/metadata.json" + assert_fail "malformed or contradictory BuildKit metadata is rejected: $metadata" run_verify +done + +reset_state +FAKE_PULL_STATUS=1 +assert_fail "an anonymous pull failure is rejected" run_verify +unset FAKE_PULL_STATUS + +reset_state +FAKE_ENV_STATUS=1 +assert_fail "the real env-file capability probe must pass" run_verify +unset FAKE_ENV_STATUS + +for mutation in bare blank comment duplicate extra hardlink missing missing-final-newline mode nul symlink; do + reset_state + FAKE_ENV_FILE_MUTATION=$mutation + assert_fail "a $mutation env-file mutation is rejected before create" run_verify +done +unset FAKE_ENV_FILE_MUTATION + +reset_state +FAKE_CREATE_OUTPUT='daemon-output-is-not-container-authority' +assert_pass "daemon create output cannot redirect start or cleanup" run_verify +unset FAKE_CREATE_OUTPUT + +reset_state +printf 'HOST_POISON=must-not-survive\n' >>"$STATE/env-output" +assert_fail "an inherited image or Docker environment name is rejected" run_verify + +reset_state +FAKE_TOOLCHAIN_STATUS=1 +assert_fail "a toolchain verification failure propagates" run_verify +unset FAKE_TOOLCHAIN_STATUS + +reset_state +FAKE_SELF_TEST_STATUS=1 +assert_fail "a validator self-test failure propagates" run_verify +unset FAKE_SELF_TEST_STATUS + +reset_state +FAKE_PROCESS_MUTATE_ID=3 +FAKE_PROCESS_PATH=/bin/sh +assert_fail "a changed persisted self-test process path is rejected" run_verify +unset FAKE_PROCESS_MUTATE_ID FAKE_PROCESS_PATH + +reset_state +FAKE_PROCESS_MUTATE_ID=3 +FAKE_PROCESS_EXTRA_ARG=attacker-argument +assert_fail "a changed persisted self-test process argument is rejected" run_verify +unset FAKE_PROCESS_MUTATE_ID FAKE_PROCESS_EXTRA_ARG + +reset_state +run_verify >/dev/null +if [[ "$(cat "$STATE/create-count")" -gt 8 ]]; then + ok "the verifier exercises every baked archive fixture" +else + no "the verifier exercises every baked archive fixture" +fi +gnu_compile_count=$(grep -lFx -- /usr/local/share/edgezero/gnu-smoke.rs "$STATE"/create-*.args | wc -l | tr -d ' ') +smoke_create_count=$(grep -lFx -- /lib64/ld-linux-x86-64.so.2 "$STATE"/create-*.args | wc -l | tr -d ' ') +if [[ "$gnu_compile_count" == 1 && "$smoke_create_count" == 1 ]]; then + ok "exactly one real GNU CLI compile and controlled-loader launch are exercised" +else + no "exactly one real GNU CLI compile and controlled-loader launch are exercised" +fi +if [[ "$(cat "$STATE/create-3.env")" == $'HOME=/work/home\nPATH=/usr/local/bin:/usr/local/cargo/bin:/usr/bin:/bin\nTMPDIR=/work/tmp' ]]; then + ok "self-test receives exactly the sorted three-variable environment" +else + no "self-test receives exactly the sorted three-variable environment" +fi +if grep -RFxq -- '/work/package' "$STATE"/create-*.args; then + no "no verifier profile uses the forbidden /work/package convention" +else + ok "no verifier profile uses the forbidden /work/package convention" +fi +awk -v ref="$REF" ' + previous == "--name" { $0 = "" } + previous == "--env-file" { $0 = "" } + $0 == ref { $0 = "" } + { print; previous = $0 } +' "$STATE/create-3.args" >"$STATE/create-3.normalized" +cat >"$STATE/create-3.expected" <<'EOF' +--name + +--platform +linux/amd64 +--user +1001:1001 +--read-only +--cap-drop=ALL +--security-opt=no-new-privileges +--network=none +--memory +2g +--memory-swap +2g +--pids-limit +64 +--tmpfs +/work/home:rw,noexec,nosuid,nodev,mode=0700,uid=1001,gid=1001 +--tmpfs +/work/tmp:rw,noexec,nosuid,nodev,mode=0700,uid=1001,gid=1001 +--env-file + +--entrypoint +/usr/bin/env + +-S +-i HOME=${HOME} PATH=${PATH} TMPDIR=${TMPDIR} +/usr/local/bin/edgezero-provenance-validator +self-test +--fixtures +/usr/local/share/edgezero/provenance-fixtures +EOF +if cmp -s "$STATE/create-3.normalized" "$STATE/create-3.expected"; then + ok "self-test create argv has the exact hardened runtime contract" +else + diff -u "$STATE/create-3.expected" "$STATE/create-3.normalized" >&2 || true + no "self-test create argv has the exact hardened runtime contract" +fi +cat >"$STATE/create-3.process.expected" <<'EOF' +{"Path":"/usr/bin/env","Args":["-S","-i HOME=${HOME} PATH=${PATH} TMPDIR=${TMPDIR}","/usr/local/bin/edgezero-provenance-validator","self-test","--fixtures","/usr/local/share/edgezero/provenance-fixtures"]} +EOF +jq -c '.[0] | {Path, Args}' "$STATE/create-3.inspect.json" >"$STATE/create-3.process.actual" +if cmp -s "$STATE/create-3.process.expected" "$STATE/create-3.process.actual"; then + ok "self-test persisted Path and Args match the exact process contract" +else + diff -u "$STATE/create-3.process.expected" "$STATE/create-3.process.actual" >&2 || true + no "self-test persisted Path and Args match the exact process contract" +fi +awk ' + previous == "--attach" { $0 = "" } + /^DOCKER_CONFIG=/ { $0 = "DOCKER_CONFIG=" } + { print; previous = $0 } +' "$STATE/timeout-3.args" >"$STATE/timeout-3.normalized" +cat >"$STATE/timeout-3.expected" <<'EOF' +--signal=TERM +--kill-after=10s +600s +env +DOCKER_CONFIG= +docker +start +--attach + +EOF +if cmp -s "$STATE/timeout-3.expected" "$STATE/timeout-3.normalized"; then + ok "self-test start uses the exact bounded attach contract" +else + diff -u "$STATE/timeout-3.expected" "$STATE/timeout-3.normalized" >&2 || true + no "self-test start uses the exact bounded attach contract" +fi + +smoke_args=$(grep -lFx -- /lib64/ld-linux-x86-64.so.2 "$STATE"/create-*.args) +smoke_id=${smoke_args##*/create-} +smoke_id=${smoke_id%.args} +awk -v ref="$REF" ' + previous == "--name" { $0 = "" } + previous == "--env-file" { $0 = "" } + /^type=bind,src=.*dst=\/work\/bin\/app-cli,readonly$/ { + $0 = "type=bind,src=,dst=/work/bin/app-cli,readonly" + } + $0 == ref { $0 = "" } + { print; previous = $0 } +' "$smoke_args" >"$STATE/smoke.normalized" +cat >"$STATE/smoke.expected" <<'EOF' +--name + +--platform +linux/amd64 +--user +1001:1001 +--read-only +--cap-drop=ALL +--security-opt=no-new-privileges +--network=none +--memory +512m +--memory-swap +512m +--pids-limit +64 +--tmpfs +/work/home:rw,noexec,nosuid,nodev,mode=0700,uid=1001,gid=1001 +--tmpfs +/work/tmp:rw,noexec,nosuid,nodev,mode=0700,uid=1001,gid=1001 +--env-file + +--mount +type=bind,src=,dst=/work/bin/app-cli,readonly +--entrypoint +/usr/bin/env + +-S +-i HOME=${HOME} PATH=${PATH} TMPDIR=${TMPDIR} +/lib64/ld-linux-x86-64.so.2 +--inhibit-cache +--glibc-hwcaps-mask + +--library-path +/opt/edgezero/runtime-lib +/work/bin/app-cli +--help +EOF +if cmp -s "$STATE/smoke.expected" "$STATE/smoke.normalized"; then + ok "binary smoke create argv has the exact controlled-loader profile" +else + diff -u "$STATE/smoke.expected" "$STATE/smoke.normalized" >&2 || true + no "binary smoke create argv has the exact controlled-loader profile" +fi +cat >"$STATE/smoke.process.expected" <<'EOF' +{"Path":"/usr/bin/env","Args":["-S","-i HOME=${HOME} PATH=${PATH} TMPDIR=${TMPDIR}","/lib64/ld-linux-x86-64.so.2","--inhibit-cache","--glibc-hwcaps-mask","","--library-path","/opt/edgezero/runtime-lib","/work/bin/app-cli","--help"]} +EOF +jq -c '.[0] | {Path, Args}' "$STATE/create-$smoke_id.inspect.json" >"$STATE/smoke.process.actual" +if cmp -s "$STATE/smoke.process.expected" "$STATE/smoke.process.actual"; then + ok "binary smoke persisted Path and Args match the controlled-loader contract" +else + diff -u "$STATE/smoke.process.expected" "$STATE/smoke.process.actual" >&2 || true + no "binary smoke persisted Path and Args match the controlled-loader contract" +fi + +printf 'Passed: %d Failed: %d\n' "$pass" "$fail" +[[ "$fail" -eq 0 ]] diff --git a/.github/actions/deploy-core/tests/verify-release-prerequisites.test.sh b/.github/actions/deploy-core/tests/verify-release-prerequisites.test.sh new file mode 100755 index 00000000..4b0b0202 --- /dev/null +++ b/.github/actions/deploy-core/tests/verify-release-prerequisites.test.sh @@ -0,0 +1,569 @@ +#!/usr/bin/env bash +# shellcheck disable=SC2015,SC2321 +set -euo pipefail + +DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +SOURCE_VERIFY="$DIR/../../../docker/build-app-cli/verify-release-prerequisites.sh" + +if [[ ! -x "$SOURCE_VERIFY" ]]; then + printf 'FAIL: missing executable helper: %s\n' "$SOURCE_VERIFY" >&2 + exit 1 +fi + +WORK=$(mktemp -d) +WORK=$(cd -- "$WORK" && pwd -P) +trap 'rm -rf -- "$WORK"' EXIT + +REAL_PATH=$PATH +POLICY_TOKEN='policy-audit-secret-value' +PACKAGE_TOKEN='package-audit-secret-value' +AUDIT_TOKEN='installation-audit-secret-value' +PROBE_TOKEN='publisher-probe-secret-value' +POLICY_LOGIN=policy-auditor +PACKAGE_LOGIN=package-auditor +POLICY_REVIEWER=policy-reviewer +ADMIN_REVIEWER=administrator-reviewer +OPERATOR=rotation-operator +BOT_LOGIN='edgezero-publisher[bot]' +APP_ID=5001 +INSTALLATION_ID=5002 +TEAM_ID=5003 +BOT_ID=5004 +REPO_ID=5005 +CANDIDATE_PR=42 +COMMENT_ID=9007199254740993 +SMOKE_RUN_ID=7001 +MERGE_RUN_ID=7002 +PUSH_RUN_ID=7003 +LOCK_RUN_ID=7004 +RUN_ATTEMPT=2 +LOCK_RUN_NUMBER=9 +NOW=2026-09-10T12:00:00Z +REVIEWED_AT=2026-09-10T11:45:00Z +EXPIRES_AT=2026-09-10T13:00:00Z +CREATED_AT=2026-09-10T11:30:00Z +COMPLETED_AT=2026-09-10T12:00:00Z + +pass=0 +fail=0 + +ok() { + printf ' \033[32mok\033[0m %s\n' "$1" + pass=$((pass + 1)) +} + +no() { + printf ' \033[31mFAIL\033[0m %s\n' "$1" >&2 + fail=$((fail + 1)) +} + +hash_file() { sha256sum "$1" | awk '{print $1}'; } +hash_bytes() { printf '%s' "$1" | sha256sum | awk '{print $1}'; } + +GATE_ROOT="$WORK/gate" +FAKE_BIN="$WORK/fake-bin" +INPUT_ROOT="$WORK/inputs" +mkdir -p "$GATE_ROOT/.github/docker/build-app-cli" "$GATE_ROOT/.github/workflows" \ + "$FAKE_BIN" "$INPUT_ROOT" +cp "$SOURCE_VERIFY" "$GATE_ROOT/.github/docker/build-app-cli/verify-release-prerequisites.sh" +chmod 0755 "$GATE_ROOT/.github/docker/build-app-cli/verify-release-prerequisites.sh" +printf '%s\n' \ + .github/CODEOWNERS \ + .github/docker/build-app-cli/gate-paths.txt \ + .github/docker/build-app-cli/verify-release-prerequisites.sh \ + .github/workflows/build-container-ci.yml \ + >"$GATE_ROOT/.github/docker/build-app-cli/gate-paths.txt" +printf '%s\n' \ + '/.github/CODEOWNERS @stackpop/edgezero-build-container-gate-reviewers' \ + '/.github/docker/build-app-cli/gate-paths.txt @stackpop/edgezero-build-container-gate-reviewers' \ + '/.github/docker/build-app-cli/verify-release-prerequisites.sh @stackpop/edgezero-build-container-gate-reviewers' \ + '/.github/workflows/build-container-ci.yml @stackpop/edgezero-build-container-gate-reviewers' \ + >"$GATE_ROOT/.github/CODEOWNERS" +printf 'name: build-container-ci\n' >"$GATE_ROOT/.github/workflows/build-container-ci.yml" +git -C "$GATE_ROOT" init -q -b main +git -C "$GATE_ROOT" config user.name fixture +git -C "$GATE_ROOT" config user.email fixture@example.invalid +git -C "$GATE_ROOT" add . +git -C "$GATE_ROOT" commit -q -m gate +G=$(git -C "$GATE_ROOT" rev-parse HEAD) +printf '{"gate-sha":"%s","provenance-protocol":1,"release-tag":"build-container-v1"}' "$G" \ + >"$GATE_ROOT/.github/docker/build-app-cli/release-request.json" +git -C "$GATE_ROOT" add .github/docker/build-app-cli/release-request.json +git -C "$GATE_ROOT" commit -q -m release-request +S=$(git -C "$GATE_ROOT" rev-parse HEAD) +S_TREE=$(git -C "$GATE_ROOT" rev-parse "$S^{tree}") +CANDIDATE_BRANCH_HEAD=$(GIT_AUTHOR_DATE=2026-09-10T10:00:00Z GIT_COMMITTER_DATE=2026-09-10T10:00:00Z \ + git -C "$GATE_ROOT" commit-tree "$S_TREE" -p "$G" -m candidate-head) +CANDIDATE_HEAD=$S +MERGE_GROUP_SHA=$S +git -C "$GATE_ROOT" checkout -q --detach "$G" +printf 'gate-prime\n' >"$GATE_ROOT/.github/docker/build-app-cli/prime" +git -C "$GATE_ROOT" add .github/docker/build-app-cli/prime +git -C "$GATE_ROOT" commit -q -m gate-prime +G_PRIME=$(git -C "$GATE_ROOT" rev-parse HEAD) +Q_D=$G +Q_F=$G_PRIME +git -C "$GATE_ROOT" checkout -q --detach "$G" +VERIFY="$GATE_ROOT/.github/docker/build-app-cli/verify-release-prerequisites.sh" + +POLICY_PNG="$INPUT_ROOT/policy.png" +ADMIN_PNG="$INPUT_ROOT/administrator.png" +KEY_FILE="$INPUT_ROOT/app-private-key.pem" +printf '\211PNG\r\n\032\npolicy-review' >"$POLICY_PNG" +printf '\211PNG\r\n\032\nadministrator-review' >"$ADMIN_PNG" +openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out "$KEY_FILE" 2>/dev/null +chmod 0600 "$KEY_FILE" +POLICY_PNG_DIGEST="sha256:$(hash_file "$POLICY_PNG")" +ADMIN_PNG_DIGEST="sha256:$(hash_file "$ADMIN_PNG")" +POLICY_REVIEW="$INPUT_ROOT/policy-review.json" +printf '%s' "{\"expires-at\":\"$EXPIRES_AT\",\"organization-grants\":{\"administration\":\"write\",\"members\":\"read\",\"other-displayed\":\"none\"},\"repository-grants\":{\"actions\":\"read\",\"administration\":\"write\",\"checks\":\"read\",\"contents\":\"read\",\"environments\":\"read\",\"metadata\":\"read\",\"other-displayed\":\"none\",\"pull-requests\":\"read\",\"variables\":\"read\"},\"resource-owner\":\"stackpop\",\"reviewed-at\":\"$REVIEWED_AT\",\"reviewer-login\":\"$POLICY_REVIEWER\",\"schema-version\":1,\"screenshot-sha256\":\"$POLICY_PNG_DIGEST\",\"selected-repositories\":[\"stackpop/edgezero\"],\"subject-login\":\"$POLICY_LOGIN\",\"token-id\":\"18446744073709551615\"}" >"$POLICY_REVIEW" +BOOTSTRAP_EVIDENCE="sha256:$(printf '1%.0s' {1..64})" +BOOTSTRAP_RECORD="{\"evidence-sha256\":\"$BOOTSTRAP_EVIDENCE\",\"evidence-url\":null,\"gate-sha\":\"$G\",\"previous-value-sha256\":null,\"rotation-history\":{\"state\":\"bootstrap-no-rotation\"},\"schema-version\":2,\"source-pr\":null,\"source-revision\":null}" + +cat >"$FAKE_BIN/date" <<'SH' +#!/usr/bin/env bash +set -euo pipefail +fixture=$(cd -- "$(dirname -- "$0")" && pwd) +[[ "$#" -eq 2 && "$1" == -u && "$2" == +%s ]] +[[ "${LC_ALL:-}" == C ]] +[[ -z "${EDGEZERO_RELEASE_POLICY_AUDIT_TOKEN+x}${EDGEZERO_RELEASE_PACKAGE_AUDIT_TOKEN+x}${EDGEZERO_RELEASE_APP_PRIVATE_KEY_FILE+x}${AMBIENT_SECRET+x}${HOME+x}" ]] +cat "$fixture/now-epoch" +SH +chmod 0755 "$FAKE_BIN/date" +jq -nr --arg value "$NOW" '$value | fromdateiso8601' >"$FAKE_BIN/now-epoch" + +cat >"$FAKE_BIN/curl" <<'SH' +#!/usr/bin/env bash +set -euo pipefail +fixture=$(cd -- "$(dirname -- "$0")" && pwd) +# shellcheck disable=SC1091 +source "$fixture/values" +[[ "${LC_ALL:-}" == C ]] +[[ -z "${EDGEZERO_RELEASE_POLICY_AUDIT_TOKEN+x}${EDGEZERO_RELEASE_PACKAGE_AUDIT_TOKEN+x}${EDGEZERO_RELEASE_APP_PRIVATE_KEY_FILE+x}${AMBIENT_SECRET+x}${HOME+x}${CURL_HOME+x}${XDG_CONFIG_HOME+x}${HTTPS_PROXY+x}${https_proxy+x}" ]] +expected=(--disable --silent --show-error --connect-timeout 10 --max-time 30 --max-redirs 0 --request) +for expected_arg in "${expected[@]}"; do [[ "$1" == "$expected_arg" ]]; shift; done +method=$1 +shift +body_file= header_file= data_file= url= +while (($#)); do + case "$1" in + --output) body_file=$2; shift 2 ;; + --dump-header) header_file=$2; shift 2 ;; + --config) [[ "$2" == - ]]; shift 2 ;; + --data-binary) data_file=${2#@}; shift 2 ;; + https://api.github.com/*) url=$1; shift ;; + *) exit 91 ;; + esac +done +[[ -n "$body_file" && -n "$header_file" && -n "$url" ]] +config=$(cat) +[[ "$config" == *'header = "Accept: application/vnd.github+json"'* ]] +[[ "$config" == *'header = "X-GitHub-Api-Version: 2026-03-10"'* ]] +[[ "$config" == *'header = "User-Agent: edgezero-build-container-gate/1"'* ]] +authorization=$(printf '%s\n' "$config" | sed -n 's/^header = "Authorization: Bearer \(.*\)"$/\1/p') +[[ -n "$authorization" ]] +path=${url#https://api.github.com} +credential=unknown +case "$authorization" in + "$POLICY_TOKEN") credential=policy ;; + "$PACKAGE_TOKEN") credential=package ;; + "$AUDIT_TOKEN") credential=installation-audit ;; + "$PROBE_TOKEN") credential=publisher-probe ;; + eyJ*) credential=app-jwt ;; +esac +case "$path" in + /user|/orgs/stackpop/memberships/*|/orgs/stackpop/teams/*|/orgs/stackpop/actions/permissions|/orgs/stackpop/rulesets*|/repos/stackpop/edgezero/actions/permissions|/repos/stackpop/edgezero/immutable-releases|/users/*|/repos/stackpop/edgezero/pulls/*|/repos/stackpop/edgezero/rulesets*|/repos/stackpop/edgezero/actions/variables/*|/repos/stackpop/edgezero/environments/*|/repos/stackpop/edgezero/commits/*|/repos/stackpop/edgezero/actions/runs/*|/repos/stackpop/edgezero/actions/workflows/*|/repos/stackpop/edgezero/git/ref/heads/main|/repos/stackpop/edgezero) + if [[ "$path" == /user || "$path" == /orgs/stackpop/memberships/* ]]; then [[ "$credential" == policy || "$credential" == package ]]; else [[ "$credential" == policy ]]; fi + ;; + /orgs/stackpop/packages*) [[ "$credential" == package ]] ;; + /app|/app/installations/*) [[ "$credential" == app-jwt ]] ;; + /installation/repositories*) [[ "$credential" == installation-audit || "$credential" == publisher-probe ]] ;; + /installation/token) [[ "$credential" == installation-audit || "$credential" == publisher-probe ]] ;; + *) exit 92 ;; +esac +printf '%s|%s|%s\n' "$credential" "$method" "$path" >>"$fixture/calls" +fault=$(cat "$fixture/fault") +status=200 content_type=application/json selected=2026-03-10 link= scopes= body='{}' +if [[ "$fault" == redirect && "$path" == /user ]]; then status=302; fi +if [[ "$fault" == media && "$path" == /user ]]; then content_type=text/plain; fi +if [[ "$fault" == version && "$path" == /user ]]; then selected=2022-11-28; fi +if [[ "$fault" == leaky-error && "$path" == /user ]]; then printf 'curl failed with %s %s %s %s\n' "$POLICY_TOKEN" "$PACKAGE_TOKEN" "$AUDIT_TOKEN" "$PROBE_TOKEN" >&2; exit 28; fi +case "$path" in + /user) + if [[ "$credential" == policy ]]; then body="{\"id\":101,\"login\":\"$POLICY_LOGIN\",\"type\":\"User\"}"; else body="{\"id\":102,\"login\":\"$PACKAGE_LOGIN\",\"type\":\"User\"}"; scopes='read:packages, read:org'; [[ "$fault" != scopes ]] || scopes='read:org, repo'; fi ;; + /orgs/stackpop/memberships/*) body='{"role":"admin","state":"active"}'; [[ "$credential" != package ]] || scopes='read:packages, read:org' ;; + /orgs/stackpop/teams/edgezero-build-container-releasers/memberships/*) body='{"role":"member","state":"active"}' ;; + /orgs/stackpop/actions/permissions|/repos/stackpop/edgezero/actions/permissions) body='{"enabled_repositories":"all","sha_pinning_required":false}' ;; + /repos/stackpop/edgezero) body="{\"archived\":false,\"default_branch\":\"main\",\"full_name\":\"stackpop/edgezero\",\"id\":$REPO_ID,\"owner\":{\"login\":\"stackpop\"}}" ;; + /repos/stackpop/edgezero/immutable-releases) body='{"enabled":true,"enforced_by_owner":false}' ;; + /users/*) body="{\"id\":$BOT_ID,\"login\":\"$BOT_LOGIN\",\"type\":\"Bot\"}" ;; + /orgs/stackpop/rulesets\?per_page=100\&page=1) body='[{"id":11,"name":"edgezero-build-container-required-workflow"}]' ;; + /orgs/stackpop/rulesets/11) body="{\"bypass_actors\":[],\"conditions\":{\"ref_name\":{\"exclude\":[],\"include\":[\"refs/heads/main\"]},\"repository_id\":{\"repository_ids\":[$REPO_ID]}},\"enforcement\":\"active\",\"id\":11,\"name\":\"edgezero-build-container-required-workflow\",\"rules\":[{\"parameters\":{\"do_not_enforce_on_create\":false,\"workflows\":[{\"path\":\".github/workflows/build-container-ci.yml\",\"repository_id\":$REPO_ID,\"sha\":\"$FINAL_GATE\"}]},\"type\":\"workflows\"}],\"source\":\"stackpop\",\"source_type\":\"Organization\",\"target\":\"branch\"}" ;; + /repos/stackpop/edgezero/rulesets\?per_page=100\&page=1) + body='[{"id":21},{"id":22},{"id":23},{"id":24},{"id":25},{"id":26}]' + [[ "$fault" != pagination-missing-link ]] || body=$(jq -nc '[range(0;100)|{"id":(1000+.)}]') + [[ "$fault" != pagination-duplicate ]] || body='[{"id":21},{"id":21}]' ;; + /repos/stackpop/edgezero/rulesets\?per_page=100\&page=2) body='[]' ;; + /repos/stackpop/edgezero/rulesets/21) body='{"bypass_actors":[],"conditions":{"ref_name":{"exclude":[],"include":["refs/heads/main"]}},"enforcement":"active","id":21,"name":"edgezero-build-container-main","rules":[{"parameters":{"allowed_merge_methods":["squash"],"dismiss_stale_reviews_on_push":true,"require_code_owner_review":true,"require_last_push_approval":true,"required_approving_review_count":2,"required_review_thread_resolution":true},"type":"pull_request"},{"parameters":{"check_response_timeout_minutes":60,"grouping_strategy":"ALLGREEN","max_entries_to_build":1,"max_entries_to_merge":1,"merge_method":"SQUASH","min_entries_to_merge":1,"min_entries_to_merge_wait_minutes":0},"type":"merge_queue"}],"source":"stackpop/edgezero","source_type":"Repository","target":"branch"}' ;; + /repos/stackpop/edgezero/rulesets/22) body="{\"bypass_actors\":[{\"actor_id\":$TEAM_ID,\"actor_type\":\"Team\",\"bypass_mode\":\"always\"}],\"conditions\":{\"ref_name\":{\"exclude\":[],\"include\":[\"refs/tags/build-container-v*\"]}},\"enforcement\":\"active\",\"id\":22,\"name\":\"edgezero-build-container-tag-creation\",\"rules\":[{\"type\":\"creation\"}],\"source\":\"stackpop/edgezero\",\"source_type\":\"Repository\",\"target\":\"tag\"}" ;; + /repos/stackpop/edgezero/rulesets/23) body='{"bypass_actors":[],"conditions":{"ref_name":{"exclude":[],"include":["refs/tags/build-container-v*"]}},"enforcement":"active","id":23,"name":"edgezero-build-container-tag-immutability","rules":[{"parameters":{"update_allows_fetch_and_merge":false},"type":"update"},{"type":"deletion"}],"source":"stackpop/edgezero","source_type":"Repository","target":"tag"}' ;; + /repos/stackpop/edgezero/rulesets/24) body="{\"bypass_actors\":[{\"actor_id\":$TEAM_ID,\"actor_type\":\"Team\",\"bypass_mode\":\"always\"}],\"conditions\":{\"ref_name\":{\"exclude\":[],\"include\":[\"refs/tags/v*\"]}},\"enforcement\":\"active\",\"id\":24,\"name\":\"edgezero-action-version-tag-creation\",\"rules\":[{\"type\":\"creation\"}],\"source\":\"stackpop/edgezero\",\"source_type\":\"Repository\",\"target\":\"tag\"}" ;; + /repos/stackpop/edgezero/rulesets/25) body='{"bypass_actors":[],"conditions":{"ref_name":{"exclude":[],"include":["refs/tags/v*"]}},"enforcement":"active","id":25,"name":"edgezero-action-version-tag-immutability","rules":[{"parameters":{"update_allows_fetch_and_merge":false},"type":"update"},{"type":"deletion"}],"source":"stackpop/edgezero","source_type":"Repository","target":"tag"}' ;; + /repos/stackpop/edgezero/rulesets/26) body="{\"bypass_actors\":[{\"actor_id\":$APP_ID,\"actor_type\":\"Integration\",\"bypass_mode\":\"always\"}],\"conditions\":{\"ref_name\":{\"exclude\":[],\"include\":[\"refs/heads/edgezero-build-container-pin/*\"]}},\"enforcement\":\"active\",\"id\":26,\"name\":\"edgezero-build-container-pin-branches\",\"rules\":[{\"type\":\"creation\"},{\"parameters\":{\"update_allows_fetch_and_merge\":false},\"type\":\"update\"},{\"type\":\"deletion\"}],\"source\":\"stackpop/edgezero\",\"source_type\":\"Repository\",\"target\":\"branch\"}" ;; + /repos/stackpop/edgezero/environments/build-container-release) body='{"deployment_branch_policy":{"custom_branch_policies":true,"protected_branches":false},"protection_rules":[{"prevent_self_review":true,"reviewers":[{"reviewer":{"id":5003,"slug":"edgezero-build-container-releasers","type":"Team"},"type":"Team"}],"type":"required_reviewers"}]}' ;; + /repos/stackpop/edgezero/environments/build-container-release/deployment-branch-policies\?per_page=100\&page=1) body='{"branch_policies":[{"id":31,"name":"build-container-v*","type":"tag"}],"total_count":1}' ;; + /repos/stackpop/edgezero/environments/build-container-release/deployment_protection_rules) body='{"custom_deployment_protection_rules":[],"total_count":0}' ;; + /repos/stackpop/edgezero/environments/build-container-release/variables/EDGEZERO_BUILD_CONTAINER_APP_ID) body="{\"created_at\":\"2026-09-09T10:00:00Z\",\"name\":\"EDGEZERO_BUILD_CONTAINER_APP_ID\",\"updated_at\":\"2026-09-10T10:00:00Z\",\"value\":\"$APP_ID\"}" ;; + /repos/stackpop/edgezero/environments/build-container-release/variables/EDGEZERO_BUILD_CONTAINER_APP_INSTALLATION_ID) body="{\"created_at\":\"2026-09-09T10:00:00Z\",\"name\":\"EDGEZERO_BUILD_CONTAINER_APP_INSTALLATION_ID\",\"updated_at\":\"2026-09-10T10:00:00Z\",\"value\":\"$INSTALLATION_ID\"}" ;; + /repos/stackpop/edgezero/environments/build-container-release/variables/EDGEZERO_BUILD_CONTAINER_RELEASE_TEAM_ID) body="{\"created_at\":\"2026-09-09T10:00:00Z\",\"name\":\"EDGEZERO_BUILD_CONTAINER_RELEASE_TEAM_ID\",\"updated_at\":\"2026-09-10T10:00:00Z\",\"value\":\"$TEAM_ID\"}" ;; + /repos/stackpop/edgezero/environments/build-container-release/secrets/EDGEZERO_BUILD_CONTAINER_APP_PRIVATE_KEY) body='{"created_at":"2026-09-09T10:00:00Z","name":"EDGEZERO_BUILD_CONTAINER_APP_PRIVATE_KEY","updated_at":"2026-09-10T10:00:00Z"}' ;; + /repos/stackpop/edgezero/actions/variables/EDGEZERO_BUILD_CONTAINER_GATE_SHA) body="{\"name\":\"EDGEZERO_BUILD_CONTAINER_GATE_SHA\",\"value\":\"$FINAL_GATE\"}" ;; + /repos/stackpop/edgezero/actions/variables/EDGEZERO_BUILD_CONTAINER_RELEASE_STATE) body='{"name":"EDGEZERO_BUILD_CONTAINER_RELEASE_STATE","value":"enabled"}' ;; + /repos/stackpop/edgezero/actions/variables/EDGEZERO_BUILD_CONTAINER_PUBLISHER_APP_ID) body="{\"name\":\"EDGEZERO_BUILD_CONTAINER_PUBLISHER_APP_ID\",\"value\":\"$APP_ID\"}" ;; + /repos/stackpop/edgezero/actions/variables/EDGEZERO_BUILD_CONTAINER_PUBLISHER_BOT_ID) body="{\"name\":\"EDGEZERO_BUILD_CONTAINER_PUBLISHER_BOT_ID\",\"value\":\"$BOT_ID\"}" ;; + /repos/stackpop/edgezero/actions/variables/EDGEZERO_BUILD_CONTAINER_PUBLISHER_BOT_LOGIN) body="{\"name\":\"EDGEZERO_BUILD_CONTAINER_PUBLISHER_BOT_LOGIN\",\"value\":\"$BOT_LOGIN\"}" ;; + /repos/stackpop/edgezero/actions/variables/EDGEZERO_BUILD_CONTAINER_PUBLISHER_PREREQUISITE) body=$(jq -nc --arg value "$(cat "$fixture/prerequisite")" '{name:"EDGEZERO_BUILD_CONTAINER_PUBLISHER_PREREQUISITE",value:$value}') ;; + /repos/stackpop/edgezero/pulls/$CANDIDATE_PR) body="{\"base\":{\"ref\":\"main\",\"repo\":{\"full_name\":\"stackpop/edgezero\"}},\"head\":{\"repo\":{\"full_name\":\"stackpop/edgezero\"},\"sha\":\"$CANDIDATE_HEAD\"},\"merge_commit_sha\":\"$SOURCE\",\"merged\":true,\"number\":$CANDIDATE_PR,\"state\":\"closed\"}" ;; + /repos/stackpop/edgezero/commits/*/check-runs\?check_name=build-container-release-preflight\&filter=latest\&app_id=15368\&per_page=100\&page=1) body="{\"check_runs\":[{\"app\":{\"id\":15368},\"conclusion\":\"success\",\"details_url\":\"https://github.com/stackpop/edgezero/actions/runs/$SMOKE_RUN_ID\",\"head_sha\":\"$CANDIDATE_HEAD\",\"id\":81,\"name\":\"build-container-release-preflight\",\"status\":\"completed\"}],\"total_count\":1}" ;; + /repos/stackpop/edgezero/commits/*/check-runs\?check_name=build-container-local\&filter=latest\&app_id=15368\&per_page=100\&page=1) body="{\"check_runs\":[{\"app\":{\"id\":15368},\"conclusion\":\"success\",\"head_sha\":\"$MERGE_GROUP_SHA\",\"id\":82,\"name\":\"build-container-local\",\"status\":\"completed\"}],\"total_count\":1}" ;; + /repos/stackpop/edgezero/commits/*/check-runs\?check_name=build-container-pin\&filter=latest\&app_id=15368\&per_page=100\&page=1) body="{\"check_runs\":[{\"app\":{\"id\":15368},\"conclusion\":\"success\",\"head_sha\":\"$MERGE_GROUP_SHA\",\"id\":83,\"name\":\"build-container-pin\",\"status\":\"completed\"}],\"total_count\":1}" ;; + /repos/stackpop/edgezero/actions/runs/$SMOKE_RUN_ID) body="{\"conclusion\":\"success\",\"created_at\":\"$CREATED_AT\",\"display_title\":\"build-container-release-preflight pr=$CANDIDATE_PR repo=stackpop/edgezero sha=$CANDIDATE_HEAD\",\"event\":\"workflow_dispatch\",\"head_sha\":\"$Q_D\",\"html_url\":\"https://github.com/stackpop/edgezero/actions/runs/$SMOKE_RUN_ID\",\"id\":$SMOKE_RUN_ID,\"path\":\".github/workflows/build-container-ci.yml\",\"run_attempt\":$RUN_ATTEMPT,\"status\":\"completed\"}" ;; + /repos/stackpop/edgezero/actions/runs/$MERGE_RUN_ID) body="{\"conclusion\":\"success\",\"created_at\":\"$CREATED_AT\",\"event\":\"merge_group\",\"head_sha\":\"$MERGE_GROUP_SHA\",\"html_url\":\"https://github.com/stackpop/edgezero/actions/runs/$MERGE_RUN_ID\",\"id\":$MERGE_RUN_ID,\"path\":\".github/workflows/build-container-ci.yml\",\"run_attempt\":$RUN_ATTEMPT,\"status\":\"completed\"}" ;; + /repos/stackpop/edgezero/actions/runs/$PUSH_RUN_ID) body="{\"conclusion\":\"success\",\"created_at\":\"$CREATED_AT\",\"event\":\"push\",\"head_sha\":\"$SOURCE\",\"html_url\":\"https://github.com/stackpop/edgezero/actions/runs/$PUSH_RUN_ID\",\"id\":$PUSH_RUN_ID,\"path\":\".github/workflows/build-container-ci.yml\",\"run_attempt\":$RUN_ATTEMPT,\"status\":\"completed\"}" ;; + /repos/stackpop/edgezero/actions/runs/$LOCK_RUN_ID) + if [[ "$(cat "$fixture/rotation-state")" == review ]]; then body="{\"actor\":{\"login\":\"rotation-actor\"},\"conclusion\":null,\"created_at\":\"$CREATED_AT\",\"event\":\"workflow_dispatch\",\"head_sha\":\"$Q_D\",\"id\":$LOCK_RUN_ID,\"path\":\".github/workflows/rotate-build-container-gate.yml@main\",\"run_attempt\":$RUN_ATTEMPT,\"run_number\":$LOCK_RUN_NUMBER,\"status\":\"in_progress\"}"; else body="{\"actor\":{\"login\":\"rotation-actor\"},\"conclusion\":\"success\",\"created_at\":\"$CREATED_AT\",\"event\":\"workflow_dispatch\",\"head_sha\":\"$Q_D\",\"id\":$LOCK_RUN_ID,\"path\":\".github/workflows/rotate-build-container-gate.yml@main\",\"run_attempt\":$RUN_ATTEMPT,\"run_number\":$LOCK_RUN_NUMBER,\"status\":\"completed\"}"; fi ;; + /repos/stackpop/edgezero/actions/runs/$SMOKE_RUN_ID/attempts/$RUN_ATTEMPT/jobs\?per_page=100\&page=1) body="{\"jobs\":[{\"completed_at\":\"$COMPLETED_AT\",\"conclusion\":\"success\",\"head_sha\":\"$Q_D\",\"id\":91,\"name\":\"build-container-release-preflight\",\"steps\":[{\"conclusion\":\"success\",\"name\":\"assert-exact-g-dispatch-context\"}]}],\"total_count\":1}" ;; + /repos/stackpop/edgezero/actions/runs/$MERGE_RUN_ID/attempts/$RUN_ATTEMPT/jobs\?per_page=100\&page=1) body="{\"jobs\":[{\"conclusion\":\"success\",\"head_sha\":\"$MERGE_GROUP_SHA\",\"id\":92,\"name\":\"build-container-local\",\"steps\":[]},{\"conclusion\":\"success\",\"head_sha\":\"$MERGE_GROUP_SHA\",\"id\":93,\"name\":\"build-container-pin\",\"steps\":[]}],\"total_count\":2}" ;; + /repos/stackpop/edgezero/actions/runs/$PUSH_RUN_ID/attempts/$RUN_ATTEMPT/jobs\?per_page=100\&page=1) body="{\"jobs\":[{\"conclusion\":\"success\",\"head_sha\":\"$SOURCE\",\"id\":94,\"name\":\"build-container-local\",\"steps\":[{\"conclusion\":\"success\",\"name\":\"assert-exact-main-push-context\"}]},{\"conclusion\":\"success\",\"head_sha\":\"$SOURCE\",\"id\":95,\"name\":\"build-container-pin\",\"steps\":[{\"conclusion\":\"success\",\"name\":\"assert-exact-main-push-context\"}]}],\"total_count\":2}" ;; + /repos/stackpop/edgezero/actions/runs/$LOCK_RUN_ID/attempts/$RUN_ATTEMPT/jobs\?per_page=100\&page=1) body="{\"jobs\":[{\"conclusion\":\"success\",\"head_sha\":\"$Q_D\",\"id\":96,\"name\":\"acquire-rotation-lock\",\"steps\":[]},{\"completed_at\":\"$COMPLETED_AT\",\"conclusion\":\"success\",\"head_sha\":\"$Q_D\",\"id\":97,\"name\":\"wait-for-rotation-review\",\"steps\":[{\"conclusion\":\"success\",\"name\":\"assert-exact-rotation-context\"}]}],\"total_count\":2}" ;; + /repos/stackpop/edgezero/actions/runs/$LOCK_RUN_ID/approvals) body=$(jq -Rs '[{comment:.,environment_name:"build-container-gate-rotation-lock",reviewed_at:"2026-09-10T12:00:00Z",state:"approved",user:{login:"rotation-reviewer"}}]' <"$fixture/approval-comment") ;; + /repos/stackpop/edgezero/actions/workflows/rotate-build-container-gate.yml/runs\?event=workflow_dispatch\&per_page=100\&page=1) body="{\"total_count\":1,\"workflow_runs\":[{\"created_at\":\"$CREATED_AT\",\"id\":$LOCK_RUN_ID,\"run_attempt\":$RUN_ATTEMPT,\"run_number\":$LOCK_RUN_NUMBER}]}" ;; + /repos/stackpop/edgezero/git/ref/heads/main) body="{\"object\":{\"sha\":\"$MAIN_SHA\",\"type\":\"commit\"},\"ref\":\"refs/heads/main\"}" ;; + /orgs/stackpop/packages\?package_type=container\&per_page=100\&page=1) + scopes='read:packages, read:org'; body='[]' + if [[ "$fault" == pagination-bad-last ]]; then + body=$(jq -nc '[range(0;100) | {id:(6000 + .),name:("other-" + (.|tostring))}]') + link='; rel="next", ; rel="last"' + fi ;; + /orgs/stackpop/packages\?package_type=container\&per_page=100\&page=2) scopes='read:packages, read:org'; body='[]' ;; + /orgs/stackpop/packages/container/edgezero-build-app-cli) scopes='read:packages, read:org'; body='{"name":"edgezero-build-app-cli","package_type":"container","repository":{"full_name":"stackpop/edgezero","id":5005},"visibility":"public"}' ;; + /app) body="{\"id\":$APP_ID,\"slug\":\"edgezero-publisher\"}" ;; + /app/installations/$INSTALLATION_ID) body="{\"account\":{\"login\":\"stackpop\",\"type\":\"Organization\"},\"id\":$INSTALLATION_ID,\"permissions\":{\"contents\":\"write\",\"metadata\":\"read\",\"pull_requests\":\"write\"},\"repository_selection\":\"selected\",\"suspended_at\":null}" ;; + /app/installations/$INSTALLATION_ID/access_tokens) + [[ "$method" == POST && -n "$data_file" ]]; mint=0; [[ ! -f "$fixture/mint-count" ]] || mint=$(cat "$fixture/mint-count"); mint=$((mint + 1)); printf '%s' "$mint" >"$fixture/mint-count" + if [[ "$mint" -eq 1 ]]; then [[ "$(cat "$data_file")" == '{"permissions":{"metadata":"read"}}' ]]; body="{\"expires_at\":\"$EXPIRES_AT\",\"permissions\":{\"metadata\":\"read\"},\"token\":\"$AUDIT_TOKEN\"}"; else [[ "$(cat "$data_file")" == "{\"repository_ids\":[$REPO_ID],\"permissions\":{\"contents\":\"write\",\"pull_requests\":\"write\"}}" ]]; body="{\"expires_at\":\"$EXPIRES_AT\",\"permissions\":{\"contents\":\"write\",\"metadata\":\"read\",\"pull_requests\":\"write\"},\"token\":\"$PROBE_TOKEN\"}"; fi + status=201 ;; + /installation/repositories\?per_page=100\&page=1) body="{\"repositories\":[{\"full_name\":\"stackpop/edgezero\",\"id\":$REPO_ID}],\"total_count\":1}" ;; + /installation/token) [[ "$method" == DELETE ]]; body=; status=204; content_type= ;; + *) exit 93 ;; +esac +if [[ "$fault" == duplicate-key && "$path" == /user ]]; then body='{"id":101,"id":102,"login":"policy-auditor","type":"User"}'; fi +if [[ "$fault" == policy-hidden-bypass && "$path" == /repos/stackpop/edgezero/rulesets/21 ]]; then body=$(printf '%s' "$body" | jq -c 'del(.bypass_actors)'); fi +if [[ "$fault" == approval-reviewer-only && "$path" == "/repos/stackpop/edgezero/actions/runs/$LOCK_RUN_ID/approvals" ]]; then body=$(printf '%s' "$body" | jq -c 'map(.reviewer = .user | del(.user))'); fi +if [[ "$fault" == approval-comment-trailing-lf && "$path" == "/repos/stackpop/edgezero/actions/runs/$LOCK_RUN_ID/approvals" ]]; then body=$(printf '%s' "$body" | jq -c '.[0].comment += "\n"'); fi +if [[ "$fault" == approval-user-trailing-lf && "$path" == "/repos/stackpop/edgezero/actions/runs/$LOCK_RUN_ID/approvals" ]]; then body=$(printf '%s' "$body" | jq -c '.[0].user.login += "\n"'); fi +if [[ "$fault" == history-attempt-mismatch && "$path" == '/repos/stackpop/edgezero/actions/workflows/rotate-build-container-gate.yml/runs?event=workflow_dispatch&per_page=100&page=1' ]]; then body=$(printf '%s' "$body" | jq -c '.workflow_runs[0].run_attempt = 1'); fi +if [[ "$fault" == history-future-created && "$path" == '/repos/stackpop/edgezero/actions/workflows/rotate-build-container-gate.yml/runs?event=workflow_dispatch&per_page=100&page=1' ]]; then body=$(printf '%s' "$body" | jq -c '.workflow_runs[0].created_at = "2026-09-10T12:00:01Z"'); fi +if [[ "$fault" == detail-created-mismatch && "$path" == "/repos/stackpop/edgezero/actions/runs/$LOCK_RUN_ID" ]]; then body=$(printf '%s' "$body" | jq -c '.created_at = "2026-09-10T11:29:59Z"'); fi +if [[ "$fault" == rotation-job-head-mismatch && "$path" == "/repos/stackpop/edgezero/actions/runs/$LOCK_RUN_ID/attempts/$RUN_ATTEMPT/jobs?per_page=100&page=1" ]]; then body=$(printf '%s' "$body" | jq -c --arg head "$FINAL_GATE" '.jobs[].head_sha = $head'); fi +if [[ "$fault" == prerequisite-trailing-lf && "$path" == /repos/stackpop/edgezero/actions/variables/EDGEZERO_BUILD_CONTAINER_PUBLISHER_PREREQUISITE ]]; then body=$(printf '%s' "$body" | jq -c '.value += "\n"'); fi +if [[ "$fault" == audit-enumeration-failure && "$credential" == installation-audit && "$path" == '/installation/repositories?per_page=100&page=1' ]]; then status=500; fi +if [[ "$fault" == probe-read-failure && "$credential" == publisher-probe && "$path" == /repos/stackpop/edgezero ]]; then status=500; fi +if [[ "$fault" == audit-permissions-broadened && "$credential" == app-jwt && "$path" == "/app/installations/$INSTALLATION_ID/access_tokens" && "$mint" -eq 1 ]]; then body=$(printf '%s' "$body" | jq -c '.permissions.contents = "read"'); fi +if [[ "$fault" == revoke-failure && "$path" == /installation/token ]]; then status=500; fi +if [[ "$fault" == repeated-json && "$path" == /user ]]; then body="$body$body"; fi +if [[ "$fault" == pagination-bad-link && "$path" == '/repos/stackpop/edgezero/rulesets?per_page=100&page=1' ]]; then body=$(jq -nc '[range(0;100)|{"id":(1000+.)}]'); link='; rel="next"'; fi +{ + printf 'HTTP/1.1 %s status\r\n' "$status" + printf 'X-GitHub-Api-Version-Selected: %s\r\n' "$selected" + [[ -z "$content_type" ]] || printf 'Content-Type: %s\r\n' "$content_type" + [[ -z "$link" ]] || printf 'Link: %s\r\n' "$link" + [[ -z "$scopes" ]] || printf 'X-OAuth-Scopes: %s\r\n' "$scopes" + printf '\r\n' +} >"$header_file" +printf '%s' "$body" >"$body_file" +SH +chmod 0755 "$FAKE_BIN/curl" + +write_values() { + cat >"$FAKE_BIN/values" <"$FAKE_BIN/calls"; : >"$FAKE_BIN/fault"; : >"$FAKE_BIN/approval-comment" + printf '%s' "$BOOTSTRAP_RECORD" >"$FAKE_BIN/prerequisite" + printf review >"$FAKE_BIN/rotation-state" + rm -f "$FAKE_BIN/mint-count" + write_values +} + +configuration_args() { + CONFIGURATION_ARGS=(configuration --gate-root "$GATE_ROOT" --gate-sha "$G" --smoke-run-id "$SMOKE_RUN_ID" --smoke-run-attempt "$RUN_ATTEMPT" --expected-app-id "$APP_ID" --expected-installation-id "$INSTALLATION_ID" --expected-team-id "$TEAM_ID" --expected-bot-id "$BOT_ID" --expected-bot-login "$BOT_LOGIN" --policy-token-review-json "$POLICY_REVIEW" --policy-token-review-png "$POLICY_PNG" --administrator-bypass-png "$ADMIN_PNG" --administrator-bypass-reviewer "$ADMIN_REVIEWER" --administrator-bypass-reviewed-at "$REVIEWED_AT" --evidence-out "$EVIDENCE_OUT") +} + +release_args() { + RELEASE_ARGS=(release --gate-root "$GATE_ROOT" --gate-sha "$G" --candidate-pr "$CANDIDATE_PR" --evidence-url "https://github.com/stackpop/edgezero/pull/$CANDIDATE_PR#issuecomment-$COMMENT_ID" --source-revision "$S" --merge-group-sha "$MERGE_GROUP_SHA" --merge-group-run-id "$MERGE_RUN_ID" --merge-group-run-attempt "$RUN_ATTEMPT" --smoke-run-id "$SMOKE_RUN_ID" --smoke-run-attempt "$RUN_ATTEMPT" --push-run-id "$PUSH_RUN_ID" --push-run-attempt "$RUN_ATTEMPT" --expected-app-id "$APP_ID" --expected-installation-id "$INSTALLATION_ID" --expected-team-id "$TEAM_ID" --expected-bot-id "$BOT_ID" --expected-bot-login "$BOT_LOGIN" --package-auditor-login "$PACKAGE_LOGIN" --package-state absent --policy-token-review-json "$POLICY_REVIEW" --policy-token-review-png "$POLICY_PNG" --administrator-bypass-png "$ADMIN_PNG" --administrator-bypass-reviewer "$ADMIN_REVIEWER" --administrator-bypass-reviewed-at "$REVIEWED_AT" --evidence-out "$EVIDENCE_OUT" --publisher-prerequisite-out "$PREREQUISITE_OUT") +} + +rotation_review_args() { + ROTATION_REVIEW_ARGS=(rotation-review --gate-root "$GATE_ROOT" --old-gate-sha "$G" --new-gate-sha "$G_PRIME" --dispatch-sha "$Q_D" --final-head-sha "$Q_F" --lock-run-id "$LOCK_RUN_ID" --lock-run-attempt "$RUN_ATTEMPT" --operator-login "$OPERATOR" --result activated --policy-token-review-json "$POLICY_REVIEW" --policy-token-review-png "$POLICY_PNG" --evidence-out "$EVIDENCE_OUT" --approval-comment-out "$COMMENT_OUT") +} + +rotation_complete_args() { + ROTATION_COMPLETE_ARGS=(rotation-complete --gate-root "$GATE_ROOT" --gate-sha "$G" --lock-run-id "$LOCK_RUN_ID" --lock-run-attempt "$RUN_ATTEMPT" --policy-token-review-json "$POLICY_REVIEW" --policy-token-review-png "$POLICY_PNG" --evidence-out "$EVIDENCE_OUT" --publisher-prerequisite-out "$PREREQUISITE_OUT") +} + +run_helper() { + env PATH="$FAKE_BIN:$REAL_PATH" EDGEZERO_RELEASE_POLICY_AUDIT_TOKEN="$POLICY_TOKEN" EDGEZERO_RELEASE_PACKAGE_AUDIT_TOKEN="$PACKAGE_TOKEN" EDGEZERO_RELEASE_APP_PRIVATE_KEY_FILE="$KEY_FILE" AMBIENT_SECRET='ambient-secret-value' "$VERIFY" "$@" >"$STDOUT" 2>"$STDERR" +} + +assert_success() { local description=$1; shift; if run_helper "$@"; then ok "$description"; else no "$description"; sed -n '1,5p' "$STDERR" >&2; fi; } +assert_contract_failure() { local description=$1; shift; local status=0; run_helper "$@" || status=$?; if [[ "$status" -eq 1 && ! -e "$EVIDENCE_OUT" && ! -e "$PREREQUISITE_OUT" && ! -e "$COMMENT_OUT" ]]; then ok "$description"; else no "$description (status=$status)"; fi; } +assert_usage_failure() { local description=$1; shift; local status=0; run_helper "$@" || status=$?; if [[ "$status" -eq 2 && ! -s "$FAKE_BIN/calls" && ! -e "$EVIDENCE_OUT" ]]; then ok "$description"; else no "$description (status=$status)"; fi; } + +printf 'verify-release-prerequisites tests\n' + +new_case configuration +configuration_args +assert_success 'configuration mode accepts the complete reviewed baseline' "${CONFIGURATION_ARGS[@]}" +[[ ! -s "$STDOUT" ]] && ok 'configuration success is silent' || no 'configuration success is silent' +jq -e --arg gate "$G" --arg head "$CANDIDATE_HEAD" --arg admin "$ADMIN_PNG_DIGEST" '.mode == "configuration" and ."gate-sha" == $gate and .environment."administrator-bypass"."candidate-head-sha" == $head and .environment."administrator-bypass"."sha256" == $admin and .policy."required-workflow-sha" == $gate and .credentials."app-probes"."publisher-probe-revoked" == true' "$EVIDENCE_OUT" >/dev/null && ok 'configuration emits canonical bound audit evidence' || no 'configuration emits canonical bound audit evidence' +[[ "$(tail -c 1 "$EVIDENCE_OUT" | od -An -tuC | tr -d ' ')" != 10 ]] && ok 'configuration evidence has no trailing LF' || no 'configuration evidence has no trailing LF' + +new_case copied-helper +configuration_args +COPIED_HELPER="$CASE_ROOT/verify-release-prerequisites.sh" +cp "$VERIFY" "$COPIED_HELPER"; chmod 0755 "$COPIED_HELPER"; status=0 +env PATH="$FAKE_BIN:$REAL_PATH" EDGEZERO_RELEASE_POLICY_AUDIT_TOKEN="$POLICY_TOKEN" EDGEZERO_RELEASE_APP_PRIVATE_KEY_FILE="$KEY_FILE" "$COPIED_HELPER" "${CONFIGURATION_ARGS[@]}" >"$STDOUT" 2>"$STDERR" || status=$? +[[ "$status" -eq 1 && ! -s "$FAKE_BIN/calls" && ! -e "$EVIDENCE_OUT" ]] && ok 'auditor rejects execution outside the verified gate path before network' || no 'auditor rejects execution outside the verified gate path before network' + +new_case release +release_args +assert_success 'release mode verifies queue, push, package absence, and credential probes' "${RELEASE_ARGS[@]}" +expected_previous="sha256:$(hash_bytes "$BOOTSTRAP_RECORD")"; expected_evidence="sha256:$(hash_file "$EVIDENCE_OUT")" +jq -e --arg evidence "$expected_evidence" --arg previous "$expected_previous" --arg gate "$G" --arg source "$S" --arg pr "$CANDIDATE_PR" --arg url "https://github.com/stackpop/edgezero/pull/$CANDIDATE_PR#issuecomment-$COMMENT_ID" 'keys == ["evidence-sha256","evidence-url","gate-sha","previous-value-sha256","rotation-history","schema-version","source-pr","source-revision"] and ."schema-version" == 2 and ."evidence-sha256" == $evidence and ."previous-value-sha256" == $previous and ."gate-sha" == $gate and ."source-revision" == $source and ."source-pr" == $pr and ."evidence-url" == $url and ."rotation-history" == {"state":"bootstrap-no-rotation"}' "$PREREQUISITE_OUT" >/dev/null && ok 'release emits the exact schema-version-2 source tuple' || no 'release emits the exact schema-version-2 source tuple' +[[ ! -s "$STDOUT" ]] && ok 'release success is silent' || no 'release success is silent' +grep -q '^package|GET|/orgs/stackpop/packages?package_type=container&per_page=100&page=1$' "$FAKE_BIN/calls" && grep -q '^installation-audit|DELETE|/installation/token$' "$FAKE_BIN/calls" && grep -q '^publisher-probe|DELETE|/installation/token$' "$FAKE_BIN/calls" && ok 'release routes each API surface through its exact credential' || no 'release routes each API surface through its exact credential' + +new_case release-public +release_args +for i in "${!RELEASE_ARGS[@]}"; do [[ "${RELEASE_ARGS[$i]}" != --package-state ]] || RELEASE_ARGS[$((i + 1))]=public-linked; done +assert_success 'release accepts a public repository-linked package' "${RELEASE_ARGS[@]}" + +new_case release-distinct-pr-head +sed -i.bak "s/CANDIDATE_HEAD='$S'/CANDIDATE_HEAD='$CANDIDATE_BRANCH_HEAD'/" "$FAKE_BIN/values" +release_args +assert_success 'release distinguishes the preflight PR head from the merged source revision' "${RELEASE_ARGS[@]}" + +new_case rotation-review +sed -i.bak "s/FINAL_GATE='$G'/FINAL_GATE='$G_PRIME'/; s/MAIN_SHA='$S'/MAIN_SHA='$Q_F'/" "$FAKE_BIN/values" +rotation_review_args +assert_success 'rotation-review verifies restored policy while the lock is held' "${ROTATION_REVIEW_ARGS[@]}" +line_one=$(sed -n '1p' "$COMMENT_OUT"); line_two=$(sed -n '2p' "$COMMENT_OUT"); policy_object=${line_two#edgezero-gate-rotation-policy-v1 }; receipt_object=${line_one#edgezero-gate-rotation-v1 } +[[ "$(wc -l <"$COMMENT_OUT" | tr -d ' ')" == 1 && "$(tail -c 1 "$COMMENT_OUT" | od -An -tuC | tr -d ' ')" != 10 ]] && ok 'rotation-review emits exactly two lines without a trailing LF' || no 'rotation-review emits exactly two lines without a trailing LF' +jq -e --arg digest "sha256:$(hash_bytes "$policy_object")" --arg gate "$G_PRIME" --arg head "$Q_F" '."evidence-sha256" == $digest and ."new-gate-sha" == $gate and ."head-sha" == $head and .result == "activated"' <<<"$receipt_object" >/dev/null && ok 'rotation-review binds the policy receipt digest and final identities' || no 'rotation-review binds the policy receipt digest and final identities' +[[ "$(jq -r '."policy-sha256"' <<<"$policy_object")" == "sha256:$(hash_file "$EVIDENCE_OUT")" ]] && ok 'rotation policy line binds the opaque evidence bytes' || no 'rotation policy line binds the opaque evidence bytes' +! grep -Eq '^(package|app-jwt|installation-audit|publisher-probe)\|' "$FAKE_BIN/calls" && ok 'rotation-review uses only the policy credential' || no 'rotation-review uses only the policy credential' + +new_case rotation-complete +git -C "$GATE_ROOT" checkout -q --detach "$G_PRIME" +sed -i.bak "s/FINAL_GATE='$G'/FINAL_GATE='$G_PRIME'/; s/MAIN_SHA='$S'/MAIN_SHA='$Q_F'/" "$FAKE_BIN/values" +cp "$WORK/cases/rotation-review/output/comment.txt" "$FAKE_BIN/approval-comment" +printf completed >"$FAKE_BIN/rotation-state" +rotation_complete_args +for i in "${!ROTATION_COMPLETE_ARGS[@]}"; do [[ "${ROTATION_COMPLETE_ARGS[$i]}" != --gate-sha ]] || ROTATION_COMPLETE_ARGS[$((i + 1))]=$G_PRIME; done +assert_success 'rotation-complete authenticates completed lock history' "${ROTATION_COMPLETE_ARGS[@]}" +expected_history=$(printf '[{"run-attempt":"%s","run-id":"%s","run-number":"%s"}]' "$RUN_ATTEMPT" "$LOCK_RUN_ID" "$LOCK_RUN_NUMBER") +jq -e --arg evidence "sha256:$(hash_file "$EVIDENCE_OUT")" --arg history "sha256:$(hash_bytes "$expected_history")" --arg gate "$G_PRIME" --arg id "$LOCK_RUN_ID" --arg attempt "$RUN_ATTEMPT" --arg number "$LOCK_RUN_NUMBER" '."schema-version" == 2 and ."evidence-sha256" == $evidence and ."gate-sha" == $gate and ."source-revision" == null and ."source-pr" == null and ."evidence-url" == null and ."rotation-history"."history-sha256" == $history and ."rotation-history"."run-id" == $id and ."rotation-history"."run-attempt" == $attempt and ."rotation-history"."run-number" == $number and ."rotation-history".state == "verified"' "$PREREQUISITE_OUT" >/dev/null && ok 'rotation-complete emits exact schema-version-2 verified inert state' || no 'rotation-complete emits exact schema-version-2 verified inert state' +[[ "$(grep -c 'actions/workflows/rotate-build-container-gate.yml/runs' "$FAKE_BIN/calls")" -eq 2 ]] && ok 'rotation-complete uses a two-pass fail-closed history read' || no 'rotation-complete uses a two-pass fail-closed history read' + +new_case rotation-approval-reviewer-only +sed -i.bak "s/FINAL_GATE='$G'/FINAL_GATE='$G_PRIME'/; s/MAIN_SHA='$S'/MAIN_SHA='$Q_F'/" "$FAKE_BIN/values" +cp "$WORK/cases/rotation-review/output/comment.txt" "$FAKE_BIN/approval-comment" +printf completed >"$FAKE_BIN/rotation-state" +printf approval-reviewer-only >"$FAKE_BIN/fault" +rotation_complete_args +for i in "${!ROTATION_COMPLETE_ARGS[@]}"; do [[ "${ROTATION_COMPLETE_ARGS[$i]}" != --gate-sha ]] || ROTATION_COMPLETE_ARGS[$((i + 1))]=$G_PRIME; done +assert_contract_failure 'rotation-complete rejects reviewer-only workflow approval identity' "${ROTATION_COMPLETE_ARGS[@]}" + +for approval_case in approval-comment-trailing-lf approval-user-trailing-lf; do + new_case "rotation-$approval_case" + sed -i.bak "s/FINAL_GATE='$G'/FINAL_GATE='$G_PRIME'/; s/MAIN_SHA='$S'/MAIN_SHA='$Q_F'/" "$FAKE_BIN/values" + cp "$WORK/cases/rotation-review/output/comment.txt" "$FAKE_BIN/approval-comment" + printf completed >"$FAKE_BIN/rotation-state" + printf '%s' "$approval_case" >"$FAKE_BIN/fault" + rotation_complete_args + for i in "${!ROTATION_COMPLETE_ARGS[@]}"; do [[ "${ROTATION_COMPLETE_ARGS[$i]}" != --gate-sha ]] || ROTATION_COMPLETE_ARGS[$((i + 1))]=$G_PRIME; done + assert_contract_failure "rotation-complete rejects $approval_case" "${ROTATION_COMPLETE_ARGS[@]}" +done + +for history_case in history-attempt-mismatch history-future-created detail-created-mismatch rotation-job-head-mismatch; do + new_case "rotation-$history_case" + sed -i.bak "s/FINAL_GATE='$G'/FINAL_GATE='$G_PRIME'/; s/MAIN_SHA='$S'/MAIN_SHA='$Q_F'/" "$FAKE_BIN/values" + cp "$WORK/cases/rotation-review/output/comment.txt" "$FAKE_BIN/approval-comment" + printf completed >"$FAKE_BIN/rotation-state" + printf '%s' "$history_case" >"$FAKE_BIN/fault" + rotation_complete_args + for i in "${!ROTATION_COMPLETE_ARGS[@]}"; do [[ "${ROTATION_COMPLETE_ARGS[$i]}" != --gate-sha ]] || ROTATION_COMPLETE_ARGS[$((i + 1))]=$G_PRIME; done + assert_contract_failure "rotation-complete rejects $history_case" "${ROTATION_COMPLETE_ARGS[@]}" +done + +new_case rotation-large-run-number +sed -i.bak "s/FINAL_GATE='$G'/FINAL_GATE='$G_PRIME'/; s/MAIN_SHA='$S'/MAIN_SHA='$Q_F'/; s/LOCK_RUN_NUMBER='$LOCK_RUN_NUMBER'/LOCK_RUN_NUMBER='9007199254740993'/" "$FAKE_BIN/values" +cp "$WORK/cases/rotation-review/output/comment.txt" "$FAKE_BIN/approval-comment" +printf completed >"$FAKE_BIN/rotation-state" +rotation_complete_args +for i in "${!ROTATION_COMPLETE_ARGS[@]}"; do [[ "${ROTATION_COMPLETE_ARGS[$i]}" != --gate-sha ]] || ROTATION_COMPLETE_ARGS[$((i + 1))]=$G_PRIME; done +assert_success 'rotation-complete preserves a run number above the JSON safe-integer range' "${ROTATION_COMPLETE_ARGS[@]}" +[[ "$(jq -r '."rotation-history"."run-number"' "$PREREQUISITE_OUT" 2>/dev/null)" == 9007199254740993 ]] && ok 'rotation-complete emits the exact lossless run-number string' || no 'rotation-complete emits the exact lossless run-number string' + +new_case rotation-rollback +git -C "$GATE_ROOT" checkout -q --detach "$G" +sed -i.bak "s/MAIN_SHA='$S'/MAIN_SHA='$Q_F'/" "$FAKE_BIN/values" +rotation_review_args +for i in "${!ROTATION_REVIEW_ARGS[@]}"; do [[ "${ROTATION_REVIEW_ARGS[$i]}" != --result ]] || ROTATION_REVIEW_ARGS[$((i + 1))]=rolled-back; done +assert_success 'rotation-review accepts an exact old-gate rollback restoration' "${ROTATION_REVIEW_ARGS[@]}" +jq -e --arg gate "$G" '."gate-sha" == $gate and ."required-workflow-sha" == $gate' <<<"$(sed -n '2s/^edgezero-gate-rotation-policy-v1 //p' "$COMMENT_OUT")" >/dev/null && ok 'rollback receipt identifies the restored old gate' || no 'rollback receipt identifies the restored old gate' + +for mode_case in missing-mode unknown-mode duplicate extra cross-mode missing-evidence bad-package empty-value; do + new_case "cli-$mode_case"; configuration_args + case "$mode_case" in + missing-mode) args=() ;; + unknown-mode) args=(other "${CONFIGURATION_ARGS[@]:1}") ;; + duplicate) args=("${CONFIGURATION_ARGS[@]}" --gate-sha "$G") ;; + extra) args=("${CONFIGURATION_ARGS[@]}" --unknown value) ;; + cross-mode) args=("${CONFIGURATION_ARGS[@]}" --candidate-pr "$CANDIDATE_PR") ;; + missing-evidence) release_args; args=("${RELEASE_ARGS[@]}"); for i in "${!args[@]}"; do if [[ "${args[$i]}" == --evidence-url ]]; then unset 'args[i]' 'args[i+1]'; break; fi; done; args=("${args[@]}") ;; + bad-package) release_args; args=("${RELEASE_ARGS[@]}"); for i in "${!args[@]}"; do [[ "${args[$i]}" != --package-state ]] || args[$((i + 1))]=private; done ;; + empty-value) args=("${CONFIGURATION_ARGS[@]}"); args[3]= ;; + esac + if [[ "$mode_case" == missing-mode ]]; then + assert_usage_failure "CLI rejects $mode_case before API access" + else + assert_usage_failure "CLI rejects $mode_case before API access" "${args[@]}" + fi +done + +for credential_case in configuration-policy configuration-key release-policy release-package release-key equal-audit-tokens rotation-policy; do + new_case "credential-$credential_case"; configuration_args; args=("${CONFIGURATION_ARGS[@]}") + env_args=(PATH="$FAKE_BIN:$REAL_PATH" EDGEZERO_RELEASE_POLICY_AUDIT_TOKEN="$POLICY_TOKEN" EDGEZERO_RELEASE_PACKAGE_AUDIT_TOKEN="$PACKAGE_TOKEN" EDGEZERO_RELEASE_APP_PRIVATE_KEY_FILE="$KEY_FILE") + case "$credential_case" in + configuration-policy) env_args[1]='EDGEZERO_RELEASE_POLICY_AUDIT_TOKEN=' ;; + configuration-key) env_args[3]='EDGEZERO_RELEASE_APP_PRIVATE_KEY_FILE=' ;; + release-policy) release_args; args=("${RELEASE_ARGS[@]}"); env_args[1]='EDGEZERO_RELEASE_POLICY_AUDIT_TOKEN=' ;; + release-package) release_args; args=("${RELEASE_ARGS[@]}"); env_args[2]='EDGEZERO_RELEASE_PACKAGE_AUDIT_TOKEN=' ;; + release-key) release_args; args=("${RELEASE_ARGS[@]}"); env_args[3]='EDGEZERO_RELEASE_APP_PRIVATE_KEY_FILE=' ;; + equal-audit-tokens) release_args; args=("${RELEASE_ARGS[@]}"); env_args[2]="EDGEZERO_RELEASE_PACKAGE_AUDIT_TOKEN=$POLICY_TOKEN" ;; + rotation-policy) rotation_review_args; args=("${ROTATION_REVIEW_ARGS[@]}"); env_args[1]='EDGEZERO_RELEASE_POLICY_AUDIT_TOKEN=' ;; + esac + status=0; env "${env_args[@]}" "$VERIFY" "${args[@]}" >"$STDOUT" 2>"$STDERR" || status=$? + [[ "$status" -eq 1 && ! -s "$FAKE_BIN/calls" ]] && ok "mode credentials reject $credential_case before network" || no "mode credentials reject $credential_case before network" +done + +for review_case in duplicate reordered extra trailing wrong-grants self-review future expired bad-digest bad-policy-png bad-admin-png same-png key-mode key-symlink; do + new_case "input-$review_case"; configuration_args; review="$CASE_ROOT/review.json"; cp "$POLICY_REVIEW" "$review"; policy_png="$POLICY_PNG"; admin_png="$ADMIN_PNG"; key="$KEY_FILE" + case "$review_case" in + duplicate) sed 's/"token-id":/"token-id":"7","token-id":/' "$POLICY_REVIEW" >"$review" ;; + reordered) jq -c '{"schema-version":."schema-version","expires-at":."expires-at","organization-grants":."organization-grants","repository-grants":."repository-grants","resource-owner":."resource-owner","reviewed-at":."reviewed-at","reviewer-login":."reviewer-login","screenshot-sha256":."screenshot-sha256","selected-repositories":."selected-repositories","subject-login":."subject-login","token-id":."token-id"}' "$POLICY_REVIEW" >"$review" ;; + extra) jq -c '.extra=true' "$POLICY_REVIEW" >"$review" ;; + trailing) printf '\n' >>"$review" ;; + wrong-grants) sed 's/"checks":"read"/"checks":"write"/' "$POLICY_REVIEW" >"$review" ;; + self-review) sed "s/\"reviewer-login\":\"$POLICY_REVIEWER\"/\"reviewer-login\":\"$POLICY_LOGIN\"/" "$POLICY_REVIEW" >"$review" ;; + future) sed "s/$REVIEWED_AT/2026-09-10T12:00:01Z/" "$POLICY_REVIEW" >"$review" ;; + expired) sed "s/$EXPIRES_AT/$NOW/" "$POLICY_REVIEW" >"$review" ;; + bad-digest) sed "s/$POLICY_PNG_DIGEST/sha256:$(printf 'f%.0s' {1..64})/" "$POLICY_REVIEW" >"$review" ;; + bad-policy-png) policy_png="$CASE_ROOT/policy.png"; printf not-a-png >"$policy_png" ;; + bad-admin-png) admin_png="$CASE_ROOT/admin.png"; printf not-a-png >"$admin_png" ;; + same-png) admin_png="$POLICY_PNG" ;; + key-mode) key="$CASE_ROOT/key"; cp "$KEY_FILE" "$key"; chmod 0640 "$key" ;; + key-symlink) key="$CASE_ROOT/key"; ln -s "$KEY_FILE" "$key" ;; + esac + for i in "${!CONFIGURATION_ARGS[@]}"; do case "${CONFIGURATION_ARGS[$i]}" in --policy-token-review-json) CONFIGURATION_ARGS[$((i + 1))]=$review ;; --policy-token-review-png) CONFIGURATION_ARGS[$((i + 1))]=$policy_png ;; --administrator-bypass-png) CONFIGURATION_ARGS[$((i + 1))]=$admin_png ;; esac; done + status=0; env PATH="$FAKE_BIN:$REAL_PATH" EDGEZERO_RELEASE_POLICY_AUDIT_TOKEN="$POLICY_TOKEN" EDGEZERO_RELEASE_APP_PRIVATE_KEY_FILE="$key" "$VERIFY" "${CONFIGURATION_ARGS[@]}" >"$STDOUT" 2>"$STDERR" || status=$? + [[ "$status" -eq 1 && ! -s "$FAKE_BIN/calls" && ! -e "$EVIDENCE_OUT" ]] && ok "local input rejects $review_case before network" || no "local input rejects $review_case before network" +done + +for api_case in redirect media version repeated-json duplicate-key scopes pagination-missing-link pagination-duplicate pagination-bad-link pagination-bad-last policy-hidden-bypass; do + new_case "api-$api_case"; printf '%s' "$api_case" >"$FAKE_BIN/fault" + if [[ "$api_case" == scopes || "$api_case" == pagination-bad-last ]]; then release_args; args=("${RELEASE_ARGS[@]}"); else configuration_args; args=("${CONFIGURATION_ARGS[@]}"); fi + assert_contract_failure "API rejects $api_case" "${args[@]}" +done + +new_case prerequisite-trailing-lf; printf prerequisite-trailing-lf >"$FAKE_BIN/fault"; configuration_args +assert_contract_failure 'API rejects a publisher-prerequisite value with a trailing LF' "${CONFIGURATION_ARGS[@]}" + +new_case audit-cleanup; printf audit-enumeration-failure >"$FAKE_BIN/fault"; configuration_args; status=0 +run_helper "${CONFIGURATION_ARGS[@]}" || status=$? +if [[ "$status" -eq 1 ]] && grep -q '^installation-audit|DELETE|/installation/token$' "$FAKE_BIN/calls" && ! grep -Fq "$AUDIT_TOKEN" "$STDOUT" "$STDERR"; then ok 'failure after metadata-audit mint revokes and scrubs the token'; else no 'failure after metadata-audit mint revokes and scrubs the token'; fi + +new_case audit-response-cleanup; printf audit-permissions-broadened >"$FAKE_BIN/fault"; configuration_args; status=0 +run_helper "${CONFIGURATION_ARGS[@]}" || status=$? +if [[ "$status" -eq 1 ]] && grep -q '^installation-audit|DELETE|/installation/token$' "$FAKE_BIN/calls" && ! grep -Fq "$AUDIT_TOKEN" "$STDOUT" "$STDERR"; then ok 'invalid metadata-audit response still revokes and scrubs the minted token'; else no 'invalid metadata-audit response still revokes and scrubs the minted token'; fi + +new_case probe-cleanup; printf probe-read-failure >"$FAKE_BIN/fault"; configuration_args; status=0 +run_helper "${CONFIGURATION_ARGS[@]}" || status=$? +if [[ "$status" -eq 1 ]] && grep -q '^publisher-probe|DELETE|/installation/token$' "$FAKE_BIN/calls" && ! grep -Fq "$PROBE_TOKEN" "$STDOUT" "$STDERR"; then ok 'failure after publisher-probe mint revokes and scrubs the token'; else no 'failure after publisher-probe mint revokes and scrubs the token'; fi + +new_case revoke-failure; printf revoke-failure >"$FAKE_BIN/fault"; configuration_args +assert_contract_failure 'App token revocation failure blocks evidence publication' "${CONFIGURATION_ARGS[@]}" + +new_case secret-scrub; printf leaky-error >"$FAKE_BIN/fault"; configuration_args; status=0 +env PATH="$FAKE_BIN:$REAL_PATH" EDGEZERO_RELEASE_POLICY_AUDIT_TOKEN="$POLICY_TOKEN" EDGEZERO_RELEASE_APP_PRIVATE_KEY_FILE="$KEY_FILE" AMBIENT_SECRET='ambient-secret-value' bash -x "$VERIFY" "${CONFIGURATION_ARGS[@]}" >"$STDOUT" 2>"$STDERR" || status=$? +if [[ "$status" -eq 1 ]] && ! grep -Fq "$POLICY_TOKEN" "$STDOUT" "$STDERR" && ! grep -Fq "$PACKAGE_TOKEN" "$STDOUT" "$STDERR" && ! grep -Fq 'ambient-secret-value' "$STDOUT" "$STDERR"; then ok 'diagnostics and inherited xtrace scrub every credential'; else no 'diagnostics and inherited xtrace scrub every credential'; fi + +for output_case in exists nonprivate repository duplicate; do + new_case "output-$output_case"; release_args + case "$output_case" in + exists) : >"$EVIDENCE_OUT" ;; + nonprivate) chmod 0755 "$CASE_ROOT/output" ;; + repository) EVIDENCE_OUT="$GATE_ROOT/evidence.json"; for i in "${!RELEASE_ARGS[@]}"; do [[ "${RELEASE_ARGS[$i]}" != --evidence-out ]] || RELEASE_ARGS[$((i + 1))]=$EVIDENCE_OUT; done ;; + duplicate) PREREQUISITE_OUT=$EVIDENCE_OUT; for i in "${!RELEASE_ARGS[@]}"; do [[ "${RELEASE_ARGS[$i]}" != --publisher-prerequisite-out ]] || RELEASE_ARGS[$((i + 1))]=$PREREQUISITE_OUT; done ;; + esac + if [[ "$output_case" == exists ]]; then + status=0; run_helper "${RELEASE_ARGS[@]}" || status=$? + [[ "$status" -eq 1 && -f "$EVIDENCE_OUT" && ! -e "$PREREQUISITE_OUT" && ! -e "$COMMENT_OUT" ]] && ok "output contract rejects $output_case without replacing it" || no "output contract rejects $output_case (status=$status)" + else + assert_contract_failure "output contract rejects $output_case" "${RELEASE_ARGS[@]}" + fi +done + +printf '\nPassed: %d Failed: %d\n' "$pass" "$fail" +((fail == 0)) diff --git a/.github/actions/deploy-core/tests/verify-toolchain.test.sh b/.github/actions/deploy-core/tests/verify-toolchain.test.sh new file mode 100755 index 00000000..68f5ae0b --- /dev/null +++ b/.github/actions/deploy-core/tests/verify-toolchain.test.sh @@ -0,0 +1,344 @@ +#!/usr/bin/env bash +set -euo pipefail + +DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +VERIFY="$DIR/../../../docker/build-app-cli/verify-toolchain.sh" +WORK=$(mktemp -d "${TMPDIR:-/tmp}/edgezero verify-toolchain.XXXXXX") +trap 'rm -rf "$WORK"' EXIT + +pass=0 +fail=0 +ok() { + printf ' \033[32mok\033[0m %s\n' "$1" + pass=$((pass + 1)) +} +no() { + printf ' \033[31mFAIL\033[0m %s\n' "$1" >&2 + fail=$((fail + 1)) +} +assert_pass() { + local description=$1 log="$WORK/assert-pass.log" + shift + if "$@" >"$log" 2>&1; then + ok "$description" + else + cat "$log" >&2 + no "$description" + fi +} +assert_fail() { + local description=$1 + shift + if "$@" >/dev/null 2>&1; then no "$description"; else ok "$description"; fi +} + +write_executable() { + local path=$1 + shift + mkdir -p "$(dirname -- "$path")" + printf '%s\n' '#!/usr/bin/env bash' 'set -euo pipefail' "$@" >"$path" + chmod 0755 "$path" +} + +make_root() { + local name=$1 + ROOT="$WORK/$name" + STATE="$ROOT/state" + mkdir -p \ + "$ROOT/usr/local/cargo/bin" \ + "$ROOT/usr/local/bin" \ + "$ROOT/usr/local/share/edgezero" \ + "$ROOT/lib64" \ + "$ROOT/opt/edgezero/runtime-lib" \ + "$ROOT/etc" \ + "$ROOT/usr/bin" \ + "$STATE" + + cat >"$STATE/rustc" <<'EOF' +rustc 1.95.0 (59807616e 2026-04-14) +binary: rustc +commit-hash: 59807616e1fa2540724bfbac14d7976d7e4a3860 +commit-date: 2026-04-14 +host: x86_64-unknown-linux-gnu +release: 1.95.0 +LLVM version: 22.1.2 +EOF + cat >"$STATE/fastly" <<'EOF' +Fastly CLI version v15.1.0 (e58c0f5e) +Built with go version go1.26.3 linux/amd64 (2026-09-09) +EOF + printf 'sccache 0.10.0\n' >"$STATE/sccache" + printf 'wasm32-wasip1\nx86_64-unknown-linux-gnu\n' >"$STATE/targets" + printf '0\n' >"$STATE/compile-status" + printf 'wasm\n' >"$STATE/compile-output" + printf '0\n' >"$STATE/validator-status" + printf '1001\n' >"$STATE/uid" + printf '1001\n' >"$STATE/gid" + printf '1\n' >"$STATE/touch-status" + printf 'EDGEZERO_ENV_PROBE=space quote" slash\\ dollar$ hash# equals= unicode-\303\251\n' \ + >"$STATE/env-output" + printf '#![no_std]\n' >"$ROOT/usr/local/share/edgezero/wasm-smoke.rs" + printf loader >"$ROOT/lib64/ld-linux-x86-64.so.2" + printf libc >"$ROOT/opt/edgezero/runtime-lib/libc.so.6" + printf libgcc >"$ROOT/opt/edgezero/runtime-lib/libgcc_s.so.1" + printf libm >"$ROOT/opt/edgezero/runtime-lib/libm.so.6" + cat >"$STATE/elf-header" <<'EOF' +Class: ELF64 +Data: 2's complement, little endian +Version: 1 (current) +OS/ABI: UNIX - GNU +ABI Version: 0 +Type: DYN (Shared object file) +Machine: Advanced Micro Devices X86-64 +EOF + : >"$STATE/program-loader" + : >"$STATE/program-libc.so.6" + : >"$STATE/program-libgcc_s.so.1" + : >"$STATE/program-libm.so.6" + printf 'SONAME=libc.so.6\nNEEDED=ld-linux-x86-64.so.2\n' >"$STATE/dynamic-libc.so.6" + printf 'SONAME=libgcc_s.so.1\nNEEDED=libc.so.6\n' >"$STATE/dynamic-libgcc_s.so.1" + printf 'SONAME=libm.so.6\nNEEDED=libc.so.6\nNEEDED=ld-linux-x86-64.so.2\n' >"$STATE/dynamic-libm.so.6" + printf 'SONAME=ld-linux-x86-64.so.2\n' >"$STATE/dynamic-loader" + printf 'expected\n' >"$STATE/closure-hash-status" + + # shellcheck disable=SC2016 # These lines are literal bodies for fake executables. + write_executable "$ROOT/usr/local/cargo/bin/rustup" \ + 'if [[ "$RUSTUP_HOME" != "$FAKE_ROOT/usr/local/rustup" ]]; then exit 91; fi' \ + 'if [[ "$RUSTUP_TOOLCHAIN" != "1.95.0-x86_64-unknown-linux-gnu" ]]; then exit 92; fi' \ + 'if [[ "$(basename -- "$0")" == rustc ]]; then' \ + ' if [[ "$*" == "--version --verbose" ]]; then cat "$FAKE_STATE/rustc"; exit 0; fi' \ + ' [[ "$(cat "$FAKE_STATE/compile-status")" == 0 ]] || exit 1' \ + ' case " $* " in *" --crate-type cdylib "*) ;; *) exit 93 ;; esac' \ + ' output=' \ + ' while (($#)); do if [[ "$1" == -o ]]; then output=$2; shift 2; else shift; fi; done' \ + ' [[ -n "$output" ]]' \ + ' if [[ "$(cat "$FAKE_STATE/compile-output")" == wasm ]]; then printf "\\0asm" >"$output"; else printf bad >"$output"; fi' \ + 'else' \ + ' [[ "$*" == "target list --installed" ]]' \ + ' cat "$FAKE_STATE/targets"' \ + 'fi' + ln -s rustup "$ROOT/usr/local/cargo/bin/rustc" + # shellcheck disable=SC2016 + write_executable "$ROOT/usr/local/bin/fastly" \ + '[[ "$*" == "--quiet version" ]]' \ + 'cat "$FAKE_STATE/fastly"' + # shellcheck disable=SC2016 + write_executable "$ROOT/usr/local/bin/sccache" \ + '[[ "$*" == "--version" ]]' \ + 'cat "$FAKE_STATE/sccache"' + # shellcheck disable=SC2016 + write_executable "$ROOT/usr/local/bin/edgezero-provenance-validator" \ + '[[ "$*" == "self-test --fixtures /usr/local/share/edgezero/provenance-fixtures" ]]' \ + 'exit "$(cat "$FAKE_STATE/validator-status")"' + # shellcheck disable=SC2016 + write_executable "$ROOT/usr/bin/id" \ + 'case "$1" in -u) cat "$FAKE_STATE/uid" ;; -g) cat "$FAKE_STATE/gid" ;; *) exit 1 ;; esac' + # shellcheck disable=SC2016 + write_executable "$ROOT/usr/bin/touch" \ + 'exit "$(cat "$FAKE_STATE/touch-status")"' + # shellcheck disable=SC2016 + write_executable "$ROOT/usr/bin/env" \ + 'cat "$FAKE_STATE/env-output"' + # shellcheck disable=SC2016 + write_executable "$ROOT/usr/bin/x86_64-linux-gnu-readelf" \ + 'base=$(basename -- "$2")' \ + '[[ "$base" != ld-linux-x86-64.so.2 ]] || base=loader' \ + 'case "$1" in' \ + ' -h) cat "$FAKE_STATE/elf-header" ;;' \ + ' -l) cat "$FAKE_STATE/program-$base" ;;' \ + ' -d) cat "$FAKE_STATE/dynamic-$base" ;;' \ + ' *) exit 1 ;;' \ + 'esac' + # shellcheck disable=SC2016 + write_executable "$ROOT/usr/bin/sha256sum" \ + '[[ "$(cat "$FAKE_STATE/closure-hash-status")" == expected ]] || { printf "0%.0s" {1..64}; printf " %s\n" "$1"; exit 0; }' \ + 'case "$(basename -- "$1")" in' \ + ' ld-linux-x86-64.so.2) hash=02bcda52c1a5dfc236f94d9e5255b4a0e26347d8a372a5223b650e31f291ce3c ;;' \ + ' libc.so.6) hash=6b4a45352fd0c540a9c7c718f35ce8c8e46a4e482f9d3885a910c32d1a0e1421 ;;' \ + ' libgcc_s.so.1) hash=2bd1552c47799ef67e701e81d4383061fd76059868e446e63560f0dd0d5ec14e ;;' \ + ' libm.so.6) hash=7f2ca87f652f56b094462474b076749e90e689d0ecb9cb63c7679820b271b4e7 ;;' \ + ' *) exit 1 ;;' \ + 'esac' \ + 'printf "%s %s\n" "$hash" "$1"' +} + +run_verify() { + local fake_root + fake_root=$(cd -- "$ROOT" && pwd -P) + FAKE_ROOT="$fake_root" \ + FAKE_STATE="$STATE" \ + RUSTUP_HOME=/hostile/rustup \ + RUSTUP_TOOLCHAIN=nightly \ + TMPDIR="$WORK/tmp" \ + bash "$VERIFY" --root "$ROOT" +} + +echo "== build-container toolchain verification ==" +mkdir "$WORK/tmp" + +make_root exact +assert_pass "exact toolchain and runtime capabilities pass" run_verify + +make_root rust-prerelease +sed -i.bak 's/release: 1.95.0/release: 1.95.0-beta.1/' "$STATE/rustc" +rm "$STATE/rustc.bak" +assert_fail "a prerelease Rust version is rejected" run_verify + +make_root rust-extra +sed -i.bak '1s/$/ extra/' "$STATE/rustc" +rm "$STATE/rustc.bak" +assert_fail "extra Rust version text is rejected" run_verify + +make_root rust-missing +sed -i.bak '/^release:/d' "$STATE/rustc" +rm "$STATE/rustc.bak" +assert_fail "missing Rust release output is rejected" run_verify + +make_root rust-malformed +sed -i.bak 's/release: 1.95.0/release: 1.95/' "$STATE/rustc" +rm "$STATE/rustc.bak" +assert_fail "malformed Rust version output is rejected" run_verify + +make_root fastly-malformed +printf 'Fastly CLI version 15.1.0\n' >"$STATE/fastly" +assert_fail "malformed Fastly version output is rejected" run_verify + +make_root fastly-prerelease +sed -i.bak 's/v15.1.0/v15.1.0-rc.1/' "$STATE/fastly" +rm "$STATE/fastly.bak" +assert_fail "a prerelease Fastly version is rejected" run_verify + +make_root fastly-extra +printf 'unexpected\n' >>"$STATE/fastly" +assert_fail "extra Fastly version text is rejected" run_verify + +make_root fastly-missing +: >"$STATE/fastly" +assert_fail "missing Fastly version output is rejected" run_verify + +make_root fastly-wrong +sed -i.bak 's/v15.1.0/v15.1.1/' "$STATE/fastly" +rm "$STATE/fastly.bak" +assert_fail "a different Fastly version is rejected" run_verify + +make_root sccache-prerelease +printf 'sccache 0.10.0-rc.1\n' >"$STATE/sccache" +assert_fail "a prerelease sccache version is rejected" run_verify + +make_root sccache-extra +printf 'sccache 0.10.0 extra\n' >"$STATE/sccache" +assert_fail "extra sccache version text is rejected" run_verify + +make_root sccache-missing +: >"$STATE/sccache" +assert_fail "missing sccache version output is rejected" run_verify + +make_root sccache-malformed +printf 'sccache version 0.10.0\n' >"$STATE/sccache" +assert_fail "malformed sccache version output is rejected" run_verify + +make_root sccache-wrong +printf 'sccache 0.10.1\n' >"$STATE/sccache" +assert_fail "a different sccache version is rejected" run_verify + +make_root target-missing +: >"$STATE/targets" +assert_fail "an absent wasm32-wasip1 target is rejected" run_verify + +make_root target-extra +printf 'aarch64-unknown-linux-gnu\nwasm32-wasip1\nx86_64-unknown-linux-gnu\n' >"$STATE/targets" +assert_fail "an extra installed target is rejected" run_verify + +make_root compile-failure +printf '1\n' >"$STATE/compile-status" +assert_fail "a failed minimal wasm compile is rejected" run_verify + +make_root invalid-wasm +printf 'bad\n' >"$STATE/compile-output" +assert_fail "invalid wasm magic is rejected" run_verify + +make_root validator-failure +printf '1\n' >"$STATE/validator-status" +assert_fail "a validator self-test failure is rejected" run_verify + +make_root wrong-uid +printf '1002\n' >"$STATE/uid" +assert_fail "a wrong uid is rejected" run_verify + +make_root wrong-gid +printf '1002\n' >"$STATE/gid" +assert_fail "a wrong gid is rejected" run_verify + +make_root wrong-rustc-alias +rm "$ROOT/usr/local/cargo/bin/rustc" +ln -s ../../bin/fastly "$ROOT/usr/local/cargo/bin/rustc" +assert_fail "a rustc proxy pointing anywhere except rustup is rejected" run_verify + +make_root symlinked-loader-parent +mkdir -p "$ROOT/usr/lib64" +mv "$ROOT/lib64/ld-linux-x86-64.so.2" "$ROOT/usr/lib64/ld-linux-x86-64.so.2" +rmdir "$ROOT/lib64" +ln -s usr/lib64 "$ROOT/lib64" +assert_fail "a symlinked /lib64 acquisition path is rejected" run_verify + +make_root writable-root +printf '0\n' >"$STATE/touch-status" +assert_fail "a writable root filesystem is rejected" run_verify + +make_root missing-env +rm "$ROOT/usr/bin/env" +assert_fail "a missing GNU env is rejected" run_verify + +make_root incompatible-env +printf 'EDGEZERO_ENV_PROBE=unexpanded\n' >"$STATE/env-output" +assert_fail "an env without expansion-before-clear semantics is rejected" run_verify + +make_root bookworm-libc-interpreter +printf '[Requesting program interpreter: /lib64/ld-linux-x86-64.so.2]\n' \ + >"$STATE/program-libc.so.6" +assert_pass "Bookworm libc may carry the exact supported PT_INTERP" run_verify + +make_root missing-runtime-member +rm "$ROOT/opt/edgezero/runtime-lib/libc.so.6" +assert_fail "a missing flat runtime-library member is rejected" run_verify + +make_root extra-runtime-member +printf rogue >"$ROOT/opt/edgezero/runtime-lib/librogue.so.1" +assert_fail "an extra flat runtime-library member is rejected" run_verify + +make_root hidden-runtime-member +printf rogue >"$ROOT/opt/edgezero/runtime-lib/.hidden.so" +assert_fail "a hidden flat runtime-library member is rejected" run_verify + +make_root linked-runtime-member +rm "$ROOT/opt/edgezero/runtime-lib/libm.so.6" +ln -s libc.so.6 "$ROOT/opt/edgezero/runtime-lib/libm.so.6" +assert_fail "a symlinked flat runtime-library member is rejected" run_verify + +make_root replaced-runtime-member +printf 'wrong\n' >"$STATE/closure-hash-status" +assert_fail "a byte-replaced runtime closure member is rejected" run_verify + +make_root wrong-runtime-soname +printf 'SONAME=libwrong.so.6\nNEEDED=libc.so.6\n' >"$STATE/dynamic-libm.so.6" +assert_fail "a filename and SONAME disagreement is rejected" run_verify + +make_root runtime-interpreter +printf '[Requesting program interpreter: /attacker/ld.so]\n' \ + >"$STATE/program-libm.so.6" +assert_fail "a runtime library carrying a foreign PT_INTERP is rejected" run_verify + +make_root hardlinked-loader +ln "$ROOT/lib64/ld-linux-x86-64.so.2" "$ROOT/lib64/loader-alias" +assert_fail "a multiply linked dynamic interpreter is rejected" run_verify + +make_root wrong-loader-soname +printf 'SONAME=attacker-loader.so\n' >"$STATE/dynamic-loader" +assert_fail "a dynamic interpreter carrying the wrong SONAME is rejected" run_verify + +make_root preload-present +printf '/attacker/lib.so\n' >"$ROOT/etc/ld.so.preload" +assert_fail "a system preload file is rejected" run_verify + +printf 'Passed: %d Failed: %d\n' "$pass" "$fail" +[[ "$fail" -eq 0 ]] diff --git a/.github/actions/deploy-core/tests/write-image-release-record.test.sh b/.github/actions/deploy-core/tests/write-image-release-record.test.sh new file mode 100755 index 00000000..741343bb --- /dev/null +++ b/.github/actions/deploy-core/tests/write-image-release-record.test.sh @@ -0,0 +1,296 @@ +#!/usr/bin/env bash +set -euo pipefail + +DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +TOOLS="$DIR/../../../docker/build-app-cli" +WRITE="$TOOLS/write-image-release-record.sh" +CHECK="$TOOLS/check-image-pin.sh" +WORK_RAW=$(mktemp -d) +WORK=$(cd -- "$WORK_RAW" && pwd -P) +trap 'rm -rf "$WORK"' EXIT + +REPO="ghcr.io/stackpop/edgezero-build-app-cli" +DIGEST="sha256:$(printf '1%.0s' {1..64})" +SOURCE=$(printf '2%.0s' {1..40}) +CHALLENGE=$(printf '3%.0s' {1..64}) +SCREENSHOT="sha256:$(printf '4%.0s' {1..64})" +TAG="build-container-v7" +NOW=$(date -u '+%Y-%m-%dT%H:%M:%SZ') +RUN_ID="9007199254740993" +RUN_ATTEMPT="2" + +pass=0 +fail=0 +ok() { + printf ' \033[32mok\033[0m %s\n' "$1" + pass=$((pass + 1)) +} +no() { + printf ' \033[31mFAIL\033[0m %s\n' "$1" >&2 + fail=$((fail + 1)) +} +assert_pass() { + local description=$1 + shift + if "$@" >/dev/null 2>&1; then ok "$description"; else no "$description"; fi +} +assert_fail() { + local description=$1 + shift + if "$@" >/dev/null 2>&1; then no "$description"; else ok "$description"; fi +} +assert_bytes() { + local description=$1 expected=$2 path=$3 expected_file="$WORK/expected" + printf '%s' "$expected" >"$expected_file" + if cmp -s "$expected_file" "$path"; then ok "$description"; else no "$description"; fi +} +assert_silent_success() { + local description=$1 image_path=$2 evidence_path=$3 stdout_file="$WORK/stdout" stderr_file="$WORK/stderr" + if run_writer "$image_path" "$evidence_path" >"$stdout_file" 2>"$stderr_file" && + [[ ! -s "$stdout_file" && ! -s "$stderr_file" ]]; then + ok "$description" + else + no "$description" + fi +} + +writer_args() { + printf '%s\0' \ + --repository "$REPO" \ + --release-tag "$TAG" \ + --image-digest "$DIGEST" \ + --source-revision "$SOURCE" \ + --provenance-protocol 1 \ + --approval-challenge "$CHALLENGE" \ + --approver-login release-reviewer \ + --reviewed-at "$NOW" \ + --run-attempt "$RUN_ATTEMPT" \ + --run-id "$RUN_ID" \ + --screenshot-sha256 "$SCREENSHOT" +} + +run_writer() { + local image_path=$1 evidence_path=$2 + shift 2 + local -a args=() + while IFS= read -r -d '' arg; do args+=("$arg"); done < <(writer_args) + bash "$WRITE" --image-path "$image_path" --evidence-path "$evidence_path" "${args[@]}" "$@" +} + +write_image() { + local path=$1 digest=${2:-$DIGEST} source=${3:-$SOURCE} tag=${4:-$TAG} + printf '%s' "{\"digest\":\"$digest\",\"image-source-revision\":\"$source\",\"provenance-protocol\":1,\"repository\":\"$REPO\",\"tag\":\"$tag\"}" >"$path" +} + +write_evidence() { + local path=$1 content + if [[ $# -eq 2 ]]; then + content=$2 + else + content="{\"approval-challenge\":\"$CHALLENGE\",\"approver-login\":\"release-reviewer\",\"image-digest\":\"$DIGEST\",\"release-tag\":\"$TAG\",\"reviewed-at\":\"$NOW\",\"run-attempt\":\"$RUN_ATTEMPT\",\"run-id\":\"$RUN_ID\",\"schema-version\":1,\"screenshot-sha256\":\"$SCREENSHOT\",\"source-revision\":\"$SOURCE\"}" + fi + printf '%s' "$content" >"$path" +} + +echo "== build container image release record writer and pair validator ==" + +image="$WORK/image.json" +evidence="$WORK/image-release-evidence.json" +assert_pass "typed writer creates both records" run_writer "$image" "$evidence" +expected_image="{\"digest\":\"$DIGEST\",\"image-source-revision\":\"$SOURCE\",\"provenance-protocol\":1,\"repository\":\"$REPO\",\"tag\":\"$TAG\"}" +expected_evidence="{\"approval-challenge\":\"$CHALLENGE\",\"approver-login\":\"release-reviewer\",\"image-digest\":\"$DIGEST\",\"release-tag\":\"$TAG\",\"reviewed-at\":\"$NOW\",\"run-attempt\":\"$RUN_ATTEMPT\",\"run-id\":\"$RUN_ID\",\"schema-version\":1,\"screenshot-sha256\":\"$SCREENSHOT\",\"source-revision\":\"$SOURCE\"}" +assert_bytes "image record bytes are exact and newline-free" "$expected_image" "$image" +assert_bytes "evidence bytes are exact JCS and newline-free" "$expected_evidence" "$evidence" +assert_pass "writer output passes the independent pair validator" \ + bash "$CHECK" validate-pair "$image" "$evidence" +assert_fail "writer never replaces an existing pair" run_writer "$image" "$evidence" + +mkdir "$WORK/preflight" +printf sentinel >"$WORK/preflight/evidence.json" +assert_fail "an existing evidence path blocks before image creation" \ + run_writer "$WORK/preflight/image.json" "$WORK/preflight/evidence.json" +if [[ ! -e "$WORK/preflight/image.json" ]]; then + ok "blocked preflight leaves no partial image record" +else + no "blocked preflight leaves no partial image record" +fi + +mkdir "$WORK/preflight-image" +printf sentinel >"$WORK/preflight-image/image.json" +assert_fail "an existing image path blocks before evidence creation" \ + run_writer "$WORK/preflight-image/image.json" "$WORK/preflight-image/evidence.json" +if [[ ! -e "$WORK/preflight-image/evidence.json" ]]; then + ok "blocked image preflight leaves no partial evidence record" +else + no "blocked image preflight leaves no partial evidence record" +fi + +assert_fail "an unknown writer flag is rejected" \ + run_writer "$WORK/unknown-image.json" "$WORK/unknown-evidence.json" --raw-json '{}' +assert_fail "a duplicate writer flag is rejected" \ + run_writer "$WORK/dup-image.json" "$WORK/dup-evidence.json" --run-id 7 +assert_fail "a normalized protocol spelling is rejected" \ + run_writer "$WORK/protocol-image.json" "$WORK/protocol-evidence.json" \ + --provenance-protocol 1.0 + +mkdir "$WORK/other-parent" +assert_fail "the writer cannot split the record pair across directories" \ + run_writer "$WORK/split-image.json" "$WORK/other-parent/split-evidence.json" + +mkdir "$WORK/silent" +chmod 0700 "$WORK/silent" +assert_silent_success "writer success is silent" \ + "$WORK/silent/image.json" "$WORK/silent/evidence.json" + +mkdir "$WORK/public-parent" +chmod 0755 "$WORK/public-parent" +assert_fail "writer rejects a non-private output parent" \ + run_writer "$WORK/public-parent/image.json" "$WORK/public-parent/evidence.json" + +mkdir -p "$WORK/nested/child" +chmod 0700 "$WORK/nested" "$WORK/nested/child" +assert_fail "writer outputs must be direct children of the canonical parent path" \ + run_writer "$WORK/nested/child/../image.json" "$WORK/nested/evidence.json" + +mkdir "$WORK/repository" +git -C "$WORK/repository" init -q +mkdir "$WORK/repository/private" +chmod 0700 "$WORK/repository/private" +assert_fail "writer rejects an output parent inside a Git repository" \ + run_writer "$WORK/repository/private/image.json" "$WORK/repository/private/evidence.json" + +mkdir "$WORK/relative" +chmod 0700 "$WORK/relative" +# The child shell expands its positional parameters (intentional SC2016). +# shellcheck disable=SC2016 +assert_fail "writer rejects relative output paths" \ + bash -c 'cd "$1" && shift && "$@"' _ "$WORK" \ + bash "$WRITE" --image-path relative/image.json --evidence-path relative/evidence.json \ + --repository "$REPO" --release-tag "$TAG" --image-digest "$DIGEST" \ + --source-revision "$SOURCE" --provenance-protocol 1 --approval-challenge "$CHALLENGE" \ + --approver-login release-reviewer --reviewed-at "$NOW" --run-attempt "$RUN_ATTEMPT" \ + --run-id "$RUN_ID" --screenshot-sha256 "$SCREENSHOT" + +args=() +while IFS= read -r -d '' arg; do args+=("$arg"); done < <(writer_args) +missing_args=() +skip_next=false +for arg in "${args[@]}"; do + if [[ "$skip_next" == true ]]; then + skip_next=false + continue + fi + if [[ "$arg" == --run-attempt ]]; then + skip_next=true + continue + fi + missing_args+=("$arg") +done +assert_fail "a missing typed scalar is rejected" \ + bash "$WRITE" --image-path "$WORK/missing-image.json" \ + --evidence-path "$WORK/missing-evidence.json" "${missing_args[@]}" + +bad_image="$WORK/manual-image.json" +bad_evidence="$WORK/manual-evidence.json" +write_image "$bad_image" +write_evidence "$bad_evidence" + +printf '%s' "${expected_evidence/\"run-id\":\"$RUN_ID\"/\"run-id\":\"18446744073709551616\"}" >"$bad_evidence" +assert_fail "u64 overflow run id is rejected" bash "$CHECK" validate-pair "$bad_image" "$bad_evidence" +printf '%s' "${expected_evidence/\"run-attempt\":\"$RUN_ATTEMPT\"/\"run-attempt\":\"4294967296\"}" >"$bad_evidence" +assert_fail "u32 overflow run attempt is rejected" bash "$CHECK" validate-pair "$bad_image" "$bad_evidence" +printf '%s' "${expected_evidence/\"run-attempt\":\"$RUN_ATTEMPT\"/\"run-attempt\":\"02\"}" >"$bad_evidence" +assert_fail "noncanonical run attempt is rejected" bash "$CHECK" validate-pair "$bad_image" "$bad_evidence" + +for replacement in \ + "\"schema-version\":\"1\"" \ + "\"schema-version\":1.5" \ + "\"schema-version\":2"; do + printf '%s' "${expected_evidence/\"schema-version\":1/$replacement}" >"$bad_evidence" + assert_fail "invalid $replacement is rejected" bash "$CHECK" validate-pair "$bad_image" "$bad_evidence" +done + +max_evidence="${expected_evidence/\"run-attempt\":\"$RUN_ATTEMPT\"/\"run-attempt\":\"4294967295\"}" +max_evidence="${max_evidence/\"run-id\":\"$RUN_ID\"/\"run-id\":\"18446744073709551615\"}" +printf '%s' "$max_evidence" >"$bad_evidence" +assert_pass "maximum u32/u64 run identifiers retain exact precision" \ + bash "$CHECK" validate-pair "$bad_image" "$bad_evidence" + +for field in approval-challenge approver-login image-digest release-tag reviewed-at run-attempt run-id \ + screenshot-sha256 source-revision; do + invalid=$(jq -cS --arg field "$field" '.[$field]=7' "$evidence") + printf '%s' "$invalid" >"$bad_evidence" + assert_fail "non-string evidence $field is rejected" \ + bash "$CHECK" validate-pair "$bad_image" "$bad_evidence" +done + +for mutation in \ + "${expected_evidence/\"image-digest\":\"$DIGEST\"/\"image-digest\":\"sha256:$(printf '5%.0s' {1..64})\"}" \ + "${expected_evidence/\"release-tag\":\"$TAG\"/\"release-tag\":\"build-container-v8\"}" \ + "${expected_evidence/\"source-revision\":\"$SOURCE\"/\"source-revision\":\"$(printf '6%.0s' {1..40})\"}"; do + printf '%s' "$mutation" >"$bad_evidence" + assert_fail "a cross-file mismatch is rejected" bash "$CHECK" validate-pair "$bad_image" "$bad_evidence" +done + +printf '%s\n' "$expected_evidence" >"$bad_evidence" +assert_fail "a trailing newline violates canonical evidence bytes" \ + bash "$CHECK" validate-pair "$bad_image" "$bad_evidence" +printf ' %s' "$expected_evidence" >"$bad_evidence" +assert_fail "surrounding whitespace violates canonical evidence bytes" \ + bash "$CHECK" validate-pair "$bad_image" "$bad_evidence" +reordered=$(jq -c '{"approver-login":."approver-login", + "approval-challenge":."approval-challenge","image-digest":."image-digest", + "release-tag":."release-tag","reviewed-at":."reviewed-at","run-attempt":."run-attempt", + "run-id":."run-id","schema-version":."schema-version", + "screenshot-sha256":."screenshot-sha256","source-revision":."source-revision"}' "$evidence") +printf '%s' "$reordered" >"$bad_evidence" +assert_fail "reordered evidence keys are rejected" bash "$CHECK" validate-pair "$bad_image" "$bad_evidence" +printf '%s' "${expected_evidence/\"approval-challenge\":\"$CHALLENGE\"/\"approval-challenge\":\"$CHALLENGE\",\"unexpected\":true}" >"$bad_evidence" +assert_fail "an extra evidence key is rejected" bash "$CHECK" validate-pair "$bad_image" "$bad_evidence" +printf '%s' "${expected_evidence/\"approval-challenge\":\"$CHALLENGE\",/}" >"$bad_evidence" +assert_fail "a missing evidence key is rejected" bash "$CHECK" validate-pair "$bad_image" "$bad_evidence" +printf '%s' "${expected_evidence/\"approval-challenge\":\"$CHALLENGE\"/\"approval-challenge\":\"$CHALLENGE\",\"approval-challenge\":\"$CHALLENGE\"}" >"$bad_evidence" +assert_fail "a duplicate evidence key is rejected before object construction" \ + bash "$CHECK" validate-pair "$bad_image" "$bad_evidence" +printf '%s' "${expected_evidence/\"approval-challenge\"/\"\\u0061pproval-challenge\"}" >"$bad_evidence" +assert_fail "a noncanonical escaped evidence key is rejected" \ + bash "$CHECK" validate-pair "$bad_image" "$bad_evidence" +printf 'not json' >"$bad_evidence" +assert_fail "malformed evidence JSON is rejected" \ + bash "$CHECK" validate-pair "$bad_image" "$bad_evidence" + +for login in '' '-reviewer' 'reviewer-' 'reviewer--two' 'reviewer_name' \ + 'reviewer-login-that-is-more-than-thirty-nine-characters'; do + printf '%s' "${expected_evidence/\"approver-login\":\"release-reviewer\"/\"approver-login\":\"$login\"}" >"$bad_evidence" + assert_fail "login '$login' is rejected" bash "$CHECK" validate-pair "$bad_image" "$bad_evidence" +done +for field_and_value in \ + 'run-id=0' 'run-id=01' 'run-id=-1' 'run-id=1.5' \ + 'run-attempt=0' 'run-attempt=01' 'run-attempt=-1' 'run-attempt=1.5'; do + field=${field_and_value%%=*} + value=${field_and_value#*=} + invalid=$(jq -cS --arg field "$field" --arg value "$value" '.[$field]=$value' "$evidence") + printf '%s' "$invalid" >"$bad_evidence" + assert_fail "$field value '$value' is rejected" \ + bash "$CHECK" validate-pair "$bad_image" "$bad_evidence" +done +for reviewed_at in '2026-02-30T12:00:00Z' '2026-01-01T12:00:00+00:00' \ + '2026-01-01T12:00:00.000Z' '2026-01-01T25:00:00Z'; do + printf '%s' "${expected_evidence/\"reviewed-at\":\"$NOW\"/\"reviewed-at\":\"$reviewed_at\"}" >"$bad_evidence" + assert_fail "review time '$reviewed_at' is rejected" bash "$CHECK" validate-pair "$bad_image" "$bad_evidence" +done +for reviewed_at in '2000-01-01T00:00:00Z' '2099-01-01T00:00:00Z'; do + printf '%s' "${expected_evidence/\"reviewed-at\":\"$NOW\"/\"reviewed-at\":\"$reviewed_at\"}" >"$bad_evidence" + assert_pass "archived review time '$reviewed_at' is not re-aged" \ + bash "$CHECK" validate-pair "$bad_image" "$bad_evidence" +done + +for field in approval-challenge image-digest screenshot-sha256 source-revision release-tag; do + invalid=$(jq -cS --arg field "$field" '.[$field]="invalid"' "$evidence") + printf '%s' "$invalid" >"$bad_evidence" + assert_fail "invalid evidence $field grammar is rejected" \ + bash "$CHECK" validate-pair "$bad_image" "$bad_evidence" +done + +printf 'Passed: %d Failed: %d\n' "$pass" "$fail" +[[ "$fail" -eq 0 ]] diff --git a/.github/actions/deploy-core/tests/write-publisher-prerequisite.test.sh b/.github/actions/deploy-core/tests/write-publisher-prerequisite.test.sh new file mode 100755 index 00000000..d667b598 --- /dev/null +++ b/.github/actions/deploy-core/tests/write-publisher-prerequisite.test.sh @@ -0,0 +1,1069 @@ +#!/usr/bin/env bash +# shellcheck disable=SC2016 +set -euo pipefail + +DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +WRITE="$DIR/../../../docker/build-app-cli/write-publisher-prerequisite.sh" + +if [[ ! -x "$WRITE" ]]; then + printf 'FAIL: missing executable helper: %s\n' "$WRITE" >&2 + exit 1 +fi + +WORK=$(mktemp -d) +WORK=$(cd -- "$WORK" && pwd -P) +trap 'rm -rf -- "$WORK"' EXIT + +TOKEN=writer-token-secret-value +TOKEN_ID=18446744073709551615 +SOURCE_PR=347 +EVIDENCE_COMMENT_ID=9007199254740993 +REAL_GIT=$(command -v git) +REAL_JQ=$(command -v jq) +NOW=2026-09-10T12:00:00Z +REVIEWED_AT=2026-09-10T11:59:00Z +EXPIRES_AT=2026-09-10T12:30:00Z +ROTATION_AT=2026-09-10T11:00:00Z +ROTATION_DIGEST="sha256:$(printf '8%.0s' {1..64})" +HISTORY_DIGEST="sha256:$(printf '7%.0s' {1..64})" +VARIABLE=EDGEZERO_BUILD_CONTAINER_PUBLISHER_PREREQUISITE +API=https://api.github.com + +pass=0 +fail=0 +case_number=0 + +ok() { + printf ' \033[32mok\033[0m %s\n' "$1" + pass=$((pass + 1)) +} + +no() { + printf ' \033[31mFAIL\033[0m %s\n' "$1" >&2 + fail=$((fail + 1)) +} + +hash_file() { + sha256sum "$1" | awk '{print $1}' +} + +hash_bytes() { + printf '%s' "$1" | sha256sum | awk '{print $1}' +} + +GATE_ROOT="$WORK/gate" +FAKE_BIN="$WORK/fake-bin" +INPUT_ROOT="$WORK/inputs" +mkdir -p "$GATE_ROOT/.github/docker/build-app-cli" "$FAKE_BIN" "$INPUT_ROOT" +git -C "$GATE_ROOT" init -q -b main +git -C "$GATE_ROOT" config user.name fixture +git -C "$GATE_ROOT" config user.email fixture@example.invalid +printf base >"$GATE_ROOT/base" +git -C "$GATE_ROOT" add base +git -C "$GATE_ROOT" commit -q -m base +OLD_G=$(git -C "$GATE_ROOT" rev-parse HEAD) +cp "$WRITE" "$GATE_ROOT/.github/docker/build-app-cli/write-publisher-prerequisite.sh" +chmod 0755 "$GATE_ROOT/.github/docker/build-app-cli/write-publisher-prerequisite.sh" +git -C "$GATE_ROOT" add .github/docker/build-app-cli/write-publisher-prerequisite.sh +git -C "$GATE_ROOT" commit -q -m gate +G=$(git -C "$GATE_ROOT" rev-parse HEAD) +printf one >"$GATE_ROOT/source" +git -C "$GATE_ROOT" add source +git -C "$GATE_ROOT" commit -q -m source-one +S1=$(git -C "$GATE_ROOT" rev-parse HEAD) +printf two >"$GATE_ROOT/source" +git -C "$GATE_ROOT" commit -qam source-two +S2=$(git -C "$GATE_ROOT" rev-parse HEAD) +git -C "$GATE_ROOT" checkout -q --detach "$G" +printf side >"$GATE_ROOT/side" +git -C "$GATE_ROOT" add side +git -C "$GATE_ROOT" commit -q -m side +SIDE=$(git -C "$GATE_ROOT" rev-parse HEAD) +git -C "$GATE_ROOT" checkout -q --detach "$G" +WRITE="$GATE_ROOT/.github/docker/build-app-cli/write-publisher-prerequisite.sh" + +cat >"$FAKE_BIN/date" <<'SH' +#!/usr/bin/env bash +set -euo pipefail +fixture=$(cd -- "$(dirname -- "$0")" && pwd) +[[ "$#" -eq 2 && "$1" == -u && "$2" == +%s ]] +[[ "${LC_ALL:-}" == C ]] +[[ -z "${EDGEZERO_PUBLISHER_PREREQUISITE_WRITE_TOKEN+x}${AMBIENT_SECRET+x}${HOME+x}" ]] +cat "$fixture/now-epoch" +SH +chmod 0755 "$FAKE_BIN/date" +jq -nr --arg value "$NOW" '$value | fromdateiso8601' >"$FAKE_BIN/now-epoch" + +cat >"$FAKE_BIN/curl" <<'SH' +#!/usr/bin/env bash +set -euo pipefail +fixture=$(cd -- "$(dirname -- "$0")" && pwd) +[[ "${LC_ALL:-}" == C ]] +[[ -z "${EDGEZERO_PUBLISHER_PREREQUISITE_WRITE_TOKEN+x}${GITHUB_TOKEN+x}${GH_TOKEN+x}" ]] +[[ -z "${AMBIENT_SECRET+x}${HOME+x}${CURL_HOME+x}${XDG_CONFIG_HOME+x}" ]] +count=0 +[[ ! -f "$fixture/curl-count" ]] || count=$(<"$fixture/curl-count") +count=$((count + 1)) +printf '%s' "$count" >"$fixture/curl-count" +printf '%s\n' "$@" >"$fixture/args-$count" +cat >"$fixture/config-$count" + +request= output= data= url= +while (($#)); do + case "$1" in + --request) request=$2; shift 2 ;; + --output) output=$2; shift 2 ;; + --data-binary) data=$2; shift 2 ;; + --disable | --silent | --show-error) shift ;; + --connect-timeout | --max-time | --max-redirs | --config | --write-out) shift 2 ;; + *) url=$1; shift ;; + esac +done +SH + +run_remaining_tests() { + +for invalid in review-duplicate review-reordered wrong-grants wrong-selection self-review \ + invalid-review-time future-review expired-review wrong-png-digest bad-png small-png large-png \ + empty-evidence large-evidence wrong-evidence-digest prereq-duplicate prereq-reordered \ + prereq-wrong-type schema-1 partial-url partial-pr partial-source url-pr-mismatch source-pr-zero \ + source-pr-leading-zero source-pr-overflow comment-id-zero comment-id-overflow \ + nested-duplicate nested-reordered nested-missing \ + numeric-token-id numeric-run-id \ + run-attempt-zero run-attempt-overflow run-id-zero rotation-overflow run-number-zero \ + run-number-overflow invalid-rotation-time \ + review-trailing prereq-trailing \ + symlink-review symlink-png symlink-evidence symlink-prereq; do + new_case + case "$invalid" in + review-duplicate) + sed 's/"token-id":/"token-id":7,"token-id":/' "$REVIEW_JSON" >"$CASE_ROOT/x" + mv "$CASE_ROOT/x" "$REVIEW_JSON" + ;; + review-reordered) + jq -c '{"schema-version":."schema-version","expires-at":."expires-at","organization-grants":."organization-grants","repository-grants":."repository-grants","resource-owner":."resource-owner","reviewed-at":."reviewed-at","reviewer-login":."reviewer-login","screenshot-sha256":."screenshot-sha256","selected-repositories":."selected-repositories","subject-login":."subject-login","token-id":."token-id"}' \ + "$REVIEW_JSON" >"$CASE_ROOT/x" + mv "$CASE_ROOT/x" "$REVIEW_JSON" + ;; + wrong-grants) + sed 's/"variables":"write"/"variables":"read"/' "$REVIEW_JSON" >"$CASE_ROOT/x" + mv "$CASE_ROOT/x" "$REVIEW_JSON" + ;; + wrong-selection) + sed 's#stackpop/edgezero#stackpop/other#' "$REVIEW_JSON" >"$CASE_ROOT/x" + mv "$CASE_ROOT/x" "$REVIEW_JSON" + ;; + self-review) review_json "$REVIEWED_AT" "$EXPIRES_AT" variable-writer variable-writer >"$REVIEW_JSON" ;; + invalid-review-time) review_json 2026-02-30T12:00:00Z >"$REVIEW_JSON" ;; + future-review) review_json 2026-09-10T12:00:01Z >"$REVIEW_JSON" ;; + expired-review) review_json "$REVIEWED_AT" "$NOW" >"$REVIEW_JSON" ;; + wrong-png-digest) printf x >>"$PNG" ;; + bad-png) printf 'not-a-png' >"$PNG" ;; + small-png) printf 1234567 >"$PNG" ;; + large-png) dd if=/dev/zero of="$PNG" bs=10485761 count=1 2>/dev/null ;; + empty-evidence) : >"$EVIDENCE_JSON" ;; + large-evidence) dd if=/dev/zero of="$EVIDENCE_JSON" bs=1048577 count=1 2>/dev/null ;; + wrong-evidence-digest) + set_requested "${REQUESTED/$EVIDENCE_DIGEST/sha256:$(printf 'f%.0s' {1..64})}" + ;; + prereq-duplicate) + sed 's/"schema-version":2/"schema-version":2,"schema-version":2/' "$PREREQUISITE_JSON" >"$CASE_ROOT/x" + mv "$CASE_ROOT/x" "$PREREQUISITE_JSON" + ;; + prereq-reordered) + jq -c '{"gate-sha":."gate-sha","evidence-sha256":."evidence-sha256","evidence-url":."evidence-url","previous-value-sha256":."previous-value-sha256","rotation-history":."rotation-history","schema-version":."schema-version","source-pr":."source-pr","source-revision":."source-revision"}' \ + "$PREREQUISITE_JSON" >"$CASE_ROOT/x" + mv "$CASE_ROOT/x" "$PREREQUISITE_JSON" + ;; + prereq-wrong-type) + sed 's/"schema-version":2/"schema-version":"2"/' "$PREREQUISITE_JSON" >"$CASE_ROOT/x" + mv "$CASE_ROOT/x" "$PREREQUISITE_JSON" + ;; + schema-1) + sed 's/"schema-version":2/"schema-version":1/' "$PREREQUISITE_JSON" >"$CASE_ROOT/x" + mv "$CASE_ROOT/x" "$PREREQUISITE_JSON" + ;; + partial-url | partial-pr | partial-source | url-pr-mismatch | source-pr-zero | \ + source-pr-leading-zero | source-pr-overflow | comment-id-zero | comment-id-overflow) + case "$invalid" in + partial-url) jq -c '."evidence-url" = null' "$PREREQUISITE_JSON" >"$CASE_ROOT/x" ;; + partial-pr) jq -c '."source-pr" = null' "$PREREQUISITE_JSON" >"$CASE_ROOT/x" ;; + partial-source) jq -c '."source-revision" = null' "$PREREQUISITE_JSON" >"$CASE_ROOT/x" ;; + url-pr-mismatch) + jq -c '."evidence-url" = "https://github.com/stackpop/edgezero/pull/999#issuecomment-9007199254740993"' \ + "$PREREQUISITE_JSON" >"$CASE_ROOT/x" + ;; + source-pr-zero) jq -c '."source-pr" = "0"' "$PREREQUISITE_JSON" >"$CASE_ROOT/x" ;; + source-pr-leading-zero) jq -c '."source-pr" = "0347"' "$PREREQUISITE_JSON" >"$CASE_ROOT/x" ;; + source-pr-overflow) + jq -c '."source-pr" = "18446744073709551616" | ."evidence-url" = "https://github.com/stackpop/edgezero/pull/18446744073709551616#issuecomment-9007199254740993"' \ + "$PREREQUISITE_JSON" >"$CASE_ROOT/x" + ;; + comment-id-zero) + jq -c '."evidence-url" = "https://github.com/stackpop/edgezero/pull/347#issuecomment-0"' \ + "$PREREQUISITE_JSON" >"$CASE_ROOT/x" + ;; + comment-id-overflow) + jq -c '."evidence-url" = "https://github.com/stackpop/edgezero/pull/347#issuecomment-18446744073709551616"' \ + "$PREREQUISITE_JSON" >"$CASE_ROOT/x" + ;; + esac + mv "$CASE_ROOT/x" "$PREREQUISITE_JSON" + ;; + nested-duplicate) + previous="\"sha256:$(hash_bytes "$CURRENT")\"" + set_requested "$(verified_record "$EVIDENCE_DIGEST" "$G" "$previous" null 10 1)" + sed 's/"run-number":"10"/"run-number":"10","run-number":"10"/' \ + "$PREREQUISITE_JSON" >"$CASE_ROOT/x" + mv "$CASE_ROOT/x" "$PREREQUISITE_JSON" + ;; + nested-reordered) + previous="\"sha256:$(hash_bytes "$CURRENT")\"" + set_requested "$(verified_record "$EVIDENCE_DIGEST" "$G" "$previous" null 10 1)" + jq -c '."rotation-history" |= {"state":.state,"created-at":."created-at","evidence-sha256":."evidence-sha256","history-sha256":."history-sha256","run-attempt":."run-attempt","run-id":."run-id","run-number":."run-number"}' \ + "$PREREQUISITE_JSON" >"$CASE_ROOT/x" + mv "$CASE_ROOT/x" "$PREREQUISITE_JSON" + ;; + nested-missing) + previous="\"sha256:$(hash_bytes "$CURRENT")\"" + set_requested "$(verified_record "$EVIDENCE_DIGEST" "$G" "$previous" null 10 1)" + jq -c 'del(."rotation-history"."history-sha256")' "$PREREQUISITE_JSON" >"$CASE_ROOT/x" + mv "$CASE_ROOT/x" "$PREREQUISITE_JSON" + ;; + numeric-token-id) + sed 's/"token-id":"18446744073709551615"/"token-id":18446744073709551615/' \ + "$REVIEW_JSON" >"$CASE_ROOT/x" + mv "$CASE_ROOT/x" "$REVIEW_JSON" + ;; + numeric-run-id) + previous="\"sha256:$(hash_bytes "$CURRENT")\"" + set_requested "$(verified_record "$EVIDENCE_DIGEST" "$G" "$previous" null 10 1)" + sed 's/"run-id":"10"/"run-id":10/' "$PREREQUISITE_JSON" >"$CASE_ROOT/x" + mv "$CASE_ROOT/x" "$PREREQUISITE_JSON" + ;; + run-attempt-zero) + previous="\"sha256:$(hash_bytes "$CURRENT")\"" + set_requested "$(verified_record "$EVIDENCE_DIGEST" "$G" "$previous" null 10 0)" + ;; + run-attempt-overflow) + previous="\"sha256:$(hash_bytes "$CURRENT")\"" + set_requested "$(verified_record "$EVIDENCE_DIGEST" "$G" "$previous" null 10 4294967296)" + ;; + run-id-zero) + previous="\"sha256:$(hash_bytes "$CURRENT")\"" + set_requested "$(verified_record "$EVIDENCE_DIGEST" "$G" "$previous" null 0 1)" + ;; + rotation-overflow) + previous="\"sha256:$(hash_bytes "$CURRENT")\"" + set_requested "$(verified_record "$EVIDENCE_DIGEST" "$G" "$previous" null 18446744073709551616 1)" + ;; + run-number-overflow) + previous="\"sha256:$(hash_bytes "$CURRENT")\"" + set_requested "$(verified_record "$EVIDENCE_DIGEST" "$G" "$previous" null 10 1 \ + "$ROTATION_AT" "$ROTATION_DIGEST" 18446744073709551616)" + ;; + run-number-zero) + previous="\"sha256:$(hash_bytes "$CURRENT")\"" + set_requested "$(verified_record "$EVIDENCE_DIGEST" "$G" "$previous" null 10 1 \ + "$ROTATION_AT" "$ROTATION_DIGEST" 0)" + ;; + invalid-rotation-time) + previous="\"sha256:$(hash_bytes "$CURRENT")\"" + set_requested "$(verified_record "$EVIDENCE_DIGEST" "$G" "$previous" null 10 1 \ + 2026-02-30T11:00:00Z)" + ;; + review-trailing) printf '\n' >>"$REVIEW_JSON" ;; + prereq-trailing) printf '\n' >>"$PREREQUISITE_JSON" ;; + symlink-review) mv "$REVIEW_JSON" "$CASE_ROOT/real-review"; ln -s "$CASE_ROOT/real-review" "$REVIEW_JSON" ;; + symlink-png) mv "$PNG" "$CASE_ROOT/real-png"; ln -s "$CASE_ROOT/real-png" "$PNG" ;; + symlink-evidence) mv "$EVIDENCE_JSON" "$CASE_ROOT/real-evidence"; ln -s "$CASE_ROOT/real-evidence" "$EVIDENCE_JSON" ;; + symlink-prereq) mv "$PREREQUISITE_JSON" "$CASE_ROOT/real-prereq"; ln -s "$CASE_ROOT/real-prereq" "$PREREQUISITE_JSON" ;; + esac + assert_result 1 "$invalid input is rejected" run_writer + assert_no_patch "$invalid input fails before PATCH" +done + +for path_failure in relative-input noncanonical-input noncanonical-gate; do + new_case + case "$path_failure" in + relative-input) EVIDENCE_JSON=${EVIDENCE_JSON#/} ;; + noncanonical-input) EVIDENCE_JSON="$CASE_ROOT/../case-$case_number/evidence.json" ;; + noncanonical-gate) CLI_GATE_ROOT="$GATE_ROOT/../gate" ;; + esac + assert_result 1 "$path_failure path is rejected" run_writer + assert_no_patch "$path_failure path fails before PATCH" +done + +for api_failure in wrong-user inactive-member gate-mismatch malformed-current current-404 \ + get-status get-version get-media transport; do + new_case + case "$api_failure" in + wrong-user) jq -cn --argjson id "$TOKEN_ID" '{login:"other",id:$id}' >"$FAKE_BIN/user.body" ;; + inactive-member) jq -cn '{state:"pending",user:{login:"variable-writer"}}' >"$FAKE_BIN/membership.body" ;; + gate-mismatch) jq -cn --arg value "$OLD_G" '{name:"EDGEZERO_BUILD_CONTAINER_GATE_SHA",value:$value}' >"$FAKE_BIN/gate.body" ;; + malformed-current) write_variable_body "$FAKE_BIN/before.body" '{}' ;; + current-404) set_metadata before 404 ;; + get-status) set_metadata user 302 ;; + get-version) set_metadata user 200 2022-11-28 ;; + get-media) set_metadata user 200 2026-03-10 text/json ;; + transport) touch "$FAKE_BIN/user.transport-failure" ;; + esac + assert_result 1 "$api_failure API state is rejected" run_writer + assert_no_patch "$api_failure API failure does not PATCH" +done + +new_case +set_metadata patch 200 +assert_result 1 'PATCH requires HTTP 204' run_writer +if [[ $(patch_calls) == 1 && $(curl_calls) == 5 ]]; then + ok 'failed PATCH is not retried or read back' +else + no 'failed PATCH is not retried or read back' +fi + +new_case +printf x >"$FAKE_BIN/patch.body" +assert_result 1 'PATCH requires an empty response body' run_writer + +new_case +write_variable_body "$FAKE_BIN/after.body" "$CURRENT" +assert_result 1 'post-write readback must equal requested exact bytes' run_writer +if [[ $(patch_calls) == 1 && $(curl_calls) == 6 ]]; then + ok 'readback failure follows exactly one PATCH' +else + no 'readback failure follows exactly one PATCH' +fi + +for exact_bytes in current-trailing-lf current-nul readback-trailing-lf readback-nul; do + new_case + if [[ "$exact_bytes" == current-* ]]; then + current=$(bootstrap_record "sha256:$(printf '1%.0s' {1..64})" "$G" null null) + set_current "$current" + set_requested "$current" + if [[ "$exact_bytes" == current-trailing-lf ]]; then + write_variable_body_control "$FAKE_BIN/before.body" "$current" lf + else + write_variable_body_control "$FAKE_BIN/before.body" "$current" nul + fi + assert_result 1 "$exact_bytes is not normalized into idempotent success" run_writer + assert_no_patch "$exact_bytes fails before PATCH" + else + if [[ "$exact_bytes" == readback-trailing-lf ]]; then + write_variable_body_control "$FAKE_BIN/after.body" "$REQUESTED" lf + else + write_variable_body_control "$FAKE_BIN/after.body" "$REQUESTED" nul + fi + assert_result 1 "$exact_bytes cannot satisfy exact readback" run_writer + if [[ $(patch_calls) == 1 ]]; then + ok "$exact_bytes follows exactly one PATCH" + else + no "$exact_bytes follows exactly one PATCH" + fi + fi +done + +new_case +jq -cn '{login:"variable-writer",id:42}' >"$FAKE_BIN/user.body" +assert_result 0 'review token inventory id is not compared with authenticated user id' run_writer + +new_case +: >"$FAKE_BIN/patch.media" +assert_result 0 'PATCH accepts HTTP 204 with absent Content-Type' run_writer + +for mode in unknown duplicate missing empty; do + new_case + status=0 + case "$mode" in + unknown) run_writer --unknown value >"$CASE_ROOT/stdout" 2>"$CASE_ROOT/stderr" || status=$? ;; + duplicate) run_writer --gate-sha "$G" >"$CASE_ROOT/stdout" 2>"$CASE_ROOT/stderr" || status=$? ;; + missing) + PATH="$FAKE_BIN:$PATH" EDGEZERO_PUBLISHER_PREREQUISITE_WRITE_TOKEN="$TOKEN" bash "$WRITE" \ + --gate-root "$GATE_ROOT" --gate-sha "$G" --evidence-json "$EVIDENCE_JSON" \ + --publisher-prerequisite-json "$PREREQUISITE_JSON" --writer-token-review-json "$REVIEW_JSON" \ + >"$CASE_ROOT/stdout" 2>"$CASE_ROOT/stderr" || status=$? + ;; + empty) CLI_GATE_SHA=; run_writer >"$CASE_ROOT/stdout" 2>"$CASE_ROOT/stderr" || status=$? ;; + esac + if [[ "$status" -eq 2 && ! -s "$CASE_ROOT/stdout" && -s "$CASE_ROOT/stderr" ]]; then + ok "$mode flags are rejected as usage" + else + no "$mode flags are rejected as usage" + fi + assert_no_patch "$mode usage failure does not PATCH" +done + +new_case +mkdir "$CASE_ROOT/empty-path" +status=0 +PATH="$CASE_ROOT/empty-path" EDGEZERO_PUBLISHER_PREREQUISITE_WRITE_TOKEN="$TOKEN" /bin/bash "$WRITE" \ + --gate-root "$GATE_ROOT" --gate-sha "$G" --evidence-json "$EVIDENCE_JSON" \ + --publisher-prerequisite-json "$PREREQUISITE_JSON" --writer-token-review-json "$REVIEW_JSON" \ + --writer-token-review-png "$PNG" >"$CASE_ROOT/stdout" 2>"$CASE_ROOT/stderr" || status=$? +if [[ "$status" -eq 2 ]]; then ok 'missing required tooling exits 2'; else no 'missing required tooling exits 2'; fi + +new_case +assert_result 0 'inherited xtrace and allexport are disabled before secret expansion' run_writer_with_shellopts + +new_case +GUARD_BIN="$CASE_ROOT/guard-jq" +mkdir "$GUARD_BIN" +printf '%s\n' "$REAL_JQ" >"$GUARD_BIN/real-jq" +cat >"$GUARD_BIN/jq" <<'SH' +#!/usr/bin/env bash +set -euo pipefail +[[ -z "${EDGEZERO_PUBLISHER_PREREQUISITE_WRITE_TOKEN+x}${TOKEN+x}${WRITER_TOKEN+x}${AMBIENT_SECRET+x}" ]] +IFS= read -r real_jq <"${0%/*}/real-jq" +exec "$real_jq" "$@" +SH +chmod 0755 "$GUARD_BIN/jq" +assert_result 0 'JSON subprocesses receive no credential or ambient secret' run_writer_with_path "$GUARD_BIN" + +new_case +GUARD_BIN="$CASE_ROOT/guard-git" +mkdir "$GUARD_BIN" +printf '%s\n' "$REAL_GIT" >"$GUARD_BIN/real-git" +cat >"$GUARD_BIN/git" <<'SH' +#!/usr/bin/env bash +set -euo pipefail +[[ "${GIT_NO_LAZY_FETCH:-}" == 1 ]] +[[ -z "${EDGEZERO_PUBLISHER_PREREQUISITE_WRITE_TOKEN+x}${TOKEN+x}${WRITER_TOKEN+x}${AMBIENT_SECRET+x}" ]] +IFS= read -r real_git <"${0%/*}/real-git" +exec "$real_git" "$@" +SH +chmod 0755 "$GUARD_BIN/git" +assert_result 0 'Git subprocesses disable lazy fetch and receive no credential' run_writer_with_path "$GUARD_BIN" + +new_case +status=0 +PATH="$FAKE_BIN:$PATH" AMBIENT_SECRET=must-not-reach-tools bash "$WRITE" \ + --gate-root "$GATE_ROOT" --gate-sha "$G" --evidence-json "$EVIDENCE_JSON" \ + --publisher-prerequisite-json "$PREREQUISITE_JSON" --writer-token-review-json "$REVIEW_JSON" \ + --writer-token-review-png "$PNG" >"$CASE_ROOT/stdout" 2>"$CASE_ROOT/stderr" || status=$? +if [[ "$status" -eq 1 && $(curl_calls) == 0 ]]; then ok 'missing credential is rejected before subprocess validation'; else no 'missing credential is rejected before subprocess validation'; fi + +new_case +TOKEN=$'unsafe\ntoken' +assert_result 1 'unsafe credential is rejected without disclosure' run_writer +assert_no_patch 'unsafe credential does not PATCH' +TOKEN=writer-token-secret-value + +new_case +printf dirty >"$GATE_ROOT/untracked" +assert_result 1 'dirty gate root is rejected before credential use' run_writer +assert_no_patch 'dirty gate root does not PATCH' +rm -f "$GATE_ROOT/untracked" + +new_case +git -C "$GATE_ROOT" checkout -q main +assert_result 1 'attached gate HEAD is rejected before credential use' run_writer +assert_no_patch 'attached gate root does not PATCH' +git -C "$GATE_ROOT" checkout -q --detach "$G" + +new_case +git -C "$GATE_ROOT" checkout -q --detach "$SIDE" +assert_result 1 'wrong detached gate HEAD is rejected before credential use' run_writer +assert_no_patch 'wrong detached gate HEAD does not PATCH' +git -C "$GATE_ROOT" checkout -q --detach "$G" + +new_case +git -C "$GATE_ROOT" config core.repositoryFormatVersion 1 +git -C "$GATE_ROOT" config extensions.partialClone origin +assert_result 1 'partial-clone extension is rejected before credential use' run_writer +assert_no_patch 'partial-clone extension does not PATCH' +git -C "$GATE_ROOT" config --unset extensions.partialClone +git -C "$GATE_ROOT" config core.repositoryFormatVersion 0 + +new_case +git -C "$GATE_ROOT" config remote.origin.promisor true +assert_result 1 'promisor remote configuration is rejected before credential use' run_writer +assert_no_patch 'promisor remote configuration does not PATCH' +git -C "$GATE_ROOT" config --unset remote.origin.promisor + +new_case +ALTERNATE_OBJECTS="$CASE_ROOT/alternate-objects" +mkdir -p "$ALTERNATE_OBJECTS" +GATE_GIT_DIR=$(git -C "$GATE_ROOT" rev-parse --absolute-git-dir) +mkdir -p "$GATE_GIT_DIR/objects/info" +printf '%s\n' "$ALTERNATE_OBJECTS" >"$GATE_GIT_DIR/objects/info/alternates" +assert_result 1 'on-disk object alternates are rejected before credential use' run_writer +assert_no_patch 'on-disk object alternates do not PATCH' +rm -f "$GATE_GIT_DIR/objects/info/alternates" + +new_case +ALTERNATE_OBJECTS="$CASE_ROOT/environment-alternate-objects" +mkdir -p "$ALTERNATE_OBJECTS" +assert_result 1 'environment-provided object alternates are rejected' \ + run_writer_with_git_alternate "$ALTERNATE_OBJECTS" +assert_no_patch 'environment-provided object alternates do not PATCH' + +new_case +touch "$FAKE_BIN/user.transport-failure" +assert_result 1 'transport errors are sanitized and temporary files are cleaned' run_writer +residue=$(find /tmp -maxdepth 1 -name '.edgezero-publisher-*' -print -quit) +if [[ -z "$residue" ]]; then ok 'temporary files are removed on failure'; else no 'temporary files are removed on failure'; fi + +new_case +touch "$FAKE_BIN/user.block" +PATH="$FAKE_BIN:$PATH" \ + EDGEZERO_PUBLISHER_PREREQUISITE_WRITE_TOKEN="$TOKEN" \ + AMBIENT_SECRET=must-not-reach-tools \ + /bin/bash "$WRITE" \ + --gate-root "$CLI_GATE_ROOT" --gate-sha "$CLI_GATE_SHA" \ + --evidence-json "$EVIDENCE_JSON" \ + --publisher-prerequisite-json "$PREREQUISITE_JSON" \ + --writer-token-review-json "$REVIEW_JSON" \ + --writer-token-review-png "$PNG" \ + >"$CASE_ROOT/signal-stdout" 2>"$CASE_ROOT/signal-stderr" & +writer_pid=$! +deadline=$((SECONDS + 3)) +while [[ ! -f "$FAKE_BIN/curl-blocked" && $SECONDS -lt $deadline ]]; do sleep 0.01; done +signal_status=0 +if [[ -f "$FAKE_BIN/curl-blocked" ]]; then + kill -TERM "$writer_pid" 2>/dev/null || true + kill -TERM "$(<"$FAKE_BIN/curl-child-pid")" 2>/dev/null || true +else + : >"$FAKE_BIN/release-curl" +fi +wait "$writer_pid" 2>/dev/null || signal_status=$? +residue=$(find /tmp -maxdepth 1 -name '.edgezero-publisher-*' -print -quit) +if [[ "$signal_status" -eq 143 && ! -s "$CASE_ROOT/signal-stdout" && -z "$residue" ]]; then + ok 'TERM preserves status 143 and cleans all temporary files' +else + cat "$CASE_ROOT/signal-stdout" "$CASE_ROOT/signal-stderr" >&2 + no 'TERM preserves status 143 and cleans all temporary files' +fi +assert_no_patch 'signal failure does not PATCH' + +printf 'Passed: %d Failed: %d\n' "$pass" "$fail" +[[ "$fail" -eq 0 ]] +} + +cat >>"$FAKE_BIN/curl" <<'SH' +[[ -n "$request" && -n "$output" && -n "$url" ]] +case "$request $url" in + "GET https://api.github.com/user") endpoint=user ;; + "GET https://api.github.com/orgs/stackpop/memberships/"*) endpoint=membership ;; + "GET https://api.github.com/repos/stackpop/edgezero/actions/variables/EDGEZERO_BUILD_CONTAINER_GATE_SHA") endpoint=gate ;; + "GET https://api.github.com/repos/stackpop/edgezero/actions/variables/EDGEZERO_BUILD_CONTAINER_PUBLISHER_PREREQUISITE") + reads=0 + [[ ! -f "$fixture/prerequisite-reads" ]] || reads=$(<"$fixture/prerequisite-reads") + reads=$((reads + 1)) + printf '%s' "$reads" >"$fixture/prerequisite-reads" + if [[ "$reads" -eq 1 ]]; then endpoint=before; else endpoint=after; fi + ;; + "PATCH https://api.github.com/repos/stackpop/edgezero/actions/variables/EDGEZERO_BUILD_CONTAINER_PUBLISHER_PREREQUISITE") + endpoint=patch + [[ "$data" == @* ]] + cat "${data#@}" >"$fixture/patch-body" + ;; + *) printf 'unexpected request\n' >&2; exit 97 ;; +esac +if [[ -f "$fixture/$endpoint.transport-failure" ]]; then + printf 'transport detail must not leak\n' >&2 + exit 28 +fi +if [[ -f "$fixture/$endpoint.block" ]]; then + printf '%s' "$$" >"$fixture/curl-child-pid" + : >"$fixture/curl-blocked" + trap 'exit 143' TERM + while [[ ! -f "$fixture/release-curl" ]]; do sleep 0.01; done +fi +cat "$fixture/$endpoint.body" >"$output" +printf '%s\n%s\n%s' \ + "$(<"$fixture/$endpoint.status")" \ + "$(<"$fixture/$endpoint.version")" \ + "$(<"$fixture/$endpoint.media")" +SH +chmod 0755 "$FAKE_BIN/curl" + +bootstrap_record() { + local evidence_url=null source_pr=null + if [[ "$4" != null ]]; then + source_pr="\"${5:-$SOURCE_PR}\"" + evidence_url="\"${6:-https://github.com/stackpop/edgezero/pull/${5:-$SOURCE_PR}#issuecomment-$EVIDENCE_COMMENT_ID}\"" + fi + printf '{"evidence-sha256":"%s","evidence-url":%s,"gate-sha":"%s","previous-value-sha256":%s,"rotation-history":{"state":"bootstrap-no-rotation"},"schema-version":2,"source-pr":%s,"source-revision":%s}' \ + "$1" "$evidence_url" "$2" "$3" "$source_pr" "$4" +} + +verified_record() { + local created=${7:-$ROTATION_AT} nested=${8:-$ROTATION_DIGEST} + local run_number=${9:-$5} history=${10:-$HISTORY_DIGEST} + local evidence_url=null source_pr=null + if [[ "$4" != null ]]; then + source_pr="\"${11:-$SOURCE_PR}\"" + evidence_url="\"${12:-https://github.com/stackpop/edgezero/pull/${11:-$SOURCE_PR}#issuecomment-$EVIDENCE_COMMENT_ID}\"" + fi + printf '{"evidence-sha256":"%s","evidence-url":%s,"gate-sha":"%s","previous-value-sha256":%s,"rotation-history":{"created-at":"%s","evidence-sha256":"%s","history-sha256":"%s","run-attempt":"%s","run-id":"%s","run-number":"%s","state":"verified"},"schema-version":2,"source-pr":%s,"source-revision":%s}' \ + "$1" "$evidence_url" "$2" "$3" "$created" "$nested" "$history" "$6" "$5" "$run_number" "$source_pr" "$4" +} + +review_json() { + local reviewed_at=${1:-$REVIEWED_AT} expires_at=${2:-$EXPIRES_AT} + local reviewer=${3:-security-reviewer} subject=${4:-variable-writer} + printf '{"expires-at":"%s","organization-grants":{"members":"read","other-displayed":"none"},"repository-grants":{"metadata":"read","other-displayed":"none","variables":"write"},"resource-owner":"stackpop","reviewed-at":"%s","reviewer-login":"%s","schema-version":1,"screenshot-sha256":"%s","selected-repositories":["stackpop/edgezero"],"subject-login":"%s","token-id":"%s"}' \ + "$expires_at" "$reviewed_at" "$reviewer" "$PNG_DIGEST" "$subject" "$TOKEN_ID" +} + +set_metadata() { + local endpoint=$1 status=${2:-200} version=${3:-2026-03-10} media=${4:-application/json} + printf '%s' "$status" >"$FAKE_BIN/$endpoint.status" + printf '%s' "$version" >"$FAKE_BIN/$endpoint.version" + printf '%s' "$media" >"$FAKE_BIN/$endpoint.media" +} + +write_variable_body() { + jq -cn --arg name "$VARIABLE" --arg value "$2" \ + '{name:$name,value:$value,created_at:"2026-09-10T10:00:00Z",updated_at:"2026-09-10T11:00:00Z"}' >"$1" +} + +write_variable_body_control() { + local suffix + case "$3" in + lf) suffix='\n' ;; + nul) suffix='\u0000' ;; + *) return 2 ;; + esac + jq -cn --arg name "$VARIABLE" --arg value "$2" --argjson suffix "\"$suffix\"" \ + '{name:$name,value:($value + $suffix),created_at:"2026-09-10T10:00:00Z",updated_at:"2026-09-10T11:00:00Z"}' >"$1" +} + +set_current() { + CURRENT=$1 + write_variable_body "$FAKE_BIN/before.body" "$CURRENT" +} + +set_requested() { + REQUESTED=$1 + printf '%s' "$REQUESTED" >"$PREREQUISITE_JSON" + write_variable_body "$FAKE_BIN/after.body" "$REQUESTED" +} + +reset_api() { + rm -f -- "$FAKE_BIN"/args-* "$FAKE_BIN"/config-* "$FAKE_BIN/curl-count" \ + "$FAKE_BIN/prerequisite-reads" "$FAKE_BIN/patch-body" "$FAKE_BIN"/*.transport-failure \ + "$FAKE_BIN"/*.block "$FAKE_BIN/curl-child-pid" "$FAKE_BIN/curl-blocked" \ + "$FAKE_BIN/release-curl" + jq -cn --arg login variable-writer --argjson id "$TOKEN_ID" '{login:$login,id:$id}' >"$FAKE_BIN/user.body" + jq -cn '{state:"active",user:{login:"variable-writer"}}' >"$FAKE_BIN/membership.body" + jq -cn --arg value "$G" \ + '{name:"EDGEZERO_BUILD_CONTAINER_GATE_SHA",value:$value,created_at:"2026-09-10T10:00:00Z",updated_at:"2026-09-10T11:00:00Z"}' >"$FAKE_BIN/gate.body" + : >"$FAKE_BIN/patch.body" + for endpoint in user membership gate before after; do set_metadata "$endpoint"; done + set_metadata patch 204 2026-03-10 '' +} + +new_case() { + case_number=$((case_number + 1)) + CASE_ROOT="$INPUT_ROOT/case-$case_number" + mkdir "$CASE_ROOT" + EVIDENCE_JSON="$CASE_ROOT/evidence.json" + PREREQUISITE_JSON="$CASE_ROOT/prerequisite.json" + REVIEW_JSON="$CASE_ROOT/review.json" + PNG="$CASE_ROOT/review.png" + printf 'opaque-audit-evidence-%s' "$case_number" >"$EVIDENCE_JSON" + printf '\211PNG\r\n\032\nfixture-png-%s' "$case_number" >"$PNG" + EVIDENCE_DIGEST="sha256:$(hash_file "$EVIDENCE_JSON")" + PNG_DIGEST="sha256:$(hash_file "$PNG")" + review_json >"$REVIEW_JSON" + reset_api + current=$(bootstrap_record "sha256:$(printf '1%.0s' {1..64})" "$G" null null) + set_current "$current" + previous="\"sha256:$(hash_bytes "$current")\"" + set_requested "$(bootstrap_record "$EVIDENCE_DIGEST" "$G" "$previous" "\"$S1\"")" + CLI_GATE_ROOT=$GATE_ROOT + CLI_GATE_SHA=$G +} + +run_writer() { + PATH="$FAKE_BIN:$PATH" \ + EDGEZERO_PUBLISHER_PREREQUISITE_WRITE_TOKEN="$TOKEN" \ + AMBIENT_SECRET=must-not-reach-tools \ + bash "$WRITE" \ + --gate-root "$CLI_GATE_ROOT" --gate-sha "$CLI_GATE_SHA" \ + --evidence-json "$EVIDENCE_JSON" \ + --publisher-prerequisite-json "$PREREQUISITE_JSON" \ + --writer-token-review-json "$REVIEW_JSON" \ + --writer-token-review-png "$PNG" "$@" +} + +run_writer_with_path() { + local prefix=$1 + PATH="$prefix:$FAKE_BIN:$PATH" \ + EDGEZERO_PUBLISHER_PREREQUISITE_WRITE_TOKEN="$TOKEN" \ + AMBIENT_SECRET=must-not-reach-tools \ + bash "$WRITE" \ + --gate-root "$CLI_GATE_ROOT" --gate-sha "$CLI_GATE_SHA" \ + --evidence-json "$EVIDENCE_JSON" \ + --publisher-prerequisite-json "$PREREQUISITE_JSON" \ + --writer-token-review-json "$REVIEW_JSON" \ + --writer-token-review-png "$PNG" +} + +run_writer_with_shellopts() { + env SHELLOPTS=xtrace:allexport \ + PATH="$FAKE_BIN:$PATH" \ + EDGEZERO_PUBLISHER_PREREQUISITE_WRITE_TOKEN="$TOKEN" \ + TOKEN=ambient-exported-token-alias \ + WRITER_TOKEN=ambient-exported-writer-token-alias \ + AMBIENT_SECRET=must-not-reach-tools \ + bash "$WRITE" \ + --gate-root "$CLI_GATE_ROOT" --gate-sha "$CLI_GATE_SHA" \ + --evidence-json "$EVIDENCE_JSON" \ + --publisher-prerequisite-json "$PREREQUISITE_JSON" \ + --writer-token-review-json "$REVIEW_JSON" \ + --writer-token-review-png "$PNG" +} + +run_writer_with_git_alternate() { + local alternate=$1 + GIT_ALTERNATE_OBJECT_DIRECTORIES="$alternate" \ + PATH="$FAKE_BIN:$PATH" \ + EDGEZERO_PUBLISHER_PREREQUISITE_WRITE_TOKEN="$TOKEN" \ + AMBIENT_SECRET=must-not-reach-tools \ + bash "$WRITE" \ + --gate-root "$CLI_GATE_ROOT" --gate-sha "$CLI_GATE_SHA" \ + --evidence-json "$EVIDENCE_JSON" \ + --publisher-prerequisite-json "$PREREQUISITE_JSON" \ + --writer-token-review-json "$REVIEW_JSON" \ + --writer-token-review-png "$PNG" +} + +curl_calls() { + if [[ -f "$FAKE_BIN/curl-count" ]]; then cat "$FAKE_BIN/curl-count"; else printf 0; fi +} + +patch_calls() { + local count=0 path + for path in "$FAKE_BIN"/args-*; do + [[ -f "$path" ]] || continue + if grep -Fxq PATCH "$path"; then count=$((count + 1)); fi + done + printf '%s' "$count" +} + +assert_result() { + local expected=$1 description=$2 status=0 + shift 2 + "$@" >"$CASE_ROOT/stdout" 2>"$CASE_ROOT/stderr" || status=$? + if [[ "$status" -ne "$expected" ]]; then + cat "$CASE_ROOT/stdout" "$CASE_ROOT/stderr" >&2 + no "$description (status $status, expected $expected)" + elif [[ -s "$CASE_ROOT/stdout" ]]; then + no "$description emitted stdout" + elif [[ "$expected" -eq 0 && -s "$CASE_ROOT/stderr" ]]; then + cat "$CASE_ROOT/stderr" >&2 + no "$description emitted stderr" + elif [[ "$expected" -ne 0 && (! -s "$CASE_ROOT/stderr" || $(<"$CASE_ROOT/stderr") == *"$TOKEN"*) ]]; then + cat "$CASE_ROOT/stderr" >&2 + no "$description did not emit sanitized diagnostics" + else + ok "$description" + fi +} + +assert_no_patch() { + if [[ $(patch_calls) == 0 ]]; then ok "$1"; else no "$1"; fi +} + +configure_transition() { + set_current "$1" + set_requested "$2" +} + +assert_transport_contract() { + local good=true call args config output data url index expected_body + local -a actual expected + [[ $(curl_calls) == 6 ]] || good=false + for call in 1 2 3 4 5 6; do + args="$FAKE_BIN/args-$call" + config="$FAKE_BIN/config-$call" + [[ -f "$args" && -f "$config" ]] || { good=false; continue; } + actual=() + while IFS= read -r argument || [[ -n "$argument" ]]; do + actual+=("$argument") + done <"$args" + if [[ "$call" -eq 5 ]]; then + data=${actual[14]:-} + output=${actual[16]:-} + expected=( + --disable --silent --show-error --connect-timeout 10 --max-time 30 --max-redirs 0 + --request PATCH --config - --data-binary "$data" --output "$output" + --write-out '%{http_code}\n%header{x-github-api-version-selected}\n%header{content-type}' + "$API/repos/stackpop/edgezero/actions/variables/$VARIABLE" + ) + [[ "$data" == @/tmp/.edgezero-publisher-request.?????? ]] || good=false + printf '%s\n' \ + 'header = "Accept: application/vnd.github+json"' \ + 'header = "X-GitHub-Api-Version: 2026-03-10"' \ + 'header = "User-Agent: edgezero-build-container-gate/1"' \ + 'header = "Content-Type: application/json"' \ + "header = \"Authorization: Bearer $TOKEN\"" >"$CASE_ROOT/expected-config" + else + output=${actual[14]:-} + case "$call" in + 1) url="$API/user" ;; + 2) url="$API/orgs/stackpop/memberships/variable-writer" ;; + 3) url="$API/repos/stackpop/edgezero/actions/variables/EDGEZERO_BUILD_CONTAINER_GATE_SHA" ;; + 4 | 6) url="$API/repos/stackpop/edgezero/actions/variables/$VARIABLE" ;; + esac + expected=( + --disable --silent --show-error --connect-timeout 10 --max-time 30 --max-redirs 0 + --request GET --config - --output "$output" + --write-out '%{http_code}\n%header{x-github-api-version-selected}\n%header{content-type}' "$url" + ) + printf '%s\n' \ + 'header = "Accept: application/vnd.github+json"' \ + 'header = "X-GitHub-Api-Version: 2026-03-10"' \ + 'header = "User-Agent: edgezero-build-container-gate/1"' \ + "header = \"Authorization: Bearer $TOKEN\"" >"$CASE_ROOT/expected-config" + fi + cmp -s "$CASE_ROOT/expected-config" "$config" || good=false + [[ "${#actual[@]}" -eq "${#expected[@]}" ]] || good=false + for index in "${!expected[@]}"; do + [[ "${actual[index]:-}" == "${expected[index]}" ]] || good=false + done + done + expected_body=$(jq -cn --arg name "$VARIABLE" --arg value "$REQUESTED" '{name:$name,value:$value}') + printf '%s' "$expected_body" >"$CASE_ROOT/expected-body" + [[ -f "$FAKE_BIN/patch-body" ]] && cmp -s "$CASE_ROOT/expected-body" "$FAKE_BIN/patch-body" || good=false + if [[ "$good" == true ]]; then + ok 'curl uses exact ordered routes, flags, headers, API version, and PATCH body' + else + no 'curl uses exact ordered routes, flags, headers, API version, and PATCH body' + fi +} + +echo '== publisher prerequisite writer ==' + +new_case +assert_result 0 'inert state can bind the first source silently' run_writer +assert_transport_contract + +for boundary in minimum maximum; do + new_case + if [[ "$boundary" == minimum ]]; then + printf x >"$EVIDENCE_JSON" + printf '\211PNG\r\n\032\n' >"$PNG" + else + dd if=/dev/zero of="$EVIDENCE_JSON" bs=1048576 count=1 2>/dev/null + printf '\211PNG\r\n\032\n' >"$PNG" + dd if=/dev/zero bs=10485752 count=1 2>/dev/null >>"$PNG" + fi + EVIDENCE_DIGEST="sha256:$(hash_file "$EVIDENCE_JSON")" + PNG_DIGEST="sha256:$(hash_file "$PNG")" + review_json >"$REVIEW_JSON" + previous="\"sha256:$(hash_bytes "$CURRENT")\"" + set_requested "$(bootstrap_record "$EVIDENCE_DIGEST" "$G" "$previous" "\"$S1\"")" + assert_result 0 "exact $boundary evidence and PNG bounds are accepted" run_writer +done + +new_case +current=$(bootstrap_record "sha256:$(printf '1%.0s' {1..64})" "$G" null null) +set_current "$current" +previous="\"sha256:$(hash_bytes "$current")\"" +set_requested "$(verified_record "$EVIDENCE_DIGEST" "$G" "$previous" null \ + 18446744073709551615 4294967295 "$ROTATION_AT" "$ROTATION_DIGEST" \ + 18446744073709551615)" +assert_result 0 'maximum run attempt, id, and number bounds are accepted' run_writer + +new_case +current=$(bootstrap_record "sha256:$(printf '1%.0s' {1..64})" "$G" null null) +set_current "$current" +previous="\"sha256:$(hash_bytes "$current")\"" +set_requested "$(bootstrap_record "$EVIDENCE_DIGEST" "$G" "$previous" "\"$S1\"" \ + 18446744073709551615 \ + https://github.com/stackpop/edgezero/pull/18446744073709551615#issuecomment-18446744073709551615)" +assert_result 0 'maximum source PR and evidence comment id bounds are accepted' run_writer + +new_case +current=$(bootstrap_record "$EVIDENCE_DIGEST" "$G" null null) +set_current "$current" +set_requested "$current" +assert_result 0 'byte-identical state is an authenticated idempotent success' run_writer +if [[ $(curl_calls) == 4 && $(patch_calls) == 0 ]]; then + ok 'idempotence reads current state but skips PATCH and readback' +else + no 'idempotence reads current state but skips PATCH and readback' +fi + +for transition in same-source-refresh same-source-url-refresh forward-source forward-source-tuple \ + verified-forward-source gate-rotation \ + verified-gate-rotation first-bootstrap-rollback clear-on-forward-rotation inert-forward-rotation; do + new_case + old_previous="\"sha256:$(printf '3%.0s' {1..64})\"" + case "$transition" in + same-source-refresh) + from=$(bootstrap_record "sha256:$(printf '2%.0s' {1..64})" "$G" "$old_previous" "\"$S1\"") + previous="\"sha256:$(hash_bytes "$from")\"" + to=$(bootstrap_record "$EVIDENCE_DIGEST" "$G" "$previous" "\"$S1\"") + ;; + same-source-url-refresh) + from=$(bootstrap_record "sha256:$(printf '2%.0s' {1..64})" "$G" "$old_previous" "\"$S1\"" \ + 347 https://github.com/stackpop/edgezero/pull/347#issuecomment-700) + previous="\"sha256:$(hash_bytes "$from")\"" + to=$(bootstrap_record "$EVIDENCE_DIGEST" "$G" "$previous" "\"$S1\"" \ + 347 https://github.com/stackpop/edgezero/pull/347#issuecomment-701) + ;; + forward-source) + from=$(bootstrap_record "sha256:$(printf '2%.0s' {1..64})" "$G" "$old_previous" "\"$S1\"") + previous="\"sha256:$(hash_bytes "$from")\"" + to=$(bootstrap_record "$EVIDENCE_DIGEST" "$G" "$previous" "\"$S2\"") + ;; + forward-source-tuple) + from=$(bootstrap_record "sha256:$(printf '2%.0s' {1..64})" "$G" "$old_previous" "\"$S1\"" \ + 347 https://github.com/stackpop/edgezero/pull/347#issuecomment-700) + previous="\"sha256:$(hash_bytes "$from")\"" + to=$(bootstrap_record "$EVIDENCE_DIGEST" "$G" "$previous" "\"$S2\"" \ + 348 https://github.com/stackpop/edgezero/pull/348#issuecomment-701) + ;; + verified-forward-source) + from=$(verified_record "sha256:$(printf '2%.0s' {1..64})" "$G" "$old_previous" "\"$S1\"" 10 1) + previous="\"sha256:$(hash_bytes "$from")\"" + to=$(verified_record "$EVIDENCE_DIGEST" "$G" "$previous" "\"$S2\"" 10 1) + ;; + gate-rotation) + from=$(bootstrap_record "sha256:$(printf '2%.0s' {1..64})" "$OLD_G" "$old_previous" null) + previous="\"sha256:$(hash_bytes "$from")\"" + to=$(verified_record "$EVIDENCE_DIGEST" "$G" "$previous" null 9007199254740993 2) + ;; + verified-gate-rotation) + from=$(verified_record "sha256:$(printf '2%.0s' {1..64})" "$OLD_G" "$old_previous" null 10 1) + previous="\"sha256:$(hash_bytes "$from")\"" + to=$(verified_record "$EVIDENCE_DIGEST" "$G" "$previous" null 11 1 \ + 2026-09-10T11:01:00Z "sha256:$(printf '9%.0s' {1..64})" 11 "sha256:$(printf '6%.0s' {1..64})") + ;; + first-bootstrap-rollback) + from=$(bootstrap_record "sha256:$(printf '2%.0s' {1..64})" "$G" "$old_previous" "\"$S1\"") + previous="\"sha256:$(hash_bytes "$from")\"" + to=$(verified_record "$EVIDENCE_DIGEST" "$G" "$previous" null 10 1) + ;; + clear-on-forward-rotation) + from=$(verified_record "sha256:$(printf '2%.0s' {1..64})" "$G" "$old_previous" "\"$S1\"" 10 1) + previous="\"sha256:$(hash_bytes "$from")\"" + to=$(verified_record "$EVIDENCE_DIGEST" "$G" "$previous" null 11 1 \ + 2026-09-10T11:01:00Z "sha256:$(printf '9%.0s' {1..64})" 11 "sha256:$(printf '6%.0s' {1..64})") + ;; + inert-forward-rotation) + from=$(verified_record "sha256:$(printf '2%.0s' {1..64})" "$G" "$old_previous" null 10 1) + previous="\"sha256:$(hash_bytes "$from")\"" + to=$(verified_record "$EVIDENCE_DIGEST" "$G" "$previous" null 11 1 \ + 2026-09-10T11:01:00Z "sha256:$(printf '9%.0s' {1..64})" 11 "sha256:$(printf '6%.0s' {1..64})") + ;; + esac + configure_transition "$from" "$to" + assert_result 0 "$transition transition is accepted" run_writer +done + +for transition in null-current wrong-previous source-clear source-regression source-incomparable \ + source-missing source-before-gate cross-gate-source gate-bootstrap rotation-regression \ + gate-stale-rotation rotation-same-identity-changed rotation-forward-stale-detail \ + rotation-same-run-id rotation-stale-receipt rotation-stale-history \ + source-history-change same-source-pr-change verified-to-bootstrap; do + new_case + old_previous="\"sha256:$(printf '3%.0s' {1..64})\"" + from=$CURRENT + previous="\"sha256:$(hash_bytes "$from")\"" + case "$transition" in + null-current) + jq -cn --arg name "$VARIABLE" '{name:$name,value:null}' >"$FAKE_BIN/before.body" + to=$REQUESTED + ;; + wrong-previous) + to=$(bootstrap_record "$EVIDENCE_DIGEST" "$G" '"sha256:'"$(printf 'f%.0s' {1..64})"'"' "\"$S1\"") + ;; + source-clear) + from=$(verified_record "sha256:$(printf '2%.0s' {1..64})" "$G" "$old_previous" "\"$S1\"" 10 1) + previous="\"sha256:$(hash_bytes "$from")\"" + to=$(verified_record "$EVIDENCE_DIGEST" "$G" "$previous" null 10 1) + ;; + source-regression) + from=$(bootstrap_record "sha256:$(printf '2%.0s' {1..64})" "$G" "$old_previous" "\"$S2\"") + previous="\"sha256:$(hash_bytes "$from")\"" + to=$(bootstrap_record "$EVIDENCE_DIGEST" "$G" "$previous" "\"$S1\"") + ;; + source-incomparable) + from=$(bootstrap_record "sha256:$(printf '2%.0s' {1..64})" "$G" "$old_previous" "\"$S1\"") + previous="\"sha256:$(hash_bytes "$from")\"" + to=$(bootstrap_record "$EVIDENCE_DIGEST" "$G" "$previous" "\"$SIDE\"") + ;; + source-missing) + to=$(bootstrap_record "$EVIDENCE_DIGEST" "$G" "$previous" "\"$(printf 'f%.0s' {1..40})\"") + ;; + source-before-gate) + to=$(bootstrap_record "$EVIDENCE_DIGEST" "$G" "$previous" "\"$OLD_G\"") + ;; + cross-gate-source) + from=$(bootstrap_record "sha256:$(printf '2%.0s' {1..64})" "$OLD_G" "$old_previous" null) + previous="\"sha256:$(hash_bytes "$from")\"" + to=$(verified_record "$EVIDENCE_DIGEST" "$G" "$previous" "\"$S1\"" 7 1) + ;; + gate-bootstrap) + from=$(bootstrap_record "sha256:$(printf '2%.0s' {1..64})" "$OLD_G" "$old_previous" null) + previous="\"sha256:$(hash_bytes "$from")\"" + to=$(bootstrap_record "$EVIDENCE_DIGEST" "$G" "$previous" null) + ;; + rotation-regression) + from=$(verified_record "sha256:$(printf '2%.0s' {1..64})" "$G" "$old_previous" null 10 2) + previous="\"sha256:$(hash_bytes "$from")\"" + to=$(verified_record "$EVIDENCE_DIGEST" "$G" "$previous" null 9 1 \ + 2026-09-10T11:01:00Z "sha256:$(printf '9%.0s' {1..64})" 9 "sha256:$(printf '6%.0s' {1..64})") + ;; + gate-stale-rotation) + from=$(verified_record "sha256:$(printf '2%.0s' {1..64})" "$OLD_G" "$old_previous" null 10 2) + previous="\"sha256:$(hash_bytes "$from")\"" + to=$(verified_record "$EVIDENCE_DIGEST" "$G" "$previous" null 10 2) + ;; + rotation-same-identity-changed) + from=$(verified_record "sha256:$(printf '2%.0s' {1..64})" "$G" "$old_previous" null 10 2) + previous="\"sha256:$(hash_bytes "$from")\"" + to=$(verified_record "$EVIDENCE_DIGEST" "$G" "$previous" null 10 2 2026-09-10T11:01:00Z) + ;; + rotation-forward-stale-detail) + from=$(verified_record "sha256:$(printf '2%.0s' {1..64})" "$G" "$old_previous" null 10 2) + previous="\"sha256:$(hash_bytes "$from")\"" + to=$(verified_record "$EVIDENCE_DIGEST" "$G" "$previous" null 11 1 "$ROTATION_AT" "$ROTATION_DIGEST" 11 "$HISTORY_DIGEST") + ;; + rotation-same-run-id) + from=$(verified_record "sha256:$(printf '2%.0s' {1..64})" "$G" "$old_previous" null 10 1) + previous="\"sha256:$(hash_bytes "$from")\"" + to=$(verified_record "$EVIDENCE_DIGEST" "$G" "$previous" null 10 2 \ + 2026-09-10T11:01:00Z "sha256:$(printf '9%.0s' {1..64})" 11 "sha256:$(printf '6%.0s' {1..64})") + ;; + rotation-stale-receipt) + from=$(verified_record "sha256:$(printf '2%.0s' {1..64})" "$G" "$old_previous" null 10 1) + previous="\"sha256:$(hash_bytes "$from")\"" + to=$(verified_record "$EVIDENCE_DIGEST" "$G" "$previous" null 11 1 \ + 2026-09-10T11:01:00Z "$ROTATION_DIGEST" 11 "sha256:$(printf '6%.0s' {1..64})") + ;; + rotation-stale-history) + from=$(verified_record "sha256:$(printf '2%.0s' {1..64})" "$G" "$old_previous" null 10 1) + previous="\"sha256:$(hash_bytes "$from")\"" + to=$(verified_record "$EVIDENCE_DIGEST" "$G" "$previous" null 11 1 \ + 2026-09-10T11:01:00Z "sha256:$(printf '9%.0s' {1..64})" 11 "$HISTORY_DIGEST") + ;; + source-history-change) + from=$(verified_record "sha256:$(printf '2%.0s' {1..64})" "$G" "$old_previous" "\"$S1\"" 10 1) + previous="\"sha256:$(hash_bytes "$from")\"" + to=$(verified_record "$EVIDENCE_DIGEST" "$G" "$previous" "\"$S2\"" 11 1 \ + 2026-09-10T11:01:00Z "sha256:$(printf '9%.0s' {1..64})" 11 "sha256:$(printf '6%.0s' {1..64})") + ;; + same-source-pr-change) + from=$(bootstrap_record "sha256:$(printf '2%.0s' {1..64})" "$G" "$old_previous" "\"$S1\"" \ + 347 https://github.com/stackpop/edgezero/pull/347#issuecomment-700) + previous="\"sha256:$(hash_bytes "$from")\"" + to=$(bootstrap_record "$EVIDENCE_DIGEST" "$G" "$previous" "\"$S1\"" \ + 348 https://github.com/stackpop/edgezero/pull/348#issuecomment-701) + ;; + verified-to-bootstrap) + from=$(verified_record "sha256:$(printf '2%.0s' {1..64})" "$G" "$old_previous" null 10 2) + previous="\"sha256:$(hash_bytes "$from")\"" + to=$(bootstrap_record "$EVIDENCE_DIGEST" "$G" "$previous" null) + ;; + esac + if [[ "$transition" != null-current ]]; then configure_transition "$from" "$to"; else set_requested "$to"; fi + assert_result 1 "$transition transition is rejected" run_writer + assert_no_patch "$transition fails before PATCH" +done + +run_remaining_tests diff --git a/.github/actions/deploy-fastly/action.yml b/.github/actions/deploy-fastly/action.yml index adb0576b..9987a56d 100644 --- a/.github/actions/deploy-fastly/action.yml +++ b/.github/actions/deploy-fastly/action.yml @@ -147,7 +147,7 @@ runs: run: exec "$GITHUB_ACTION_PATH/scripts/validate.sh" - name: Download CLI artifact - uses: actions/download-artifact@v8 + uses: actions/download-artifact@v8.0.1 with: name: ${{ inputs['app-cli-artifact'] }} path: ${{ steps.ws.outputs.root }}/cli-download @@ -235,7 +235,7 @@ runs: # skipped, so restoring would be a no-op; skip it too, matching the docs. if: ${{ inputs.cache == 'true' && steps.resolve.outputs['effective-build-mode'] == 'always' }} id: cache-restore - uses: actions/cache/restore@v6 + uses: actions/cache/restore@v6.1.0 with: key: ${{ steps.resolve.outputs['cache-key'] }} path: ${{ steps.resolve.outputs['cache-path'] }} @@ -258,10 +258,10 @@ runs: FASTLY_HOME: "" - name: Install Rust toolchain - # Pinned to a released major version tag, per the repo's pin policy - # (check-action-pins.sh + .github/zizmor.yml accept a SHA or a version tag; + # Pinned to an exact stable patch version, per the repo's pin policy + # (check-action-pins.sh is stricter than zizmor's symbolic-ref check; # branch/floating refs are rejected). - uses: actions-rust-lang/setup-rust-toolchain@v1 + uses: actions-rust-lang/setup-rust-toolchain@v1.17.0 with: toolchain: ${{ steps.resolve.outputs['rust-toolchain'] }} target: wasm32-wasip1 @@ -358,7 +358,7 @@ runs: # saved. - name: Save application target cache if: ${{ inputs.cache == 'true' && steps.resolve.outputs['effective-build-mode'] == 'always' && steps.cache-restore.outputs['cache-hit'] != 'true' }} - uses: actions/cache/save@v6 + uses: actions/cache/save@v6.1.0 with: key: ${{ steps.resolve.outputs['cache-key'] }} path: ${{ steps.resolve.outputs['cache-path'] }} diff --git a/.github/actions/healthcheck-fastly/action.yml b/.github/actions/healthcheck-fastly/action.yml index c4b0631d..1426c049 100644 --- a/.github/actions/healthcheck-fastly/action.yml +++ b/.github/actions/healthcheck-fastly/action.yml @@ -114,7 +114,7 @@ runs: run: exec "$GITHUB_ACTION_PATH/scripts/validate.sh" - name: Download CLI artifact - uses: actions/download-artifact@v8 + uses: actions/download-artifact@v8.0.1 with: name: ${{ inputs['app-cli-artifact'] }} path: ${{ steps.ws.outputs.root }}/cli-download diff --git a/.github/actions/rollback-fastly/action.yml b/.github/actions/rollback-fastly/action.yml index 80389ccf..086cc96b 100644 --- a/.github/actions/rollback-fastly/action.yml +++ b/.github/actions/rollback-fastly/action.yml @@ -98,7 +98,7 @@ runs: run: exec "$GITHUB_ACTION_PATH/scripts/validate.sh" - name: Download CLI artifact - uses: actions/download-artifact@v8 + uses: actions/download-artifact@v8.0.1 with: name: ${{ inputs['app-cli-artifact'] }} path: ${{ steps.ws.outputs.root }}/cli-download diff --git a/.github/docker/build-app-cli/Dockerfile b/.github/docker/build-app-cli/Dockerfile new file mode 100644 index 00000000..706bbd89 --- /dev/null +++ b/.github/docker/build-app-cli/Dockerfile @@ -0,0 +1,85 @@ +FROM docker.io/library/rust@sha256:6f9e63259f12e1e599296f5ecfed2bae46de4af0ee0525dd8b89c046e236d5c5 AS builder + +RUN apt-get update \ + && apt-get install --yes --no-install-recommends ca-certificates curl jq \ + && rm -rf /var/lib/apt/lists/* + +COPY .tool-versions /edgezero-input/.tool-versions +COPY .github/actions/deploy-fastly/versions.json /edgezero-input/fastly-versions.json + +RUN set -eux; \ + fastly_version=$(jq -er '.fastly.version' /edgezero-input/fastly-versions.json); \ + fastly_url=$(jq -er '.fastly.linux_amd64.url' /edgezero-input/fastly-versions.json); \ + fastly_sha=$(jq -er '.fastly.linux_amd64.sha256' /edgezero-input/fastly-versions.json); \ + test "$fastly_version" = "15.1.0"; \ + test "$(awk '$1 == "fastly" { print $2 }' /edgezero-input/.tool-versions)" = "$fastly_version"; \ + curl --fail --location --silent --show-error "$fastly_url" --output /tmp/fastly.tar.gz; \ + printf '%s %s\n' "$fastly_sha" /tmp/fastly.tar.gz | sha256sum --check --strict; \ + tar -xzf /tmp/fastly.tar.gz -C /tmp fastly; \ + install -m 0755 /tmp/fastly /usr/local/bin/fastly; \ + rm -f /tmp/fastly /tmp/fastly.tar.gz + +RUN set -eux; \ + asset="sccache-v0.10.0-x86_64-unknown-linux-musl.tar.gz"; \ + base="https://github.com/mozilla/sccache/releases/download/v0.10.0"; \ + sccache_sha="1fbb35e135660d04a2d5e42b59c7874d39b3deb17de56330b25b713ec59f849b"; \ + curl --fail --location --silent --show-error "$base/$asset" --output "/tmp/$asset"; \ + curl --fail --location --silent --show-error "$base/$asset.sha256" --output "/tmp/$asset.sha256"; \ + test "$(wc -c < "/tmp/$asset.sha256")" -eq 64; \ + test "$(cat "/tmp/$asset.sha256")" = "$sccache_sha"; \ + printf '%s %s\n' "$sccache_sha" "/tmp/$asset" | sha256sum --check --strict; \ + tar -xzf "/tmp/$asset" -C /tmp; \ + install -m 0755 "/tmp/${asset%.tar.gz}/sccache" /usr/local/bin/sccache; \ + rm -rf "/tmp/$asset" "/tmp/$asset.sha256" "/tmp/${asset%.tar.gz}" + +RUN rustup target add wasm32-wasip1 + +WORKDIR /build +COPY .github/tools/edgezero-provenance-validator/Cargo.toml .github/tools/edgezero-provenance-validator/Cargo.lock .github/tools/edgezero-provenance-validator/ +COPY .github/tools/edgezero-provenance-validator/src .github/tools/edgezero-provenance-validator/src +COPY .github/tools/edgezero-provenance-validator/tests .github/tools/edgezero-provenance-validator/tests +RUN cargo build --locked --release --manifest-path .github/tools/edgezero-provenance-validator/Cargo.toml + +FROM docker.io/library/rust@sha256:6f9e63259f12e1e599296f5ecfed2bae46de4af0ee0525dd8b89c046e236d5c5 AS runtime + +ARG IMAGE_SOURCE_REVISION +RUN test "${#IMAGE_SOURCE_REVISION}" -eq 40 \ + && case "$IMAGE_SOURCE_REVISION" in *[!0-9a-f]*) exit 1 ;; esac +LABEL org.opencontainers.image.source="https://github.com/stackpop/edgezero" \ + org.opencontainers.image.revision="${IMAGE_SOURCE_REVISION}" \ + io.edgezero.provenance-protocol="1" + +RUN apt-get update \ + && apt-get install --yes --no-install-recommends \ + build-essential ca-certificates curl git jq perl tar \ + && rm -rf /var/lib/apt/lists/* \ + && groupadd --gid 1001 edgezero \ + && useradd --uid 1001 --gid 1001 --create-home --home-dir /home/edgezero edgezero \ + && mkdir -p /opt/edgezero/runtime-lib /usr/local/share/edgezero /work \ + && cp --dereference /lib/x86_64-linux-gnu/libc.so.6 /opt/edgezero/runtime-lib/libc.so.6 \ + && cp --dereference /lib/x86_64-linux-gnu/libgcc_s.so.1 /opt/edgezero/runtime-lib/libgcc_s.so.1 \ + && cp --dereference /lib/x86_64-linux-gnu/libm.so.6 /opt/edgezero/runtime-lib/libm.so.6 \ + && cp --dereference /lib64/ld-linux-x86-64.so.2 /tmp/ld-linux-x86-64.so.2 \ + && chmod 0755 /tmp/ld-linux-x86-64.so.2 \ + && perl -e 'unlink(q{/lib64}) or die $!; mkdir(q{/lib64}, 0755) or die $!; rename(q{/tmp/ld-linux-x86-64.so.2}, q{/lib64/ld-linux-x86-64.so.2}) or die $!' \ + && rm -f /etc/ld.so.preload \ + && chown 1001:1001 /home/edgezero /work + +COPY --from=builder /usr/local/bin/fastly /usr/local/bin/fastly +COPY --from=builder /usr/local/bin/sccache /usr/local/bin/sccache +COPY --from=builder /build/.github/tools/edgezero-provenance-validator/target/release/edgezero-provenance-validator /usr/local/bin/edgezero-provenance-validator +COPY --from=builder /usr/local/rustup/toolchains/1.95.0-x86_64-unknown-linux-gnu/lib/rustlib/components /usr/local/rustup/toolchains/1.95.0-x86_64-unknown-linux-gnu/lib/rustlib/components +COPY --from=builder /usr/local/rustup/toolchains/1.95.0-x86_64-unknown-linux-gnu/lib/rustlib/manifest-rust-std-wasm32-wasip1 /usr/local/rustup/toolchains/1.95.0-x86_64-unknown-linux-gnu/lib/rustlib/manifest-rust-std-wasm32-wasip1 +COPY --from=builder /usr/local/rustup/toolchains/1.95.0-x86_64-unknown-linux-gnu/lib/rustlib/multirust-config.toml /usr/local/rustup/toolchains/1.95.0-x86_64-unknown-linux-gnu/lib/rustlib/multirust-config.toml +COPY --from=builder /usr/local/rustup/toolchains/1.95.0-x86_64-unknown-linux-gnu/lib/rustlib/wasm32-wasip1 /usr/local/rustup/toolchains/1.95.0-x86_64-unknown-linux-gnu/lib/rustlib/wasm32-wasip1 +COPY .tool-versions /usr/local/share/edgezero/.tool-versions +COPY .github/actions/deploy-fastly/versions.json /usr/local/share/edgezero/fastly-versions.json +COPY .github/docker/build-app-cli/provenance.schema.json /usr/local/share/edgezero/provenance.schema.json +COPY .github/docker/build-app-cli/fixtures/provenance /usr/local/share/edgezero/provenance-fixtures +COPY .github/docker/build-app-cli/fixtures/gnu-smoke.rs /usr/local/share/edgezero/gnu-smoke.rs +COPY .github/docker/build-app-cli/fixtures/wasm-smoke.rs /usr/local/share/edgezero/wasm-smoke.rs +COPY .github/docker/build-app-cli/verify-toolchain.sh /usr/local/bin/verify-toolchain + +USER 1001:1001 +WORKDIR /work +ENTRYPOINT ["/usr/bin/env"] diff --git a/.github/docker/build-app-cli/assert-build-container-app-token.sh b/.github/docker/build-app-cli/assert-build-container-app-token.sh new file mode 100755 index 00000000..a1d7800f --- /dev/null +++ b/.github/docker/build-app-cli/assert-build-container-app-token.sh @@ -0,0 +1,65 @@ +#!/usr/bin/env bash +set -euo pipefail + +export LC_ALL=C +export BASH_ENV= +export ENV= + +die() { + printf '::error::%s\n' "$*" >&2 + exit 1 +} + +is_positive_u64() { + local value=$1 maximum=18446744073709551615 + [[ "$value" =~ ^[1-9][0-9]*$ ]] || return 1 + ((${#value} < ${#maximum})) && return 0 + # Equal-length canonical decimals are intentionally compared lexically. + # shellcheck disable=SC2071 + ((${#value} == ${#maximum})) && [[ "$value" < "$maximum" || "$value" == "$maximum" ]] +} + +[[ "$#" -eq 0 ]] || die "App token assertion accepts no arguments" +for name in EDGEZERO_APP_TOKEN EDGEZERO_INSTALLATION_ID EDGEZERO_EXPECTED_INSTALLATION_ID; do + [[ -n "${!name:-}" ]] || die "required token context is absent: $name" +done +[[ "$EDGEZERO_APP_TOKEN" != *$'\n'* && "$EDGEZERO_APP_TOKEN" != *$'\r'* && + "$EDGEZERO_APP_TOKEN" != *'"'* && "$EDGEZERO_APP_TOKEN" != *\\* ]] || + die "App token cannot be encoded safely" +is_positive_u64 "$EDGEZERO_INSTALLATION_ID" || die "installation ID is not canonical" +is_positive_u64 "$EDGEZERO_EXPECTED_INSTALLATION_ID" || + die "expected installation ID is not canonical" +[[ "$EDGEZERO_INSTALLATION_ID" == "$EDGEZERO_EXPECTED_INSTALLATION_ID" ]] || + die "minted token came from an unexpected installation" + +WORK_DIR=$(mktemp -d /tmp/edgezero-app-token.XXXXXX) +trap 'rm -rf -- "$WORK_DIR"' EXIT HUP INT TERM +CONFIG="$WORK_DIR/curl.config" +printf '%s\n' \ + 'header = "Accept: application/vnd.github+json"' \ + 'header = "X-GitHub-Api-Version: 2026-03-10"' \ + 'header = "User-Agent: edgezero-build-container-gate/1"' \ + "header = \"Authorization: Bearer $EDGEZERO_APP_TOKEN\"" \ + >"$CONFIG" +REPLY="$WORK_DIR/reply" +env -i PATH="$PATH" LC_ALL=C curl \ + --disable --silent --show-error --connect-timeout 10 --max-time 30 --max-redirs 0 \ + --request GET --config - \ + --write-out $'\n%{http_code}\n%header{x-github-api-version-selected}\n%header{content-type}' \ + https://api.github.com/repos/stackpop/edgezero \ + <"$CONFIG" >"$REPLY" || die "publisher token repository probe failed" + +STATUS=$(tail -n 3 "$REPLY" | sed -n '1p') +SELECTED_VERSION=$(tail -n 3 "$REPLY" | sed -n '2p') +CONTENT_TYPE=$(tail -n 3 "$REPLY" | sed -n '3p') +[[ "$STATUS" == 200 ]] || die "publisher token probe returned HTTP $STATUS" +[[ "$SELECTED_VERSION" == 2026-03-10 ]] || die "GitHub selected an unexpected API version" +[[ "$CONTENT_TYPE" =~ ^[Aa][Pp][Pp][Ll][Ii][Cc][Aa][Tt][Ii][Oo][Nn]/[Jj][Ss][Oo][Nn]([[:space:]]*\;[[:space:]]*[Cc][Hh][Aa][Rr][Ss][Ee][Tt][[:space:]]*=[[:space:]]*[Uu][Tt][Ff]-8)?$ ]] || + die "GitHub returned an unsupported media type" +sed '$d' "$REPLY" | sed '$d' | sed '$d' >"$WORK_DIR/body" +jq -e ' + type == "object" and + (.id | type == "number" and . >= 1 and . == floor) and + .full_name == "stackpop/edgezero" and + .private == false and .visibility == "public" +' "$WORK_DIR/body" >/dev/null 2>&1 || die "publisher token repository identity differs" diff --git a/.github/docker/build-app-cli/assert-build-container-completion.sh b/.github/docker/build-app-cli/assert-build-container-completion.sh new file mode 100755 index 00000000..8e711875 --- /dev/null +++ b/.github/docker/build-app-cli/assert-build-container-completion.sh @@ -0,0 +1,54 @@ +#!/usr/bin/env bash +set -euo pipefail + +export LC_ALL=C +export BASH_ENV= +export ENV= + +usage() { + printf 'usage: assert-build-container-completion.sh --file --kind \n' >&2 + exit 2 +} + +die() { + printf '::error::%s\n' "$*" >&2 + exit 1 +} + +FILE= +KIND= +while (($#)); do + case "$1" in + --file) + if (($# < 2)) || [[ -z "$2" || -n "$FILE" ]]; then usage; fi + FILE=$2 + shift 2 + ;; + --kind) + if (($# < 2)) || [[ -z "$2" || -n "$KIND" ]]; then usage; fi + KIND=$2 + shift 2 + ;; + *) usage ;; + esac +done + +[[ -n "$FILE" && -n "$KIND" ]] || usage +[[ "$KIND" == local || "$KIND" == pin ]] || usage +[[ "$FILE" == /* && -f "$FILE" && ! -L "$FILE" ]] || + die "completion marker must be an absolute, regular non-symlink file" +CANONICAL=$(cd -- "$(dirname -- "$FILE")" && pwd -P)/$(basename -- "$FILE") || + die "cannot resolve completion marker" +[[ "$CANONICAL" == "$FILE" ]] || die "completion marker path must already be canonical" +SIZE=$(wc -c <"$FILE" | tr -d '[:space:]') || die "cannot size completion marker" +[[ "$SIZE" =~ ^[0-9]+$ && "$SIZE" -gt 0 && "$SIZE" -le 128 ]] || + die "completion marker is empty or oversized" + +if cmp -s <(printf 'kind=%s\nmode=ordinary\nbranch=relevant\n' "$KIND") "$FILE" || + cmp -s <(printf 'kind=%s\nmode=ordinary\nbranch=not-applicable\n' "$KIND") "$FILE" || + cmp -s <(printf 'kind=%s\nmode=gate-update\nbranch=gate-update\n' "$KIND") "$FILE" || + cmp -s <(printf 'kind=%s\nmode=gate-rollback\nbranch=gate-rollback\n' "$KIND") "$FILE"; then + : +else + die "completion marker is missing, duplicated, malformed, or contradictory" +fi diff --git a/.github/docker/build-app-cli/assert-build-container-context.sh b/.github/docker/build-app-cli/assert-build-container-context.sh new file mode 100755 index 00000000..966d9d05 --- /dev/null +++ b/.github/docker/build-app-cli/assert-build-container-context.sh @@ -0,0 +1,185 @@ +#!/usr/bin/env bash +set -euo pipefail + +die() { + printf 'assert-build-container-context: %s\n' "$*" >&2 + exit 1 +} + +context= +while (($#)); do + (($# >= 2)) || die "missing value for $1" + case "$1" in + --context) + [[ -z "$context" ]] || die "duplicate --context" + context=$2 + ;; + *) die "unknown argument: $1" ;; + esac + shift 2 +done + +[[ -n "$context" ]] || die "--context is required" +context=$(cd -- "$context" 2>/dev/null && pwd -P) || die "context is not a directory" + +manifest=.github/docker/build-app-cli/image-context-paths.txt +dockerfile=.github/docker/build-app-cli/Dockerfile +validator=.github/tools/edgezero-provenance-validator +[[ -f "$context/$manifest" && ! -L "$context/$manifest" ]] || die "context manifest is not regular" +[[ -f "$context/$dockerfile" && ! -L "$context/$dockerfile" ]] || die "Dockerfile is not regular" + +valid_path() { + local path=$1 component + [[ -n "$path" && "$path" != /* && "$path" != */ && "$path" != *//* && "$path" != *\\* ]] || + return 1 + while IFS= read -r component; do + [[ -n "$component" && "$component" != "." && "$component" != ".." ]] || return 1 + done < <(printf '%s' "$path" | tr '/' '\n') +} + +previous='' +while IFS= read -r path; do + valid_path "$path" || die "unsafe manifest path" + [[ -z "$previous" || "$previous" < "$path" ]] || die "manifest is not sorted and unique" + previous=$path +done <"$context/$manifest" +[[ -n "$previous" ]] || die "manifest is empty" +[[ "$(tail -c 1 "$context/$manifest" | wc -l | tr -d ' ')" == "1" ]] || + die "manifest lacks one final newline" + +expected=$(mktemp "${TMPDIR:-/tmp}/edgezero-image-inputs.XXXXXX") +actual=$(mktemp "${TMPDIR:-/tmp}/edgezero-image-files.XXXXXX") +metadata=$(mktemp "${TMPDIR:-/tmp}/edgezero-image-metadata.XXXXXX") +trap 'rm -f "$expected" "$actual" "$metadata"' EXIT + +{ + printf '%s\n' \ + .dockerignore \ + .github/actions/deploy-fastly/versions.json \ + .github/docker/build-app-cli/Dockerfile \ + .github/docker/build-app-cli/fixtures/gnu-smoke.rs \ + .github/docker/build-app-cli/fixtures/wasm-smoke.rs \ + .github/docker/build-app-cli/image-context-paths.txt \ + .github/docker/build-app-cli/provenance.schema.json \ + .github/docker/build-app-cli/verify-toolchain.sh \ + .tool-versions + find "$context/.github/docker/build-app-cli/fixtures/provenance" \ + "$context/$validator" -type f -print | sed "s#^$context/##" +} | LC_ALL=C sort >"$expected" +cmp -s "$expected" "$context/$manifest" || die "manifest does not close over exact image inputs" + +find "$context" \( -type f -o -type l \) -print | sed "s#^$context/##" | LC_ALL=C sort >"$actual" +cmp -s "$actual" "$context/$manifest" || die "context inventory differs from manifest" + +link_count() { + if stat -c '%h' -- "$1" >/dev/null 2>&1; then + stat -c '%h' -- "$1" + else + stat -f '%l' -- "$1" + fi +} + +while IFS= read -r path; do + [[ -f "$context/$path" && ! -L "$context/$path" ]] || die "$path is not a regular file" + [[ "$(link_count "$context/$path")" == "1" ]] || die "$path has multiple links" +done <"$context/$manifest" +[[ -x "$context/.github/docker/build-app-cli/verify-toolchain.sh" ]] || + die "verify-toolchain.sh is not executable" + +cargo metadata --locked --manifest-path "$context/$validator/Cargo.toml" --format-version 1 \ + >"$metadata" +validator_root=$(cd -- "$context/$validator" && pwd -P) +jq -e --arg root "$validator_root" ' + .workspace_root == $root and + (.workspace_members | length >= 1) and + ([.packages[] | select(.source == null) | .manifest_path | + startswith($root + "/") or . == ($root + "/Cargo.toml")] | all) +' "$metadata" >/dev/null || die "validator workspace or path dependency escapes its directory" + +df="$context/$dockerfile" +! grep -Eq '^#[[:space:]]*syntax=' "$df" || die "external Dockerfile frontend is forbidden" +base='FROM docker.io/library/rust@sha256:6f9e63259f12e1e599296f5ecfed2bae46de4af0ee0525dd8b89c046e236d5c5' +[[ "$(grep -Fxc "$base AS builder" "$df")" == "1" ]] || die "builder base is not exact" +[[ "$(grep -Fxc "$base AS runtime" "$df")" == "1" ]] || die "runtime base is not exact" +[[ "$(grep -Ec '^[[:space:]]*FROM[[:space:]]' "$df")" == "2" ]] || die "unexpected FROM instruction" +grep -Fqx "RUN test \"\${#IMAGE_SOURCE_REVISION}\" -eq 40 \\" "$df" || + die "source revision length guard is missing" +grep -Fqx " && case \"\$IMAGE_SOURCE_REVISION\" in *[!0-9a-f]*) exit 1 ;; esac" "$df" || + die "source revision alphabet guard is missing" +! grep -Eq '^[[:space:]]*ADD([[:space:]]|$)' "$df" || die "ADD is forbidden" +! grep -Eq '^[[:space:]]*RUN[[:space:]]+--mount=.*type=bind' "$df" || + die "build-context bind mounts are forbidden" +! grep -Eq '^[[:space:]]*COPY[[:space:]]+(--[^[:space:]]+[[:space:]]+)*\.[[:space:]]' "$df" || + die "broad context COPY is forbidden" +! grep -Eq '^[[:space:]]*ONBUILD([[:space:]]|$)' "$df" || die "ONBUILD is forbidden" + +build='RUN cargo build --locked --release --manifest-path .github/tools/edgezero-provenance-validator/Cargo.toml' +[[ "$(grep -Fxc "$build" "$df")" == "1" ]] || die "validator must have one exact build invocation" +[[ "$(grep -Foc 'cargo build' "$df")" == "1" ]] || die "unexpected cargo build invocation" + +expected_copies=$(cat <<'EOF' +COPY .tool-versions /edgezero-input/.tool-versions +COPY .github/actions/deploy-fastly/versions.json /edgezero-input/fastly-versions.json +COPY .github/tools/edgezero-provenance-validator/Cargo.toml .github/tools/edgezero-provenance-validator/Cargo.lock .github/tools/edgezero-provenance-validator/ +COPY .github/tools/edgezero-provenance-validator/src .github/tools/edgezero-provenance-validator/src +COPY .github/tools/edgezero-provenance-validator/tests .github/tools/edgezero-provenance-validator/tests +COPY --from=builder /usr/local/bin/fastly /usr/local/bin/fastly +COPY --from=builder /usr/local/bin/sccache /usr/local/bin/sccache +COPY --from=builder /build/.github/tools/edgezero-provenance-validator/target/release/edgezero-provenance-validator /usr/local/bin/edgezero-provenance-validator +COPY --from=builder /usr/local/rustup/toolchains/1.95.0-x86_64-unknown-linux-gnu/lib/rustlib/components /usr/local/rustup/toolchains/1.95.0-x86_64-unknown-linux-gnu/lib/rustlib/components +COPY --from=builder /usr/local/rustup/toolchains/1.95.0-x86_64-unknown-linux-gnu/lib/rustlib/manifest-rust-std-wasm32-wasip1 /usr/local/rustup/toolchains/1.95.0-x86_64-unknown-linux-gnu/lib/rustlib/manifest-rust-std-wasm32-wasip1 +COPY --from=builder /usr/local/rustup/toolchains/1.95.0-x86_64-unknown-linux-gnu/lib/rustlib/multirust-config.toml /usr/local/rustup/toolchains/1.95.0-x86_64-unknown-linux-gnu/lib/rustlib/multirust-config.toml +COPY --from=builder /usr/local/rustup/toolchains/1.95.0-x86_64-unknown-linux-gnu/lib/rustlib/wasm32-wasip1 /usr/local/rustup/toolchains/1.95.0-x86_64-unknown-linux-gnu/lib/rustlib/wasm32-wasip1 +COPY .tool-versions /usr/local/share/edgezero/.tool-versions +COPY .github/actions/deploy-fastly/versions.json /usr/local/share/edgezero/fastly-versions.json +COPY .github/docker/build-app-cli/provenance.schema.json /usr/local/share/edgezero/provenance.schema.json +COPY .github/docker/build-app-cli/fixtures/provenance /usr/local/share/edgezero/provenance-fixtures +COPY .github/docker/build-app-cli/fixtures/gnu-smoke.rs /usr/local/share/edgezero/gnu-smoke.rs +COPY .github/docker/build-app-cli/fixtures/wasm-smoke.rs /usr/local/share/edgezero/wasm-smoke.rs +COPY .github/docker/build-app-cli/verify-toolchain.sh /usr/local/bin/verify-toolchain +EOF +) +actual_copies=$(grep -E '^[[:space:]]*COPY([[:space:]]|$)' "$df") +[[ "$actual_copies" == "$expected_copies" ]] || die "Dockerfile COPY sequence differs" + +final_copy_line=$(grep -Fn 'COPY --from=builder /usr/local/bin/fastly /usr/local/bin/fastly' "$df" | cut -d: -f1) +! tail -n "+$final_copy_line" "$df" | grep -Eq '^[[:space:]]*RUN([[:space:]]|$)' || + die "a command can replace final-stage installed assets" + +grep -Fq 'sccache_sha="1fbb35e135660d04a2d5e42b59c7874d39b3deb17de56330b25b713ec59f849b"' "$df" || + die "sccache checksum is not exact" +grep -Fq 'asset="sccache-v0.10.0-x86_64-unknown-linux-musl.tar.gz"' "$df" || + die "sccache asset is not exact" +# shellcheck disable=SC2016 # Match the literal Dockerfile shell variables. +grep -Fq 'curl --fail --location --silent --show-error "$base/$asset.sha256"' "$df" || + die "sccache checksum companion is not downloaded" +grep -Fq '3ba3d8a739b7a88d0a612825a9755d735efb87a9b02ea67e53a11b96d178d500' \ + "$context/.github/actions/deploy-fastly/versions.json" || die "Fastly checksum is not exact" + +for required in \ + 'COPY --from=builder /usr/local/bin/fastly /usr/local/bin/fastly' \ + 'COPY --from=builder /usr/local/bin/sccache /usr/local/bin/sccache' \ + 'COPY --from=builder /build/.github/tools/edgezero-provenance-validator/target/release/edgezero-provenance-validator /usr/local/bin/edgezero-provenance-validator' \ + 'COPY --from=builder /usr/local/rustup/toolchains/1.95.0-x86_64-unknown-linux-gnu/lib/rustlib/components /usr/local/rustup/toolchains/1.95.0-x86_64-unknown-linux-gnu/lib/rustlib/components' \ + 'COPY --from=builder /usr/local/rustup/toolchains/1.95.0-x86_64-unknown-linux-gnu/lib/rustlib/manifest-rust-std-wasm32-wasip1 /usr/local/rustup/toolchains/1.95.0-x86_64-unknown-linux-gnu/lib/rustlib/manifest-rust-std-wasm32-wasip1' \ + 'COPY --from=builder /usr/local/rustup/toolchains/1.95.0-x86_64-unknown-linux-gnu/lib/rustlib/multirust-config.toml /usr/local/rustup/toolchains/1.95.0-x86_64-unknown-linux-gnu/lib/rustlib/multirust-config.toml' \ + 'COPY .github/docker/build-app-cli/provenance.schema.json /usr/local/share/edgezero/provenance.schema.json' \ + 'COPY .github/docker/build-app-cli/fixtures/provenance /usr/local/share/edgezero/provenance-fixtures' \ + 'COPY .github/docker/build-app-cli/fixtures/gnu-smoke.rs /usr/local/share/edgezero/gnu-smoke.rs' \ + 'COPY .github/docker/build-app-cli/fixtures/wasm-smoke.rs /usr/local/share/edgezero/wasm-smoke.rs' \ + 'COPY .github/docker/build-app-cli/verify-toolchain.sh /usr/local/bin/verify-toolchain' \ + 'USER 1001:1001' \ + 'ENTRYPOINT ["/usr/bin/env"]'; do + [[ "$(grep -Fxc "$required" "$df")" == "1" ]] || die "missing exact Dockerfile contract: $required" +done + +user_line=$(grep -Fn 'USER 1001:1001' "$df" | cut -d: -f1) +! tail -n "+$((user_line + 1))" "$df" | grep -Eq '^[[:space:]]*(RUN|COPY|ADD)([[:space:]]|$)' || + die "installed image content are replaced after final USER" + +jq -e ' + .fastly.version == "15.1.0" and + .fastly.linux_amd64.url == "https://github.com/fastly/cli/releases/download/v15.1.0/fastly_v15.1.0_linux-amd64.tar.gz" and + .fastly.linux_amd64.sha256 == "3ba3d8a739b7a88d0a612825a9755d735efb87a9b02ea67e53a11b96d178d500" and + .rust_target == "wasm32-wasip1" +' "$context/.github/actions/deploy-fastly/versions.json" >/dev/null || die "Fastly metadata differs" diff --git a/.github/docker/build-app-cli/assert-build-container-dispatch-context.sh b/.github/docker/build-app-cli/assert-build-container-dispatch-context.sh new file mode 100755 index 00000000..9b9dd9e8 --- /dev/null +++ b/.github/docker/build-app-cli/assert-build-container-dispatch-context.sh @@ -0,0 +1,224 @@ +#!/usr/bin/env bash +set -euo pipefail + +export LC_ALL=C +export BASH_ENV= +export ENV= +export GIT_CONFIG_NOSYSTEM=1 +export GIT_CONFIG_GLOBAL=/dev/null +export GIT_NO_REPLACE_OBJECTS=1 +unset GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE GIT_OBJECT_DIRECTORY +unset GIT_ALTERNATE_OBJECT_DIRECTORIES GIT_COMMON_DIR GIT_CEILING_DIRECTORIES +unset GIT_REPLACE_REF_BASE + +readonly EXPECTED_REPOSITORY=stackpop/edgezero +readonly EXPECTED_WORKFLOW_REF=stackpop/edgezero/.github/workflows/build-container-ci.yml@refs/heads/main +readonly GATE_MANIFEST=.github/docker/build-app-cli/gate-paths.txt +readonly IMAGE_MANIFEST=.github/docker/build-app-cli/image-context-paths.txt +readonly CODEOWNERS=.github/CODEOWNERS +readonly GATE_TEAM=@stackpop/edgezero-build-container-gate-reviewers +readonly MAX_MANIFEST_BYTES=65536 + +usage() { + printf 'usage: assert-build-container-dispatch-context.sh --gate-root \n' >&2 + exit 2 +} + +die() { + printf '::error::%s\n' "$*" >&2 + exit 1 +} + +is_sha() { + [[ "$1" =~ ^[0-9a-f]{40}$ && "$1" != 0000000000000000000000000000000000000000 ]] +} + +gate_git() { + env -i PATH="$PATH" LC_ALL=C HOME=/dev/null TMPDIR=/tmp \ + GIT_CONFIG_NOSYSTEM=1 GIT_CONFIG_GLOBAL=/dev/null GIT_TERMINAL_PROMPT=0 \ + GIT_NO_REPLACE_OBJECTS=1 GIT_OPTIONAL_LOCKS=0 \ + git --no-replace-objects -c core.fsmonitor=false -c core.hooksPath=/dev/null \ + -C "$GATE_ROOT" "$@" +} + +valid_path() { + local path=$1 + [[ -n "$path" && "$path" =~ ^[A-Za-z0-9._/+-]+$ && "$path" != /* && + "$path" != -* && "$path" != */ && "$path" != . && "$path" != .. && + "$path" != ../* && "$path" != */../* && "$path" != */.. && + "$path" != */./* && "$path" != */. && "$path" != *//* && "$path" != *\\* ]] +} + +tree_entry() { + local revision=$1 path=$2 entry recorded metadata mode type object + entry=$(gate_git ls-tree "$revision" -- "$path") || die "cannot inspect manifested path: $path" + [[ -n "$entry" && "$entry" != *$'\n'* && "$entry" == *$'\t'* ]] || + die "manifested path is missing or ambiguous: $path" + recorded=${entry#*$'\t'} + metadata=${entry%%$'\t'*} + read -r mode type object <<<"$metadata" + [[ "$recorded" == "$path" && "$type" == blob && + ("$mode" == 100644 || "$mode" == 100755) && "$object" =~ ^[0-9a-f]{40,64}$ ]] || + die "manifested path is not a regular Git blob: $path" + printf '%s' "$entry" +} + +extract_manifest() { + local path=$1 output=$2 label=$3 size last previous current + previous= + tree_entry "$EDGEZERO_GATE_SHA" "$path" >/dev/null + gate_git show "$EDGEZERO_GATE_SHA:$path" >"$output" || die "cannot read $label" + size=$(wc -c <"$output" | tr -d '[:space:]') || die "cannot size $label" + [[ "$size" =~ ^[0-9]+$ && "$size" -gt 0 && "$size" -le "$MAX_MANIFEST_BYTES" ]] || + die "$label is empty or oversized" + last=$(tail -c 1 "$output" | od -An -tx1 | tr -d '[:space:]') || + die "cannot inspect $label terminator" + [[ "$last" == 0a ]] || die "$label must be LF-terminated" + while IFS= read -r current; do + valid_path "$current" || die "$label contains an invalid path" + [[ -z "$previous" || "$previous" < "$current" ]] || + die "$label must be byte-sorted and unique" + previous=$current + done <"$output" +} + +contains_path() { + grep -Fqx -e "$1" "$2" +} + +compare_manifested_tree() { + local manifest=$1 path gate_entry snapshot_entry + while IFS= read -r path; do + gate_entry=$(tree_entry "$EDGEZERO_GATE_SHA" "$path") + snapshot_entry=$(tree_entry "$EDGEZERO_SHA" "$path") + [[ "$snapshot_entry" == "$gate_entry" ]] || + die "protected snapshot differs from active gate at $path" + done <"$manifest" +} + +[[ "$#" -eq 2 && "$1" == --gate-root && -n "$2" ]] || usage +GATE_ROOT=$2 +[[ "$GATE_ROOT" == /* && -d "$GATE_ROOT" && ! -L "$GATE_ROOT" ]] || + die "gate root must be an absolute, non-symlink directory" +CANONICAL_ROOT=$(cd -- "$GATE_ROOT" && pwd -P) || die "cannot resolve gate root" +[[ "$CANONICAL_ROOT" == "$GATE_ROOT" ]] || die "gate root must already be canonical" + +for name in GITHUB_TOKEN EDGEZERO_EVENT_NAME EDGEZERO_REPOSITORY EDGEZERO_REF \ + EDGEZERO_REF_PROTECTED EDGEZERO_SHA EDGEZERO_WORKFLOW_SHA EDGEZERO_WORKFLOW_REF \ + EDGEZERO_GATE_SHA EDGEZERO_RELEASE_STATE EDGEZERO_CANDIDATE_PR_NUMBER \ + EDGEZERO_CANDIDATE_HEAD_REPOSITORY \ + EDGEZERO_CANDIDATE_HEAD_SHA; do + [[ -n "${!name:-}" ]] || die "required context is absent: $name" +done +[[ "$GITHUB_TOKEN" != *$'\n'* && "$GITHUB_TOKEN" != *$'\r'* && + "$GITHUB_TOKEN" != *'"'* && "$GITHUB_TOKEN" != *\\* ]] || + die "GitHub token cannot be encoded safely" +[[ "$EDGEZERO_EVENT_NAME" == workflow_dispatch ]] || die "event must be workflow_dispatch" +[[ "$EDGEZERO_REPOSITORY" == "$EXPECTED_REPOSITORY" ]] || die "repository identity differs" +[[ "$EDGEZERO_REF" == refs/heads/main && "$EDGEZERO_REF_PROTECTED" == true ]] || + die "dispatch must use protected main" +[[ "$EDGEZERO_WORKFLOW_REF" == "$EXPECTED_WORKFLOW_REF" ]] || die "workflow ref differs" +[[ "$EDGEZERO_RELEASE_STATE" == enabled ]] || die "release state must be enabled" +if ! is_sha "$EDGEZERO_SHA" || ! is_sha "$EDGEZERO_WORKFLOW_SHA" || + ! is_sha "$EDGEZERO_GATE_SHA"; then + die "workflow and gate identities must be full nonzero SHAs" +fi +[[ "$EDGEZERO_SHA" == "$EDGEZERO_WORKFLOW_SHA" ]] || die "dispatch snapshot identities differ" +[[ "$EDGEZERO_CANDIDATE_PR_NUMBER" =~ ^[1-9][0-9]*$ ]] || + die "candidate pull request number is not canonical" +[[ "$EDGEZERO_CANDIDATE_HEAD_REPOSITORY" == "$EXPECTED_REPOSITORY" ]] || + die "candidate head repository must be exact" +is_sha "$EDGEZERO_CANDIDATE_HEAD_SHA" || die "candidate head SHA is invalid" + +[[ "$(gate_git rev-parse --is-inside-work-tree 2>/dev/null)" == true ]] || + die "gate root is not a Git worktree" +[[ "$(gate_git rev-parse --show-toplevel 2>/dev/null)" == "$GATE_ROOT" ]] || + die "gate root must be the exact repository top level" +GIT_DIRECTORY=$(gate_git rev-parse --absolute-git-dir 2>/dev/null) || + die "cannot resolve gate Git directory" +COMMON_GIT_DIRECTORY=$(gate_git rev-parse --path-format=absolute --git-common-dir 2>/dev/null) || + die "cannot resolve gate common Git directory" +[[ ! -e "$GIT_DIRECTORY/info/grafts" && ! -L "$GIT_DIRECTORY/info/grafts" && + ! -e "$COMMON_GIT_DIRECTORY/info/grafts" && ! -L "$COMMON_GIT_DIRECTORY/info/grafts" ]] || + die "gate checkout cannot contain legacy grafts" +[[ -z "$(gate_git for-each-ref --format='%(refname)' refs/replace/)" ]] || + die "gate checkout cannot contain replacement refs" +[[ "$(gate_git rev-parse --is-shallow-repository 2>/dev/null)" == false ]] || + die "gate checkout must contain full history" +[[ "$(gate_git config --bool core.sparseCheckout 2>/dev/null || true)" != true ]] || + die "gate checkout cannot be sparse" +[[ -z "$(gate_git status --porcelain=v1 --untracked-files=all)" ]] || + die "gate checkout must be clean" +[[ "$(gate_git cat-file -t "$EDGEZERO_GATE_SHA" 2>/dev/null)" == commit ]] || + die "active gate commit is not a commit" +[[ "$(gate_git cat-file -t "$EDGEZERO_SHA" 2>/dev/null)" == commit ]] || + die "dispatch snapshot is not a commit" +[[ "$(gate_git rev-parse --verify HEAD 2>/dev/null)" == "$EDGEZERO_GATE_SHA" ]] || + die "gate checkout HEAD differs from active gate" +gate_git merge-base --is-ancestor "$EDGEZERO_GATE_SHA" "$EDGEZERO_SHA" || + die "active gate is not an ancestor of dispatch snapshot" +SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P) +[[ "$SCRIPT_DIR/assert-build-container-dispatch-context.sh" == "$GATE_ROOT/.github/docker/build-app-cli/assert-build-container-dispatch-context.sh" ]] || + die "dispatch helper must execute from the active gate checkout" + +WORK_DIR=$(mktemp -d /tmp/edgezero-dispatch-context.XXXXXX) +trap 'rm -rf -- "$WORK_DIR"' EXIT HUP INT TERM +GATE_PATHS="$WORK_DIR/gate-paths.txt" +IMAGE_PATHS="$WORK_DIR/image-context-paths.txt" +extract_manifest "$GATE_MANIFEST" "$GATE_PATHS" "gate manifest" +extract_manifest "$IMAGE_MANIFEST" "$IMAGE_PATHS" "image-context manifest" +contains_path "$GATE_MANIFEST" "$GATE_PATHS" || die "gate manifest must contain itself" +contains_path "$IMAGE_MANIFEST" "$GATE_PATHS" || die "gate manifest omits image manifest" +contains_path "$CODEOWNERS" "$GATE_PATHS" || die "gate manifest omits CODEOWNERS" +contains_path "$IMAGE_MANIFEST" "$IMAGE_PATHS" || die "image manifest must contain itself" +while IFS= read -r path; do + contains_path "$path" "$GATE_PATHS" || die "image path is not gate-owned: $path" +done <"$IMAGE_PATHS" +IMAGE_COUNT=$(wc -l <"$IMAGE_PATHS" | tr -d '[:space:]') +GATE_COUNT=$(wc -l <"$GATE_PATHS" | tr -d '[:space:]') +[[ "$IMAGE_COUNT" -lt "$GATE_COUNT" ]] || + die "image manifest must be a strict gate-manifest subset" + +EXPECTED_CODEOWNERS="$WORK_DIR/CODEOWNERS" +: >"$EXPECTED_CODEOWNERS" +while IFS= read -r path; do + printf '/%s %s\n' "$path" "$GATE_TEAM" >>"$EXPECTED_CODEOWNERS" +done <"$GATE_PATHS" +gate_git show "$EDGEZERO_GATE_SHA:$CODEOWNERS" >"$WORK_DIR/actual-CODEOWNERS" || + die "cannot read CODEOWNERS" +cmp -s "$EXPECTED_CODEOWNERS" "$WORK_DIR/actual-CODEOWNERS" || + die "CODEOWNERS does not exactly protect every gate path" +compare_manifested_tree "$GATE_PATHS" + +CONFIG="$WORK_DIR/curl.config" +printf '%s\n' \ + 'header = "Accept: application/vnd.github+json"' \ + 'header = "X-GitHub-Api-Version: 2026-03-10"' \ + 'header = "User-Agent: edgezero-build-container-gate/1"' \ + "header = \"Authorization: Bearer $GITHUB_TOKEN\"" \ + >"$CONFIG" +REPLY="$WORK_DIR/reply" +env -i PATH="$PATH" LC_ALL=C curl \ + --disable --silent --show-error --connect-timeout 10 --max-time 30 --max-redirs 0 \ + --request GET --config - \ + --write-out $'\n%{http_code}\n%header{x-github-api-version-selected}\n%header{content-type}' \ + "https://api.github.com/repos/stackpop/edgezero/pulls/$EDGEZERO_CANDIDATE_PR_NUMBER" \ + <"$CONFIG" >"$REPLY" || die "candidate pull request lookup failed" + +STATUS=$(tail -n 3 "$REPLY" | sed -n '1p') +SELECTED_VERSION=$(tail -n 3 "$REPLY" | sed -n '2p') +CONTENT_TYPE=$(tail -n 3 "$REPLY" | sed -n '3p') +[[ "$STATUS" == 200 ]] || die "candidate pull request lookup returned HTTP $STATUS" +[[ "$SELECTED_VERSION" == 2026-03-10 ]] || die "GitHub selected an unexpected API version" +[[ "$CONTENT_TYPE" =~ ^[Aa][Pp][Pp][Ll][Ii][Cc][Aa][Tt][Ii][Oo][Nn]/[Jj][Ss][Oo][Nn]([[:space:]]*\;[[:space:]]*[Cc][Hh][Aa][Rr][Ss][Ee][Tt][[:space:]]*=[[:space:]]*[Uu][Tt][Ff]-8)?$ ]] || + die "GitHub returned an unsupported media type" +sed '$d' "$REPLY" | sed '$d' | sed '$d' >"$WORK_DIR/body" +jq -e \ + --argjson number "$EDGEZERO_CANDIDATE_PR_NUMBER" \ + --arg repository "$EDGEZERO_CANDIDATE_HEAD_REPOSITORY" \ + --arg head "$EDGEZERO_CANDIDATE_HEAD_SHA" ' + type == "object" and + .number == $number and .state == "open" and .merged == false and + .base.ref == "main" and .base.repo.full_name == "stackpop/edgezero" and + .head.repo.full_name == $repository and .head.sha == $head + ' "$WORK_DIR/body" >/dev/null 2>&1 || die "candidate pull request identity differs from inputs" diff --git a/.github/docker/build-app-cli/check-build-container-publisher.sh b/.github/docker/build-app-cli/check-build-container-publisher.sh new file mode 100755 index 00000000..9bffd777 --- /dev/null +++ b/.github/docker/build-app-cli/check-build-container-publisher.sh @@ -0,0 +1,365 @@ +#!/usr/bin/env bash +set +x +set +a +set -euo pipefail + +export LC_ALL=C +export BASH_ENV= +export ENV= +unset GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE GIT_OBJECT_DIRECTORY +unset GIT_ALTERNATE_OBJECT_DIRECTORIES GIT_COMMON_DIR GIT_CEILING_DIRECTORIES + +readonly PUBLISH=.github/workflows/publish-build-container.yml +readonly ROTATE=.github/workflows/rotate-build-container-gate.yml +readonly GROUP=edgezero-build-container-publication +readonly ZERO_SHA=0000000000000000000000000000000000000000 + +usage() { + printf '%s\n' \ + "usage: check-build-container-publisher.sh \\" \ + " --gate-root \\" \ + " --subject-root \\" \ + " --gate-sha \\" \ + ' --candidate-sha ' >&2 + exit 2 +} + +die() { + printf '::error::%s\n' "$*" >&2 + exit 1 +} + +tool_die() { + printf '::error::%s\n' "$*" >&2 + exit 2 +} + +is_sha() { + [[ "$1" =~ ^[0-9a-f]{40}$ && "$1" != "$ZERO_SHA" ]] +} + +repo_git() { + local root=$1 + shift + env -i PATH="$PATH" LC_ALL=C HOME=/dev/null TMPDIR=/tmp \ + GIT_CONFIG_NOSYSTEM=1 GIT_CONFIG_GLOBAL=/dev/null GIT_TERMINAL_PROMPT=0 \ + GIT_NO_REPLACE_OBJECTS=1 GIT_NO_LAZY_FETCH=1 GIT_OPTIONAL_LOCKS=0 \ + git --no-replace-objects -c core.fsmonitor=false -c core.hooksPath=/dev/null \ + -C "$root" "$@" +} + +canonical_root() { + local supplied=$1 label=$2 canonical + [[ "$supplied" == /* && -d "$supplied" && ! -L "$supplied" ]] || + die "$label must be an absolute non-symlink directory" + canonical=$(cd -- "$supplied" && pwd -P) || die "cannot resolve $label" + [[ "$canonical" == "$supplied" ]] || die "$label must already be canonical" + printf '%s' "$canonical" +} + +require_repository() { + local root=$1 revision=$2 label=$3 common_name=$4 objects_name=$5 + local top git_dir common shallow sparse status partial alternates objects physical + [[ "$(repo_git "$root" rev-parse --is-inside-work-tree 2>/dev/null)" == true ]] || + die "$label is not a Git worktree" + top=$(repo_git "$root" rev-parse --show-toplevel 2>/dev/null) || die "cannot resolve $label top" + [[ "$top" == "$root" ]] || die "$label must be the exact repository top level" + git_dir=$(repo_git "$root" rev-parse --absolute-git-dir 2>/dev/null) || die "cannot resolve $label Git directory" + common=$(repo_git "$root" rev-parse --path-format=absolute --git-common-dir 2>/dev/null) || + die "cannot resolve $label common directory" + objects=$(repo_git "$root" rev-parse --path-format=absolute --git-path objects 2>/dev/null) || + die "cannot resolve $label object directory" + [[ "$common" == /* && -d "$common" && ! -L "$common" ]] || + die "$label common directory must be an absolute non-symlink directory" + physical=$(cd -- "$common" && pwd -P) || die "cannot canonicalize $label common directory" + [[ "$physical" == "$common" ]] || die "$label common directory must already be canonical" + common=$physical + [[ "$objects" == /* && -d "$objects" && ! -L "$objects" ]] || + die "$label object directory must be an absolute non-symlink directory" + physical=$(cd -- "$objects" && pwd -P) || die "cannot canonicalize $label object directory" + [[ "$physical" == "$objects" ]] || die "$label object directory must already be canonical" + objects=$physical + [[ ! -e "$git_dir/info/grafts" && ! -L "$git_dir/info/grafts" && + ! -e "$common/info/grafts" && ! -L "$common/info/grafts" ]] || die "$label has grafts" + [[ -z "$(repo_git "$root" for-each-ref --format='%(refname)' refs/replace/ 2>/dev/null)" ]] || + die "$label has replacement refs" + shallow=$(repo_git "$root" rev-parse --is-shallow-repository 2>/dev/null) || + die "cannot inspect $label history" + [[ "$shallow" == false ]] || die "$label must be full" + sparse=$(repo_git "$root" config --bool core.sparseCheckout 2>/dev/null || true) + [[ "$sparse" != true ]] || die "$label cannot be sparse" + partial=$(repo_git "$root" config --local --get-regexp \ + '^(extensions\.partialClone|remote\..*\.promisor|remote\..*\.partialclonefilter)$' 2>/dev/null || true) + [[ -z "$partial" ]] || die "$label cannot be partial or promisor" + alternates="$objects/info/alternates" + [[ ! -e "$alternates" && ! -L "$alternates" ]] || die "$label cannot use object alternates" + [[ "$(repo_git "$root" rev-parse --verify HEAD 2>/dev/null)" == "$revision" ]] || + die "$label HEAD differs from its supplied revision" + if repo_git "$root" symbolic-ref -q HEAD >/dev/null 2>&1; then + die "$label must be detached" + fi + status=$(repo_git "$root" status --porcelain=v1 --untracked-files=all --ignore-submodules=none) || + die "cannot inspect $label status" + [[ -z "$status" ]] || die "$label must be clean" + printf -v "$common_name" '%s' "$common" + printf -v "$objects_name" '%s' "$objects" +} + +GATE_ROOT= +SUBJECT_ROOT= +GATE_SHA= +CANDIDATE_SHA= +seen=' ' +while (($#)); do + (($# >= 2)) || usage + flag=$1 + value=$2 + shift 2 + case "$flag" in + --gate-root | --subject-root | --gate-sha | --candidate-sha) ;; + *) usage ;; + esac + [[ -n "$value" && "$seen" != *" $flag "* ]] || usage + seen+="$flag " + case "$flag" in + --gate-root) GATE_ROOT=$value ;; + --subject-root) SUBJECT_ROOT=$value ;; + --gate-sha) GATE_SHA=$value ;; + --candidate-sha) CANDIDATE_SHA=$value ;; + esac +done +for required in --gate-root --subject-root --gate-sha --candidate-sha; do + [[ "$seen" == *" $required "* ]] || usage +done + +is_sha "$GATE_SHA" || die "gate SHA is not a full nonzero lowercase SHA" +is_sha "$CANDIDATE_SHA" || die "candidate SHA is not a full nonzero lowercase SHA" +for tool in env git jq yq mktemp rm cp chmod awk grep tr; do + command -v "$tool" >/dev/null 2>&1 || tool_die "publisher checker requires $tool" +done +[[ "$(yq --version 2>&1)" == 'yq (https://github.com/mikefarah/yq/) version v4.53.3' ]] || + tool_die "publisher checker requires mikefarah yq v4.53.3" + +GATE_ROOT=$(canonical_root "$GATE_ROOT" 'gate root') +SUBJECT_ROOT=$(canonical_root "$SUBJECT_ROOT" 'subject root') +[[ "$GATE_ROOT" != "$SUBJECT_ROOT" ]] || die "gate and subject roots must differ" +require_repository "$GATE_ROOT" "$GATE_SHA" gate GATE_COMMON GATE_OBJECTS +require_repository "$SUBJECT_ROOT" "$CANDIDATE_SHA" subject SUBJECT_COMMON SUBJECT_OBJECTS +[[ "$GATE_COMMON" != "$SUBJECT_COMMON" && "$GATE_OBJECTS" != "$SUBJECT_OBJECTS" ]] || + die "gate and subject repositories must have separate object storage" + +SCRIPT=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)/check-build-container-publisher.sh +[[ "$SCRIPT" == "$GATE_ROOT/.github/docker/build-app-cli/check-build-container-publisher.sh" ]] || + die "publisher checker must execute from the canonical gate checkout" + +WORK=$(mktemp -d "${TMPDIR:-/tmp}/edgezero-publisher-check.XXXXXX") || + tool_die "cannot create publisher checker workspace" +cleanup() { + local status=$? + trap - EXIT HUP INT TERM + rm -rf -- "$WORK" + exit "$status" +} +trap cleanup EXIT HUP INT TERM + +WORKFLOW_LIST="$WORK/workflows" +repo_git "$SUBJECT_ROOT" ls-tree -r --name-only "$CANDIDATE_SHA" -- .github/workflows >"$WORKFLOW_LIST" || + die "cannot enumerate candidate workflows" + +PUBLISH_FILE= +ROTATE_FILE= +workflow_count=0 +while IFS= read -r path; do + [[ "$path" =~ ^\.github/workflows/[A-Za-z0-9._+-]+\.(yml|yaml)$ ]] || + die "candidate workflow path is not canonical" + entry=$(repo_git "$SUBJECT_ROOT" ls-tree "$CANDIDATE_SHA" -- "$path") || + die "cannot inspect candidate workflow" + [[ "$entry" != *$'\n'* && "$entry" == 100644\ blob\ *$'\t'"$path" ]] || + die "candidate workflow is not one mode-0644 Git blob" + output="$WORK/workflow-$workflow_count.yml" + repo_git "$SUBJECT_ROOT" show "$CANDIDATE_SHA:$path" >"$output" || + die "cannot extract candidate workflow" + parsed="$WORK/workflow-$workflow_count.json" + yq -o=json -I=0 \ + '{"document": ., "aliases": [... | select(kind == "alias")], "duplicates": [.. | select(kind == "map") | to_entries | group_by(.key) | .[] | select(length > 1)]}' \ + "$output" >"$parsed" 2>/dev/null || die "candidate workflow is not valid YAML" + jq -e '.aliases == [] and .duplicates == [] and (.document | type) == "object"' "$parsed" >/dev/null || + die "candidate workflow has aliases, duplicate keys, or wrong shape" + if [[ "$path" == "$PUBLISH" ]]; then PUBLISH_FILE=$parsed; fi + if [[ "$path" == "$ROTATE" ]]; then ROTATE_FILE=$parsed; fi + groups="$WORK/workflow-$workflow_count.groups" + jq -e '[ + (.document.concurrency.group? // empty), + (.document.jobs[]?.concurrency.group? // empty) + ] | all(.[]; type == "string")' "$parsed" >/dev/null || + die "candidate workflow concurrency group is malformed" + jq -r '[ + (.document.concurrency.group? // empty), + (.document.jobs[]?.concurrency.group? // empty) + ][]' "$parsed" >"$groups" + if [[ "$path" != "$PUBLISH" && "$path" != "$ROTATE" ]]; then + while IFS= read -r group; do + if [[ "$group" == *"\${{"* ]]; then + [[ "$group" == "\${{ github.workflow }}-\${{ github.ref }}" ]] || + die "a third workflow has an unprovable dynamic concurrency group" + elif [[ "$(printf '%s' "$group" | tr '[:upper:]' '[:lower:]')" == "$GROUP" ]]; then + die "a third workflow claims the publication concurrency group" + fi + done <"$groups" + fi + workflow_count=$((workflow_count + 1)) +done <"$WORKFLOW_LIST" +[[ -n "$PUBLISH_FILE" && -n "$ROTATE_FILE" ]] || die "publisher or rotation workflow is absent" + +parse_gate_workflow() { + local path=$1 name=$2 destination_name=$3 entry output parsed + entry=$(repo_git "$GATE_ROOT" ls-tree "$GATE_SHA" -- "$path") || + die "cannot inspect gate $name workflow" + [[ "$entry" != *$'\n'* && "$entry" == 100644\ blob\ *$'\t'"$path" ]] || + die "gate $name workflow is not one mode-0644 Git blob" + output="$WORK/gate-$name.yml" + repo_git "$GATE_ROOT" show "$GATE_SHA:$path" >"$output" || + die "cannot extract gate $name workflow" + parsed="$WORK/gate-$name.json" + yq -o=json -I=0 \ + '{"document": ., "aliases": [... | select(kind == "alias")], "duplicates": [.. | select(kind == "map") | to_entries | group_by(.key) | .[] | select(length > 1)]}' \ + "$output" >"$parsed" 2>/dev/null || die "gate $name workflow is not valid YAML" + jq -e '.aliases == [] and .duplicates == [] and (.document | type) == "object"' "$parsed" >/dev/null || + die "gate $name workflow has aliases, duplicate keys, or wrong shape" + printf -v "$destination_name" '%s' "$parsed" +} + +GATE_PUBLISH_FILE= +GATE_ROTATE_FILE= +parse_gate_workflow "$PUBLISH" publisher GATE_PUBLISH_FILE +parse_gate_workflow "$ROTATE" rotation GATE_ROTATE_FILE +jq -e --slurpfile gate "$GATE_PUBLISH_FILE" '.document == $gate[0].document' "$PUBLISH_FILE" >/dev/null || + die "publisher workflow execution graph differs from the active gate" +jq -e --slurpfile gate "$GATE_ROTATE_FILE" '.document == $gate[0].document' "$ROTATE_FILE" >/dev/null || + die "rotation workflow execution graph differs from the active gate" + +# shellcheck disable=SC2016 # Match literal GitHub expression strings. +common_jq=' + def no_masks: + [.. | objects | select(has("continue-on-error"))] | length == 0; + def exact_versions: + [.. | objects | .uses? // empty] + | all(type == "string" and + (startswith("./") or test("^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+(/[A-Za-z0-9_.-]+)*@v(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)$"))); + def first_guard: + .[0].name == "assert-hosted-runner-context" + and .[0].shell == "bash" + and (. [0] | has("if") | not) + and (. [0] | has("continue-on-error") | not) + and .[0].env.BASH_ENV == "" and .[0].env.ENV == "" + and .[0].env.EDGEZERO_RUNNER_ENVIRONMENT == "${{ runner.environment }}" + and .[0].env.EDGEZERO_RUNNER_OS == "${{ runner.os }}" + and .[0].env.EDGEZERO_RUNNER_ARCH == "${{ runner.arch }}" + and .[0].env.EDGEZERO_REPOSITORY == "${{ github.repository }}" + and (. [0].run | startswith("set -euo pipefail\n") + and contains("[[ \"$EDGEZERO_RUNNER_ENVIRONMENT\" == github-hosted ]]") + and contains("[[ \"$EDGEZERO_RUNNER_OS\" == Linux ]]") + and contains("[[ \"$EDGEZERO_RUNNER_ARCH\" == X64 ]]") + and contains("[[ \"$EDGEZERO_REPOSITORY\" == stackpop/edgezero ]]")); + def checkout($name; $ref; $path): + [ .[] | select(.name == $name) ] == [{ + name:$name, if:"${{ success() }}", uses:"actions/checkout@v7.0.1", + with:{repository:"stackpop/edgezero",ref:$ref,path:$path,"persist-credentials":false,"fetch-depth":0} + }]; +' + +jq -e "$common_jq"' + .document as $w | + ($w | no_masks) and ($w | exact_versions) and + $w.name == "Publish build container" and + ($w.on | keys) == ["push"] and $w.on.push == {tags:["build-container-v*"]} and + $w.permissions == {} and + $w.concurrency == {group:"edgezero-build-container-publication","cancel-in-progress":false,queue:"max"} and + ($w.jobs | keys | sort) == ["build-and-verify","update-pin"] and + ($w.jobs["build-and-verify"] as $build | + $build["runs-on"] == "ubuntu-24.04" and + $build.permissions == {actions:"read",contents:"read",packages:"write"} and + ($build | has("environment") | not) and + ($build | tostring | contains("secrets.") | not) and + ($build.outputs | keys | sort) == ["approval-challenge","build-attempt","image-digest","provenance-protocol","release-tag","source-revision"] and + ($build.steps | first_guard) and + ($build.steps | checkout("checkout-active-gate"; "${{ vars.EDGEZERO_BUILD_CONTAINER_GATE_SHA }}"; ".edgezero-gate")) and + ($build.steps | checkout("checkout-release-source"; "${{ github.sha }}"; ".edgezero-source")) and + ($build.steps | map(.name)) == ["assert-hosted-runner-context","checkout-active-gate","checkout-release-source","assert-exact-publisher-context","verify-rotation-prerequisite","validate-release-request","stage-trusted-build-context","build-publish-and-verify","generate-approval-challenge"] and + ([ $build.steps[] | select(.name == "assert-exact-publisher-context" and .if == "${{ success() }}") ] | length) == 1 and + ($build.steps[4].run | contains(".edgezero-gate/.github/docker/build-app-cli/verify-gate-rotation-lock.sh")) and + ($build.steps[5].env.EDGEZERO_RELEASE_TAG == "${{ github.ref_name }}") and + ($build.steps[5].run | + contains(".edgezero-gate/.github/docker/build-app-cli/classify-build-container-change.sh") and + contains("--kind local") and contains("mode=ordinary") and contains("relevant=true") and + contains("release-request.json") and contains("EDGEZERO_RELEASE_TAG")) and + ($build.steps[6].run | contains(".edgezero-gate/.github/docker/build-app-cli/stage-build-context.sh")) and + ($build.steps[7].env.BASH_ENV == "" and $build.steps[7].env.ENV == "" and + $build.steps[7].env.EDGEZERO_REGISTRY_TOKEN == "${{ github.token }}" and + $build.steps[7].env.EDGEZERO_REGISTRY_ACTOR == "${{ github.actor }}") and + ($build.steps[7].run | contains("docker login ghcr.io") and contains("unset EDGEZERO_REGISTRY_TOKEN") and + contains("trap cleanup_registry EXIT") and contains("--provenance=false") and contains("--sbom=false") and + contains("--file") and contains("verify-published-image.sh") and contains("docker logout ghcr.io") and + contains("rm -rf -- \"$DOCKER_CONFIG\"")) and + (($build.steps[8].run | contains("/dev/urandom")) and + ($build.steps[8].run | contains("od -An -N32 -tx1")) and + (($build.steps[8].run | contains("verify-published-image.sh")) | not)) and + ($build.steps[1:] | all(.if == "${{ success() }}"))) and + ($w.jobs["update-pin"] as $pin | + $pin["runs-on"] == "ubuntu-24.04" and $pin.needs == "build-and-verify" and + $pin.environment == {name:"build-container-release",deployment:false} and + $pin.permissions == {actions:"read",contents:"read"} and + ($pin.steps | first_guard) and + ($pin.steps | checkout("checkout-active-gate"; "${{ vars.EDGEZERO_BUILD_CONTAINER_GATE_SHA }}"; ".edgezero-gate")) and + ($pin.steps | checkout("checkout-release-source"; "${{ needs.build-and-verify.outputs.source-revision }}"; ".edgezero-source")) and + ($pin.steps | map(.name)) == ["assert-hosted-runner-context","checkout-active-gate","checkout-release-source","assert-exact-publisher-context","verify-rotation-prerequisite","verify-release-approval","mint-publisher-token","assert-publisher-token","update-pin-branch"] and + ([ $pin.steps[] | select(.name == "assert-exact-publisher-context" and .if == "${{ success() }}") ] | length) == 1 and + $pin.steps[6].uses == "actions/create-github-app-token@v3.2.0" and + $pin.steps[6].with["permission-contents"] == "write" and + $pin.steps[6].with["permission-pull-requests"] == "write" and + ($pin.steps[5].run | contains(".edgezero-gate/.github/docker/build-app-cli/release-approval-gate.sh")) and + ($pin.steps[7].run | contains(".edgezero-gate/.github/docker/build-app-cli/assert-build-container-app-token.sh")) and + $pin.steps[8].env.EDGEZERO_APPROVAL_JSON == "${{ steps.approval.outputs.approval-json }}" and + ($pin.steps[8].run | contains(".edgezero-gate/.github/docker/build-app-cli/update-image-pin-pr.sh") and + contains("--approval-json \"$EDGEZERO_APPROVAL_JSON\"") and + (contains("${{ steps.approval.outputs.approval-json }}") | not)) and + ($pin.steps[1:] | all(.if == "${{ success() }}"))) and + ([ $w | tostring | scan("delete:packages|delete-packages|packages:delete|/packages/.*/versions/.+DELETE|package-admin") ] | length) == 0 +' "$PUBLISH_FILE" >/dev/null || die "publisher workflow violates its structural contract" + +jq -e "$common_jq"' + .document as $w | + ($w | no_masks) and ($w | exact_versions) and + $w.name == "Rotate build container gate" and + ($w.on | keys) == ["workflow_dispatch"] and $w.on.workflow_dispatch == null and + $w.permissions == {} and + $w.concurrency == {group:"edgezero-build-container-publication","cancel-in-progress":false,queue:"max"} and + ($w.jobs | keys | sort) == ["acquire","wait"] and + all($w.jobs[]; .["runs-on"] == "ubuntu-24.04" and (.steps | first_guard)) and + ($w.jobs.acquire as $acquire | + $acquire.permissions == {actions:"read",contents:"read"} and + ($acquire | has("environment") | not) and + ($acquire | tostring | contains("secrets.") | not) and + ($acquire.outputs | keys | sort) == ["dispatch-sha","old-gate-sha","run-actor-login"] and + ($acquire.steps | map(.name)) == ["assert-hosted-runner-context","checkout-captured-old-gate","capture-protected-dispatch"] and + ($acquire.steps | checkout("checkout-captured-old-gate"; "${{ vars.EDGEZERO_BUILD_CONTAINER_GATE_SHA }}"; ".edgezero-gate")) and + ($acquire.steps[2].env | has("EDGEZERO_PUBLISHER_PREREQUISITE") | not) and + ($acquire.steps[2].run | contains("gate-paths.txt") and contains("ls-tree") and contains("gate_entry") and contains("dispatch_entry")) and + ($acquire.steps[1:] | all(.if == "${{ success() }}"))) and + ($w.jobs.wait as $wait | + $wait.needs == "acquire" and $wait.permissions == {actions:"read",contents:"read"} and + $wait.environment == {name:"build-container-gate-rotation-lock",deployment:false} and + ($wait.steps | map(.name)) == ["assert-hosted-runner-context","checkout-captured-old-gate","assert-exact-rotation-context"] and + ($wait.steps | checkout("checkout-captured-old-gate"; "${{ needs.acquire.outputs.old-gate-sha }}"; ".edgezero-gate")) and + ($wait.steps[2] | has("if") | not) and + ($wait.steps[2] | has("continue-on-error") | not) and + $wait.steps[2].env == { + GITHUB_TOKEN:"${{ github.token }}", + EDGEZERO_OLD_GATE_SHA:"${{ needs.acquire.outputs.old-gate-sha }}", + EDGEZERO_DISPATCH_SHA:"${{ needs.acquire.outputs.dispatch-sha }}", + EDGEZERO_RUN_ID:"${{ github.run_id }}", + EDGEZERO_RUN_ATTEMPT:"${{ github.run_attempt }}", + EDGEZERO_RUN_ACTOR_LOGIN:"${{ needs.acquire.outputs.run-actor-login }}" + } and + ($wait.steps[2].run | startswith("set -euo pipefail\n") and contains(".edgezero-gate/.github/docker/build-app-cli/verify-gate-rotation-lock.sh") and contains("waiting"))) +' "$ROTATE_FILE" >/dev/null || die "rotation workflow violates its structural contract" diff --git a/.github/docker/build-app-cli/check-image-pin.sh b/.github/docker/build-app-cli/check-image-pin.sh new file mode 100755 index 00000000..e6fea024 --- /dev/null +++ b/.github/docker/build-app-cli/check-image-pin.sh @@ -0,0 +1,257 @@ +#!/usr/bin/env bash +set -euo pipefail + +export LC_ALL=C + +readonly EXPECTED_REPO="ghcr.io/stackpop/edgezero-build-app-cli" +ZERO_SHA256="sha256:$(printf '0%.0s' {1..64})" +readonly ZERO_SHA256 +ZERO_SHA1="$(printf '0%.0s' {1..40})" +readonly ZERO_SHA1 +readonly MAX_RECORD_BYTES=4096 + +usage() { + cat >&2 <<'EOF' +usage: + check-image-pin.sh + check-image-pin.sh validate + check-image-pin.sh runtime-ref + check-image-pin.sh source-revision + check-image-pin.sh provenance-protocol + check-image-pin.sh validate-pair +EOF + exit 2 +} + +die() { + printf '::error::%s\n' "$*" >&2 + exit 1 +} + +require_jq() { + command -v jq >/dev/null 2>&1 || { + printf '::error::check-image-pin.sh requires jq\n' >&2 + exit 2 + } +} + +require_record_file() { + local path=$1 label=$2 size + [[ -f "$path" && ! -L "$path" ]] || die "$label must be a regular, non-symlink file: $path" + size=$(wc -c <"$path" | tr -d '[:space:]') || die "cannot measure $label: $path" + [[ "$size" =~ ^[0-9]+$ ]] || die "cannot measure $label: $path" + ((size > 0 && size <= MAX_RECORD_BYTES)) || + die "$label must contain 1..$MAX_RECORD_BYTES bytes: $path" +} + +# All fields in both records are scalars. Streaming first preserves repeated +# top-level keys that an ordinary jq object parse would silently overwrite. +require_exact_top_level_keys() { + local path=$1 label=$2 + shift 2 + local expected_json events keys + expected_json=$(printf '%s\n' "$@" | jq -Rsc 'split("\n")[:-1]') + if ! events=$(jq -c --stream . "$path" 2>/dev/null); then + die "$label is not valid JSON: $path" + fi + if ! keys=$(jq -ces --argjson expected "$expected_json" ' + if any(.[]; length == 2 and (.[0] | length) != 1) then + error("nested value") + else + [.[] | select(length == 2) | .[0][0]] as $keys + | if (($keys | length) == ($keys | unique | length) + and ($keys | sort) == ($expected | sort)) + then $keys + else error("wrong or duplicate keys") + end + end + ' <<<"$events" 2>/dev/null); then + die "$label must contain exactly the required, unique top-level keys: $path" + fi + [[ -n "$keys" ]] || die "$label has no fields: $path" +} + +require_single_object() { + local path=$1 label=$2 + jq -ces 'if length == 1 and (.[0] | type) == "object" then .[0] else error("not one object") end' \ + "$path" 2>/dev/null || die "$label must be exactly one JSON object: $path" +} + +is_nonzero_digest() { + is_digest "$1" && [[ "$1" != "$ZERO_SHA256" ]] +} + +is_digest() { + [[ "$1" =~ ^sha256:[0-9a-f]{64}$ ]] +} + +is_nonzero_sha() { + [[ "$1" =~ ^[0-9a-f]{40}$ && "$1" != "$ZERO_SHA1" ]] +} + +is_release_tag() { + [[ "$1" =~ ^build-container-v[1-9][0-9]*$ ]] +} + +is_github_login() { + [[ "$1" =~ ^[A-Za-z0-9]([A-Za-z0-9-]{0,37}[A-Za-z0-9])?$ && "$1" != *--* ]] +} + +is_positive_decimal_at_most() { + local value=$1 maximum=$2 + [[ "$value" =~ ^[1-9][0-9]*$ ]] || return 1 + ((${#value} < ${#maximum})) && return 0 + ((${#value} == ${#maximum})) && [[ "$value" < "$maximum" || "$value" == "$maximum" ]] +} + +validate_image() { + local path=$1 json + require_record_file "$path" "image record" + require_exact_top_level_keys "$path" "image record" \ + repository tag digest image-source-revision provenance-protocol + json=$(require_single_object "$path" "image record") + + jq -e ' + (.repository | type) == "string" + and (.tag | type) == "string" + and (.digest | type) == "string" + and (."image-source-revision" | type) == "string" + and (."provenance-protocol" | type) == "number" + and ."provenance-protocol" == 1 + ' <<<"$json" >/dev/null || die "image record fields have invalid JSON types or protocol" + + IMAGE_REPOSITORY=$(jq -r '.repository' <<<"$json") + IMAGE_TAG=$(jq -r '.tag' <<<"$json") + IMAGE_DIGEST=$(jq -r '.digest' <<<"$json") + IMAGE_SOURCE_REVISION=$(jq -r '."image-source-revision"' <<<"$json") + IMAGE_PROVENANCE_PROTOCOL=$(jq -r '."provenance-protocol"' <<<"$json") + + [[ "$IMAGE_REPOSITORY" == "$EXPECTED_REPO" ]] || + die "image repository must be $EXPECTED_REPO" + is_release_tag "$IMAGE_TAG" || die "image tag is not canonical: $IMAGE_TAG" + is_nonzero_digest "$IMAGE_DIGEST" || die "image digest is not a nonzero sha256 digest" + is_nonzero_sha "$IMAGE_SOURCE_REVISION" || die "image source revision is not a nonzero SHA" + [[ "$IMAGE_PROVENANCE_PROTOCOL" == 1 ]] || die "image provenance protocol must equal 1" +} + +validate_review_time() { + local value=$1 epoch round_trip + [[ "$value" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$ ]] || + die "reviewed-at must use exact YYYY-MM-DDTHH:MM:SSZ UTC form" + epoch=$(jq -nr --arg value "$value" '$value | fromdateiso8601' 2>/dev/null) || + die "reviewed-at is not a valid UTC instant" + round_trip=$(jq -nr --argjson epoch "$epoch" '$epoch | strftime("%Y-%m-%dT%H:%M:%SZ")') || + die "reviewed-at cannot be normalized" + [[ "$round_trip" == "$value" ]] || die "reviewed-at is not a valid calendar instant" +} + +validate_evidence() { + local path=$1 json canonical_json + require_record_file "$path" "release evidence" + require_exact_top_level_keys "$path" "release evidence" \ + approval-challenge approver-login image-digest release-tag reviewed-at run-attempt run-id \ + schema-version screenshot-sha256 source-revision + json=$(require_single_object "$path" "release evidence") + + jq -e ' + (."approval-challenge" | type) == "string" + and (."approver-login" | type) == "string" + and (."image-digest" | type) == "string" + and (."release-tag" | type) == "string" + and (."reviewed-at" | type) == "string" + and (."run-attempt" | type) == "string" + and (."run-id" | type) == "string" + and (."schema-version" | type) == "number" + and ."schema-version" == 1 + and (."screenshot-sha256" | type) == "string" + and (."source-revision" | type) == "string" + ' <<<"$json" >/dev/null || die "release evidence fields have invalid JSON types or schema" + + EVIDENCE_APPROVAL_CHALLENGE=$(jq -r '."approval-challenge"' <<<"$json") + EVIDENCE_APPROVER_LOGIN=$(jq -r '."approver-login"' <<<"$json") + EVIDENCE_IMAGE_DIGEST=$(jq -r '."image-digest"' <<<"$json") + EVIDENCE_RELEASE_TAG=$(jq -r '."release-tag"' <<<"$json") + EVIDENCE_REVIEWED_AT=$(jq -r '."reviewed-at"' <<<"$json") + EVIDENCE_RUN_ATTEMPT=$(jq -r '."run-attempt"' <<<"$json") + EVIDENCE_RUN_ID=$(jq -r '."run-id"' <<<"$json") + EVIDENCE_SCREENSHOT_SHA256=$(jq -r '."screenshot-sha256"' <<<"$json") + EVIDENCE_SOURCE_REVISION=$(jq -r '."source-revision"' <<<"$json") + + [[ "$EVIDENCE_APPROVAL_CHALLENGE" =~ ^[0-9a-f]{64}$ ]] || + die "approval challenge is not a 64-lowercase-hex value" + is_github_login "$EVIDENCE_APPROVER_LOGIN" || die "approver login is not canonical" + is_nonzero_digest "$EVIDENCE_IMAGE_DIGEST" || die "evidence image digest is invalid" + is_release_tag "$EVIDENCE_RELEASE_TAG" || die "evidence release tag is invalid" + validate_review_time "$EVIDENCE_REVIEWED_AT" + is_positive_decimal_at_most "$EVIDENCE_RUN_ATTEMPT" 4294967295 || + die "run attempt is not a canonical positive u32" + is_positive_decimal_at_most "$EVIDENCE_RUN_ID" 18446744073709551615 || + die "run id is not a canonical positive u64" + is_digest "$EVIDENCE_SCREENSHOT_SHA256" || die "screenshot digest is invalid" + is_nonzero_sha "$EVIDENCE_SOURCE_REVISION" || die "evidence source revision is invalid" + + canonical_json=$(jq -cnS \ + --arg challenge "$EVIDENCE_APPROVAL_CHALLENGE" \ + --arg login "$EVIDENCE_APPROVER_LOGIN" \ + --arg digest "$EVIDENCE_IMAGE_DIGEST" \ + --arg tag "$EVIDENCE_RELEASE_TAG" \ + --arg reviewed "$EVIDENCE_REVIEWED_AT" \ + --arg attempt "$EVIDENCE_RUN_ATTEMPT" \ + --arg run_id "$EVIDENCE_RUN_ID" \ + --arg screenshot "$EVIDENCE_SCREENSHOT_SHA256" \ + --arg source "$EVIDENCE_SOURCE_REVISION" \ + '{"approval-challenge":$challenge,"approver-login":$login,"image-digest":$digest, + "release-tag":$tag,"reviewed-at":$reviewed,"run-attempt":$attempt,"run-id":$run_id, + "schema-version":1,"screenshot-sha256":$screenshot,"source-revision":$source}') + cmp -s <(printf '%s' "$canonical_json") "$path" || + die "release evidence bytes are not exact RFC 8785 JCS" +} + +validate_pair() { + validate_image "$1" + validate_evidence "$2" + [[ "$EVIDENCE_IMAGE_DIGEST" == "$IMAGE_DIGEST" ]] || die "image digest differs across records" + [[ "$EVIDENCE_RELEASE_TAG" == "$IMAGE_TAG" ]] || die "release tag differs across records" + [[ "$EVIDENCE_SOURCE_REVISION" == "$IMAGE_SOURCE_REVISION" ]] || + die "source revision differs across records" +} + +main() { + require_jq + local mode path + if (($# == 1)); then + mode=validate + path=$1 + elif (($# == 2)); then + mode=$1 + path=$2 + elif (($# == 3)) && [[ "$1" == validate-pair ]]; then + validate_pair "$2" "$3" + printf 'image release record pair is valid\n' + return + else + usage + fi + + case "$mode" in + validate) + validate_image "$path" + printf 'image pin is valid\n' + ;; + runtime-ref) + validate_image "$path" + printf '%s@%s\n' "$IMAGE_REPOSITORY" "$IMAGE_DIGEST" + ;; + source-revision) + validate_image "$path" + printf '%s\n' "$IMAGE_SOURCE_REVISION" + ;; + provenance-protocol) + validate_image "$path" + printf '%s\n' "$IMAGE_PROVENANCE_PROTOCOL" + ;; + *) usage ;; + esac +} + +main "$@" diff --git a/.github/docker/build-app-cli/classify-build-container-change.sh b/.github/docker/build-app-cli/classify-build-container-change.sh new file mode 100755 index 00000000..858a17db --- /dev/null +++ b/.github/docker/build-app-cli/classify-build-container-change.sh @@ -0,0 +1,512 @@ +#!/usr/bin/env bash +set -euo pipefail + +export LC_ALL=C +export BASH_ENV= +export ENV= +export GIT_CONFIG_NOSYSTEM=1 +export GIT_CONFIG_GLOBAL=/dev/null +export GIT_NO_REPLACE_OBJECTS=1 +unset GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE GIT_OBJECT_DIRECTORY +unset GIT_ALTERNATE_OBJECT_DIRECTORIES GIT_COMMON_DIR GIT_CEILING_DIRECTORIES +unset GIT_REPLACE_REF_BASE + +readonly GATE_MANIFEST=.github/docker/build-app-cli/gate-paths.txt +readonly IMAGE_MANIFEST=.github/docker/build-app-cli/image-context-paths.txt +readonly CODEOWNERS=.github/CODEOWNERS +readonly RELEASE_REQUEST=.github/docker/build-app-cli/release-request.json +readonly IMAGE_RECORD=.github/docker/build-app-cli/image.json +readonly EVIDENCE_RECORD=.github/docker/build-app-cli/image-release-evidence.json +readonly GATE_TEAM=@stackpop/edgezero-build-container-gate-reviewers +readonly MAX_MANIFEST_BYTES=65536 +readonly MAX_RELEASE_REQUEST_BYTES=4096 + +usage() { + cat >&2 <<'EOF' +usage: classify-build-container-change.sh \ + --subject-root --gate-root \ + --base <40-lowercase-hex> --head <40-lowercase-hex> \ + --kind --gate-sha <40-lowercase-hex> \ + --release-state +EOF + exit 2 +} + +die() { + printf '::error::%s\n' "$*" >&2 + exit 1 +} + +require_value() { + (($# >= 2)) || usage + [[ -n "$2" ]] || usage +} + +set_once() { + local name=$1 current=$2 value=$3 + [[ -z "$current" ]] || usage + printf -v "$name" '%s' "$value" +} + +is_sha() { + [[ "$1" =~ ^[0-9a-f]{40}$ && "$1" != 0000000000000000000000000000000000000000 ]] +} + +is_positive_u64() { + local value=$1 maximum=18446744073709551615 + [[ "$value" =~ ^[1-9][0-9]*$ ]] || return 1 + ((${#value} < ${#maximum})) && return 0 + # Equal-length canonical decimals are intentionally compared lexically. + # shellcheck disable=SC2071 + ((${#value} == ${#maximum})) && [[ "$value" < "$maximum" || "$value" == "$maximum" ]] +} + +canonical_root() { + local supplied=$1 label=$2 canonical + [[ "$supplied" == /* && -d "$supplied" && ! -L "$supplied" ]] || + die "$label must be an absolute, non-symlink directory" + canonical=$(cd -- "$supplied" && pwd -P) || die "cannot resolve $label" + [[ "$canonical" == "$supplied" ]] || die "$label must already be canonical" + printf '%s\n' "$canonical" +} + +repo_git() { + local root=$1 + shift + env -i PATH="$PATH" LC_ALL=C HOME=/dev/null TMPDIR=/tmp \ + GIT_CONFIG_NOSYSTEM=1 GIT_CONFIG_GLOBAL=/dev/null GIT_TERMINAL_PROMPT=0 \ + GIT_NO_REPLACE_OBJECTS=1 GIT_OPTIONAL_LOCKS=0 \ + git --no-replace-objects -c core.fsmonitor=false -c core.hooksPath=/dev/null \ + -C "$root" "$@" +} + +subject_git() { + repo_git "$SUBJECT_ROOT" "$@" +} + +gate_git() { + repo_git "$GATE_ROOT" "$@" +} + +require_clean_full_repository() { + local root=$1 label=$2 result_name=$3 + local status sparse top git_directory common_directory + [[ "$(repo_git "$root" rev-parse --is-inside-work-tree 2>/dev/null)" == true ]] || + die "$label is not a Git worktree" + top=$(repo_git "$root" rev-parse --show-toplevel 2>/dev/null) || + die "cannot resolve $label top level" + [[ "$top" == "$root" ]] || die "$label must be the exact repository top level" + git_directory=$(repo_git "$root" rev-parse --absolute-git-dir 2>/dev/null) || + die "cannot resolve $label Git directory" + common_directory=$(repo_git "$root" rev-parse --path-format=absolute --git-common-dir 2>/dev/null) || + die "cannot resolve $label common Git directory" + [[ ! -e "$git_directory/info/grafts" && ! -L "$git_directory/info/grafts" && + ! -e "$common_directory/info/grafts" && ! -L "$common_directory/info/grafts" ]] || + die "$label cannot contain legacy grafts" + [[ -z "$(repo_git "$root" for-each-ref --format='%(refname)' refs/replace/)" ]] || + die "$label cannot contain replacement refs" + [[ "$(repo_git "$root" rev-parse --is-shallow-repository 2>/dev/null)" == false ]] || + die "$label must be a full, non-shallow checkout" + sparse=$(repo_git "$root" config --bool core.sparseCheckout 2>/dev/null || true) + [[ "$sparse" != true ]] || die "$label cannot be sparse" + status=$(repo_git "$root" status --porcelain=v1 --untracked-files=all) || + die "cannot inspect $label status" + [[ -z "$status" ]] || die "$label must be clean" + printf -v "$result_name" '%s' "$common_directory" +} + +require_commit() { + local root=$1 revision=$2 label=$3 type + type=$(repo_git "$root" cat-file -t "$revision" 2>/dev/null) || die "$label commit is missing" + [[ "$type" == commit ]] || die "$label must identify one exact commit" +} + +valid_path() { + local path=$1 + [[ -n "$path" && "$path" != /* && "$path" != -* && "$path" != */ && + "$path" != . && "$path" != .. && "$path" != ../* && "$path" != */../* && + "$path" != */.. && "$path" != */./* && "$path" != */. && "$path" != *//* && + "$path" != *\\* ]] || return 1 + ! LC_ALL=C grep -q '[[:cntrl:]]' <<<"$path" +} + +manifest_path() { + [[ "$1" =~ ^[A-Za-z0-9._/+-]+$ ]] && valid_path "$1" +} + +tree_entry() { + local root=$1 revision=$2 path=$3 entry recorded_path + entry=$(repo_git "$root" ls-tree "$revision" -- "$path") || + die "cannot inspect $path at $revision" + if [[ -n "$entry" ]]; then + [[ "$entry" != *$'\n'* && "$entry" == *$'\t'* ]] || + die "ambiguous tree entry for $path at $revision" + recorded_path=${entry#*$'\t'} + [[ "$recorded_path" == "$path" ]] || die "tree entry path mismatch for $path" + fi + printf '%s' "$entry" +} + +require_regular_tree_file() { + local root=$1 revision=$2 path=$3 label=$4 entry metadata mode type object + entry=$(tree_entry "$root" "$revision" "$path") + [[ -n "$entry" ]] || die "$label is missing: $path" + metadata=${entry%%$'\t'*} + read -r mode type object <<<"$metadata" + [[ "$type" == blob && ("$mode" == 100644 || "$mode" == 100755) && + "$object" =~ ^[0-9a-f]{40,64}$ ]] || die "$label must be a regular Git blob: $path" +} + +extract_tree_file() { + local root=$1 revision=$2 path=$3 destination=$4 label=$5 + require_regular_tree_file "$root" "$revision" "$path" "$label" + repo_git "$root" show "$revision:$path" >"$destination" || die "cannot read $label" +} + +require_canonical_path_file() { + local file=$1 label=$2 size last path previous= + size=$(wc -c <"$file" | tr -d '[:space:]') || die "cannot measure $label" + [[ "$size" =~ ^[0-9]+$ ]] || die "cannot measure $label" + ((size > 0 && size <= MAX_MANIFEST_BYTES)) || die "$label has an invalid size" + last=$(tail -c 1 "$file" | od -An -tx1 | tr -d '[:space:]') || + die "cannot inspect $label terminator" + [[ "$last" == 0a ]] || die "$label must end in exactly one LF-delimited record" + while IFS= read -r path; do + manifest_path "$path" || die "$label contains an invalid path" + [[ -z "$previous" || "$previous" < "$path" ]] || + die "$label must be byte-sorted with unique paths" + previous=$path + done <"$file" +} + +contains_path() { + grep -Fqx -e "$1" "$2" +} + +validate_codeowners() { + local root=$1 revision=$2 manifest=$3 prefix=$4 expected actual path + expected="$WORK_DIR/codeowners-$prefix.expected" + actual="$WORK_DIR/codeowners-$prefix.actual" + : >"$expected" + while IFS= read -r path; do + printf '/%s %s\n' "$path" "$GATE_TEAM" >>"$expected" + done <"$manifest" + extract_tree_file "$root" "$revision" "$CODEOWNERS" "$actual" "CODEOWNERS" + cmp -s "$expected" "$actual" || + die "CODEOWNERS must assign every manifested path exactly to $GATE_TEAM" +} + +validate_manifest_at() { + local root=$1 revision=$2 prefix=$3 output_manifest=$4 output_image=$5 + local path gate_count image_count + extract_tree_file "$root" "$revision" "$GATE_MANIFEST" "$output_manifest" \ + "gate path manifest" + require_canonical_path_file "$output_manifest" "gate path manifest" + contains_path "$GATE_MANIFEST" "$output_manifest" || die "gate manifest must contain itself" + contains_path "$CODEOWNERS" "$output_manifest" || die "gate manifest must contain CODEOWNERS" + contains_path "$IMAGE_MANIFEST" "$output_manifest" || + die "gate manifest must contain the image-context manifest" + ! contains_path "$RELEASE_REQUEST" "$output_manifest" || + die "release request cannot be gate-owned" + ! contains_path "$IMAGE_RECORD" "$output_manifest" || die "image record cannot be gate-owned" + ! contains_path "$EVIDENCE_RECORD" "$output_manifest" || + die "image release evidence cannot be gate-owned" + + while IFS= read -r path; do + require_regular_tree_file "$root" "$revision" "$path" "manifested gate path" + done <"$output_manifest" + + extract_tree_file "$root" "$revision" "$IMAGE_MANIFEST" "$output_image" \ + "image-context manifest" + require_canonical_path_file "$output_image" "image-context manifest" + contains_path "$IMAGE_MANIFEST" "$output_image" || + die "image-context manifest must contain itself" + while IFS= read -r path; do + contains_path "$path" "$output_manifest" || + die "every image-context path must also be gate-owned: $path" + require_regular_tree_file "$root" "$revision" "$path" "manifested image-context path" + done <"$output_image" + gate_count=$(wc -l <"$output_manifest" | tr -d '[:space:]') + image_count=$(wc -l <"$output_image" | tr -d '[:space:]') + ((image_count < gate_count)) || die "image-context manifest must be a strict gate-manifest subset" + validate_codeowners "$root" "$revision" "$output_manifest" "$prefix" +} + +manifest_tree_equal() { + local left_root=$1 left_revision=$2 right_root=$3 right_revision=$4 manifest=$5 path + local left_entry right_entry + while IFS= read -r path; do + left_entry=$(tree_entry "$left_root" "$left_revision" "$path") + right_entry=$(tree_entry "$right_root" "$right_revision" "$path") + [[ "$left_entry" == "$right_entry" ]] || return 1 + done <"$manifest" +} + +make_union() { + LC_ALL=C sort -u "$1" "$2" >"$3" +} + +collect_changes() { + local from=$1 to=$2 destination=$3 raw status path + raw="$destination.raw" + : >"$destination" + subject_git diff --name-status --no-renames -z "$from" "$to" >"$raw" || + die "cannot inspect candidate range" + exec 3<"$raw" + while IFS= read -r -d '' status <&3; do + IFS= read -r -d '' path <&3 || die "candidate range has truncated path data" + [[ "$status" == A || "$status" == M || "$status" == D ]] || + die "candidate range contains unsupported status: $status" + valid_path "$path" || die "candidate range contains an ambiguous path" + printf '%s\t%s\n' "$status" "$path" >>"$destination" + done + exec 3<&- +} + +change_count() { + wc -l <"$1" | tr -d '[:space:]' +} + +changed_status() { + local wanted=$1 changes=$2 status path + while IFS=$'\t' read -r status path; do + if [[ "$path" == "$wanted" ]]; then + printf '%s\n' "$status" + return 0 + fi + done <"$changes" + return 1 +} + +require_changed_entries_not_gitlinks() { + local changes=$1 status path entry metadata mode type object revision + while IFS=$'\t' read -r status path; do + for revision in "$BASE" "$HEAD"; do + entry=$(tree_entry "$SUBJECT_ROOT" "$revision" "$path") + [[ -z "$entry" ]] && continue + metadata=${entry%%$'\t'*} + read -r mode type object <<<"$metadata" + [[ "$mode" != 160000 && "$type" != commit ]] || + die "candidate range contains a gitlink: $path" + done + done <"$changes" +} + +require_changes_within_manifest() { + local changes=$1 manifest=$2 status path seen=0 + while IFS=$'\t' read -r status path; do + contains_path "$path" "$manifest" || return 1 + seen=1 + done <"$changes" + ((seen == 1)) +} + +validate_release_request() { + local file=$1 size events keys json canonical gate protocol tag + size=$(wc -c <"$file" | tr -d '[:space:]') || die "cannot measure release request" + [[ "$size" =~ ^[0-9]+$ ]] || die "cannot measure release request" + ((size > 0 && size <= MAX_RELEASE_REQUEST_BYTES)) || die "release request has invalid size" + events=$(jq -c --stream . "$file" 2>/dev/null) || die "release request is not valid JSON" + keys=$(jq -ces ' + if any(.[]; length == 2 and (.[0] | length) != 1) then error("nested") else + [.[] | select(length == 2) | .[0][0]] as $keys + | if (($keys | length) == 3 and ($keys | unique | length) == 3 + and ($keys | sort) == ["gate-sha","provenance-protocol","release-tag"]) + then $keys else error("keys") end + end + ' <<<"$events" 2>/dev/null) || die "release request has wrong or duplicate fields" + [[ -n "$keys" ]] || die "release request has no fields" + json=$(jq -ces 'if length == 1 and (.[0] | type) == "object" then .[0] else error("object") end' \ + "$file" 2>/dev/null) || die "release request must be one object" + jq -e ' + (."gate-sha" | type) == "string" + and (."provenance-protocol" | type) == "number" + and (."release-tag" | type) == "string" + ' <<<"$json" >/dev/null || die "release request fields have wrong types" + gate=$(jq -r '."gate-sha"' <<<"$json") + protocol=$(jq -r '."provenance-protocol"' <<<"$json") + tag=$(jq -r '."release-tag"' <<<"$json") + [[ "$gate" == "$GATE_SHA" && "$protocol" == 1 && + "$tag" =~ ^build-container-v[1-9][0-9]*$ ]] || die "release request identity is invalid" + canonical=$(jq -cn --arg gate "$gate" --arg tag "$tag" \ + '{"gate-sha":$gate,"provenance-protocol":1,"release-tag":$tag}') + cmp -s <(printf '%s' "$canonical") "$file" || die "release request bytes are not canonical" +} + +emit() { + printf 'mode=%s\nrelevant=%s\n' "$1" "$2" +} + +SUBJECT_ROOT= +GATE_ROOT= +BASE= +HEAD= +KIND= +GATE_SHA= +RELEASE_STATE= + +while (($#)); do + case "$1" in + --subject-root) + require_value "$@" + set_once SUBJECT_ROOT "$SUBJECT_ROOT" "$2" + shift 2 + ;; + --gate-root) + require_value "$@" + set_once GATE_ROOT "$GATE_ROOT" "$2" + shift 2 + ;; + --base) + require_value "$@" + set_once BASE "$BASE" "$2" + shift 2 + ;; + --head) + require_value "$@" + set_once HEAD "$HEAD" "$2" + shift 2 + ;; + --kind) + require_value "$@" + set_once KIND "$KIND" "$2" + shift 2 + ;; + --gate-sha) + require_value "$@" + set_once GATE_SHA "$GATE_SHA" "$2" + shift 2 + ;; + --release-state) + require_value "$@" + set_once RELEASE_STATE "$RELEASE_STATE" "$2" + shift 2 + ;; + *) usage ;; + esac +done + +[[ -n "$SUBJECT_ROOT" && -n "$GATE_ROOT" && -n "$BASE" && -n "$HEAD" && + -n "$KIND" && -n "$GATE_SHA" && -n "$RELEASE_STATE" ]] || usage +[[ "$KIND" == local || "$KIND" == pin ]] || usage +if ! is_sha "$BASE" || ! is_sha "$HEAD" || ! is_sha "$GATE_SHA"; then + usage +fi + +SUBJECT_ROOT=$(canonical_root "$SUBJECT_ROOT" "subject root") +GATE_ROOT=$(canonical_root "$GATE_ROOT" "gate root") +[[ "$SUBJECT_ROOT" != "$GATE_ROOT" ]] || die "gate and subject roots must be distinct" +require_clean_full_repository "$SUBJECT_ROOT" "subject checkout" SUBJECT_COMMON_DIRECTORY +require_clean_full_repository "$GATE_ROOT" "gate checkout" GATE_COMMON_DIRECTORY +[[ "$SUBJECT_COMMON_DIRECTORY" != "$GATE_COMMON_DIRECTORY" ]] || + die "gate and subject roots must use separate Git repositories" +require_commit "$SUBJECT_ROOT" "$BASE" "base" +require_commit "$SUBJECT_ROOT" "$HEAD" "head" +require_commit "$SUBJECT_ROOT" "$GATE_SHA" "gate" +require_commit "$GATE_ROOT" "$GATE_SHA" "gate checkout" +[[ "$(gate_git rev-parse HEAD)" == "$GATE_SHA" ]] || + die "gate checkout HEAD differs from active gate SHA" +subject_git merge-base --is-ancestor "$BASE" "$HEAD" || + die "base must be an ancestor of head" + +WORK_DIR=$(mktemp -d "${TMPDIR:-/tmp}/edgezero-classify.XXXXXX") +trap 'rm -rf "$WORK_DIR"' EXIT HUP INT TERM +OLD_MANIFEST="$WORK_DIR/old-gate-paths.txt" +OLD_IMAGE_MANIFEST="$WORK_DIR/old-image-context-paths.txt" +HEAD_MANIFEST="$WORK_DIR/head-gate-paths.txt" +HEAD_IMAGE_MANIFEST="$WORK_DIR/head-image-context-paths.txt" +UNION_MANIFEST="$WORK_DIR/union-gate-paths.txt" +CHANGES="$WORK_DIR/changes.txt" +BASE_MANIFEST="$WORK_DIR/base-gate-paths.txt" +BASE_IMAGE_MANIFEST="$WORK_DIR/base-image-context-paths.txt" + +validate_manifest_at "$GATE_ROOT" "$GATE_SHA" old "$OLD_MANIFEST" "$OLD_IMAGE_MANIFEST" +validate_manifest_at "$SUBJECT_ROOT" "$HEAD" head "$HEAD_MANIFEST" "$HEAD_IMAGE_MANIFEST" +if [[ "$RELEASE_STATE" =~ ^disabled:[1-9][0-9]*:[0-9a-f]{40}:[0-9a-f]{40}$ ]]; then + HEAD_OLD_UNION="$WORK_DIR/head-old-union.txt" + validate_manifest_at "$SUBJECT_ROOT" "$BASE" base "$BASE_MANIFEST" "$BASE_IMAGE_MANIFEST" + make_union "$OLD_MANIFEST" "$HEAD_MANIFEST" "$HEAD_OLD_UNION" + make_union "$HEAD_OLD_UNION" "$BASE_MANIFEST" "$UNION_MANIFEST" +else + make_union "$OLD_MANIFEST" "$HEAD_MANIFEST" "$UNION_MANIFEST" +fi +collect_changes "$BASE" "$HEAD" "$CHANGES" +require_changed_entries_not_gitlinks "$CHANGES" + +gate_changed=false +non_gate_changed=false +while IFS=$'\t' read -r status path; do + if contains_path "$path" "$UNION_MANIFEST"; then + gate_changed=true + else + non_gate_changed=true + fi +done <"$CHANGES" + +if [[ "$gate_changed" == true ]]; then + [[ "$non_gate_changed" == false ]] || die "gate and non-gate changes cannot be mixed" + require_changes_within_manifest "$CHANGES" "$UNION_MANIFEST" || + die "gate changes escaped the old/candidate manifest union" + if manifest_tree_equal "$SUBJECT_ROOT" "$BASE" "$GATE_ROOT" "$GATE_SHA" "$OLD_MANIFEST"; then + [[ "$RELEASE_STATE" == enabled ]] || die "gate update requires enabled release state" + emit gate-update true + exit 0 + fi + + [[ "$RELEASE_STATE" =~ ^disabled:([1-9][0-9]*):([0-9a-f]{40}):([0-9a-f]{40})$ ]] || + die "gate rollback requires the exact disabled release state" + LOCK_RUN_ID=${BASH_REMATCH[1]} + DISABLED_OLD_GATE=${BASH_REMATCH[2]} + FAILED_GATE=${BASH_REMATCH[3]} + is_positive_u64 "$LOCK_RUN_ID" || die "gate rollback lock run id is invalid" + [[ "$DISABLED_OLD_GATE" == "$GATE_SHA" && "$FAILED_GATE" == "$BASE" ]] || + die "gate rollback state does not bind the active and failed gates" + subject_git merge-base --is-ancestor "$GATE_SHA" "$BASE" || + die "failed gate does not descend from active gate" + + FAILED_UNION="$WORK_DIR/failed-union.txt" + FAILED_RANGE="$WORK_DIR/failed-range.txt" + make_union "$OLD_MANIFEST" "$BASE_MANIFEST" "$FAILED_UNION" + collect_changes "$GATE_SHA" "$BASE" "$FAILED_RANGE" + require_changed_entries_not_gitlinks "$FAILED_RANGE" + require_changes_within_manifest "$FAILED_RANGE" "$FAILED_UNION" || + die "failed gate was not a canonical gate-only update" + manifest_tree_equal "$SUBJECT_ROOT" "$HEAD" "$GATE_ROOT" "$GATE_SHA" "$FAILED_UNION" || + die "rollback head does not restore the active gate tree exactly" + emit gate-rollback true + exit 0 +fi + +[[ "$RELEASE_STATE" == enabled ]] || die "ordinary changes require enabled release state" +manifest_tree_equal "$SUBJECT_ROOT" "$BASE" "$GATE_ROOT" "$GATE_SHA" "$OLD_MANIFEST" || + die "protected base gate bytes differ from active gate" +manifest_tree_equal "$SUBJECT_ROOT" "$HEAD" "$GATE_ROOT" "$GATE_SHA" "$OLD_MANIFEST" || + die "candidate gate bytes differ from active gate" + +release_status=$(changed_status "$RELEASE_REQUEST" "$CHANGES" || true) +image_status=$(changed_status "$IMAGE_RECORD" "$CHANGES" || true) +evidence_status=$(changed_status "$EVIDENCE_RECORD" "$CHANGES" || true) + +if [[ -n "$release_status" ]]; then + [[ "$(change_count "$CHANGES")" -eq 1 && "$release_status" == A ]] || + die "release request must be the sole newly added path" + RELEASE_FILE="$WORK_DIR/release-request.json" + extract_tree_file "$SUBJECT_ROOT" "$HEAD" "$RELEASE_REQUEST" "$RELEASE_FILE" "release request" + validate_release_request "$RELEASE_FILE" + if [[ "$KIND" == local ]]; then emit ordinary true; else emit ordinary false; fi + exit 0 +fi + +if [[ -n "$image_status" || -n "$evidence_status" ]]; then + [[ -n "$image_status" && -n "$evidence_status" && "$(change_count "$CHANGES")" -eq 2 ]] || + die "image pin records must change as an exact pair and no other path" + [[ "$image_status" == "$evidence_status" && + ("$image_status" == A || "$image_status" == M) ]] || + die "image pin records must be added or changed together" + require_regular_tree_file "$SUBJECT_ROOT" "$HEAD" "$IMAGE_RECORD" "image record" + require_regular_tree_file "$SUBJECT_ROOT" "$HEAD" "$EVIDENCE_RECORD" "release evidence" + if [[ "$KIND" == pin ]]; then emit ordinary true; else emit ordinary false; fi + exit 0 +fi + +emit ordinary false diff --git a/.github/docker/build-app-cli/fixtures/gnu-smoke.rs b/.github/docker/build-app-cli/fixtures/gnu-smoke.rs new file mode 100644 index 00000000..5ae0a7cf --- /dev/null +++ b/.github/docker/build-app-cli/fixtures/gnu-smoke.rs @@ -0,0 +1,21 @@ +use std::{collections::BTreeMap, env, process}; + +fn main() { + let expected = BTreeMap::from([ + ("HOME".to_owned(), "/work/home".to_owned()), + ( + "PATH".to_owned(), + "/usr/local/bin:/usr/local/cargo/bin:/usr/bin:/bin".to_owned(), + ), + ("TMPDIR".to_owned(), "/work/tmp".to_owned()), + ]); + if env::vars().collect::>() != expected { + eprintln!("unexpected runtime environment"); + process::exit(2); + } + if env::args().skip(1).collect::>() != ["--help"] { + eprintln!("unexpected runtime arguments"); + process::exit(3); + } + println!("edgezero image runtime smoke"); +} diff --git a/.github/docker/build-app-cli/fixtures/provenance/invalid/archive-base256.tar b/.github/docker/build-app-cli/fixtures/provenance/invalid/archive-base256.tar new file mode 100644 index 00000000..a710e121 Binary files /dev/null and b/.github/docker/build-app-cli/fixtures/provenance/invalid/archive-base256.tar differ diff --git a/.github/docker/build-app-cli/fixtures/provenance/invalid/archive-checksum.tar b/.github/docker/build-app-cli/fixtures/provenance/invalid/archive-checksum.tar new file mode 100644 index 00000000..9b83758e Binary files /dev/null and b/.github/docker/build-app-cli/fixtures/provenance/invalid/archive-checksum.tar differ diff --git a/.github/docker/build-app-cli/fixtures/provenance/invalid/archive-duplicate.tar b/.github/docker/build-app-cli/fixtures/provenance/invalid/archive-duplicate.tar new file mode 100644 index 00000000..d70ce540 Binary files /dev/null and b/.github/docker/build-app-cli/fixtures/provenance/invalid/archive-duplicate.tar differ diff --git a/.github/docker/build-app-cli/fixtures/provenance/invalid/archive-embedded-nul.tar b/.github/docker/build-app-cli/fixtures/provenance/invalid/archive-embedded-nul.tar new file mode 100644 index 00000000..7c38e455 Binary files /dev/null and b/.github/docker/build-app-cli/fixtures/provenance/invalid/archive-embedded-nul.tar differ diff --git a/.github/docker/build-app-cli/fixtures/provenance/invalid/archive-end-block.tar b/.github/docker/build-app-cli/fixtures/provenance/invalid/archive-end-block.tar new file mode 100644 index 00000000..56559194 Binary files /dev/null and b/.github/docker/build-app-cli/fixtures/provenance/invalid/archive-end-block.tar differ diff --git a/.github/docker/build-app-cli/fixtures/provenance/invalid/archive-extra-end-block.tar b/.github/docker/build-app-cli/fixtures/provenance/invalid/archive-extra-end-block.tar new file mode 100644 index 00000000..1688b33e Binary files /dev/null and b/.github/docker/build-app-cli/fixtures/provenance/invalid/archive-extra-end-block.tar differ diff --git a/.github/docker/build-app-cli/fixtures/provenance/invalid/archive-extra.tar b/.github/docker/build-app-cli/fixtures/provenance/invalid/archive-extra.tar new file mode 100644 index 00000000..815a7ac3 Binary files /dev/null and b/.github/docker/build-app-cli/fixtures/provenance/invalid/archive-extra.tar differ diff --git a/.github/docker/build-app-cli/fixtures/provenance/invalid/archive-gnu.tar b/.github/docker/build-app-cli/fixtures/provenance/invalid/archive-gnu.tar new file mode 100644 index 00000000..f574e779 Binary files /dev/null and b/.github/docker/build-app-cli/fixtures/provenance/invalid/archive-gnu.tar differ diff --git a/.github/docker/build-app-cli/fixtures/provenance/invalid/archive-header.tar b/.github/docker/build-app-cli/fixtures/provenance/invalid/archive-header.tar new file mode 100644 index 00000000..7df2466f Binary files /dev/null and b/.github/docker/build-app-cli/fixtures/provenance/invalid/archive-header.tar differ diff --git a/.github/docker/build-app-cli/fixtures/provenance/invalid/archive-link.tar b/.github/docker/build-app-cli/fixtures/provenance/invalid/archive-link.tar new file mode 100644 index 00000000..a98d5cb4 Binary files /dev/null and b/.github/docker/build-app-cli/fixtures/provenance/invalid/archive-link.tar differ diff --git a/.github/docker/build-app-cli/fixtures/provenance/invalid/archive-max-size.tar b/.github/docker/build-app-cli/fixtures/provenance/invalid/archive-max-size.tar new file mode 100644 index 00000000..5dbcbc72 Binary files /dev/null and b/.github/docker/build-app-cli/fixtures/provenance/invalid/archive-max-size.tar differ diff --git a/.github/docker/build-app-cli/fixtures/provenance/invalid/archive-octal-digit.tar b/.github/docker/build-app-cli/fixtures/provenance/invalid/archive-octal-digit.tar new file mode 100644 index 00000000..9caa1441 Binary files /dev/null and b/.github/docker/build-app-cli/fixtures/provenance/invalid/archive-octal-digit.tar differ diff --git a/.github/docker/build-app-cli/fixtures/provenance/invalid/archive-octal-padding.tar b/.github/docker/build-app-cli/fixtures/provenance/invalid/archive-octal-padding.tar new file mode 100644 index 00000000..db7432f7 Binary files /dev/null and b/.github/docker/build-app-cli/fixtures/provenance/invalid/archive-octal-padding.tar differ diff --git a/.github/docker/build-app-cli/fixtures/provenance/invalid/archive-order.tar b/.github/docker/build-app-cli/fixtures/provenance/invalid/archive-order.tar new file mode 100644 index 00000000..f3dd2c1d Binary files /dev/null and b/.github/docker/build-app-cli/fixtures/provenance/invalid/archive-order.tar differ diff --git a/.github/docker/build-app-cli/fixtures/provenance/invalid/archive-padding.tar b/.github/docker/build-app-cli/fixtures/provenance/invalid/archive-padding.tar new file mode 100644 index 00000000..11b7960b Binary files /dev/null and b/.github/docker/build-app-cli/fixtures/provenance/invalid/archive-padding.tar differ diff --git a/.github/docker/build-app-cli/fixtures/provenance/invalid/archive-pax.tar b/.github/docker/build-app-cli/fixtures/provenance/invalid/archive-pax.tar new file mode 100644 index 00000000..c27dae0a Binary files /dev/null and b/.github/docker/build-app-cli/fixtures/provenance/invalid/archive-pax.tar differ diff --git a/.github/docker/build-app-cli/fixtures/provenance/invalid/archive-size-limit.tar b/.github/docker/build-app-cli/fixtures/provenance/invalid/archive-size-limit.tar new file mode 100644 index 00000000..78ea8220 Binary files /dev/null and b/.github/docker/build-app-cli/fixtures/provenance/invalid/archive-size-limit.tar differ diff --git a/.github/docker/build-app-cli/fixtures/provenance/invalid/archive-size.tar b/.github/docker/build-app-cli/fixtures/provenance/invalid/archive-size.tar new file mode 100644 index 00000000..0d26d8f1 Binary files /dev/null and b/.github/docker/build-app-cli/fixtures/provenance/invalid/archive-size.tar differ diff --git a/.github/docker/build-app-cli/fixtures/provenance/invalid/archive-sparse.tar b/.github/docker/build-app-cli/fixtures/provenance/invalid/archive-sparse.tar new file mode 100644 index 00000000..50037d40 Binary files /dev/null and b/.github/docker/build-app-cli/fixtures/provenance/invalid/archive-sparse.tar differ diff --git a/.github/docker/build-app-cli/fixtures/provenance/invalid/archive-special.tar b/.github/docker/build-app-cli/fixtures/provenance/invalid/archive-special.tar new file mode 100644 index 00000000..19a163ab Binary files /dev/null and b/.github/docker/build-app-cli/fixtures/provenance/invalid/archive-special.tar differ diff --git a/.github/docker/build-app-cli/fixtures/provenance/invalid/archive-trailing.tar b/.github/docker/build-app-cli/fixtures/provenance/invalid/archive-trailing.tar new file mode 100644 index 00000000..ca5ca79d Binary files /dev/null and b/.github/docker/build-app-cli/fixtures/provenance/invalid/archive-trailing.tar differ diff --git a/.github/docker/build-app-cli/fixtures/provenance/invalid/archive-traversal.tar b/.github/docker/build-app-cli/fixtures/provenance/invalid/archive-traversal.tar new file mode 100644 index 00000000..601aafa9 Binary files /dev/null and b/.github/docker/build-app-cli/fixtures/provenance/invalid/archive-traversal.tar differ diff --git a/.github/docker/build-app-cli/fixtures/provenance/invalid/elf-malformed/app-cli b/.github/docker/build-app-cli/fixtures/provenance/invalid/elf-malformed/app-cli new file mode 100644 index 00000000..56ecaa36 --- /dev/null +++ b/.github/docker/build-app-cli/fixtures/provenance/invalid/elf-malformed/app-cli @@ -0,0 +1 @@ +not ELF diff --git a/.github/docker/build-app-cli/fixtures/provenance/invalid/expected-duplicate.json b/.github/docker/build-app-cli/fixtures/provenance/invalid/expected-duplicate.json new file mode 100644 index 00000000..23b9ffc6 --- /dev/null +++ b/.github/docker/build-app-cli/fixtures/provenance/invalid/expected-duplicate.json @@ -0,0 +1 @@ +{"caller":{"app-cli-bin":"edgezero","app-cli-package":"edgezero-cli","app-repo-id":"123456","app-repo-id":"123456","source-revision":"1111111111111111111111111111111111111111","workspace-id":"sha256:2222222222222222222222222222222222222222222222222222222222222222"},"platform":{"container-ref":"ghcr.io/stackpop/edgezero-build-app-cli@sha256:3333333333333333333333333333333333333333333333333333333333333333","platform-id":"sha256:3333333333333333333333333333333333333333333333333333333333333333","provenance-protocol":1},"schema-version":1} diff --git a/.github/docker/build-app-cli/fixtures/provenance/invalid/meta-missing-interpreter.json b/.github/docker/build-app-cli/fixtures/provenance/invalid/meta-missing-interpreter.json new file mode 100644 index 00000000..e6d81e18 --- /dev/null +++ b/.github/docker/build-app-cli/fixtures/provenance/invalid/meta-missing-interpreter.json @@ -0,0 +1 @@ +{"abi":{"machine":"x86_64","needed":[]},"app-cli-version":"0.1.0","binary-sha256":"sha256:4444444444444444444444444444444444444444444444444444444444444444","binary-size":123,"caller":{"app-cli-bin":"edgezero","app-cli-package":"edgezero-cli","app-repo-id":"123456","source-revision":"1111111111111111111111111111111111111111","workspace-id":"sha256:2222222222222222222222222222222222222222222222222222222222222222"},"platform":{"container-ref":"ghcr.io/stackpop/edgezero-build-app-cli@sha256:3333333333333333333333333333333333333333333333333333333333333333","platform-id":"sha256:3333333333333333333333333333333333333333333333333333333333333333","provenance-protocol":1},"schema-version":1} diff --git a/.github/docker/build-app-cli/fixtures/provenance/valid/archive.tar b/.github/docker/build-app-cli/fixtures/provenance/valid/archive.tar new file mode 100644 index 00000000..7e8b841f Binary files /dev/null and b/.github/docker/build-app-cli/fixtures/provenance/valid/archive.tar differ diff --git a/.github/docker/build-app-cli/fixtures/provenance/valid/dynamic-meta.json b/.github/docker/build-app-cli/fixtures/provenance/valid/dynamic-meta.json new file mode 100644 index 00000000..01cfe673 --- /dev/null +++ b/.github/docker/build-app-cli/fixtures/provenance/valid/dynamic-meta.json @@ -0,0 +1 @@ +{"abi":{"interpreter":"/lib64/ld-linux-x86-64.so.2","machine":"x86_64","needed":["libc.so.6","libc.so.6","libm.so.6"]},"app-cli-version":"0.1.0","binary-sha256":"sha256:4444444444444444444444444444444444444444444444444444444444444444","binary-size":123,"caller":{"app-cli-bin":"edgezero","app-cli-package":"edgezero-cli","app-repo-id":"123456","source-revision":"1111111111111111111111111111111111111111","workspace-id":"sha256:2222222222222222222222222222222222222222222222222222222222222222"},"platform":{"container-ref":"ghcr.io/stackpop/edgezero-build-app-cli@sha256:3333333333333333333333333333333333333333333333333333333333333333","platform-id":"sha256:3333333333333333333333333333333333333333333333333333333333333333","provenance-protocol":1},"schema-version":1} \ No newline at end of file diff --git a/.github/docker/build-app-cli/fixtures/provenance/valid/elf-static/app-cli b/.github/docker/build-app-cli/fixtures/provenance/valid/elf-static/app-cli new file mode 100644 index 00000000..08fec690 Binary files /dev/null and b/.github/docker/build-app-cli/fixtures/provenance/valid/elf-static/app-cli differ diff --git a/.github/docker/build-app-cli/fixtures/provenance/valid/expected.json b/.github/docker/build-app-cli/fixtures/provenance/valid/expected.json new file mode 100644 index 00000000..7633c1d8 --- /dev/null +++ b/.github/docker/build-app-cli/fixtures/provenance/valid/expected.json @@ -0,0 +1 @@ +{"caller":{"app-cli-bin":"edgezero","app-cli-package":"edgezero-cli","app-repo-id":"123456","source-revision":"1111111111111111111111111111111111111111","workspace-id":"sha256:2222222222222222222222222222222222222222222222222222222222222222"},"platform":{"container-ref":"ghcr.io/stackpop/edgezero-build-app-cli@sha256:3333333333333333333333333333333333333333333333333333333333333333","platform-id":"sha256:3333333333333333333333333333333333333333333333333333333333333333","provenance-protocol":1},"schema-version":1} \ No newline at end of file diff --git a/.github/docker/build-app-cli/fixtures/provenance/valid/static-meta.json b/.github/docker/build-app-cli/fixtures/provenance/valid/static-meta.json new file mode 100644 index 00000000..38410c87 --- /dev/null +++ b/.github/docker/build-app-cli/fixtures/provenance/valid/static-meta.json @@ -0,0 +1 @@ +{"abi":{"interpreter":null,"machine":"x86_64","needed":[]},"app-cli-version":"0.1.0","binary-sha256":"sha256:6b25433eed518a44b19e8c821749f4dd07156d0962727ed27c15f8816c3c1c96","binary-size":2048,"caller":{"app-cli-bin":"edgezero","app-cli-package":"edgezero-cli","app-repo-id":"123456","source-revision":"1111111111111111111111111111111111111111","workspace-id":"sha256:2222222222222222222222222222222222222222222222222222222222222222"},"platform":{"container-ref":"ghcr.io/stackpop/edgezero-build-app-cli@sha256:3333333333333333333333333333333333333333333333333333333333333333","platform-id":"sha256:3333333333333333333333333333333333333333333333333333333333333333","provenance-protocol":1},"schema-version":1} \ No newline at end of file diff --git a/.github/docker/build-app-cli/fixtures/wasm-smoke.rs b/.github/docker/build-app-cli/fixtures/wasm-smoke.rs new file mode 100644 index 00000000..3cf27810 --- /dev/null +++ b/.github/docker/build-app-cli/fixtures/wasm-smoke.rs @@ -0,0 +1,13 @@ +#![no_std] + +use core::panic::PanicInfo; + +#[panic_handler] +fn panic(_info: &PanicInfo<'_>) -> ! { + loop {} +} + +#[unsafe(no_mangle)] +pub extern "C" fn edgezero_wasm_smoke() -> u32 { + 1 +} diff --git a/.github/docker/build-app-cli/gate-paths.txt b/.github/docker/build-app-cli/gate-paths.txt new file mode 100644 index 00000000..cde6d4f9 --- /dev/null +++ b/.github/docker/build-app-cli/gate-paths.txt @@ -0,0 +1,108 @@ +.dockerignore +.github/CODEOWNERS +.github/actions/deploy-core/tests/assert-build-container-app-token.test.sh +.github/actions/deploy-core/tests/assert-build-container-completion.test.sh +.github/actions/deploy-core/tests/assert-build-container-context.test.sh +.github/actions/deploy-core/tests/assert-build-container-dispatch-context.test.sh +.github/actions/deploy-core/tests/build-container-workflows.test.sh +.github/actions/deploy-core/tests/check-action-pins.sh +.github/actions/deploy-core/tests/check-build-container-publisher.test.sh +.github/actions/deploy-core/tests/check-doc-action-pins.mjs +.github/actions/deploy-core/tests/check-doc-action-pins.sh +.github/actions/deploy-core/tests/check-doc-action-pins.test.mjs +.github/actions/deploy-core/tests/check-image-pin.test.sh +.github/actions/deploy-core/tests/classify-build-container-change.test.sh +.github/actions/deploy-core/tests/install-actionlint.test.sh +.github/actions/deploy-core/tests/install-yq.test.sh +.github/actions/deploy-core/tests/release-approval-gate.test.sh +.github/actions/deploy-core/tests/run-actionlint.test.sh +.github/actions/deploy-core/tests/run-build-container-gate.test.sh +.github/actions/deploy-core/tests/run.sh +.github/actions/deploy-core/tests/select-build-container-range.test.sh +.github/actions/deploy-core/tests/stage-build-context.test.sh +.github/actions/deploy-core/tests/update-image-pin-pr.test.sh +.github/actions/deploy-core/tests/verify-build-container-publication.test.sh +.github/actions/deploy-core/tests/verify-gate-rotation-lock.test.sh +.github/actions/deploy-core/tests/verify-published-image.test.sh +.github/actions/deploy-core/tests/verify-release-prerequisites.test.sh +.github/actions/deploy-core/tests/verify-toolchain.test.sh +.github/actions/deploy-core/tests/write-image-release-record.test.sh +.github/actions/deploy-core/tests/write-publisher-prerequisite.test.sh +.github/actions/deploy-fastly/versions.json +.github/docker/build-app-cli/Dockerfile +.github/docker/build-app-cli/assert-build-container-app-token.sh +.github/docker/build-app-cli/assert-build-container-completion.sh +.github/docker/build-app-cli/assert-build-container-context.sh +.github/docker/build-app-cli/assert-build-container-dispatch-context.sh +.github/docker/build-app-cli/check-build-container-publisher.sh +.github/docker/build-app-cli/check-image-pin.sh +.github/docker/build-app-cli/classify-build-container-change.sh +.github/docker/build-app-cli/fixtures/gnu-smoke.rs +.github/docker/build-app-cli/fixtures/provenance/invalid/archive-base256.tar +.github/docker/build-app-cli/fixtures/provenance/invalid/archive-checksum.tar +.github/docker/build-app-cli/fixtures/provenance/invalid/archive-duplicate.tar +.github/docker/build-app-cli/fixtures/provenance/invalid/archive-embedded-nul.tar +.github/docker/build-app-cli/fixtures/provenance/invalid/archive-end-block.tar +.github/docker/build-app-cli/fixtures/provenance/invalid/archive-extra-end-block.tar +.github/docker/build-app-cli/fixtures/provenance/invalid/archive-extra.tar +.github/docker/build-app-cli/fixtures/provenance/invalid/archive-gnu.tar +.github/docker/build-app-cli/fixtures/provenance/invalid/archive-header.tar +.github/docker/build-app-cli/fixtures/provenance/invalid/archive-link.tar +.github/docker/build-app-cli/fixtures/provenance/invalid/archive-max-size.tar +.github/docker/build-app-cli/fixtures/provenance/invalid/archive-octal-digit.tar +.github/docker/build-app-cli/fixtures/provenance/invalid/archive-octal-padding.tar +.github/docker/build-app-cli/fixtures/provenance/invalid/archive-order.tar +.github/docker/build-app-cli/fixtures/provenance/invalid/archive-padding.tar +.github/docker/build-app-cli/fixtures/provenance/invalid/archive-pax.tar +.github/docker/build-app-cli/fixtures/provenance/invalid/archive-size-limit.tar +.github/docker/build-app-cli/fixtures/provenance/invalid/archive-size.tar +.github/docker/build-app-cli/fixtures/provenance/invalid/archive-sparse.tar +.github/docker/build-app-cli/fixtures/provenance/invalid/archive-special.tar +.github/docker/build-app-cli/fixtures/provenance/invalid/archive-trailing.tar +.github/docker/build-app-cli/fixtures/provenance/invalid/archive-traversal.tar +.github/docker/build-app-cli/fixtures/provenance/invalid/elf-malformed/app-cli +.github/docker/build-app-cli/fixtures/provenance/invalid/expected-duplicate.json +.github/docker/build-app-cli/fixtures/provenance/invalid/meta-missing-interpreter.json +.github/docker/build-app-cli/fixtures/provenance/valid/archive.tar +.github/docker/build-app-cli/fixtures/provenance/valid/dynamic-meta.json +.github/docker/build-app-cli/fixtures/provenance/valid/elf-static/app-cli +.github/docker/build-app-cli/fixtures/provenance/valid/expected.json +.github/docker/build-app-cli/fixtures/provenance/valid/static-meta.json +.github/docker/build-app-cli/fixtures/wasm-smoke.rs +.github/docker/build-app-cli/gate-paths.txt +.github/docker/build-app-cli/image-context-paths.txt +.github/docker/build-app-cli/provenance.schema.json +.github/docker/build-app-cli/release-approval-gate.sh +.github/docker/build-app-cli/run-build-container-gate.sh +.github/docker/build-app-cli/select-build-container-range.sh +.github/docker/build-app-cli/stage-build-context.sh +.github/docker/build-app-cli/update-image-pin-pr.sh +.github/docker/build-app-cli/verify-build-container-publication.sh +.github/docker/build-app-cli/verify-gate-rotation-lock.sh +.github/docker/build-app-cli/verify-published-image.sh +.github/docker/build-app-cli/verify-release-prerequisites.sh +.github/docker/build-app-cli/verify-toolchain.sh +.github/docker/build-app-cli/write-image-release-record.sh +.github/docker/build-app-cli/write-publisher-prerequisite.sh +.github/tools/edgezero-provenance-validator/Cargo.lock +.github/tools/edgezero-provenance-validator/Cargo.toml +.github/tools/edgezero-provenance-validator/src/archive.rs +.github/tools/edgezero-provenance-validator/src/command.rs +.github/tools/edgezero-provenance-validator/src/elf.rs +.github/tools/edgezero-provenance-validator/src/extract.rs +.github/tools/edgezero-provenance-validator/src/json_contract.rs +.github/tools/edgezero-provenance-validator/src/lib.rs +.github/tools/edgezero-provenance-validator/src/main.rs +.github/tools/edgezero-provenance-validator/src/orchestration.rs +.github/tools/edgezero-provenance-validator/src/self_test.rs +.github/tools/edgezero-provenance-validator/tests/cli.rs +.github/workflows/build-container-ci.yml +.github/workflows/publish-build-container.yml +.github/workflows/rotate-build-container-gate.yml +.github/zizmor.yml +.tool-versions +docs/package-lock.json +docs/package.json +scripts/install-actionlint.sh +scripts/install-yq.sh +scripts/run-actionlint.sh diff --git a/.github/docker/build-app-cli/image-context-paths.txt b/.github/docker/build-app-cli/image-context-paths.txt new file mode 100644 index 00000000..dc1e7c1d --- /dev/null +++ b/.github/docker/build-app-cli/image-context-paths.txt @@ -0,0 +1,51 @@ +.dockerignore +.github/actions/deploy-fastly/versions.json +.github/docker/build-app-cli/Dockerfile +.github/docker/build-app-cli/fixtures/gnu-smoke.rs +.github/docker/build-app-cli/fixtures/provenance/invalid/archive-base256.tar +.github/docker/build-app-cli/fixtures/provenance/invalid/archive-checksum.tar +.github/docker/build-app-cli/fixtures/provenance/invalid/archive-duplicate.tar +.github/docker/build-app-cli/fixtures/provenance/invalid/archive-embedded-nul.tar +.github/docker/build-app-cli/fixtures/provenance/invalid/archive-end-block.tar +.github/docker/build-app-cli/fixtures/provenance/invalid/archive-extra-end-block.tar +.github/docker/build-app-cli/fixtures/provenance/invalid/archive-extra.tar +.github/docker/build-app-cli/fixtures/provenance/invalid/archive-gnu.tar +.github/docker/build-app-cli/fixtures/provenance/invalid/archive-header.tar +.github/docker/build-app-cli/fixtures/provenance/invalid/archive-link.tar +.github/docker/build-app-cli/fixtures/provenance/invalid/archive-max-size.tar +.github/docker/build-app-cli/fixtures/provenance/invalid/archive-octal-digit.tar +.github/docker/build-app-cli/fixtures/provenance/invalid/archive-octal-padding.tar +.github/docker/build-app-cli/fixtures/provenance/invalid/archive-order.tar +.github/docker/build-app-cli/fixtures/provenance/invalid/archive-padding.tar +.github/docker/build-app-cli/fixtures/provenance/invalid/archive-pax.tar +.github/docker/build-app-cli/fixtures/provenance/invalid/archive-size-limit.tar +.github/docker/build-app-cli/fixtures/provenance/invalid/archive-size.tar +.github/docker/build-app-cli/fixtures/provenance/invalid/archive-sparse.tar +.github/docker/build-app-cli/fixtures/provenance/invalid/archive-special.tar +.github/docker/build-app-cli/fixtures/provenance/invalid/archive-trailing.tar +.github/docker/build-app-cli/fixtures/provenance/invalid/archive-traversal.tar +.github/docker/build-app-cli/fixtures/provenance/invalid/elf-malformed/app-cli +.github/docker/build-app-cli/fixtures/provenance/invalid/expected-duplicate.json +.github/docker/build-app-cli/fixtures/provenance/invalid/meta-missing-interpreter.json +.github/docker/build-app-cli/fixtures/provenance/valid/archive.tar +.github/docker/build-app-cli/fixtures/provenance/valid/dynamic-meta.json +.github/docker/build-app-cli/fixtures/provenance/valid/elf-static/app-cli +.github/docker/build-app-cli/fixtures/provenance/valid/expected.json +.github/docker/build-app-cli/fixtures/provenance/valid/static-meta.json +.github/docker/build-app-cli/fixtures/wasm-smoke.rs +.github/docker/build-app-cli/image-context-paths.txt +.github/docker/build-app-cli/provenance.schema.json +.github/docker/build-app-cli/verify-toolchain.sh +.github/tools/edgezero-provenance-validator/Cargo.lock +.github/tools/edgezero-provenance-validator/Cargo.toml +.github/tools/edgezero-provenance-validator/src/archive.rs +.github/tools/edgezero-provenance-validator/src/command.rs +.github/tools/edgezero-provenance-validator/src/elf.rs +.github/tools/edgezero-provenance-validator/src/extract.rs +.github/tools/edgezero-provenance-validator/src/json_contract.rs +.github/tools/edgezero-provenance-validator/src/lib.rs +.github/tools/edgezero-provenance-validator/src/main.rs +.github/tools/edgezero-provenance-validator/src/orchestration.rs +.github/tools/edgezero-provenance-validator/src/self_test.rs +.github/tools/edgezero-provenance-validator/tests/cli.rs +.tool-versions diff --git a/.github/docker/build-app-cli/provenance.schema.json b/.github/docker/build-app-cli/provenance.schema.json new file mode 100644 index 00000000..0951a5e4 --- /dev/null +++ b/.github/docker/build-app-cli/provenance.schema.json @@ -0,0 +1,119 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "EdgeZero protocol-1 provenance", + "$comment": "JCS bytes, duplicate keys, UTF-8 byte limits, decimal u64 range, derivation, ABI ordering, and identity equality are enforced by the protocol validator.", + "oneOf": [{ "$ref": "#/$defs/expected" }, { "$ref": "#/$defs/metadata" }], + "$defs": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "not": { "pattern": "[\\u0000-\\u001f\\u007f-\\u009f/\\\\]" } + }, + "digest": { + "type": "string", + "minLength": 71, + "maxLength": 71, + "pattern": "^sha256:[0-9a-f]{64}$", + "not": { + "const": "sha256:0000000000000000000000000000000000000000000000000000000000000000" + } + }, + "caller": { + "type": "object", + "additionalProperties": false, + "required": [ + "app-cli-bin", + "app-cli-package", + "app-repo-id", + "source-revision", + "workspace-id" + ], + "properties": { + "app-cli-bin": { "$ref": "#/$defs/name" }, + "app-cli-package": { "$ref": "#/$defs/name" }, + "app-repo-id": { + "type": "string", + "minLength": 1, + "maxLength": 20, + "pattern": "^[1-9][0-9]*$" + }, + "source-revision": { + "type": "string", + "minLength": 40, + "maxLength": 40, + "pattern": "^[0-9a-f]{40}$", + "not": { "const": "0000000000000000000000000000000000000000" } + }, + "workspace-id": { "$ref": "#/$defs/digest" } + } + }, + "platform": { + "type": "object", + "additionalProperties": false, + "required": ["container-ref", "platform-id", "provenance-protocol"], + "properties": { + "container-ref": { + "type": "string", + "pattern": "^ghcr\\.io/stackpop/edgezero-build-app-cli@sha256:[0-9a-f]{64}$" + }, + "platform-id": { "$ref": "#/$defs/digest" }, + "provenance-protocol": { "type": "integer", "const": 1 } + } + }, + "abi": { + "type": "object", + "additionalProperties": false, + "required": ["interpreter", "machine", "needed"], + "properties": { + "interpreter": { "enum": [null, "/lib64/ld-linux-x86-64.so.2"] }, + "machine": { "type": "string", "const": "x86_64" }, + "needed": { + "type": "array", + "items": { + "allOf": [ + { "$ref": "#/$defs/name" }, + { "not": { "pattern": "\\$" } } + ] + } + } + } + }, + "expected": { + "type": "object", + "additionalProperties": false, + "required": ["caller", "platform", "schema-version"], + "properties": { + "caller": { "$ref": "#/$defs/caller" }, + "platform": { "$ref": "#/$defs/platform" }, + "schema-version": { "type": "integer", "const": 1 } + } + }, + "metadata": { + "type": "object", + "additionalProperties": false, + "required": [ + "abi", + "app-cli-version", + "binary-sha256", + "binary-size", + "caller", + "platform", + "schema-version" + ], + "properties": { + "abi": { "$ref": "#/$defs/abi" }, + "app-cli-version": { "$ref": "#/$defs/name" }, + "binary-sha256": { "$ref": "#/$defs/digest" }, + "binary-size": { + "type": "integer", + "minimum": 1, + "maximum": 536870912 + }, + "caller": { "$ref": "#/$defs/caller" }, + "platform": { "$ref": "#/$defs/platform" }, + "schema-version": { "type": "integer", "const": 1 } + } + } + } +} diff --git a/.github/docker/build-app-cli/release-approval-gate.sh b/.github/docker/build-app-cli/release-approval-gate.sh new file mode 100755 index 00000000..ae69cc5a --- /dev/null +++ b/.github/docker/build-app-cli/release-approval-gate.sh @@ -0,0 +1,456 @@ +#!/usr/bin/env bash +set -euo pipefail + +export LC_ALL=C +export BASH_ENV= +export ENV= +export GIT_CONFIG_NOSYSTEM=1 +export GIT_CONFIG_GLOBAL=/dev/null +export GIT_NO_REPLACE_OBJECTS=1 +unset GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE GIT_OBJECT_DIRECTORY +unset GIT_ALTERNATE_OBJECT_DIRECTORIES GIT_COMMON_DIR GIT_CEILING_DIRECTORIES +unset GIT_REPLACE_REF_BASE + +readonly API_VERSION=2026-03-10 +readonly API_ROOT=https://api.github.com/repos/stackpop/edgezero/actions/runs +readonly COMMENT_PREFIX='edgezero-release-evidence-v1 ' +readonly RELEASE_ENVIRONMENT=build-container-release +readonly U64_MAX=18446744073709551615 +readonly U32_MAX=4294967295 + +usage() { + printf '%s\n' \ + "usage: release-approval-gate.sh \\" \ + " --gate-root \\" \ + " --gate-sha \\" \ + " --run-id \\" \ + " --run-attempt \\" \ + " --build-attempt \\" \ + " --source-revision <40-lowercase-hex> \\" \ + " --release-tag > \\" \ + " --image-digest \\" \ + " --approval-challenge <64-lowercase-hex> \\" \ + ' --approval-out ' >&2 + exit 2 +} + +die() { + printf '::error::%s\n' "$*" >&2 + exit 1 +} + +tool_die() { + printf '::error::%s\n' "$*" >&2 + exit 2 +} + +is_positive_decimal_at_most() { + local value=$1 maximum=$2 + [[ "$value" =~ ^[1-9][0-9]*$ ]] || return 1 + ((${#value} < ${#maximum})) && return 0 + ((${#value} == ${#maximum})) && [[ "$value" < "$maximum" || "$value" == "$maximum" ]] +} + +isolated_git() { + env -i PATH="$PATH" LC_ALL=C HOME=/dev/null TMPDIR=/tmp \ + GIT_CONFIG_NOSYSTEM=1 GIT_CONFIG_GLOBAL=/dev/null GIT_TERMINAL_PROMPT=0 \ + GIT_NO_REPLACE_OBJECTS=1 GIT_OPTIONAL_LOCKS=0 \ + git --no-replace-objects -c core.fsmonitor=false -c core.hooksPath=/dev/null "$@" +} + +gate_git() { + isolated_git -C "$GATE_ROOT" "$@" +} + +temporary_files=() +remove_temporary_files() { + local path + for path in ${temporary_files[@]+"${temporary_files[@]}"}; do + if [[ -n "$path" ]]; then + rm -f -- "$path" 2>/dev/null || true + fi + done +} + +cleanup_exit() { + local status=$? + trap - EXIT HUP INT TERM + remove_temporary_files + exit "$status" +} + +cleanup_signal() { + local status=$1 + trap - EXIT HUP INT TERM + remove_temporary_files + exit "$status" +} + +trap cleanup_exit EXIT +trap 'cleanup_signal 129' HUP +trap 'cleanup_signal 130' INT +trap 'cleanup_signal 143' TERM + +curl_get() { + local label=$1 url=$2 result_name=$3 body metadata line + local -a metadata_lines=() + + body=$(mktemp "$OUTPUT_PARENT/.edgezero-github-body.XXXXXX" 2>/dev/null) || + die "cannot create API response file" + temporary_files+=("$body") + metadata=$(mktemp "$OUTPUT_PARENT/.edgezero-github-metadata.XXXXXX" 2>/dev/null) || + die "cannot create API metadata file" + temporary_files+=("$metadata") + + if ! printf '%s\n' \ + 'header = "Accept: application/vnd.github+json"' \ + 'header = "X-GitHub-Api-Version: 2026-03-10"' \ + 'header = "User-Agent: edgezero-build-container-gate/1"' \ + "header = \"Authorization: Bearer $TOKEN\"" | + env -i PATH="$PATH" LC_ALL=C curl \ + --disable --silent --show-error --connect-timeout 10 --max-time 30 --max-redirs 0 \ + --request GET --config - \ + --output "$body" \ + --write-out '%{http_code}\n%header{x-github-api-version-selected}\n%header{content-type}' \ + "$url" >"$metadata" 2>/dev/null; then + die "$label request failed" + fi + + while IFS= read -r line || [[ -n "$line" ]]; do + metadata_lines+=("$line") + done <"$metadata" + [[ "${#metadata_lines[@]}" -eq 3 ]] || die "$label response metadata is malformed" + [[ "${metadata_lines[0]}" == 200 ]] || die "$label response did not return HTTP 200" + [[ "${metadata_lines[1]}" == "$API_VERSION" ]] || + die "$label response selected an unexpected API version" + [[ "${metadata_lines[2]}" =~ ^[Aa][Pp][Pp][Ll][Ii][Cc][Aa][Tt][Ii][Oo][Nn]/[Jj][Ss][Oo][Nn]([[:space:]]*\;[[:space:]]*[Cc][Hh][Aa][Rr][Ss][Ee][Tt][[:space:]]*=[[:space:]]*[Uu][Tt][Ff]-8)?$ ]] || + die "$label response has an unsupported media type" + + printf -v "$result_name" '%s' "$body" +} + +validate_protocol_json_keys() { + local approvals_body=$1 protocol_count index raw_protocol + protocol_count=$(jq -er -s --arg prefix "$COMMENT_PREFIX" ' + if length == 1 and (.[0] | type) == "array" then + [.[0][] + | select(type == "object" + and (.comment | type) == "string" + and (.comment | startswith($prefix)))] + | length + else error("invalid approval body") + end + ' "$approvals_body" 2>/dev/null) || + die "approval history response is not one valid JSON array" + [[ "$protocol_count" =~ ^[0-9]+$ ]] || die "approval protocol count is malformed" + + index=0 + while ((index < protocol_count)); do + raw_protocol=$(mktemp "$OUTPUT_PARENT/.edgezero-approval-comment.XXXXXX" 2>/dev/null) || + die "cannot create protocol comment file" + temporary_files+=("$raw_protocol") + jq -e -j -r -s \ + --arg prefix "$COMMENT_PREFIX" \ + --argjson index "$index" ' + [.[0][] + | select(type == "object" + and (.comment | type) == "string" + and (.comment | startswith($prefix)))] + | .[$index].comment + | ltrimstr($prefix) + ' "$approvals_body" >"$raw_protocol" 2>/dev/null || + die "cannot extract protocol comment" + jq -ne --stream ' + [inputs | select(length == 2)] as $events + | (($events | length) > 0) + and all($events[]; ((.[0] | length) == 1)) + and ([$events[] | .[0][0]] as $keys + | ($keys | length) == ($keys | unique | length)) + ' "$raw_protocol" >/dev/null 2>&1 || + die "approval history contains duplicate or malformed protocol JSON keys" + index=$((index + 1)) + done +} + +GATE_ROOT= +GATE_SHA= +RUN_ID= +RUN_ATTEMPT= +BUILD_ATTEMPT= +SOURCE_REVISION= +RELEASE_TAG= +IMAGE_DIGEST= +APPROVAL_CHALLENGE= +APPROVAL_OUT= +seen_flags=' ' + +while (($#)); do + (($# >= 2)) || usage + flag=$1 + value=$2 + shift 2 + case "$flag" in + --gate-root | --gate-sha | --run-id | --run-attempt | --build-attempt | \ + --source-revision | --release-tag | --image-digest | --approval-challenge | \ + --approval-out) ;; + *) usage ;; + esac + [[ -n "$value" ]] || usage + [[ "$seen_flags" != *" $flag "* ]] || usage + seen_flags+="$flag " + case "$flag" in + --gate-root) GATE_ROOT=$value ;; + --gate-sha) GATE_SHA=$value ;; + --run-id) RUN_ID=$value ;; + --run-attempt) RUN_ATTEMPT=$value ;; + --build-attempt) BUILD_ATTEMPT=$value ;; + --source-revision) SOURCE_REVISION=$value ;; + --release-tag) RELEASE_TAG=$value ;; + --image-digest) IMAGE_DIGEST=$value ;; + --approval-challenge) APPROVAL_CHALLENGE=$value ;; + --approval-out) APPROVAL_OUT=$value ;; + esac +done + +for required in --gate-root --gate-sha --run-id --run-attempt --build-attempt \ + --source-revision --release-tag --image-digest --approval-challenge --approval-out; do + [[ "$seen_flags" == *" $required "* ]] || usage +done + +[[ "$GATE_SHA" =~ ^[0-9a-f]{40}$ ]] || die "gate SHA is not a full lowercase SHA" +is_positive_decimal_at_most "$RUN_ID" "$U64_MAX" || die "run id is not a canonical positive u64" +is_positive_decimal_at_most "$RUN_ATTEMPT" "$U32_MAX" || + die "run attempt is not a canonical positive u32" +is_positive_decimal_at_most "$BUILD_ATTEMPT" "$U32_MAX" || + die "build attempt is not a canonical positive u32" +[[ "$BUILD_ATTEMPT" == "$RUN_ATTEMPT" ]] || + die "build attempt differs from the current run attempt" +[[ "$SOURCE_REVISION" =~ ^[0-9a-f]{40}$ ]] || + die "source revision is not a full lowercase SHA" +[[ "$RELEASE_TAG" =~ ^build-container-v[1-9][0-9]*$ ]] || + die "release tag is not canonical" +[[ "$IMAGE_DIGEST" =~ ^sha256:[0-9a-f]{64}$ ]] || die "image digest is not canonical" +[[ "$APPROVAL_CHALLENGE" =~ ^[0-9a-f]{64}$ ]] || + die "approval challenge is not canonical" + +for tool in env git jq curl date mktemp stat chmod ln rm; do + command -v "$tool" >/dev/null 2>&1 || tool_die "release approval gate requires $tool" +done + +[[ "$GATE_ROOT" == /* && -d "$GATE_ROOT" && ! -L "$GATE_ROOT" ]] || + die "gate root must be an absolute, non-symlink directory" +CANONICAL_GATE_ROOT=$(cd -- "$GATE_ROOT" && pwd -P) || die "cannot resolve gate root" +[[ "$CANONICAL_GATE_ROOT" == "$GATE_ROOT" ]] || die "gate root must already be canonical" +[[ "$(gate_git rev-parse --is-inside-work-tree 2>/dev/null)" == true ]] || + die "gate root is not a Git worktree" +[[ "$(gate_git rev-parse --show-toplevel 2>/dev/null)" == "$GATE_ROOT" ]] || + die "gate root must be the exact repository top level" +GIT_DIRECTORY=$(gate_git rev-parse --absolute-git-dir 2>/dev/null) || + die "cannot resolve gate Git directory" +COMMON_GIT_DIRECTORY=$(gate_git rev-parse --path-format=absolute --git-common-dir 2>/dev/null) || + die "cannot resolve gate common Git directory" +[[ ! -e "$GIT_DIRECTORY/info/grafts" && ! -L "$GIT_DIRECTORY/info/grafts" && + ! -e "$COMMON_GIT_DIRECTORY/info/grafts" && ! -L "$COMMON_GIT_DIRECTORY/info/grafts" ]] || + die "gate checkout cannot contain legacy grafts" +REPLACEMENT_REFS=$(gate_git for-each-ref --format='%(refname)' refs/replace/) || + die "cannot inspect gate replacement refs" +[[ -z "$REPLACEMENT_REFS" ]] || + die "gate checkout cannot contain replacement refs" +[[ "$(gate_git rev-parse --is-shallow-repository 2>/dev/null)" == false ]] || + die "gate checkout must contain full history" +[[ "$(gate_git config --bool core.sparseCheckout 2>/dev/null || true)" != true ]] || + die "gate checkout cannot be sparse" +GATE_STATUS=$(gate_git status --porcelain=v1 --untracked-files=all) || + die "cannot inspect gate checkout status" +[[ -z "$GATE_STATUS" ]] || + die "gate checkout must be clean" +[[ "$(gate_git rev-parse --verify HEAD 2>/dev/null)" == "$GATE_SHA" ]] || + die "gate checkout HEAD differs from the supplied gate SHA" +if gate_git symbolic-ref -q HEAD >/dev/null 2>&1; then + die "gate checkout must be detached" +fi + +[[ "$APPROVAL_OUT" == /* && "$APPROVAL_OUT" != */ ]] || + die "approval output must be an absolute file path" +OUTPUT_NAME=${APPROVAL_OUT##*/} +OUTPUT_PARENT=${APPROVAL_OUT%/*} +[[ -n "$OUTPUT_PARENT" ]] || OUTPUT_PARENT=/ +[[ -n "$OUTPUT_NAME" && "$OUTPUT_NAME" != . && "$OUTPUT_NAME" != .. ]] || + die "approval output name is invalid" +[[ -d "$OUTPUT_PARENT" && ! -L "$OUTPUT_PARENT" ]] || + die "approval output parent must be a non-symlink directory" +CANONICAL_OUTPUT_PARENT=$(cd -- "$OUTPUT_PARENT" && pwd -P) || + die "cannot resolve approval output parent" +[[ "$CANONICAL_OUTPUT_PARENT" == "$OUTPUT_PARENT" ]] || + die "approval output parent must already be canonical" +if [[ "$OUTPUT_PARENT" == / ]]; then + EXPECTED_OUTPUT_PATH="/$OUTPUT_NAME" +else + EXPECTED_OUTPUT_PATH="$OUTPUT_PARENT/$OUTPUT_NAME" +fi +[[ "$EXPECTED_OUTPUT_PATH" == "$APPROVAL_OUT" ]] || + die "approval output must be directly below its parent" +[[ ! -e "$APPROVAL_OUT" && ! -L "$APPROVAL_OUT" ]] || die "approval output already exists" + +if OUTPUT_MODE=$(env -i PATH="$PATH" LC_ALL=C stat -f '%OLp' -- "$OUTPUT_PARENT" 2>/dev/null); then + : +elif OUTPUT_MODE=$(env -i PATH="$PATH" LC_ALL=C stat -c '%a' -- "$OUTPUT_PARENT" 2>/dev/null); then + : +else + tool_die "cannot inspect approval output directory permissions" +fi +[[ "$OUTPUT_MODE" == 700 ]] || die "approval output parent must have mode 0700" + +for repository_test in --is-inside-work-tree --is-inside-git-dir --is-bare-repository; do + [[ "$(isolated_git -C "$OUTPUT_PARENT" rev-parse "$repository_test" 2>/dev/null || true)" != true ]] || + die "approval output parent must be outside every Git repository" +done + +# Credential access deliberately follows all local trust, tooling, and output checks. +[[ -n "${GITHUB_TOKEN:-}" ]] || die "GitHub token is absent" +TOKEN=$GITHUB_TOKEN +unset GITHUB_TOKEN +[[ "$TOKEN" != *$'\n'* && "$TOKEN" != *$'\r'* && "$TOKEN" != *'"'* && "$TOKEN" != *\\* ]] || + die "GitHub token cannot be encoded safely" + +RUN_BODY= +curl_get "current run" "$API_ROOT/$RUN_ID" RUN_BODY +RUN_VALUES=$(jq -er -s ' + if length != 1 or (.[0] | type) != "object" then error("invalid run body") + else .[0] as $run + | if (($run.id | type) == "number") + and (($run.run_attempt | type) == "number") + and ($run.id == ($run.id | floor)) + and ($run.run_attempt == ($run.run_attempt | floor)) + then [($run.id | tostring), ($run.run_attempt | tostring)] | @tsv + else error("invalid run identifiers") + end + end +' "$RUN_BODY" 2>/dev/null) || die "current run response is not one valid JSON object" +IFS=$'\t' read -r API_RUN_ID API_RUN_ATTEMPT API_EXTRA <<<"$RUN_VALUES" +[[ -z "${API_EXTRA:-}" ]] || die "current run response identifiers are malformed" +is_positive_decimal_at_most "$API_RUN_ID" "$U64_MAX" || + die "current run API id is not a positive u64 integer" +is_positive_decimal_at_most "$API_RUN_ATTEMPT" "$U32_MAX" || + die "current run API attempt is not a positive u32 integer" +[[ "$API_RUN_ID" == "$RUN_ID" && "$API_RUN_ATTEMPT" == "$RUN_ATTEMPT" ]] || + die "current run API identifiers differ from the supplied context" + +APPROVALS_BODY= +curl_get "approval history" "$API_ROOT/$RUN_ID/approvals" APPROVALS_BODY +validate_protocol_json_keys "$APPROVALS_BODY" +EVIDENCE=$(jq -er -s \ + --arg prefix "$COMMENT_PREFIX" \ + --arg environment "$RELEASE_ENVIRONMENT" \ + --arg challenge "$APPROVAL_CHALLENGE" \ + --arg digest "$IMAGE_DIGEST" \ + --arg tag "$RELEASE_TAG" \ + --arg attempt "$RUN_ATTEMPT" \ + --arg run_id "$RUN_ID" \ + --arg source "$SOURCE_REVISION" ' + def decimal_at_most($maximum): + type == "string" + and test("^[1-9][0-9]*$") + and ((length < ($maximum | length)) + or (length == ($maximum | length) and . <= $maximum)); + def valid_protocol: + type == "object" + and keys == ["challenge","image-digest","png-sha256","release-tag","reviewed-at","run-attempt","run-id","source-revision"] + and all(.[]; type == "string") + and (.challenge | test("^[0-9a-f]{64}$")) + and (."image-digest" | test("^sha256:[0-9a-f]{64}$")) + and (."png-sha256" | test("^sha256:[0-9a-f]{64}$")) + and (."release-tag" | test("^build-container-v[1-9][0-9]*$")) + and (."reviewed-at" | test("^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$")) + and (."run-attempt" | decimal_at_most("4294967295")) + and (."run-id" | decimal_at_most("18446744073709551615")) + and (."source-revision" | test("^[0-9a-f]{40}$")); + def valid_login: + type == "string" + and test("^[A-Za-z0-9]([A-Za-z0-9-]{0,37}[A-Za-z0-9])?$") + and (contains("--") | not); + if length != 1 or (.[0] | type) != "array" then error("invalid approval body") + else + [.[0][] + | select(type == "object" + and (.comment | type) == "string" + and (.comment | startswith($prefix))) + | . as $review + | ($review.comment | ltrimstr($prefix) | fromjson) as $protocol + | {review:$review, protocol:$protocol} + ] as $records + | if all($records[]; + ((.protocol | type) == "object") + and (.protocol."run-attempt" | decimal_at_most("4294967295"))) + then $records + else error("unclassifiable protocol record") + end + | if all(.[]; ((.protocol."run-attempt" | tonumber) <= ($attempt | tonumber))) then . + else error("future protocol record") + end + | [.[] | select(.protocol."run-attempt" == $attempt)] as $current + | if ($current | length) != 1 then error("non-unique current record") + elif ($current[0].protocol | valid_protocol) then $current[0] + else error("malformed current record") + end + | . as $current + | if $current.protocol."run-id" == $run_id + and $current.protocol.challenge == $challenge + and $current.protocol."image-digest" == $digest + and $current.protocol."release-tag" == $tag + and $current.protocol."source-revision" == $source + and $current.review.state == "approved" + and (($current.review.environments | type) == "array") + and ($current.review.environments | length) == 1 + and (($current.review.environments[0] | type) == "object") + and $current.review.environments[0].name == $environment + and ($current.review.user.login | valid_login) + and $current.review.comment == ($prefix + ({ + "challenge":$current.protocol.challenge, + "image-digest":$current.protocol."image-digest", + "png-sha256":$current.protocol."png-sha256", + "release-tag":$current.protocol."release-tag", + "reviewed-at":$current.protocol."reviewed-at", + "run-attempt":$current.protocol."run-attempt", + "run-id":$current.protocol."run-id", + "source-revision":$current.protocol."source-revision" + } | tojson)) + then ({ + "approval-challenge":$current.protocol.challenge, + "approver-login":$current.review.user.login, + "image-digest":$current.protocol."image-digest", + "release-tag":$current.protocol."release-tag", + "reviewed-at":$current.protocol."reviewed-at", + "run-attempt":$current.protocol."run-attempt", + "run-id":$current.protocol."run-id", + "schema-version":1, + "screenshot-sha256":$current.protocol."png-sha256", + "source-revision":$current.protocol."source-revision" + } | tojson) + else error("current approval does not match") + end + end +' "$APPROVALS_BODY" 2>/dev/null) || die "approval history does not contain one valid current approval" + +REVIEWED_AT=$(jq -er '."reviewed-at"' <<<"$EVIDENCE" 2>/dev/null) || + die "approved review time is absent" +REVIEWED_EPOCH=$(jq -nr --arg value "$REVIEWED_AT" '$value | fromdateiso8601' 2>/dev/null) || + die "approved review time is not a valid UTC instant" +ROUND_TRIP=$(jq -nr --argjson epoch "$REVIEWED_EPOCH" \ + '$epoch | strftime("%Y-%m-%dT%H:%M:%SZ")' 2>/dev/null) || + die "approved review time cannot be normalized" +[[ "$ROUND_TRIP" == "$REVIEWED_AT" ]] || die "approved review time is not a real calendar instant" +NOW_EPOCH=$(env -i PATH="$PATH" LC_ALL=C date -u +%s 2>/dev/null) || + tool_die "cannot read the current UTC time" +[[ "$REVIEWED_EPOCH" =~ ^[0-9]+$ && "$NOW_EPOCH" =~ ^[0-9]+$ ]] || + tool_die "UTC time source returned a malformed epoch" +((REVIEWED_EPOCH <= NOW_EPOCH)) || die "approved review time is in the future" +((NOW_EPOCH - REVIEWED_EPOCH <= 900)) || die "approved review is stale" + +OUTPUT_TMP=$(mktemp "$OUTPUT_PARENT/.edgezero-release-approval.XXXXXX" 2>/dev/null) || + die "cannot create approval output" +temporary_files+=("$OUTPUT_TMP") +printf '%s' "$EVIDENCE" >"$OUTPUT_TMP" || die "cannot write approval output" +chmod 0600 "$OUTPUT_TMP" || die "cannot secure approval output" +ln -- "$OUTPUT_TMP" "$APPROVAL_OUT" 2>/dev/null || die "approval output appeared before publication" +[[ -f "$APPROVAL_OUT" && ! -L "$APPROVAL_OUT" ]] || die "published approval output is not regular" diff --git a/.github/docker/build-app-cli/run-build-container-gate.sh b/.github/docker/build-app-cli/run-build-container-gate.sh new file mode 100755 index 00000000..0ec89177 --- /dev/null +++ b/.github/docker/build-app-cli/run-build-container-gate.sh @@ -0,0 +1,414 @@ +#!/usr/bin/env bash +set -euo pipefail + +export LC_ALL=C +export BASH_ENV= +export ENV= +export GIT_CONFIG_NOSYSTEM=1 +export GIT_CONFIG_GLOBAL=/dev/null +export GIT_NO_REPLACE_OBJECTS=1 +unset GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE GIT_OBJECT_DIRECTORY +unset GIT_ALTERNATE_OBJECT_DIRECTORIES GIT_COMMON_DIR GIT_CEILING_DIRECTORIES +unset GIT_REPLACE_REF_BASE + +readonly CLASSIFIER_PATH=.github/docker/build-app-cli/classify-build-container-change.sh +readonly ACTION_PIN_CHECK_PATH=.github/actions/deploy-core/tests/check-action-pins.sh +readonly STAGER_PATH=.github/docker/build-app-cli/stage-build-context.sh +readonly CONTEXT_ASSERT_PATH=.github/docker/build-app-cli/assert-build-container-context.sh +readonly IMAGE_VERIFY_PATH=.github/docker/build-app-cli/verify-published-image.sh +readonly PIN_CHECK_PATH=.github/docker/build-app-cli/check-image-pin.sh +readonly PUBLISHER_CHECK_PATH=.github/docker/build-app-cli/check-build-container-publisher.sh +readonly PUBLICATION_VERIFY_PATH=.github/docker/build-app-cli/verify-build-container-publication.sh +readonly DOCKERFILE_PATH=.github/docker/build-app-cli/Dockerfile +readonly IMAGE_MANIFEST_PATH=.github/docker/build-app-cli/image-context-paths.txt +readonly IMAGE_RECORD_PATH=.github/docker/build-app-cli/image.json +readonly EVIDENCE_RECORD_PATH=.github/docker/build-app-cli/image-release-evidence.json + +usage() { + cat >&2 <<'EOF' +usage: run-build-container-gate.sh \ + --subject-root --gate-root \ + --base <40-lowercase-hex> --head <40-lowercase-hex> \ + --kind --gate-sha <40-lowercase-hex> \ + --release-state --work-root \ + --completion-file +EOF + exit 2 +} + +die() { + printf '::error::%s\n' "$*" >&2 + exit 1 +} + +require_value() { + (($# >= 2)) || usage + [[ -n "$2" ]] || usage +} + +set_once() { + local name=$1 current=$2 value=$3 + [[ -z "$current" ]] || usage + printf -v "$name" '%s' "$value" +} + +is_sha() { + [[ "$1" =~ ^[0-9a-f]{40}$ && "$1" != 0000000000000000000000000000000000000000 ]] +} + +is_digest() { + [[ "$1" =~ ^sha256:[0-9a-f]{64}$ && + "$1" != sha256:0000000000000000000000000000000000000000000000000000000000000000 ]] +} + +canonical_root() { + local supplied=$1 label=$2 canonical + [[ "$supplied" == /* && -d "$supplied" && ! -L "$supplied" ]] || + die "$label must be an absolute, non-symlink directory" + canonical=$(cd -- "$supplied" && pwd -P) || die "cannot resolve $label" + [[ "$canonical" == "$supplied" ]] || die "$label must already be canonical" + printf '%s\n' "$canonical" +} + +is_beneath() { + [[ "$1" == "$2" || "$1" == "$2/"* ]] +} + +repo_git() { + local root=$1 + shift + env -i PATH="$PATH" LC_ALL=C HOME=/dev/null TMPDIR=/tmp \ + GIT_CONFIG_NOSYSTEM=1 GIT_CONFIG_GLOBAL=/dev/null GIT_TERMINAL_PROMPT=0 \ + GIT_NO_REPLACE_OBJECTS=1 GIT_OPTIONAL_LOCKS=0 \ + git --no-replace-objects -c core.fsmonitor=false -c core.hooksPath=/dev/null \ + -C "$root" "$@" +} + +subject_git() { + repo_git "$SUBJECT_ROOT" "$@" +} + +gate_git() { + repo_git "$GATE_ROOT" "$@" +} + +require_checkout() { + local root=$1 expected=$2 label=$3 result_name=$4 + local actual status sparse top git_directory common_directory + [[ "$(repo_git "$root" rev-parse --is-inside-work-tree 2>/dev/null)" == true ]] || + die "$label is not a Git worktree" + top=$(repo_git "$root" rev-parse --show-toplevel 2>/dev/null) || + die "cannot resolve $label top level" + [[ "$top" == "$root" ]] || die "$label must be the exact repository top level" + git_directory=$(repo_git "$root" rev-parse --absolute-git-dir 2>/dev/null) || + die "cannot resolve $label Git directory" + common_directory=$(repo_git "$root" rev-parse --path-format=absolute --git-common-dir 2>/dev/null) || + die "cannot resolve $label common Git directory" + [[ ! -e "$git_directory/info/grafts" && ! -L "$git_directory/info/grafts" && + ! -e "$common_directory/info/grafts" && ! -L "$common_directory/info/grafts" ]] || + die "$label cannot contain legacy grafts" + [[ -z "$(repo_git "$root" for-each-ref --format='%(refname)' refs/replace/)" ]] || + die "$label cannot contain replacement refs" + [[ "$(repo_git "$root" rev-parse --is-shallow-repository 2>/dev/null)" == false ]] || + die "$label must be a full checkout" + actual=$(repo_git "$root" rev-parse --verify HEAD 2>/dev/null) || die "$label HEAD is absent" + [[ "$actual" == "$expected" ]] || die "$label HEAD differs from its supplied full SHA" + status=$(repo_git "$root" status --porcelain=v1 --untracked-files=all) || + die "cannot inspect $label status" + [[ -z "$status" ]] || die "$label must be clean" + sparse=$(repo_git "$root" config --bool core.sparseCheckout 2>/dev/null || true) + [[ "$sparse" != true ]] || die "$label cannot be sparse" + printf -v "$result_name" '%s' "$common_directory" +} + +require_gate_helper() { + local relative=$1 path="$GATE_ROOT/$1" + [[ -f "$path" && ! -L "$path" && -x "$path" ]] || + die "gate helper is missing, linked, or not executable: $relative" + printf '%s\n' "$path" +} + +tree_blob() { + local revision=$1 path=$2 destination=$3 entry metadata mode type recorded + entry=$(subject_git ls-tree "$revision" -- "$path") || + die "cannot inspect subject path: $path" + [[ -n "$entry" && "$entry" != *$'\n'* && "$entry" == *$'\t'* ]] || + die "subject path is missing or ambiguous: $path" + metadata=${entry%%$'\t'*} + recorded=${entry#*$'\t'} + read -r mode type _ <<<"$metadata" + [[ "$recorded" == "$path" && "$type" == blob && + ("$mode" == 100644 || "$mode" == 100755) ]] || + die "subject path is not a regular tracked file: $path" + mkdir -p -- "$(dirname -- "$destination")" + subject_git show "$revision:$path" >"$destination" || + die "cannot extract subject path: $path" + if [[ "$mode" == 100755 ]]; then chmod 0755 "$destination"; else chmod 0644 "$destination"; fi +} + +extract_subject_context() { + local destination=$1 manifest="$RUN_DIR/subject-image-context-paths.txt" path + tree_blob "$HEAD" "$IMAGE_MANIFEST_PATH" "$manifest" + mkdir -- "$destination" + while IFS= read -r path; do + [[ -n "$path" && "$path" != /* && "$path" != -* && "$path" != */ && + "$path" != *//* && "$path" != *\\* && "$path" != */../* && "$path" != ../* ]] || + die "candidate image manifest contains an invalid path" + tree_blob "$HEAD" "$path" "$destination/$path" + done <"$manifest" +} + +is_action_reference_path() { + local path=$1 relative + case "$path" in + .github/workflows/*.yml | .github/workflows/*.yaml) + relative=${path#.github/workflows/} + [[ "$relative" != */* ]] + ;; + action.yml | action.yaml | */action.yml | */action.yaml) return 0 ;; + *) return 1 ;; + esac +} + +scan_subject_action_references() { + local checker=$1 inventory="$RUN_DIR/subject-tree-paths" destination path result count=0 + local -a inputs=() + subject_git ls-tree -r -z --name-only "$HEAD" >"$inventory" || + die "cannot enumerate subject action-reference files" + while IFS= read -r -d '' path; do + is_action_reference_path "$path" || continue + [[ "$path" =~ ^[A-Za-z0-9._/+-]+$ && "$path" != /* && "$path" != -* && + "$path" != */ && "$path" != */../* && "$path" != ../* && + "$path" != */./* && "$path" != *//* ]] || + die "subject action-reference path is not canonical: $path" + destination="$RUN_DIR/action-reference-inputs/$path" + tree_blob "$HEAD" "$path" "$destination" + inputs[count]=$destination + count=$((count + 1)) + done <"$inventory" + ((count > 0)) || die "subject contains no action-reference files" + result=$(bash "$checker" ${inputs[@]+"${inputs[@]}"}) || + die "subject action-reference policy failed" + [[ "$result" =~ ^action\ reference\ policy\ passed\ \([1-9][0-9]*\ external\ references\)$ ]] || + die "subject action-reference scan was empty or malformed" +} + +publish_completion() { + local mode=$1 branch=$2 temporary="$RUN_DIR/completion" + printf 'kind=%s\nmode=%s\nbranch=%s\n' "$KIND" "$mode" "$branch" >"$temporary" + chmod 0600 "$temporary" + ln -- "$temporary" "$COMPLETION_FILE" 2>/dev/null || die "completion file already exists" + rm -f -- "$temporary" +} + +SUBJECT_ROOT= +GATE_ROOT= +BASE= +HEAD= +KIND= +GATE_SHA= +RELEASE_STATE= +WORK_ROOT= +COMPLETION_FILE= + +while (($#)); do + case "$1" in + --subject-root) + require_value "$@" + set_once SUBJECT_ROOT "$SUBJECT_ROOT" "$2" + shift 2 + ;; + --gate-root) + require_value "$@" + set_once GATE_ROOT "$GATE_ROOT" "$2" + shift 2 + ;; + --base) + require_value "$@" + set_once BASE "$BASE" "$2" + shift 2 + ;; + --head) + require_value "$@" + set_once HEAD "$HEAD" "$2" + shift 2 + ;; + --kind) + require_value "$@" + set_once KIND "$KIND" "$2" + shift 2 + ;; + --gate-sha) + require_value "$@" + set_once GATE_SHA "$GATE_SHA" "$2" + shift 2 + ;; + --release-state) + require_value "$@" + set_once RELEASE_STATE "$RELEASE_STATE" "$2" + shift 2 + ;; + --work-root) + require_value "$@" + set_once WORK_ROOT "$WORK_ROOT" "$2" + shift 2 + ;; + --completion-file) + require_value "$@" + set_once COMPLETION_FILE "$COMPLETION_FILE" "$2" + shift 2 + ;; + *) usage ;; + esac +done + +[[ -n "$SUBJECT_ROOT" && -n "$GATE_ROOT" && -n "$BASE" && -n "$HEAD" && + -n "$KIND" && -n "$GATE_SHA" && -n "$RELEASE_STATE" && -n "$WORK_ROOT" && + -n "$COMPLETION_FILE" ]] || usage +[[ "$KIND" == local || "$KIND" == pin ]] || usage +if ! is_sha "$BASE" || ! is_sha "$HEAD" || ! is_sha "$GATE_SHA"; then + usage +fi + +SUBJECT_ROOT=$(canonical_root "$SUBJECT_ROOT" "subject root") +GATE_ROOT=$(canonical_root "$GATE_ROOT" "gate root") +WORK_ROOT=$(canonical_root "$WORK_ROOT" "work root") +[[ "$SUBJECT_ROOT" != "$GATE_ROOT" ]] || die "gate and subject roots must differ" +if is_beneath "$WORK_ROOT" "$SUBJECT_ROOT" || is_beneath "$WORK_ROOT" "$GATE_ROOT"; then + die "work root must be outside both checkouts" +fi +[[ "$COMPLETION_FILE" == "$WORK_ROOT/"* && + "$(dirname -- "$COMPLETION_FILE")" == "$WORK_ROOT" ]] || + die "completion file must be a direct child of the work root" +[[ ! -e "$COMPLETION_FILE" && ! -L "$COMPLETION_FILE" ]] || + die "completion file already exists" + +require_checkout "$GATE_ROOT" "$GATE_SHA" "gate checkout" GATE_COMMON_DIRECTORY +require_checkout "$SUBJECT_ROOT" "$HEAD" "subject checkout" SUBJECT_COMMON_DIRECTORY +[[ "$GATE_COMMON_DIRECTORY" != "$SUBJECT_COMMON_DIRECTORY" ]] || + die "gate and subject roots must use separate Git repositories" +subject_git cat-file -e "$BASE^{commit}" 2>/dev/null || die "base commit is missing" +subject_git merge-base --is-ancestor "$BASE" "$HEAD" || + die "base must be an ancestor of head" + +SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P) +[[ "$SCRIPT_DIR/run-build-container-gate.sh" == "$GATE_ROOT/.github/docker/build-app-cli/run-build-container-gate.sh" ]] || + die "driver must execute from the canonical gate checkout" + +CLASSIFIER=$(require_gate_helper "$CLASSIFIER_PATH") +ACTION_PIN_CHECK=$(require_gate_helper "$ACTION_PIN_CHECK_PATH") +RUN_DIR=$(mktemp -d "$WORK_ROOT/gate-run.XXXXXX") +trap 'rm -rf -- "$RUN_DIR"' EXIT HUP INT TERM +scan_subject_action_references "$ACTION_PIN_CHECK" +CLASSIFICATION="$RUN_DIR/classification" +bash "$CLASSIFIER" \ + --subject-root "$SUBJECT_ROOT" \ + --gate-root "$GATE_ROOT" \ + --base "$BASE" \ + --head "$HEAD" \ + --kind "$KIND" \ + --gate-sha "$GATE_SHA" \ + --release-state "$RELEASE_STATE" \ + >"$CLASSIFICATION" + +if cmp -s <(printf 'mode=ordinary\nrelevant=true\n') "$CLASSIFICATION"; then + mode=ordinary + relevant=true +elif cmp -s <(printf 'mode=ordinary\nrelevant=false\n') "$CLASSIFICATION"; then + mode=ordinary + relevant=false +elif cmp -s <(printf 'mode=gate-update\nrelevant=true\n') "$CLASSIFICATION"; then + mode=gate-update + relevant=true +elif cmp -s <(printf 'mode=gate-rollback\nrelevant=true\n') "$CLASSIFICATION"; then + mode=gate-rollback + relevant=true +else + die "classifier output is missing, duplicated, malformed, or contradictory" +fi + +PUBLISHER_CHECK=$(require_gate_helper "$PUBLISHER_CHECK_PATH") +bash "$PUBLISHER_CHECK" \ + --gate-root "$GATE_ROOT" \ + --subject-root "$SUBJECT_ROOT" \ + --gate-sha "$GATE_SHA" \ + --candidate-sha "$HEAD" + +if [[ "$mode" == gate-update || "$mode" == gate-rollback ]]; then + CONTEXT_ASSERT=$(require_gate_helper "$CONTEXT_ASSERT_PATH") + SUBJECT_CONTEXT="$RUN_DIR/subject-context" + extract_subject_context "$SUBJECT_CONTEXT" + bash "$CONTEXT_ASSERT" --context "$SUBJECT_CONTEXT" + publish_completion "$mode" "$mode" + exit 0 +fi + +if [[ "$relevant" == false ]]; then + publish_completion ordinary not-applicable + exit 0 +fi + +if [[ "$KIND" == local ]]; then + STAGER=$(require_gate_helper "$STAGER_PATH") + CONTEXT_ASSERT=$(require_gate_helper "$CONTEXT_ASSERT_PATH") + IMAGE_VERIFY=$(require_gate_helper "$IMAGE_VERIFY_PATH") + BUILD_CONTEXT="$RUN_DIR/context" + IID_FILE="$RUN_DIR/image.iid" + bash "$STAGER" \ + --gate-root "$GATE_ROOT" \ + --source-root "$SUBJECT_ROOT" \ + --gate-sha "$GATE_SHA" \ + --source-sha "$HEAD" \ + --output "$BUILD_CONTEXT" + bash "$CONTEXT_ASSERT" --context "$BUILD_CONTEXT" + + DOCKER_BIN=$(command -v docker) || die "docker is required" + DOCKER_BIN=$(cd -- "$(dirname -- "$DOCKER_BIN")" && pwd -P)/$(basename -- "$DOCKER_BIN") + [[ -f "$DOCKER_BIN" && ! -L "$DOCKER_BIN" && -x "$DOCKER_BIN" ]] || + die "docker must be a regular executable" + if is_beneath "$DOCKER_BIN" "$SUBJECT_ROOT" || is_beneath "$DOCKER_BIN" "$GATE_ROOT"; then + die "docker cannot resolve from either checkout" + fi + "$DOCKER_BIN" build \ + --platform linux/amd64 \ + --provenance=false \ + --build-arg "IMAGE_SOURCE_REVISION=$HEAD" \ + --iidfile "$IID_FILE" \ + -f "$BUILD_CONTEXT/$DOCKERFILE_PATH" \ + "$BUILD_CONTEXT" + [[ -f "$IID_FILE" && ! -L "$IID_FILE" ]] || die "Docker did not create a regular iidfile" + image_id=$(sed -n '1p' "$IID_FILE") + [[ "$(wc -l <"$IID_FILE" | tr -d '[:space:]')" -eq 1 ]] || + die "Docker iidfile must contain exactly one line" + is_digest "$image_id" || die "Docker iidfile does not contain an immutable image ID" + bash "$IMAGE_VERIFY" --local-image-id "$image_id" --source-sha "$HEAD" --protocol 1 + publish_completion ordinary relevant + exit 0 +fi + +PIN_CHECK=$(require_gate_helper "$PIN_CHECK_PATH") +PUBLICATION_VERIFY=$(require_gate_helper "$PUBLICATION_VERIFY_PATH") +IMAGE_VERIFY=$(require_gate_helper "$IMAGE_VERIFY_PATH") +IMAGE_RECORD="$RUN_DIR/image.json" +EVIDENCE_RECORD="$RUN_DIR/image-release-evidence.json" +tree_blob "$HEAD" "$IMAGE_RECORD_PATH" "$IMAGE_RECORD" +tree_blob "$HEAD" "$EVIDENCE_RECORD_PATH" "$EVIDENCE_RECORD" +bash "$PIN_CHECK" validate-pair "$IMAGE_RECORD" "$EVIDENCE_RECORD" +bash "$PUBLICATION_VERIFY" \ + --gate-root "$GATE_ROOT" \ + --subject-root "$SUBJECT_ROOT" \ + --gate-sha "$GATE_SHA" \ + --candidate-sha "$HEAD" \ + --image-json "$IMAGE_RECORD" \ + --evidence-json "$EVIDENCE_RECORD" +runtime_ref=$(bash "$PIN_CHECK" runtime-ref "$IMAGE_RECORD") || die "cannot read runtime image ref" +source_revision=$(bash "$PIN_CHECK" source-revision "$IMAGE_RECORD") || + die "cannot read image source revision" +protocol=$(bash "$PIN_CHECK" provenance-protocol "$IMAGE_RECORD") || + die "cannot read image protocol" +[[ "$runtime_ref" =~ ^ghcr\.io/stackpop/edgezero-build-app-cli@sha256:[0-9a-f]{64}$ ]] || + die "pin checker returned an invalid runtime ref" +is_sha "$source_revision" || die "pin checker returned an invalid source revision" +[[ "$protocol" == 1 ]] || die "pin checker returned an invalid protocol" +bash "$IMAGE_VERIFY" --ref "$runtime_ref" --source-sha "$source_revision" --protocol "$protocol" +publish_completion ordinary relevant diff --git a/.github/docker/build-app-cli/select-build-container-range.sh b/.github/docker/build-app-cli/select-build-container-range.sh new file mode 100755 index 00000000..3b9d8039 --- /dev/null +++ b/.github/docker/build-app-cli/select-build-container-range.sh @@ -0,0 +1,322 @@ +#!/usr/bin/env bash +set -euo pipefail + +export LC_ALL=C +export BASH_ENV= +export ENV= +export GIT_CONFIG_NOSYSTEM=1 +export GIT_CONFIG_GLOBAL=/dev/null +export GIT_NO_REPLACE_OBJECTS=1 +unset GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE GIT_OBJECT_DIRECTORY +unset GIT_ALTERNATE_OBJECT_DIRECTORIES GIT_COMMON_DIR GIT_CEILING_DIRECTORIES +unset GIT_REPLACE_REF_BASE + +readonly EXPECTED_REPOSITORY=stackpop/edgezero +readonly MAIN_REF=refs/heads/main +readonly QUEUE_PREFIX=refs/heads/gh-readonly-queue/main/ +readonly ZERO_SHA=0000000000000000000000000000000000000000 +readonly MAX_EVENT_BYTES=1048576 + +usage() { + cat >&2 <<'EOF' +usage: select-build-container-range.sh \ + --event-name \ + --github-repository \ + --github-sha <40-lowercase-hex> \ + --github-workflow-sha <40-lowercase-hex> \ + --github-ref --github-ref-protected \ + --gate-sha <40-lowercase-hex> --gate-root \ + --event-json --subject-root +EOF + exit 2 +} + +die() { + printf '::error::%s\n' "$*" >&2 + exit 1 +} + +require_value() { + (($# >= 2)) || usage + [[ -n "$2" ]] || usage +} + +set_once() { + local name=$1 current=$2 value=$3 + [[ -z "$current" ]] || usage + printf -v "$name" '%s' "$value" +} + +is_sha() { + [[ "$1" =~ ^[0-9a-f]{40}$ && "$1" != "$ZERO_SHA" ]] +} + +canonical_directory() { + local supplied=$1 label=$2 canonical + [[ "$supplied" == /* && -d "$supplied" && ! -L "$supplied" ]] || + die "$label must be an absolute, non-symlink directory" + canonical=$(cd -- "$supplied" && pwd -P) || die "cannot resolve $label" + [[ "$canonical" == "$supplied" ]] || die "$label must already be canonical" + printf '%s\n' "$canonical" +} + +canonical_file() { + local supplied=$1 label=$2 directory canonical size + [[ "$supplied" == /* && -f "$supplied" && ! -L "$supplied" ]] || + die "$label must be an absolute, regular non-symlink file" + directory=$(dirname -- "$supplied") + canonical=$(cd -- "$directory" && pwd -P)/$(basename -- "$supplied") || + die "cannot resolve $label" + [[ "$canonical" == "$supplied" ]] || die "$label must already be canonical" + size=$(wc -c <"$supplied" | tr -d '[:space:]') || die "cannot size $label" + [[ "$size" =~ ^[0-9]+$ && "$size" -gt 0 && "$size" -le "$MAX_EVENT_BYTES" ]] || + die "$label is empty or exceeds its size limit" + printf '%s\n' "$canonical" +} + +subject_git() { + env -i PATH="$PATH" LC_ALL=C HOME=/dev/null TMPDIR=/tmp \ + GIT_CONFIG_NOSYSTEM=1 GIT_CONFIG_GLOBAL=/dev/null GIT_TERMINAL_PROMPT=0 \ + GIT_NO_REPLACE_OBJECTS=1 GIT_OPTIONAL_LOCKS=0 \ + git --no-replace-objects -c core.fsmonitor=false -c core.hooksPath=/dev/null \ + -C "$SUBJECT_ROOT" "$@" +} + +gate_git() { + SUBJECT_ROOT="$GATE_ROOT" subject_git "$@" +} + +require_commit() { + local revision=$1 label=$2 type + is_sha "$revision" || die "$label must be a nonzero full lowercase SHA" + type=$(subject_git cat-file -t "$revision" 2>/dev/null) || die "$label commit is missing" + [[ "$type" == commit ]] || die "$label does not name a commit object" +} + +require_ancestor() { + subject_git merge-base --is-ancestor "$1" "$2" || + die "$3 must be an ancestor of or equal to $4" +} + +json_string() { + local filter=$1 label=$2 value + jq -e "$filter | type == \"string\" and (test(\"[\\u0000-\\u001f\\u007f]\") | not)" \ + "$EVENT_JSON" >/dev/null 2>&1 || die "$label is missing, malformed, or contains controls" + value=$(jq -er "$filter" "$EVENT_JSON" 2>/dev/null) || + die "$label is missing or is not a string" + printf '%s\n' "$value" +} + +json_positive_integer() { + local filter=$1 label=$2 value + value=$(jq -er "$filter | select(type == \"number\" and . >= 1 and . == floor) | tostring" \ + "$EVENT_JSON" 2>/dev/null) || die "$label is not a positive integer" + [[ "$value" =~ ^[1-9][0-9]*$ ]] || die "$label is not canonical" + printf '%s\n' "$value" +} + +emit_range() { + printf 'base=%s\nhead=%s\n' "$1" "$2" +} + +EVENT_NAME= +GITHUB_REPOSITORY_VALUE= +GITHUB_SHA_VALUE= +GITHUB_WORKFLOW_SHA_VALUE= +GITHUB_REF_VALUE= +GITHUB_REF_PROTECTED_VALUE= +GATE_SHA= +GATE_ROOT= +EVENT_JSON= +SUBJECT_ROOT= + +while (($#)); do + case "$1" in + --event-name) + require_value "$@" + set_once EVENT_NAME "$EVENT_NAME" "$2" + shift 2 + ;; + --github-repository) + require_value "$@" + set_once GITHUB_REPOSITORY_VALUE "$GITHUB_REPOSITORY_VALUE" "$2" + shift 2 + ;; + --github-sha) + require_value "$@" + set_once GITHUB_SHA_VALUE "$GITHUB_SHA_VALUE" "$2" + shift 2 + ;; + --github-workflow-sha) + require_value "$@" + set_once GITHUB_WORKFLOW_SHA_VALUE "$GITHUB_WORKFLOW_SHA_VALUE" "$2" + shift 2 + ;; + --github-ref) + require_value "$@" + set_once GITHUB_REF_VALUE "$GITHUB_REF_VALUE" "$2" + shift 2 + ;; + --github-ref-protected) + require_value "$@" + set_once GITHUB_REF_PROTECTED_VALUE "$GITHUB_REF_PROTECTED_VALUE" "$2" + shift 2 + ;; + --gate-sha) + require_value "$@" + set_once GATE_SHA "$GATE_SHA" "$2" + shift 2 + ;; + --gate-root) + require_value "$@" + set_once GATE_ROOT "$GATE_ROOT" "$2" + shift 2 + ;; + --event-json) + require_value "$@" + set_once EVENT_JSON "$EVENT_JSON" "$2" + shift 2 + ;; + --subject-root) + require_value "$@" + set_once SUBJECT_ROOT "$SUBJECT_ROOT" "$2" + shift 2 + ;; + *) usage ;; + esac +done + +[[ -n "$EVENT_NAME" && -n "$GITHUB_REPOSITORY_VALUE" && -n "$GITHUB_SHA_VALUE" && + -n "$GITHUB_WORKFLOW_SHA_VALUE" && -n "$GITHUB_REF_VALUE" && + -n "$GITHUB_REF_PROTECTED_VALUE" && -n "$GATE_SHA" && -n "$EVENT_JSON" && + -n "$GATE_ROOT" && -n "$SUBJECT_ROOT" ]] || usage +[[ "$GITHUB_REPOSITORY_VALUE" == "$EXPECTED_REPOSITORY" ]] || + die "workflow repository must be $EXPECTED_REPOSITORY" +[[ "$GITHUB_REF_PROTECTED_VALUE" == true || "$GITHUB_REF_PROTECTED_VALUE" == false ]] || + die "github ref protection must be an exact boolean" +if ! is_sha "$GITHUB_SHA_VALUE" || ! is_sha "$GITHUB_WORKFLOW_SHA_VALUE" || ! is_sha "$GATE_SHA"; then + usage +fi + +SUBJECT_ROOT=$(canonical_directory "$SUBJECT_ROOT" "subject root") +GATE_ROOT=$(canonical_directory "$GATE_ROOT" "gate root") +[[ "$SUBJECT_ROOT" != "$GATE_ROOT" ]] || die "subject and gate roots must differ" +EVENT_JSON=$(canonical_file "$EVENT_JSON" "event JSON") +jq -e 'type == "object"' "$EVENT_JSON" >/dev/null 2>&1 || die "event JSON must be one object" + +[[ "$(subject_git rev-parse --is-inside-work-tree 2>/dev/null)" == true ]] || + die "subject root is not a Git worktree" +[[ "$(subject_git rev-parse --show-toplevel 2>/dev/null)" == "$SUBJECT_ROOT" ]] || + die "subject root must be the exact repository top level" +[[ "$(subject_git rev-parse --is-shallow-repository 2>/dev/null)" == false ]] || + die "subject checkout must contain full history" +[[ "$(subject_git config --bool core.sparseCheckout 2>/dev/null || true)" != true ]] || + die "subject checkout cannot be sparse" +[[ -z "$(subject_git status --porcelain=v1 --untracked-files=all)" ]] || + die "subject checkout must be clean" +[[ -z "$(subject_git for-each-ref --format='%(refname)' refs/replace/)" ]] || + die "subject checkout cannot contain replacement refs" +GIT_DIRECTORY=$(subject_git rev-parse --absolute-git-dir 2>/dev/null) || + die "cannot resolve subject Git directory" +SUBJECT_COMMON_DIRECTORY=$(subject_git rev-parse --path-format=absolute --git-common-dir 2>/dev/null) || + die "cannot resolve subject common Git directory" +[[ ! -e "$GIT_DIRECTORY/info/grafts" && ! -L "$GIT_DIRECTORY/info/grafts" && + ! -e "$SUBJECT_COMMON_DIRECTORY/info/grafts" && ! -L "$SUBJECT_COMMON_DIRECTORY/info/grafts" ]] || + die "subject checkout cannot contain legacy grafts" + +require_commit "$GITHUB_SHA_VALUE" "github SHA" +require_commit "$GATE_SHA" "active gate SHA" +[[ "$(subject_git rev-parse --verify HEAD 2>/dev/null)" == "$GITHUB_SHA_VALUE" ]] || + die "subject HEAD differs from github SHA" + +[[ "$(gate_git rev-parse --is-inside-work-tree 2>/dev/null)" == true ]] || + die "gate root is not a Git worktree" +[[ "$(gate_git rev-parse --show-toplevel 2>/dev/null)" == "$GATE_ROOT" ]] || + die "gate root must be the exact repository top level" +[[ "$(gate_git rev-parse --is-shallow-repository 2>/dev/null)" == false ]] || + die "gate checkout must contain full history" +[[ "$(gate_git config --bool core.sparseCheckout 2>/dev/null || true)" != true ]] || + die "gate checkout cannot be sparse" +[[ -z "$(gate_git status --porcelain=v1 --untracked-files=all)" ]] || + die "gate checkout must be clean" +[[ -z "$(gate_git for-each-ref --format='%(refname)' refs/replace/)" ]] || + die "gate checkout cannot contain replacement refs" +GATE_GIT_DIRECTORY=$(gate_git rev-parse --absolute-git-dir 2>/dev/null) || + die "cannot resolve gate Git directory" +GATE_COMMON_DIRECTORY=$(gate_git rev-parse --path-format=absolute --git-common-dir 2>/dev/null) || + die "cannot resolve gate common Git directory" +[[ "$GATE_COMMON_DIRECTORY" != "$SUBJECT_COMMON_DIRECTORY" ]] || + die "gate and subject roots must use separate Git repositories" +[[ ! -e "$GATE_GIT_DIRECTORY/info/grafts" && ! -L "$GATE_GIT_DIRECTORY/info/grafts" && + ! -e "$GATE_COMMON_DIRECTORY/info/grafts" && ! -L "$GATE_COMMON_DIRECTORY/info/grafts" ]] || + die "gate checkout cannot contain legacy grafts" +[[ "$(gate_git cat-file -t "$GATE_SHA" 2>/dev/null)" == commit ]] || + die "active gate is not a commit in gate checkout" +[[ "$(gate_git rev-parse --verify HEAD 2>/dev/null)" == "$GATE_SHA" ]] || + die "gate checkout HEAD differs from active gate SHA" + +case "$EVENT_NAME" in + pull_request) + [[ "$GITHUB_WORKFLOW_SHA_VALUE" == "$GATE_SHA" ]] || + die "required pull-request workflow must execute at active gate SHA" + PR_NUMBER=$(json_positive_integer '.number' 'pull request number') + BASE_REPOSITORY=$(json_string '.pull_request.base.repo.full_name' 'pull request base repository') + BASE_REF=$(json_string '.pull_request.base.ref' 'pull request base ref') + A=$(json_string '.pull_request.base.sha' 'pull request payload base SHA') + J=$(json_string '.pull_request.head.sha' 'pull request payload head SHA') + [[ "$BASE_REPOSITORY" == "$EXPECTED_REPOSITORY" ]] || + die "pull request base repository is not trusted" + [[ "$BASE_REF" == main ]] || die "pull request base ref must be main" + [[ "$GITHUB_REF_VALUE" == "refs/pull/$PR_NUMBER/merge" ]] || + die "pull request context ref does not match its number" + require_commit "$A" "pull request payload base" + require_commit "$J" "pull request payload head" + read -r -a PARENTS <<<"$(subject_git rev-list --parents -n 1 "$GITHUB_SHA_VALUE")" + [[ "${#PARENTS[@]}" -eq 3 && "${PARENTS[0]}" == "$GITHUB_SHA_VALUE" ]] || + die "pull request candidate must have exactly two ordered parents" + F=${PARENTS[1]} + require_commit "$F" "pull request synthetic first parent" + [[ "${PARENTS[2]}" == "$J" ]] || + die "pull request synthetic second parent differs from payload head" + require_ancestor "$A" "$F" "pull request payload base" "synthetic first parent" + emit_range "$F" "$GITHUB_SHA_VALUE" + ;; + merge_group) + [[ "$GITHUB_WORKFLOW_SHA_VALUE" == "$GATE_SHA" ]] || + die "required merge-group workflow must execute at active gate SHA" + ACTION=$(json_string '.action' 'merge group action') + BASE=$(json_string '.merge_group.base_sha' 'merge group base SHA') + HEAD=$(json_string '.merge_group.head_sha' 'merge group head SHA') + BASE_REF=$(json_string '.merge_group.base_ref' 'merge group base ref') + HEAD_REF=$(json_string '.merge_group.head_ref' 'merge group head ref') + [[ "$ACTION" == checks_requested ]] || die "merge group action must be checks_requested" + [[ "$BASE_REF" == "$MAIN_REF" ]] || die "merge group base ref must be protected main" + [[ "$HEAD_REF" == "$QUEUE_PREFIX"* && "$HEAD_REF" != "$QUEUE_PREFIX" ]] || + die "merge group head ref is outside the protected-main queue" + [[ "$HEAD_REF" == "$GITHUB_REF_VALUE" ]] || + die "merge group payload and context head refs differ" + [[ "$HEAD" == "$GITHUB_SHA_VALUE" ]] || die "merge group payload and context SHAs differ" + require_commit "$BASE" "merge group base" + require_commit "$HEAD" "merge group head" + require_ancestor "$BASE" "$HEAD" "merge group base" "merge group head" + emit_range "$BASE" "$HEAD" + ;; + push) + [[ "$GITHUB_REF_PROTECTED_VALUE" == true ]] || die "push ref must be protected" + [[ "$GITHUB_REF_VALUE" == "$MAIN_REF" ]] || die "push ref must be protected main" + [[ "$GITHUB_WORKFLOW_SHA_VALUE" == "$GITHUB_SHA_VALUE" ]] || + die "push workflow revision must equal github SHA" + PAYLOAD_REPOSITORY=$(json_string '.repository.full_name' 'push repository') + BASE=$(json_string '.before' 'push before SHA') + HEAD=$(json_string '.after' 'push after SHA') + [[ "$PAYLOAD_REPOSITORY" == "$EXPECTED_REPOSITORY" ]] || + die "push payload repository is not trusted" + [[ "$BASE" != "$ZERO_SHA" ]] || die "first push has no authenticated base" + [[ "$HEAD" == "$GITHUB_SHA_VALUE" ]] || die "push payload after differs from github SHA" + require_commit "$BASE" "push base" + require_commit "$HEAD" "push head" + require_ancestor "$BASE" "$HEAD" "push base" "push head" + emit_range "$BASE" "$HEAD" + ;; + *) die "event is not a build-container range-consumer event" ;; +esac diff --git a/.github/docker/build-app-cli/stage-build-context.sh b/.github/docker/build-app-cli/stage-build-context.sh new file mode 100755 index 00000000..1ab98399 --- /dev/null +++ b/.github/docker/build-app-cli/stage-build-context.sh @@ -0,0 +1,219 @@ +#!/usr/bin/env bash +set -euo pipefail + +die() { + printf 'stage-build-context: %s\n' "$*" >&2 + exit 1 +} + +gate_root= +source_root= +gate_sha= +source_sha= +output= + +while (($#)); do + (($# >= 2)) || die "missing value for $1" + case "$1" in + --gate-root) + [[ -z "$gate_root" ]] || die "duplicate --gate-root" + gate_root=$2 + ;; + --source-root) + [[ -z "$source_root" ]] || die "duplicate --source-root" + source_root=$2 + ;; + --gate-sha) + [[ -z "$gate_sha" ]] || die "duplicate --gate-sha" + gate_sha=$2 + ;; + --source-sha) + [[ -z "$source_sha" ]] || die "duplicate --source-sha" + source_sha=$2 + ;; + --output) + [[ -z "$output" ]] || die "duplicate --output" + output=$2 + ;; + *) die "unknown argument: $1" ;; + esac + shift 2 +done + +[[ -n "$gate_root" ]] || die "--gate-root is required" +[[ -n "$source_root" ]] || die "--source-root is required" +[[ "$gate_sha" =~ ^[0-9a-f]{40}$ ]] || die "--gate-sha must be a full lowercase SHA" +[[ "$source_sha" =~ ^[0-9a-f]{40}$ ]] || die "--source-sha must be a full lowercase SHA" +[[ -n "$output" ]] || die "--output is required" + +canonical_repo() { + local root=$1 + local canonical top + canonical=$(cd -- "$root" 2>/dev/null && pwd -P) || return 1 + top=$(git -C "$canonical" rev-parse --show-toplevel 2>/dev/null) || return 1 + top=$(cd -- "$top" 2>/dev/null && pwd -P) || return 1 + [[ "$canonical" == "$top" ]] || return 1 + printf '%s\n' "$canonical" +} + +gate_root=$(canonical_repo "$gate_root") || die "gate root is not a canonical repository root" +source_root=$(canonical_repo "$source_root") || die "source root is not a canonical repository root" +[[ "$gate_root" != "$source_root" ]] || die "gate and source roots must differ" + +assert_checkout() { + local root=$1 expected=$2 role=$3 head sparse + head=$(git --no-replace-objects -C "$root" rev-parse --verify HEAD 2>/dev/null) || + die "$role HEAD is unavailable" + [[ "$head" == "$expected" ]] || die "$role HEAD does not match its supplied SHA" + [[ -z "$(git --no-replace-objects -C "$root" status --porcelain=v1 --untracked-files=all)" ]] || + die "$role checkout is dirty" + sparse=$(git --no-replace-objects -C "$root" config --bool core.sparseCheckout 2>/dev/null || true) + [[ "$sparse" != "true" ]] || die "$role checkout is sparse" +} + +assert_checkout "$gate_root" "$gate_sha" gate +assert_checkout "$source_root" "$source_sha" source +git --no-replace-objects -C "$source_root" merge-base --is-ancestor "$gate_sha" "$source_sha" || + die "gate SHA is not an ancestor of source SHA" + +[[ ! -e "$output" && ! -L "$output" ]] || die "output already exists" +output_parent=$(cd -- "$(dirname -- "$output")" 2>/dev/null && pwd -P) || + die "output parent does not exist" +output_name=$(basename -- "$output") +[[ "$output_name" != "." && "$output_name" != ".." && "$output_name" != */* ]] || + die "invalid output basename" +output="$output_parent/$output_name" + +is_beneath() { + local child=$1 parent=$2 + [[ "$child" == "$parent" || "$child" == "$parent/"* ]] +} + +! is_beneath "$output" "$gate_root" || die "output must be outside the gate root" +! is_beneath "$output" "$source_root" || die "output must be outside the source root" + +manifest=.github/docker/build-app-cli/image-context-paths.txt +gate_manifest=.github/docker/build-app-cli/gate-paths.txt +[[ -f "$gate_root/$manifest" && ! -L "$gate_root/$manifest" ]] || die "context manifest is not regular" +[[ -f "$gate_root/$gate_manifest" && ! -L "$gate_root/$gate_manifest" ]] || die "gate manifest is not regular" + +valid_path() { + local path=$1 component + [[ -n "$path" && "$path" != /* && "$path" != */ && "$path" != *//* && "$path" != *\\* ]] || + return 1 + while IFS= read -r component; do + [[ -n "$component" && "$component" != "." && "$component" != ".." ]] || return 1 + done < <(printf '%s' "$path" | tr '/' '\n') +} + +canonical_manifest() { + local path=$1 previous='' line='' + [[ -s "$path" ]] || return 1 + [[ "$(tail -c 1 "$path" | wc -l | tr -d ' ')" == "1" ]] || return 1 + while IFS= read -r line; do + valid_path "$line" || return 1 + [[ -z "$previous" || "$previous" < "$line" ]] || return 1 + previous=$line + done <"$path" +} + +canonical_manifest "$gate_root/$manifest" || die "context manifest is not canonical" +canonical_manifest "$gate_root/$gate_manifest" || die "gate manifest is not canonical" + +expected=$(mktemp "${TMPDIR:-/tmp}/edgezero-image-context.XXXXXX") +cleanup_expected() { + rm -f -- "$expected" +} +trap cleanup_expected EXIT + +{ + printf '%s\n' \ + .dockerignore \ + .github/actions/deploy-fastly/versions.json \ + .github/docker/build-app-cli/Dockerfile \ + .github/docker/build-app-cli/fixtures/gnu-smoke.rs \ + .github/docker/build-app-cli/fixtures/wasm-smoke.rs \ + .github/docker/build-app-cli/image-context-paths.txt \ + .github/docker/build-app-cli/provenance.schema.json \ + .github/docker/build-app-cli/verify-toolchain.sh \ + .tool-versions + git --no-replace-objects -C "$gate_root" ls-files -- \ + .github/docker/build-app-cli/fixtures/provenance \ + .github/tools/edgezero-provenance-validator +} | LC_ALL=C sort >"$expected" + +cmp -s "$expected" "$gate_root/$manifest" || die "context manifest does not match its closed inputs" + +git_mode() { + local root=$1 path=$2 record mode stage indexed_path + record=$(git --no-replace-objects -C "$root" ls-files --stage -- "$path") || return 1 + [[ -n "$record" && "$record" != *$'\n'* ]] || return 1 + IFS=$' \t' read -r mode _ stage indexed_path <<<"$record" + [[ "$stage" == "0" && "$indexed_path" == "$path" ]] || return 1 + [[ "$mode" == "100644" || "$mode" == "100755" ]] || return 1 + printf '%s\n' "$mode" +} + +link_count() { + if stat -c '%h' -- "$1" >/dev/null 2>&1; then + stat -c '%h' -- "$1" + else + stat -f '%l' -- "$1" + fi +} + +assert_not_sparse() { + local path=$1 size blocks + if stat -c '%s %b' -- "$path" >/dev/null 2>&1; then + read -r size blocks < <(stat -c '%s %b' -- "$path") + else + read -r size blocks < <(stat -f '%z %b' -- "$path") + fi + ((size == 0 || blocks * 512 >= size)) +} + +while IFS= read -r path; do + grep -Fqx -- "$path" "$gate_root/$gate_manifest" || die "$path is absent from the gate manifest" + gate_mode=$(git_mode "$gate_root" "$path") || die "$path has an invalid gate index mode" + source_mode=$(git_mode "$source_root" "$path") || die "$path has an invalid source index mode" + [[ "$gate_mode" == "$source_mode" ]] || die "$path mode differs between gate and source" + [[ -f "$gate_root/$path" && ! -L "$gate_root/$path" ]] || die "$path is not a regular gate file" + [[ -f "$source_root/$path" && ! -L "$source_root/$path" ]] || die "$path is not a regular source file" + [[ "$(link_count "$gate_root/$path")" == "1" ]] || die "$path gate file has multiple links" + [[ "$(link_count "$source_root/$path")" == "1" ]] || die "$path source file has multiple links" + assert_not_sparse "$gate_root/$path" || die "$path gate file is sparse" + assert_not_sparse "$source_root/$path" || die "$path source file is sparse" + cmp -s "$gate_root/$path" "$source_root/$path" || die "$path differs between gate and source" +done <"$gate_root/$manifest" + +mkdir -- "$output" +complete=false +cleanup_output() { + if [[ "$complete" != true ]]; then + rm -rf -- "$output" + fi +} +trap 'cleanup_output; cleanup_expected' EXIT + +while IFS= read -r path; do + mkdir -p -- "$output/$(dirname -- "$path")" + cp -- "$gate_root/$path" "$output/$path" + mode=$(git_mode "$gate_root" "$path") || die "$path changed while staging" + if [[ "$mode" == "100755" ]]; then + chmod 0755 "$output/$path" + else + chmod 0644 "$output/$path" + fi +done <"$gate_root/$manifest" + +actual=$(mktemp "${TMPDIR:-/tmp}/edgezero-image-context-actual.XXXXXX") +find "$output" -type f -print | sed "s#^$output/##" | LC_ALL=C sort >"$actual" +cmp -s "$actual" "$gate_root/$manifest" || { + rm -f -- "$actual" + die "staged context inventory differs from manifest" +} +rm -f -- "$actual" + +assert_checkout "$gate_root" "$gate_sha" gate +assert_checkout "$source_root" "$source_sha" source +complete=true diff --git a/.github/docker/build-app-cli/update-image-pin-pr.sh b/.github/docker/build-app-cli/update-image-pin-pr.sh new file mode 100755 index 00000000..9dacac30 --- /dev/null +++ b/.github/docker/build-app-cli/update-image-pin-pr.sh @@ -0,0 +1,1092 @@ +#!/usr/bin/env bash +set +x +set +a +set -euo pipefail + +if [[ ${GIT_ALTERNATE_OBJECT_DIRECTORIES+x} == x ]]; then + inherited_git_alternates=true +else + inherited_git_alternates=false +fi +export -n EDGEZERO_BUILD_CONTAINER_APP_TOKEN 2>/dev/null || true +unset GITHUB_TOKEN GH_TOKEN TOKEN APP_TOKEN APP_TOKEN_LOCAL + +export LC_ALL=C +export BASH_ENV= +export ENV= +export GIT_CONFIG_NOSYSTEM=1 +export GIT_CONFIG_GLOBAL=/dev/null +export GIT_NO_LAZY_FETCH=1 +export GIT_NO_REPLACE_OBJECTS=1 +unset GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE GIT_OBJECT_DIRECTORY +unset GIT_ALTERNATE_OBJECT_DIRECTORIES GIT_COMMON_DIR GIT_CEILING_DIRECTORIES +unset GIT_REPLACE_REF_BASE + +readonly API_VERSION=2026-03-10 +readonly API_BASE=https://api.github.com +readonly REPOSITORY=stackpop/edgezero +readonly REMOTE_URL=https://github.com/stackpop/edgezero.git +readonly IMAGE_REPOSITORY=ghcr.io/stackpop/edgezero-build-app-cli +readonly PIN_PREFIX=edgezero-build-container-pin/ +readonly TITLE_PREFIX='chore(actions): pin build container for ' +readonly BODY_PREFIX='edgezero-build-container-pin-v1 ' +readonly U64_MAX=18446744073709551615 + +usage() { + printf '%s\n' \ + 'usage: update-image-pin-pr.sh' \ + ' --gate-root ' \ + ' --gate-sha ' \ + ' --repository-root ' \ + ' --source-revision ' \ + ' --release-tag ' \ + ' --image-digest ' \ + ' --provenance-protocol 1' \ + ' --approval-json ' \ + ' --source-pr ' \ + ' --evidence-url ' \ + ' --expected-bot-id ' \ + ' --expected-bot-login ' >&2 + exit 2 +} + +die() { + printf '::error::%s\n' "$*" >&2 + exit 1 +} + +tool_die() { + printf '::error::%s\n' "$*" >&2 + exit 2 +} + +is_positive_decimal_at_most() { + local value=$1 maximum=$2 + [[ "$value" =~ ^[1-9][0-9]*$ ]] || return 1 + ((${#value} < ${#maximum})) && return 0 + ((${#value} == ${#maximum})) && [[ "$value" < "$maximum" || "$value" == "$maximum" ]] +} + +is_sha() { + [[ "$1" =~ ^[0-9a-f]{40}$ ]] +} + +is_digest() { + [[ "$1" =~ ^sha256:[0-9a-f]{64}$ && "$1" != "sha256:$(printf '0%.0s' {1..64})" ]] +} + +is_app_bot_login() { + local login=$1 slug + [[ "$login" =~ ^([a-z0-9]([a-z0-9-]{0,98}[a-z0-9])?)\[bot\]$ ]] || return 1 + slug=${BASH_REMATCH[1]} + [[ "$slug" != *--* ]] +} + +is_beneath() { + local path=$1 root=$2 + [[ "$path" == "$root" || "$path" == "$root/"* ]] +} + +isolated_git() { + env -i PATH="$PATH" LC_ALL=C HOME=/dev/null TMPDIR=/tmp \ + GIT_CONFIG_NOSYSTEM=1 GIT_CONFIG_GLOBAL=/dev/null GIT_TERMINAL_PROMPT=0 \ + GIT_NO_LAZY_FETCH=1 GIT_NO_REPLACE_OBJECTS=1 GIT_OPTIONAL_LOCKS=0 \ + git --no-replace-objects -c core.fsmonitor=false -c core.hooksPath=/dev/null "$@" +} + +repo_git() { + local root=$1 + shift + isolated_git -C "$root" "$@" +} + +gate_git() { + repo_git "$GATE_ROOT" "$@" +} + +source_git() { + repo_git "$REPOSITORY_ROOT" "$@" +} + +clone_git() { + repo_git "$CLONE_ROOT" "$@" +} + +worktree_git() { + repo_git "$WORKTREE_ROOT" "$@" +} + +temporary_root= +cleanup() { + local status=$? + trap - EXIT HUP INT TERM + if [[ -n "$temporary_root" ]]; then + rm -rf -- "$temporary_root" 2>/dev/null || true + fi + exit "$status" +} + +signal_cleanup() { + local status=$1 + trap - EXIT HUP INT TERM + [[ -z "$temporary_root" ]] || rm -rf -- "$temporary_root" 2>/dev/null || true + exit "$status" +} + +trap cleanup EXIT +trap 'signal_cleanup 129' HUP +trap 'signal_cleanup 130' INT +trap 'signal_cleanup 143' TERM + +require_checkout() { + local root=$1 expected=$2 label=$3 common_result_name=$4 object_result_name=$5 + local top git_directory common_directory object_directory object_path + local replacements partial_configuration shallow actual sparse status gitlinks config_status + local index_flags index_entry + + [[ "$root" == /* && -d "$root" && ! -L "$root" ]] || + die "$label must be an absolute non-symlink directory" + top=$(cd -- "$root" 2>/dev/null && pwd -P) || die "cannot resolve $label" + [[ "$top" == "$root" ]] || die "$label must already be canonical" + [[ "$(repo_git "$root" rev-parse --is-inside-work-tree 2>/dev/null)" == true ]] || + die "$label is not a Git worktree" + [[ "$(repo_git "$root" rev-parse --show-toplevel 2>/dev/null)" == "$root" ]] || + die "$label must be the exact repository top level" + git_directory=$(repo_git "$root" rev-parse --absolute-git-dir 2>/dev/null) || + die "cannot resolve $label Git directory" + common_directory=$(repo_git "$root" rev-parse --path-format=absolute --git-common-dir 2>/dev/null) || + die "cannot resolve $label common Git directory" + object_path=$(repo_git "$root" rev-parse --path-format=absolute --git-path objects 2>/dev/null) || + die "cannot resolve $label object directory" + [[ -d "$object_path" && ! -L "$object_path" ]] || die "$label object directory is not regular" + object_directory=$(cd -- "$object_path" 2>/dev/null && pwd -P) || + die "cannot canonicalize $label object directory" + [[ "$object_directory" == "$object_path" ]] || die "$label object directory is not canonical" + [[ ! -e "$git_directory/info/grafts" && ! -L "$git_directory/info/grafts" && + ! -e "$common_directory/info/grafts" && ! -L "$common_directory/info/grafts" ]] || + die "$label cannot contain legacy grafts" + [[ ! -e "$object_directory/info/alternates" && ! -L "$object_directory/info/alternates" ]] || + die "$label cannot contain object alternates" + replacements=$(repo_git "$root" for-each-ref --format='%(refname)' refs/replace/ 2>/dev/null) || + die "cannot inspect $label replacement refs" + [[ -z "$replacements" ]] || die "$label cannot contain replacement refs" + config_status=0 + partial_configuration=$(repo_git "$root" config --includes --name-only --get-regexp \ + '^(extensions\.partial[Cc]lone|remote\..*\.promisor|remote\..*\.partial[Cc]lone[Ff]ilter)$' \ + 2>/dev/null) || config_status=$? + ((config_status == 0 || config_status == 1)) || die "cannot inspect $label partial-clone configuration" + [[ -z "$partial_configuration" ]] || die "$label cannot contain promisor or partial-clone configuration" + shallow=$(repo_git "$root" rev-parse --is-shallow-repository 2>/dev/null) || + die "cannot inspect $label history depth" + [[ "$shallow" == false ]] || die "$label must be a full checkout" + actual=$(repo_git "$root" rev-parse --verify HEAD 2>/dev/null) || die "$label HEAD is absent" + [[ "$actual" == "$expected" ]] || die "$label HEAD differs from its supplied SHA" + if repo_git "$root" symbolic-ref -q HEAD >/dev/null 2>&1; then + die "$label must be detached" + fi + sparse=$(repo_git "$root" config --bool core.sparseCheckout 2>/dev/null || true) + [[ "$sparse" != true ]] || die "$label cannot be sparse" + status=$(repo_git "$root" status --porcelain=v1 --untracked-files=all --ignore-submodules=none) || + die "cannot inspect $label status" + [[ -z "$status" ]] || die "$label must be clean" + index_flags=$(repo_git "$root" ls-files -v) || die "cannot inspect $label index flags" + while IFS= read -r index_entry || [[ -n "$index_entry" ]]; do + [[ -z "$index_entry" || "$index_entry" == 'H '* ]] || + die "$label cannot contain assume-unchanged or skip-worktree entries" + done <<<"$index_flags" + gitlinks=$(repo_git "$root" ls-tree -r "$expected" 2>/dev/null | awk '$1 == "160000" { print; exit }') || + die "cannot inspect $label submodule state" + [[ -z "$gitlinks" ]] || die "$label cannot contain submodule state" + printf -v "$common_result_name" '%s' "$common_directory" + printf -v "$object_result_name" '%s' "$object_directory" +} + +require_input_file() { + local path=$1 label=$2 parent canonical expected size + [[ "$path" == /* && -f "$path" && ! -L "$path" ]] || + die "$label must be an absolute regular non-symlink file" + parent=${path%/*} + [[ -n "$parent" ]] || parent=/ + canonical=$(cd -- "$parent" 2>/dev/null && pwd -P) || die "cannot resolve $label parent" + [[ "$canonical" == "$parent" ]] || die "$label path must already be canonical" + if [[ "$parent" == / ]]; then expected="/${path##*/}"; else expected="$parent/${path##*/}"; fi + [[ "$expected" == "$path" ]] || die "$label path must already be canonical" + ! is_beneath "$path" "$GATE_ROOT" || die "$label must be outside the gate repository" + ! is_beneath "$path" "$REPOSITORY_ROOT" || die "$label must be outside the source repository" + size=$(wc -c <"$path" | tr -d '[:space:]') || die "cannot measure $label" + [[ "$size" =~ ^[0-9]+$ ]] || die "cannot measure $label" + ((size > 0 && size <= 4096)) || die "$label must contain 1..4096 bytes" +} + +GATE_ROOT= +GATE_SHA= +REPOSITORY_ROOT= +SOURCE_REVISION= +RELEASE_TAG= +IMAGE_DIGEST= +PROVENANCE_PROTOCOL= +APPROVAL_JSON= +SOURCE_PR= +EVIDENCE_URL= +EXPECTED_BOT_ID= +EXPECTED_BOT_LOGIN= +seen_flags=' ' + +while (($#)); do + (($# >= 2)) || usage + flag=$1 + value=$2 + shift 2 + case "$flag" in + --gate-root | --gate-sha | --repository-root | --source-revision | --release-tag | \ + --image-digest | --provenance-protocol | --approval-json | --source-pr | \ + --evidence-url | --expected-bot-id | --expected-bot-login) ;; + *) usage ;; + esac + [[ -n "$value" ]] || usage + [[ "$seen_flags" != *" $flag "* ]] || usage + seen_flags+="$flag " + case "$flag" in + --gate-root) GATE_ROOT=$value ;; + --gate-sha) GATE_SHA=$value ;; + --repository-root) REPOSITORY_ROOT=$value ;; + --source-revision) SOURCE_REVISION=$value ;; + --release-tag) RELEASE_TAG=$value ;; + --image-digest) IMAGE_DIGEST=$value ;; + --provenance-protocol) PROVENANCE_PROTOCOL=$value ;; + --approval-json) APPROVAL_JSON=$value ;; + --source-pr) SOURCE_PR=$value ;; + --evidence-url) EVIDENCE_URL=$value ;; + --expected-bot-id) EXPECTED_BOT_ID=$value ;; + --expected-bot-login) EXPECTED_BOT_LOGIN=$value ;; + esac +done + +for required in --gate-root --gate-sha --repository-root --source-revision --release-tag \ + --image-digest --provenance-protocol --approval-json --source-pr --evidence-url \ + --expected-bot-id --expected-bot-login; do + [[ "$seen_flags" == *" $required "* ]] || usage +done + +is_sha "$GATE_SHA" || die "gate SHA is not a full lowercase SHA" +is_sha "$SOURCE_REVISION" || die "source revision is not a full lowercase SHA" +[[ "$RELEASE_TAG" =~ ^build-container-v[1-9][0-9]*$ ]] || die "release tag is not canonical" +is_digest "$IMAGE_DIGEST" || die "image digest is not canonical" +[[ "$PROVENANCE_PROTOCOL" == 1 ]] || die "provenance protocol must be the exact integer spelling 1" +is_positive_decimal_at_most "$SOURCE_PR" "$U64_MAX" || die "source PR is not a canonical positive u64" +is_positive_decimal_at_most "$EXPECTED_BOT_ID" "$U64_MAX" || + die "expected bot id is not a canonical positive u64" +is_app_bot_login "$EXPECTED_BOT_LOGIN" || die "expected bot login is not canonical" +EXPECTED_BOT_LOGIN_URL="${EXPECTED_BOT_LOGIN%\[bot\]}%5Bbot%5D" +readonly EXPECTED_BOT_LOGIN_URL +if [[ "$EVIDENCE_URL" =~ ^https://github\.com/stackpop/edgezero/pull/([1-9][0-9]*)#issuecomment-([1-9][0-9]*)$ ]]; then + EVIDENCE_SOURCE_PR=${BASH_REMATCH[1]} + EVIDENCE_COMMENT_ID=${BASH_REMATCH[2]} +else + die "evidence URL is not canonical" +fi +is_positive_decimal_at_most "$EVIDENCE_SOURCE_PR" "$U64_MAX" || die "evidence URL source PR is invalid" +is_positive_decimal_at_most "$EVIDENCE_COMMENT_ID" "$U64_MAX" || die "evidence URL comment id is invalid" +[[ "$EVIDENCE_SOURCE_PR" == "$SOURCE_PR" ]] || die "evidence URL source PR differs" +[[ "$inherited_git_alternates" == false ]] || die "environment-provided Git object alternates are forbidden" + +for tool in bash env git jq curl mktemp stat chmod rm cmp install wc tr sed awk sort uniq mkdir ln \ + cat cp dirname basename; do + command -v "$tool" >/dev/null 2>&1 || tool_die "image pin updater requires $tool" +done + +require_checkout "$GATE_ROOT" "$GATE_SHA" "gate checkout" GATE_COMMON_DIRECTORY GATE_OBJECT_DIRECTORY +require_checkout "$REPOSITORY_ROOT" "$SOURCE_REVISION" "source checkout" \ + SOURCE_COMMON_DIRECTORY SOURCE_OBJECT_DIRECTORY +[[ "$GATE_ROOT" != "$REPOSITORY_ROOT" && "$GATE_COMMON_DIRECTORY" != "$SOURCE_COMMON_DIRECTORY" && + "$GATE_OBJECT_DIRECTORY" != "$SOURCE_OBJECT_DIRECTORY" ]] || + die "gate and source checkouts must use separate repositories" +source_git cat-file -e "$GATE_SHA^{commit}" 2>/dev/null || die "source checkout does not contain gate commit" +source_git merge-base --is-ancestor "$GATE_SHA" "$SOURCE_REVISION" 2>/dev/null || + die "source revision does not descend from the gate" + +SCRIPT_PATH=${BASH_SOURCE[0]} +[[ "$SCRIPT_PATH" == /* ]] || die "updater must be invoked by its absolute gate path" +EXPECTED_SCRIPT="$GATE_ROOT/.github/docker/build-app-cli/update-image-pin-pr.sh" +[[ "$SCRIPT_PATH" == "$EXPECTED_SCRIPT" && -f "$SCRIPT_PATH" && ! -L "$SCRIPT_PATH" ]] || + die "updater must execute from the supplied gate revision" +CHECK="$GATE_ROOT/.github/docker/build-app-cli/check-image-pin.sh" +WRITER="$GATE_ROOT/.github/docker/build-app-cli/write-image-release-record.sh" +for helper in "$CHECK" "$WRITER"; do + [[ -f "$helper" && ! -L "$helper" ]] || die "required gate helper is absent" +done +require_input_file "$APPROVAL_JSON" "approval JSON" + +temporary_root=$(mktemp -d /tmp/edgezero-image-pin.XXXXXX 2>/dev/null) || + die "cannot create updater temporary directory" +temporary_root=$(cd -- "$temporary_root" && pwd -P) || die "cannot resolve updater temporary directory" +chmod 0700 "$temporary_root" || die "cannot secure updater temporary directory" +CLONE_ROOT="$temporary_root/clone" +WORKTREE_ROOT="$temporary_root/worktree" +RECORD_ROOT="$temporary_root/records" +API_WORK="$temporary_root/api" +mkdir -m 0700 "$RECORD_ROOT" "$API_WORK" || die "cannot create private updater directories" + +# Validate the caller's gate-produced approval with the gate's protocol owner before credentials. +VALIDATION_IMAGE="$temporary_root/approval-image.json" +jq -cnS --arg repository "$IMAGE_REPOSITORY" --arg tag "$RELEASE_TAG" \ + --arg digest "$IMAGE_DIGEST" --arg source "$SOURCE_REVISION" \ + --arg protocol "$PROVENANCE_PROTOCOL" \ + '{digest:$digest,"image-source-revision":$source,"provenance-protocol":($protocol|tonumber),repository:$repository,tag:$tag}' \ + >"$VALIDATION_IMAGE" 2>/dev/null || die "cannot construct approval validation record" +chmod 0600 "$VALIDATION_IMAGE" || die "cannot secure approval validation record" +bash "$CHECK" validate-pair "$VALIDATION_IMAGE" "$APPROVAL_JSON" >/dev/null 2>&1 || + die "approval JSON is not the exact matching gate record" + +APPROVAL_CHALLENGE=$(jq -er '."approval-challenge"' "$APPROVAL_JSON") || die "approval challenge is absent" +APPROVER_LOGIN=$(jq -er '."approver-login"' "$APPROVAL_JSON") || die "approval login is absent" +REVIEWED_AT=$(jq -er '."reviewed-at"' "$APPROVAL_JSON") || die "approval review time is absent" +RUN_ATTEMPT=$(jq -er '."run-attempt"' "$APPROVAL_JSON") || die "approval run attempt is absent" +RUN_ID=$(jq -er '."run-id"' "$APPROVAL_JSON") || die "approval run id is absent" +SCREENSHOT_SHA256=$(jq -er '."screenshot-sha256"' "$APPROVAL_JSON") || die "approval screenshot digest is absent" +IMAGE_OUTPUT="$RECORD_ROOT/image.json" +EVIDENCE_OUTPUT="$RECORD_ROOT/image-release-evidence.json" +bash "$WRITER" \ + --image-path "$IMAGE_OUTPUT" \ + --evidence-path "$EVIDENCE_OUTPUT" \ + --repository "$IMAGE_REPOSITORY" \ + --release-tag "$RELEASE_TAG" \ + --image-digest "$IMAGE_DIGEST" \ + --source-revision "$SOURCE_REVISION" \ + --provenance-protocol "$PROVENANCE_PROTOCOL" \ + --approval-challenge "$APPROVAL_CHALLENGE" \ + --approver-login "$APPROVER_LOGIN" \ + --reviewed-at "$REVIEWED_AT" \ + --run-attempt "$RUN_ATTEMPT" \ + --run-id "$RUN_ID" \ + --screenshot-sha256 "$SCREENSHOT_SHA256" >/dev/null || + die "typed image release record writer failed" +cmp -s "$EVIDENCE_OUTPUT" "$APPROVAL_JSON" || die "typed evidence differs from gate approval" + +# Read the de-exported credential only after every CLI and precredential validation has succeeded. +APP_TOKEN_LOCAL=${EDGEZERO_BUILD_CONTAINER_APP_TOKEN:-} +unset EDGEZERO_BUILD_CONTAINER_APP_TOKEN +readonly APP_TOKEN_LOCAL +[[ -n "$APP_TOKEN_LOCAL" ]] || die "build-container App token is absent" +[[ "$APP_TOKEN_LOCAL" != *$'\n'* && "$APP_TOKEN_LOCAL" != *$'\r'* && + "$APP_TOKEN_LOCAL" != *'"'* && "$APP_TOKEN_LOCAL" != *\\* ]] || + die "build-container App token is malformed" + +TOKEN_FILE="$temporary_root/token" +ASKPASS="$temporary_root/askpass" +printf '%s' "$APP_TOKEN_LOCAL" >"$TOKEN_FILE" || die "cannot create private Git credential" +chmod 0600 "$TOKEN_FILE" || die "cannot secure private Git credential" +cat >"$ASKPASS" </dev/null) || die "cannot create API body file" + metadata=$(mktemp "$API_WORK/metadata.XXXXXX" 2>/dev/null) || die "cannot create API metadata file" + if [[ "$method" == GET ]]; then + if ! printf '%s\n' \ + 'header = "Accept: application/vnd.github+json"' \ + 'header = "X-GitHub-Api-Version: 2026-03-10"' \ + 'header = "User-Agent: edgezero-build-container-gate/1"' \ + "header = \"Authorization: Bearer $APP_TOKEN_LOCAL\"" | + env -i PATH="$PATH" LC_ALL=C curl \ + --disable --silent --show-error --connect-timeout 10 --max-time 30 --max-redirs 0 \ + --request "$method" --config - --output "$body" \ + --write-out '%{http_code}\n%header{x-github-api-version-selected}\n%header{content-type}\n%header{link}' \ + "$url" >"$metadata" 2>/dev/null; then + die "GitHub REST request failed" + fi + else + [[ -f "$request_body" && ! -L "$request_body" ]] || die "internal REST request body is absent" + if ! printf '%s\n' \ + 'header = "Accept: application/vnd.github+json"' \ + 'header = "X-GitHub-Api-Version: 2026-03-10"' \ + 'header = "User-Agent: edgezero-build-container-gate/1"' \ + "header = \"Authorization: Bearer $APP_TOKEN_LOCAL\"" \ + 'header = "Content-Type: application/json"' | + env -i PATH="$PATH" LC_ALL=C curl \ + --disable --silent --show-error --connect-timeout 10 --max-time 30 --max-redirs 0 \ + --request "$method" --config - --data-binary "@$request_body" --output "$body" \ + --write-out '%{http_code}\n%header{x-github-api-version-selected}\n%header{content-type}\n%header{link}' \ + "$url" >"$metadata" 2>/dev/null; then + die "GitHub REST request failed" + fi + fi + while IFS= read -r line || [[ -n "$line" ]]; do lines+=("$line"); done <"$metadata" + [[ "${#lines[@]}" -eq 3 || "${#lines[@]}" -eq 4 ]] || + die "GitHub REST response metadata is malformed" + [[ "${lines[0]}" == "$expected_status" ]] || die "GitHub REST response has an unexpected status" + [[ "${lines[1]}" == "$API_VERSION" ]] || die "GitHub REST response selected an unexpected API version" + [[ "${lines[2]}" =~ ^[Aa][Pp][Pp][Ll][Ii][Cc][Aa][Tt][Ii][Oo][Nn]/[Jj][Ss][Oo][Nn]([[:space:]]*\;[[:space:]]*[Cc][Hh][Aa][Rr][Ss][Ee][Tt][[:space:]]*=[[:space:]]*[Uu][Tt][Ff]-8)?$ ]] || + die "GitHub REST response has an unsupported content type" + jq -e -s 'length == 1' "$body" >/dev/null 2>&1 || die "GitHub REST response is not exactly one JSON value" + API_LINK=${lines[3]:-} + printf -v "$result_name" '%s' "$body" +} + +USER_BODY= +api_request GET "$API_BASE/users/$EXPECTED_BOT_LOGIN_URL" '' USER_BODY +AUTHENTICATED=$(jq -er ' + if type == "object" and (.id|type) == "number" and .id >= 1 and .id == (.id|floor) + and (.login|type) == "string" and .type == "Bot" + then [(.id|tostring),.login] | @tsv else error("identity") end +' "$USER_BODY" 2>/dev/null) || die "authenticated App bot identity is malformed" +IFS=$'\t' read -r AUTHENTICATED_BOT_ID AUTHENTICATED_BOT_LOGIN AUTHENTICATED_EXTRA <<<"$AUTHENTICATED" +[[ -z "${AUTHENTICATED_EXTRA:-}" && "$AUTHENTICATED_BOT_ID" == "$EXPECTED_BOT_ID" && + "$AUTHENTICATED_BOT_LOGIN" == "$EXPECTED_BOT_LOGIN" ]] || die "authenticated App bot identity differs" + +REPOSITORY_BODY= +api_request GET "$API_BASE/repos/$REPOSITORY" '' REPOSITORY_BODY +jq -e ' + type == "object" and (.id|type) == "number" and .id >= 1 and .id == (.id|floor) + and .full_name == "stackpop/edgezero" and .private == false and .visibility == "public" + and .default_branch == "main" and .owner.login == "stackpop" +' "$REPOSITORY_BODY" >/dev/null 2>&1 || die "authenticated repository identity differs" + +BRANCH="${PIN_PREFIX}${SOURCE_REVISION}" +BRANCH_REF="refs/heads/$BRANCH" +REMOTE_REFS="$temporary_root/remote-refs" +authenticated_git ls-remote --refs "$REMOTE_URL" refs/heads/main "$BRANCH_REF" >"$REMOTE_REFS" 2>/dev/null || + die "cannot record remote refs" +MAIN_OID= +TARGET_OID= +while IFS=$'\t' read -r oid ref extra || [[ -n "${oid:-}" ]]; do + [[ -n "$oid" && -z "${extra:-}" && $(is_sha "$oid"; printf '%s' "$?") == 0 ]] || + die "remote ref response is malformed" + case "$ref" in + refs/heads/main) [[ -z "$MAIN_OID" ]] || die "remote main ref is duplicated"; MAIN_OID=$oid ;; + "$BRANCH_REF") [[ -z "$TARGET_OID" ]] || die "remote target ref is duplicated"; TARGET_OID=$oid ;; + *) die "remote ref response contains an unrequested ref" ;; + esac +done <"$REMOTE_REFS" +[[ -n "$MAIN_OID" ]] || die "remote main ref is absent" + +isolated_git clone --no-hardlinks --no-local --no-checkout --no-tags --no-recurse-submodules \ + "$REPOSITORY_ROOT" "$CLONE_ROOT" >/dev/null 2>&1 || die "cannot create private source clone" +chmod 0700 "$CLONE_ROOT" || die "cannot secure private source clone" +clone_git remote set-url origin "$REMOTE_URL" || die "cannot set fixed origin URL" +authenticated_git -C "$CLONE_ROOT" fetch --force --no-tags --no-recurse-submodules \ + origin refs/heads/main:refs/remotes/origin/main >/dev/null 2>&1 || die "cannot fetch remote main" +[[ "$(clone_git rev-parse --verify refs/remotes/origin/main 2>/dev/null)" == "$MAIN_OID" ]] || + die "fetched main differs from its recorded OID" +if [[ -n "$TARGET_OID" ]]; then + authenticated_git -C "$CLONE_ROOT" fetch --force --no-tags --no-recurse-submodules \ + origin "$BRANCH_REF:refs/remotes/origin/pin-target" >/dev/null 2>&1 || die "cannot fetch remote target" + [[ "$(clone_git rev-parse --verify refs/remotes/origin/pin-target 2>/dev/null)" == "$TARGET_OID" ]] || + die "fetched target differs from its recorded OID" +fi +clone_git worktree add --detach "$WORKTREE_ROOT" "$MAIN_OID" >/dev/null 2>&1 || + die "cannot create detached main worktree" +chmod 0700 "$WORKTREE_ROOT" || die "cannot secure detached main worktree" + +IMAGE_PATH=.github/docker/build-app-cli/image.json +EVIDENCE_PATH=.github/docker/build-app-cli/image-release-evidence.json +MAIN_IMAGE="$WORKTREE_ROOT/$IMAGE_PATH" +MAIN_EVIDENCE="$WORKTREE_ROOT/$EVIDENCE_PATH" + +require_regular_blob_entry() { + local commit=$1 path=$2 label=$3 entry metadata actual_path extra mode type oid metadata_extra + entry=$(clone_git ls-tree "$commit" -- "$path" 2>/dev/null) || die "cannot inspect $label tree entry" + [[ -n "$entry" && "$entry" != *$'\n'* ]] || die "$label tree entry is absent or duplicated" + IFS=$'\t' read -r metadata actual_path extra <<<"$entry" + IFS=' ' read -r mode type oid metadata_extra <<<"$metadata" + [[ -z "${extra:-}" && -z "${metadata_extra:-}" && "$mode" == 100644 && "$type" == blob && + "$actual_path" == "$path" ]] || die "$label must be a regular mode-100644 blob" + is_sha "$oid" || die "$label blob OID is malformed" +} + +BASE_SOURCE= +BASE_DIGEST= +if [[ -e "$MAIN_IMAGE" || -L "$MAIN_IMAGE" || -e "$MAIN_EVIDENCE" || -L "$MAIN_EVIDENCE" ]]; then + [[ -f "$MAIN_IMAGE" && ! -L "$MAIN_IMAGE" && -f "$MAIN_EVIDENCE" && ! -L "$MAIN_EVIDENCE" ]] || + die "remote main pin pair is incomplete or non-regular" + require_regular_blob_entry "$MAIN_OID" "$IMAGE_PATH" "remote main image record" + require_regular_blob_entry "$MAIN_OID" "$EVIDENCE_PATH" "remote main evidence record" + bash "$CHECK" validate-pair "$MAIN_IMAGE" "$MAIN_EVIDENCE" >/dev/null 2>&1 || + die "remote main pin pair is invalid" + BASE_SOURCE=$(bash "$CHECK" source-revision "$MAIN_IMAGE" 2>/dev/null) || + die "cannot read remote main source pin" + BASE_DIGEST=$(jq -er '.digest' "$MAIN_IMAGE") || die "cannot read remote main digest" + if [[ "$BASE_SOURCE" != "$SOURCE_REVISION" ]]; then + clone_git cat-file -e "$BASE_SOURCE^{commit}" 2>/dev/null || die "remote main source pin commit is absent" + if clone_git merge-base --is-ancestor "$BASE_SOURCE" "$SOURCE_REVISION" 2>/dev/null; then + : + elif clone_git merge-base --is-ancestor "$SOURCE_REVISION" "$BASE_SOURCE" 2>/dev/null; then + die "source revision regresses the protected base pin" + else + die "source revision is incomparable with the protected base pin" + fi + fi +fi + +validate_link_header() { + local header=$1 page=$2 count=$3 entry url rel target_page expected seen=' ' + local -a entries=() + [[ -z "$header" ]] && return 0 + IFS=, read -r -a entries <<<"$header" + for entry in "${entries[@]}"; do + entry=$(printf '%s' "$entry" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//') + if [[ "$entry" =~ ^\<([^\>]*)\>\;[[:space:]]rel=\"(next|prev|first|last)\"$ ]]; then + url=${BASH_REMATCH[1]} + rel=${BASH_REMATCH[2]} + else + die "pull pagination Link header is malformed" + fi + [[ "$seen" != *" $rel "* ]] || die "pull pagination Link relation is duplicated" + seen+="$rel " + [[ "$url" =~ ^https://api\.github\.com/repos/stackpop/edgezero/pulls\?state=all\&base=main\&sort=created\&direction=asc\&per_page=100\&page=([1-9][0-9]*)$ ]] || + die "pull pagination Link URL varies from the exact query" + target_page=${BASH_REMATCH[1]} + is_positive_decimal_at_most "$target_page" 100 || + die "pull pagination Link page is outside the bounded inventory" + case "$rel" in + next) expected=$((page + 1)); [[ "$count" -eq 100 && "$target_page" -eq "$expected" ]] || die "pull pagination next relation is impossible" ;; + prev) expected=$((page - 1)); [[ "$page" -gt 1 && "$target_page" -eq "$expected" ]] || die "pull pagination prev relation is impossible" ;; + first) [[ "$target_page" -eq 1 ]] || die "pull pagination first relation is impossible" ;; + last) printf '%s\t%s\n' "$page" "$target_page" >>"$LAST_LINKS" ;; + esac + done + if [[ "$seen" == *' next '* ]]; then LINK_HAS_NEXT=true; else LINK_HAS_NEXT=false; fi +} + +PULL_IDS="$temporary_root/pull-ids" +MATCHING_PULLS="$temporary_root/matching-pulls" +LAST_LINKS="$temporary_root/last-links" +: >"$PULL_IDS" +: >"$MATCHING_PULLS" +: >"$LAST_LINKS" +page=1 +while :; do + LIST_BODY= + LIST_URL="$API_BASE/repos/$REPOSITORY/pulls?state=all&base=main&sort=created&direction=asc&per_page=100&page=$page" + api_request GET "$LIST_URL" '' LIST_BODY + jq -e 'type == "array" and length <= 100 and all(.[]; + type == "object" and (.id|type) == "number" and .id >= 1 and .id == (.id|floor) + and (.number|type) == "number" and .number >= 1 and .number == (.number|floor) + and (.title|type) == "string" and (.head|type) == "object" and (.head.ref|type) == "string")' \ + "$LIST_BODY" >/dev/null 2>&1 || die "pull list page is malformed" + count=$(jq -r 'length' "$LIST_BODY") + jq -r '.[] | "id:" + (.id|tostring), "number:" + (.number|tostring)' "$LIST_BODY" >>"$PULL_IDS" + duplicate=$(sort "$PULL_IDS" | uniq -d | sed -n '1p') + [[ -z "$duplicate" ]] || die "pull pagination contains duplicate identities" + if ((page > 1)); then + prior=1 + while ((prior < page)); do + cmp -s "$LIST_BODY" "$API_WORK/list-page-$prior" && die "pull pagination repeats a page payload" + prior=$((prior + 1)) + done + fi + cp "$LIST_BODY" "$API_WORK/list-page-$page" + jq -r --arg branch "$PIN_PREFIX" --arg title "$TITLE_PREFIX" ' + .[] | select((.head.ref|startswith($branch)) or (.title|startswith($title))) | .number + ' "$LIST_BODY" >>"$MATCHING_PULLS" + LINK_HAS_NEXT=false + validate_link_header "$API_LINK" "$page" "$count" + if ((count < 100)); then + [[ "$LINK_HAS_NEXT" == false ]] || die "short pull page advertises a next relation" + final_page=$page + break + fi + ((page < 100)) || die "pull pagination is truncated at 10,000 items" + page=$((page + 1)) +done +while IFS=$'\t' read -r _ linked_last || [[ -n "${linked_last:-}" ]]; do + [[ -z "${linked_last:-}" || "$linked_last" -eq "$final_page" ]] || die "pull pagination last relation is incorrect" +done <"$LAST_LINKS" + +relation_to_source() { + local proposal=$1 result_name=$2 computed + clone_git cat-file -e "$proposal^{commit}" 2>/dev/null || die "pin proposal source commit is absent" + if [[ "$proposal" == "$SOURCE_REVISION" ]]; then + computed=equal + elif clone_git merge-base --is-ancestor "$proposal" "$SOURCE_REVISION" 2>/dev/null; then + computed=older + elif clone_git merge-base --is-ancestor "$SOURCE_REVISION" "$proposal" 2>/dev/null; then + computed=newer + else + die "pin proposal source is incomparable" + fi + printf -v "$result_name" '%s' "$computed" +} + +validate_pull() { + local path=$1 expected_number=$2 result_prefix=$3 + local number source suffix canonical body digest tag source_pr evidence relation head merge_sha merged state + jq -e ' + type == "object" and (.id|type) == "number" and .id >= 1 and .id == (.id|floor) + and (.number|type) == "number" and .number >= 1 and .number == (.number|floor) + and (.state == "open" or .state == "closed") and (.merged|type) == "boolean" + and ((.merged_at == null) or (.merged_at|type) == "string") + and ((.merge_commit_sha == null) or (.merge_commit_sha|type) == "string") + and (.user|type) == "object" and (.user.id|type) == "number" and (.user.login|type) == "string" + and .user.type == "Bot" and (.title|type) == "string" and (.body|type) == "string" + and (.base|type) == "object" and .base.ref == "main" and .base.repo.full_name == "stackpop/edgezero" + and (.head|type) == "object" and (.head.ref|type) == "string" and (.head.sha|type) == "string" + and (.head.repo|type) == "object" and (.head.repo.full_name|type) == "string" + ' "$path" >/dev/null 2>&1 || die "selected pin pull response is malformed" + number=$(jq -er '.number|tostring' "$path") + [[ "$number" == "$expected_number" ]] || die "selected pin pull number differs" + [[ "$(jq -er '.user.id|tostring' "$path")" == "$EXPECTED_BOT_ID" && + "$(jq -er '.user.login' "$path")" == "$EXPECTED_BOT_LOGIN" ]] || + die "pin pull is owned by a different actor" + [[ "$(jq -er '.head.repo.full_name' "$path")" == "$REPOSITORY" ]] || + die "pin pull belongs to a different head repository" + branch=$(jq -er '.head.ref' "$path") + head=$(jq -er '.head.sha' "$path") + is_sha "$head" || die "pin pull head commit is not a full SHA" + [[ "$branch" == "$PIN_PREFIX"* ]] || die "pin pull branch is not canonical" + source=${branch#"$PIN_PREFIX"} + is_sha "$source" || die "pin pull source suffix is not a full SHA" + [[ "$(jq -er '.title' "$path")" == "$TITLE_PREFIX$source" ]] || die "pin pull title collides with its branch" + body=$(jq -er '.body' "$path") + [[ "$body" != *$'\n'* && "$body" == "$BODY_PREFIX"* ]] || die "pin pull body is not one protocol line" + suffix=${body#"$BODY_PREFIX"} + printf '%s' "$suffix" >"$API_WORK/pull-protocol" + jq -e ' + type == "object" and keys == ["evidence-url","image-digest","release-tag","source-pr","source-revision"] + and all(.[]; type == "string") + ' "$API_WORK/pull-protocol" >/dev/null 2>&1 || die "pin pull body JSON is malformed" + digest=$(jq -er '."image-digest"' "$API_WORK/pull-protocol") + tag=$(jq -er '."release-tag"' "$API_WORK/pull-protocol") + source_pr=$(jq -er '."source-pr"' "$API_WORK/pull-protocol") + evidence=$(jq -er '."evidence-url"' "$API_WORK/pull-protocol") + [[ "$(jq -er '."source-revision"' "$API_WORK/pull-protocol")" == "$source" ]] || + die "pin pull body source differs from its branch" + is_digest "$digest" || die "pin pull digest is invalid" + [[ "$tag" =~ ^build-container-v[1-9][0-9]*$ ]] || die "pin pull release tag is invalid" + is_positive_decimal_at_most "$source_pr" "$U64_MAX" || die "pin pull source PR is invalid" + [[ "$evidence" =~ ^https://github\.com/stackpop/edgezero/pull/$source_pr#issuecomment-([1-9][0-9]*)$ ]] || + die "pin pull evidence URL is invalid" + canonical=$(jq -cnS --arg evidence "$evidence" --arg digest "$digest" --arg tag "$tag" \ + --arg source_pr "$source_pr" --arg source "$source" \ + '{"evidence-url":$evidence,"image-digest":$digest,"release-tag":$tag,"source-pr":$source_pr,"source-revision":$source}') + [[ "$suffix" == "$canonical" ]] || die "pin pull body is not exact JCS" + state=$(jq -er '.state' "$path") + merged=$(jq -er '.merged|tostring' "$path") + merge_sha=$(jq -er 'if .merge_commit_sha == null then "" else .merge_commit_sha end' "$path") + if [[ "$merged" == true ]]; then + [[ "$state" == closed && $(jq -er '.merged_at|type' "$path") == string ]] || + die "merged pin pull has inconsistent merge state" + is_sha "$merge_sha" || die "merged pin pull merge commit is not a full SHA" + clone_git cat-file -e "$merge_sha^{commit}" 2>/dev/null || die "merged pin pull commit is absent" + clone_git merge-base --is-ancestor "$merge_sha" "$MAIN_OID" 2>/dev/null || + die "merged pin pull commit is not on remote main" + else + [[ "$(jq -er '.merged_at == null' "$path")" == true ]] || + die "unmerged pin pull has a merged timestamp" + [[ -z "$merge_sha" ]] || is_sha "$merge_sha" || die "unmerged pin pull merge commit is malformed" + fi + relation_to_source "$source" relation + printf -v "${result_prefix}_NUMBER" '%s' "$number" + printf -v "${result_prefix}_SOURCE" '%s' "$source" + printf -v "${result_prefix}_DIGEST" '%s' "$digest" + printf -v "${result_prefix}_TAG" '%s' "$tag" + printf -v "${result_prefix}_STATE" '%s' "$state" + printf -v "${result_prefix}_MERGED" '%s' "$merged" + printf -v "${result_prefix}_HEAD" '%s' "$head" + printf -v "${result_prefix}_MERGE" '%s' "$merge_sha" + printf -v "${result_prefix}_RELATION" '%s' "$relation" + printf -v "${result_prefix}_BODY" '%s' "$body" +} + +snapshot_pull() { + local prefix=$1 field value value_name + for field in NUMBER STATE DIGEST HEAD BODY MERGED MERGE; do + value_name="${prefix}_${field}" + value=${!value_name} + printf -v "${prefix}_EXPECTED_${field}" '%s' "$value" + done +} + +set_expected_pull_field() { + local prefix=$1 field=$2 value=$3 + printf -v "${prefix}_EXPECTED_${field}" '%s' "$value" +} + +verify_final_pull() { + local number=$1 expected_state=$2 expected_digest=$3 expected_head=$4 expected_body=$5 + local expected_merged=$6 expected_merge=$7 final prefix + api_request GET "$API_BASE/repos/$REPOSITORY/pulls/$number" '' final + prefix=FINAL + validate_pull "$final" "$number" "$prefix" + [[ "$FINAL_STATE" == "$expected_state" && "$FINAL_DIGEST" == "$expected_digest" && + "$FINAL_HEAD" == "$expected_head" && "$FINAL_BODY" == "$expected_body" && + "$FINAL_MERGED" == "$expected_merged" && "$FINAL_MERGE" == "$expected_merge" ]] || + die "final pull state differs from the exact expected state" +} + +verify_expected_pull() { + local prefix=$1 field value value_name + local number state digest head body merged merge + for field in NUMBER STATE DIGEST HEAD BODY MERGED MERGE; do + value_name="${prefix}_EXPECTED_${field}" + value=${!value_name} + case "$field" in + NUMBER) number=$value ;; + STATE) state=$value ;; + DIGEST) digest=$value ;; + HEAD) head=$value ;; + BODY) body=$value ;; + MERGED) merged=$value ;; + MERGE) merge=$value ;; + esac + done + verify_final_pull "$number" "$state" "$digest" "$head" "$body" "$merged" "$merge" +} + +verify_selected_pulls() { + local prefix + while IFS= read -r prefix || [[ -n "$prefix" ]]; do + [[ -n "$prefix" ]] || continue + verify_expected_pull "$prefix" + done <"$SELECTED_PULLS" +} + +verify_remote_main_state() { + local path="$temporary_root/final-main-ref" oid ref extra found= + authenticated_git ls-remote --refs "$REMOTE_URL" refs/heads/main >"$path" 2>/dev/null || + die "cannot read final remote main" + while IFS=$'\t' read -r oid ref extra || [[ -n "${oid:-}" ]]; do + [[ -z "$found" && -n "$oid" && -z "${extra:-}" && "$ref" == refs/heads/main ]] || + die "final remote main response is malformed" + is_sha "$oid" || die "final remote main OID is malformed" + found=$oid + done <"$path" + [[ "$found" == "$MAIN_OID" ]] || die "final remote main differs from the proved state" +} + +DESIRED_TITLE="$TITLE_PREFIX$SOURCE_REVISION" +DESIRED_BODY="$BODY_PREFIX$(jq -cnS --arg evidence "$EVIDENCE_URL" --arg digest "$IMAGE_DIGEST" \ + --arg tag "$RELEASE_TAG" --arg source_pr "$SOURCE_PR" --arg source "$SOURCE_REVISION" \ + '{"evidence-url":$evidence,"image-digest":$digest,"release-tag":$tag,"source-pr":$source_pr,"source-revision":$source}')" + +SAME_TOTAL=0 +NEWER_COUNT=0 +OLDER_OPEN="$temporary_root/older-open" +: >"$OLDER_OPEN" +SAME_PULLS="$temporary_root/same-pulls" +: >"$SAME_PULLS" +SELECTED_PULLS="$temporary_root/selected-pulls" +: >"$SELECTED_PULLS" +index=0 +pull_relation= +pull_state= +pull_merged= +while IFS= read -r pull_number || [[ -n "$pull_number" ]]; do + [[ -n "$pull_number" ]] || continue + is_positive_decimal_at_most "$pull_number" "$U64_MAX" || die "matching pull number is invalid" + PULL_BODY= + api_request GET "$API_BASE/repos/$REPOSITORY/pulls/$pull_number" '' PULL_BODY + index=$((index + 1)) + prefix="PULL_$index" + validate_pull "$PULL_BODY" "$pull_number" "$prefix" + snapshot_pull "$prefix" + printf '%s\n' "$prefix" >>"$SELECTED_PULLS" + eval "pull_relation=\${${prefix}_RELATION}" + eval "pull_state=\${${prefix}_STATE}" + eval "pull_merged=\${${prefix}_MERGED}" + case "$pull_relation" in + equal) + SAME_TOTAL=$((SAME_TOTAL + 1)) + printf '%s\n' "$prefix" >>"$SAME_PULLS" + ;; + older) + if [[ "$pull_state" == open && "$pull_merged" == false ]]; then + printf '%s\t%s\n' "$pull_number" "$prefix" >>"$OLDER_OPEN" + fi + ;; + newer) + [[ "$pull_state" == open && "$pull_merged" == false ]] || die "non-open newer proposal is ambiguous" + NEWER_COUNT=$((NEWER_COUNT + 1)) + ;; + esac +done <"$MATCHING_PULLS" + +SAME_COUNT=0 +same_historical_prefix= +while IFS= read -r same_prefix || [[ -n "$same_prefix" ]]; do + [[ -n "$same_prefix" ]] || continue + same_candidate_state= + same_candidate_merged= + same_candidate_digest= + same_candidate_body= + eval "same_candidate_state=\${${same_prefix}_STATE}" + eval "same_candidate_merged=\${${same_prefix}_MERGED}" + eval "same_candidate_digest=\${${same_prefix}_DIGEST}" + eval "same_candidate_body=\${${same_prefix}_BODY}" + if [[ "$same_candidate_state" == open || + ("$same_candidate_digest" == "$IMAGE_DIGEST" && "$same_candidate_body" == "$DESIRED_BODY") ]]; then + SAME_COUNT=$((SAME_COUNT + 1)) + SAME_PREFIX=$same_prefix + elif [[ "$same_candidate_merged" == false ]]; then + same_historical_prefix=$same_prefix + fi +done <"$SAME_PULLS" +((SAME_COUNT <= 1)) || die "multiple current pin pulls claim the same source" +if ((SAME_COUNT == 0 && SAME_TOTAL == 1)) && [[ -n "$same_historical_prefix" ]]; then + SAME_COUNT=1 + SAME_PREFIX=$same_historical_prefix +fi +((NEWER_COUNT <= 1)) || die "multiple newer pin proposals are ambiguous" +((SAME_COUNT == 0 || NEWER_COUNT == 0)) || die "same-source and newer proposals are ambiguous" + +if ((NEWER_COUNT == 1)); then + verify_selected_pulls + verify_remote_main_state + exit 0 +fi + +TARGET_CURRENT=false +if [[ -n "$TARGET_OID" ]]; then + TARGET_IMAGE="$API_WORK/target-image" + TARGET_EVIDENCE="$API_WORK/target-evidence" + require_regular_blob_entry "$TARGET_OID" "$IMAGE_PATH" "target branch image record" + require_regular_blob_entry "$TARGET_OID" "$EVIDENCE_PATH" "target branch evidence record" + clone_git show "$TARGET_OID:$IMAGE_PATH" >"$TARGET_IMAGE" 2>/dev/null || die "target branch image record is absent" + clone_git show "$TARGET_OID:$EVIDENCE_PATH" >"$TARGET_EVIDENCE" 2>/dev/null || die "target branch evidence record is absent" + bash "$CHECK" validate-pair "$TARGET_IMAGE" "$TARGET_EVIDENCE" >/dev/null 2>&1 || + die "target branch record pair is invalid" + TARGET_SOURCE=$(bash "$CHECK" source-revision "$TARGET_IMAGE" 2>/dev/null) || + die "cannot read target branch source pin" + [[ "$TARGET_SOURCE" == "$SOURCE_REVISION" ]] || + die "target branch record source differs from its branch suffix" + TARGET_PARENT=$(clone_git show -s --format=%P "$TARGET_OID") || die "cannot inspect target branch parent" + TARGET_IDENTITY=$(clone_git show -s --format='%s%n%an%n%ae%n%cn%n%ce' "$TARGET_OID") || + die "cannot inspect target branch commit identity" + TARGET_PATHS=$(clone_git diff-tree --no-commit-id --name-only -r "$TARGET_OID" | sort) || + die "cannot inspect target branch changed paths" + EXPECTED_TARGET_PATHS=$(printf '%s\n%s' "$EVIDENCE_PATH" "$IMAGE_PATH" | sort) + TARGET_SIGNATURE=$(clone_git cat-file commit "$TARGET_OID" | sed -n '/^gpgsig\(-sha256\)\{0,1\} /p') || + die "cannot inspect target branch signature" + is_sha "$TARGET_PARENT" || die "target branch commit must have exactly one parent" + clone_git cat-file -e "$TARGET_PARENT^{commit}" 2>/dev/null || die "target branch parent commit is absent" + if [[ "$TARGET_PARENT" != "$MAIN_OID" ]]; then + clone_git merge-base --is-ancestor "$TARGET_PARENT" "$MAIN_OID" 2>/dev/null || + die "target branch parent is not an ancestor of recorded main" + fi + [[ "$TARGET_IDENTITY" == "$DESIRED_TITLE"$'\n'"$EXPECTED_BOT_LOGIN"$'\n'"$EXPECTED_BOT_ID+$EXPECTED_BOT_LOGIN@users.noreply.github.com"$'\n'"$EXPECTED_BOT_LOGIN"$'\n'"$EXPECTED_BOT_ID+$EXPECTED_BOT_LOGIN@users.noreply.github.com" ]] || + die "target branch commit author or committer identity differs" + [[ "$TARGET_PATHS" == "$EXPECTED_TARGET_PATHS" ]] || die "target branch changed paths differ from the exact pin pair" + [[ -z "$TARGET_SIGNATURE" ]] || die "target branch commit is signed" + if [[ "$TARGET_PARENT" == "$MAIN_OID" ]] && cmp -s "$TARGET_IMAGE" "$IMAGE_OUTPUT" && + cmp -s "$TARGET_EVIDENCE" "$EVIDENCE_OUTPUT"; then + TARGET_CURRENT=true + fi +fi + +ACTION=create +same_number= +same_digest= +same_tag= +same_state= +same_merged= +same_head= +same_body= +if ((SAME_COUNT == 1)); then + eval "same_number=\${${SAME_PREFIX}_NUMBER}" + eval "same_digest=\${${SAME_PREFIX}_DIGEST}" + eval "same_tag=\${${SAME_PREFIX}_TAG}" + eval "same_state=\${${SAME_PREFIX}_STATE}" + eval "same_merged=\${${SAME_PREFIX}_MERGED}" + eval "same_head=\${${SAME_PREFIX}_HEAD}" + eval "same_body=\${${SAME_PREFIX}_BODY}" + [[ "$same_head" == "$TARGET_OID" || "$same_merged" == true ]] || + die "same-source pull head differs from the recorded target branch" + if [[ "$same_merged" == true ]]; then + [[ "$same_state" == closed && "$BASE_SOURCE" == "$SOURCE_REVISION" && + "$BASE_DIGEST" == "$IMAGE_DIGEST" && "$same_digest" == "$IMAGE_DIGEST" && + "$same_tag" == "$RELEASE_TAG" && "$same_body" == "$DESIRED_BODY" && + $(cmp -s "$MAIN_IMAGE" "$IMAGE_OUTPUT"; printf '%s' "$?") == 0 && + $(cmp -s "$MAIN_EVIDENCE" "$EVIDENCE_OUTPUT"; printf '%s' "$?") == 0 ]] || + die "merged same-source pull is not the exact current pin" + verify_selected_pulls + verify_remote_main_state + exit 0 + fi + if [[ "$same_state" == open ]]; then + if [[ "$same_digest" != "$IMAGE_DIGEST" ]]; then + ACTION=replace + elif [[ "$same_tag" == "$RELEASE_TAG" && "$same_body" == "$DESIRED_BODY" ]]; then + ACTION=verify + else + ACTION=reconcile + fi + else + if [[ "$same_digest" == "$IMAGE_DIGEST" ]]; then + ACTION=reopen + else + ACTION=replace_closed + fi + fi +fi + +if [[ "$TARGET_CURRENT" != true ]]; then + install -m 0644 "$IMAGE_OUTPUT" "$WORKTREE_ROOT/$IMAGE_PATH" || die "cannot install image record" + install -m 0644 "$EVIDENCE_OUTPUT" "$WORKTREE_ROOT/$EVIDENCE_PATH" || die "cannot install evidence record" + worktree_git add -- "$IMAGE_PATH" "$EVIDENCE_PATH" || die "cannot stage pin records" + STAGED=$(worktree_git diff --cached --name-only --diff-filter=ACMRTUXB) || die "cannot inspect staged pin paths" + EXPECTED_STAGED=$(printf '%s\n%s' "$EVIDENCE_PATH" "$IMAGE_PATH" | sort) + [[ "$(printf '%s\n' "$STAGED" | sort)" == "$EXPECTED_STAGED" ]] || die "staged changes are not exactly the pin pair" + STATUS=$(worktree_git status --porcelain=v1 --untracked-files=all --ignore-submodules=none) || + die "cannot inspect pin worktree status" + while IFS= read -r status_line || [[ -n "$status_line" ]]; do + [[ -z "$status_line" ]] && continue + status_path=${status_line:3} + [[ "$status_path" == "$IMAGE_PATH" || "$status_path" == "$EVIDENCE_PATH" ]] || + die "pin worktree contains an unexpected change" + done <<<"$STATUS" + worktree_git -c user.name="$EXPECTED_BOT_LOGIN" \ + -c user.email="$EXPECTED_BOT_ID+$EXPECTED_BOT_LOGIN@users.noreply.github.com" \ + -c commit.gpgsign=false -c core.hooksPath=/dev/null \ + commit --no-gpg-sign --no-verify -m "$DESIRED_TITLE" -- "$IMAGE_PATH" "$EVIDENCE_PATH" \ + >/dev/null 2>&1 || die "cannot create pin commit" + NEW_OID=$(worktree_git rev-parse --verify HEAD) || die "cannot resolve pin commit" + is_sha "$NEW_OID" || die "pin commit OID is malformed" + [[ "$(worktree_git show -s --format=%P "$NEW_OID")" == "$MAIN_OID" ]] || + die "pin commit does not have exactly recorded main as parent" + [[ -z "$(worktree_git status --porcelain=v1 --untracked-files=all --ignore-submodules=none)" ]] || + die "pin worktree is not clean after commit" + if [[ -z "$TARGET_OID" ]]; then + RECHECK="$temporary_root/target-recheck" + authenticated_git ls-remote --refs "$REMOTE_URL" "$BRANCH_REF" >"$RECHECK" 2>/dev/null || + die "cannot recheck absent target branch" + [[ ! -s "$RECHECK" ]] || die "target branch appeared before creation push" + fi + authenticated_git -C "$WORKTREE_ROOT" push --porcelain --no-verify \ + "--force-with-lease=$BRANCH_REF:$TARGET_OID" origin "HEAD:$BRANCH_REF" >/dev/null 2>&1 || + die "leased pin branch push failed" + READBACK="$temporary_root/target-readback" + authenticated_git ls-remote --refs "$REMOTE_URL" "$BRANCH_REF" >"$READBACK" 2>/dev/null || + die "cannot read back pushed target branch" + [[ "$(awk -F '\t' -v ref="$BRANCH_REF" '$2 == ref {print $1}' "$READBACK")" == "$NEW_OID" && + $(wc -l <"$READBACK" | tr -d '[:space:]') == 1 ]] || die "pushed target branch readback differs" + TARGET_OID=$NEW_OID +fi + +while IFS= read -r same_prefix || [[ -n "$same_prefix" ]]; do + [[ -n "$same_prefix" ]] || continue + set_expected_pull_field "$same_prefix" HEAD "$TARGET_OID" +done <"$SAME_PULLS" + +request_file() { + local name bytes result_name path + name=$1 + bytes=$2 + result_name=$3 + path="$API_WORK/$name" + [[ "$bytes" != *$'\n'* && "$bytes" != *$'\r'* ]] || die "internal request body is multiline" + printf '%s' "$bytes" >"$path" || die "cannot write REST request body" + chmod 0600 "$path" || die "cannot secure REST request body" + printf -v "$result_name" '%s' "$path" +} + +require_response_pull_number() { + local path=$1 expected=$2 actual + actual=$(jq -er '.number | tostring' "$path" 2>/dev/null) || die "mutation response pull number is absent" + [[ "$actual" == "$expected" ]] || die "mutation response pull number differs" +} + +verify_final_remote_state() { + local path="$temporary_root/final-remote-refs" oid ref extra final_main='' final_target='' + authenticated_git ls-remote --refs "$REMOTE_URL" refs/heads/main "$BRANCH_REF" >"$path" 2>/dev/null || + die "cannot read final remote refs" + while IFS=$'\t' read -r oid ref extra || [[ -n "${oid:-}" ]]; do + [[ -n "$oid" && -z "${extra:-}" ]] || die "final remote ref response is malformed" + is_sha "$oid" || die "final remote ref OID is malformed" + case "$ref" in + refs/heads/main) [[ -z "$final_main" ]] || die "final remote main ref is duplicated"; final_main=$oid ;; + "$BRANCH_REF") [[ -z "$final_target" ]] || die "final remote target ref is duplicated"; final_target=$oid ;; + *) die "final remote ref response contains an unrequested ref" ;; + esac + done <"$path" + [[ "$final_main" == "$MAIN_OID" && "$final_target" == "$TARGET_OID" ]] || + die "final remote refs differ from the proved state" +} + +while IFS=$'\t' read -r old_number old_prefix || [[ -n "${old_number:-}" ]]; do + [[ -n "$old_number" ]] || continue + CLOSE_REQUEST= + request_file "close-$old_number.json" '{"state":"closed"}' CLOSE_REQUEST + MUTATION_BODY= + api_request PATCH "$API_BASE/repos/$REPOSITORY/pulls/$old_number" "$CLOSE_REQUEST" MUTATION_BODY + require_response_pull_number "$MUTATION_BODY" "$old_number" + set_expected_pull_field "$old_prefix" STATE closed + verify_expected_pull "$old_prefix" +done <"$OLDER_OPEN" + +if [[ "$ACTION" == replace ]]; then + CLOSE_REQUEST= + request_file "close-$same_number.json" '{"state":"closed"}' CLOSE_REQUEST + MUTATION_BODY= + api_request PATCH "$API_BASE/repos/$REPOSITORY/pulls/$same_number" "$CLOSE_REQUEST" MUTATION_BODY + require_response_pull_number "$MUTATION_BODY" "$same_number" + set_expected_pull_field "$SAME_PREFIX" STATE closed + verify_expected_pull "$SAME_PREFIX" +elif [[ "$ACTION" == replace_closed ]]; then + verify_expected_pull "$SAME_PREFIX" +fi + +if [[ "$ACTION" == verify ]]; then + verify_expected_pull "$SAME_PREFIX" +elif [[ "$ACTION" == reopen || "$ACTION" == reconcile ]]; then + REOPEN_JSON=$(jq -cnS --arg base main --arg body "$DESIRED_BODY" --arg state open --arg title "$DESIRED_TITLE" \ + '{base:$base,body:$body,state:$state,title:$title}') + REOPEN_REQUEST= + request_file reopen.json "$REOPEN_JSON" REOPEN_REQUEST + MUTATION_BODY= + api_request PATCH "$API_BASE/repos/$REPOSITORY/pulls/$same_number" "$REOPEN_REQUEST" MUTATION_BODY + require_response_pull_number "$MUTATION_BODY" "$same_number" + set_expected_pull_field "$SAME_PREFIX" STATE open + set_expected_pull_field "$SAME_PREFIX" DIGEST "$IMAGE_DIGEST" + set_expected_pull_field "$SAME_PREFIX" BODY "$DESIRED_BODY" + verify_expected_pull "$SAME_PREFIX" +else + CREATE_JSON=$(jq -cnS --arg base main --arg body "$DESIRED_BODY" --arg head "$BRANCH" --arg title "$DESIRED_TITLE" \ + '{base:$base,body:$body,draft:false,head:$head,title:$title}') + CREATE_REQUEST= + request_file create.json "$CREATE_JSON" CREATE_REQUEST + CREATED_BODY= + api_request POST "$API_BASE/repos/$REPOSITORY/pulls" "$CREATE_REQUEST" CREATED_BODY + CREATED_NUMBER=$(jq -er '.number|tostring' "$CREATED_BODY" 2>/dev/null) || die "created pull number is absent" + is_positive_decimal_at_most "$CREATED_NUMBER" "$U64_MAX" || die "created pull number is invalid" + validate_pull "$CREATED_BODY" "$CREATED_NUMBER" CREATED + [[ "$CREATED_STATE" == open && "$CREATED_DIGEST" == "$IMAGE_DIGEST" && + "$CREATED_HEAD" == "$TARGET_OID" && "$CREATED_BODY" == "$DESIRED_BODY" && + "$CREATED_MERGED" == false ]] || die "created pull differs from the exact requested state" + verify_final_pull "$CREATED_NUMBER" open "$IMAGE_DIGEST" "$TARGET_OID" "$DESIRED_BODY" false "$CREATED_MERGE" +fi + +verify_selected_pulls +verify_final_remote_state diff --git a/.github/docker/build-app-cli/verify-build-container-publication.sh b/.github/docker/build-app-cli/verify-build-container-publication.sh new file mode 100755 index 00000000..c2518ac8 --- /dev/null +++ b/.github/docker/build-app-cli/verify-build-container-publication.sh @@ -0,0 +1,610 @@ +#!/usr/bin/env bash +{ set +x; } 2>/dev/null +set +a +set -euo pipefail + +export LC_ALL=C +export BASH_ENV= +export ENV= +export GIT_CONFIG_NOSYSTEM=1 +export GIT_CONFIG_GLOBAL=/dev/null +export GIT_NO_REPLACE_OBJECTS=1 +unset GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE GIT_OBJECT_DIRECTORY +unset GIT_COMMON_DIR GIT_CEILING_DIRECTORIES +unset GIT_REPLACE_REF_BASE + +readonly API_ROOT=https://api.github.com/repos/stackpop/edgezero/actions/runs +readonly API_VERSION=2026-03-10 +readonly CONTENT_TYPE='application/json; charset=utf-8' +readonly COMMENT_PREFIX='edgezero-release-evidence-v1 ' +readonly ENVIRONMENT=build-container-release +readonly IMAGE_RECORD_PATH=.github/docker/build-app-cli/image.json +readonly EVIDENCE_RECORD_PATH=.github/docker/build-app-cli/image-release-evidence.json +readonly PIN_CHECK_PATH=.github/docker/build-app-cli/check-image-pin.sh +readonly U64_MAX=18446744073709551615 +readonly U32_MAX=4294967295 + +usage() { + printf '%s\n' \ + "usage: verify-build-container-publication.sh \\" \ + " --gate-root \\" \ + " --subject-root \\" \ + " --gate-sha \\" \ + " --candidate-sha \\" \ + " --image-json \\" \ + ' --evidence-json ' >&2 + exit 2 +} + +die() { + printf '::error::%s\n' "$*" >&2 + exit 1 +} + +tool_die() { + printf '::error::%s\n' "$*" >&2 + exit 2 +} + +is_sha() { + [[ "$1" =~ ^[0-9a-f]{40}$ && "$1" != 0000000000000000000000000000000000000000 ]] +} + +is_positive_decimal_at_most() { + local value=$1 maximum=$2 + [[ "$value" =~ ^[1-9][0-9]*$ ]] || return 1 + ((${#value} < ${#maximum})) && return 0 + ((${#value} == ${#maximum})) && [[ "$value" < "$maximum" || "$value" == "$maximum" ]] +} + +decimal_greater_than() { + local left=$1 right=$2 + ((${#left} > ${#right})) && return 0 + ((${#left} == ${#right})) && [[ "$left" > "$right" ]] +} + +is_beneath() { + [[ "$1" == "$2" || "$1" == "$2/"* ]] +} + +canonical_root() { + local supplied=$1 label=$2 canonical + [[ "$supplied" == /* && -d "$supplied" && ! -L "$supplied" ]] || + die "$label must be an absolute, non-symlink directory" + canonical=$(cd -- "$supplied" && pwd -P) || die "cannot resolve $label" + [[ "$canonical" == "$supplied" ]] || die "$label must already be canonical" + printf '%s\n' "$canonical" +} + +repo_git() { + local root=$1 + shift + env -i PATH="$PATH" LC_ALL=C HOME=/dev/null TMPDIR=/tmp \ + GIT_CONFIG_NOSYSTEM=1 GIT_CONFIG_GLOBAL=/dev/null GIT_TERMINAL_PROMPT=0 \ + GIT_NO_REPLACE_OBJECTS=1 GIT_NO_LAZY_FETCH=1 GIT_OPTIONAL_LOCKS=0 \ + git --no-replace-objects -c core.fsmonitor=false -c core.hooksPath=/dev/null \ + -C "$root" "$@" +} + +gate_git() { + repo_git "$GATE_ROOT" "$@" +} + +subject_git() { + repo_git "$SUBJECT_ROOT" "$@" +} + +require_checkout() { + local root=$1 expected=$2 label=$3 common_result_name=$4 object_result_name=$5 + local top git_directory common_directory object_directory + local canonical_common canonical_object config_status + local replacements shallow actual status sparse gitlinks + + [[ "$(repo_git "$root" rev-parse --is-inside-work-tree 2>/dev/null)" == true ]] || + die "$label is not a Git worktree" + top=$(repo_git "$root" rev-parse --show-toplevel 2>/dev/null) || + die "cannot resolve $label top level" + [[ "$top" == "$root" ]] || die "$label must be the exact repository top level" + git_directory=$(repo_git "$root" rev-parse --absolute-git-dir 2>/dev/null) || + die "cannot resolve $label Git directory" + common_directory=$(repo_git "$root" rev-parse --path-format=absolute --git-common-dir 2>/dev/null) || + die "cannot resolve $label common Git directory" + object_directory=$(repo_git "$root" rev-parse --path-format=absolute --git-path objects 2>/dev/null) || + die "cannot resolve $label object directory" + [[ -d "$common_directory" && -d "$object_directory" ]] || + die "$label Git directories are unavailable" + canonical_common=$(cd -- "$common_directory" && pwd -P) || + die "cannot canonicalize $label common Git directory" + canonical_object=$(cd -- "$object_directory" && pwd -P) || + die "cannot canonicalize $label object directory" + if repo_git "$root" config --name-only --get-regexp \ + '^(extensions\.partial[Cc]lone|remote\..*\.(promisor|partial[Cc]lone[Ff]ilter))$' \ + >/dev/null 2>&1; then + die "$label cannot use partial-clone or promisor configuration" + else + config_status=$? + [[ "$config_status" -eq 1 ]] || die "cannot inspect $label partial-clone configuration" + fi + [[ ! -e "$canonical_object/info/alternates" && ! -L "$canonical_object/info/alternates" ]] || + die "$label cannot use an alternate object store" + [[ ! -e "$git_directory/info/grafts" && ! -L "$git_directory/info/grafts" && + ! -e "$canonical_common/info/grafts" && ! -L "$canonical_common/info/grafts" ]] || + die "$label cannot contain legacy grafts" + replacements=$(repo_git "$root" for-each-ref --format='%(refname)' refs/replace/ 2>/dev/null) || + die "cannot inspect $label replacement refs" + [[ -z "$replacements" ]] || die "$label cannot contain replacement refs" + shallow=$(repo_git "$root" rev-parse --is-shallow-repository 2>/dev/null) || + die "cannot inspect $label history depth" + [[ "$shallow" == false ]] || die "$label must be a full checkout" + actual=$(repo_git "$root" rev-parse --verify HEAD 2>/dev/null) || die "$label HEAD is absent" + [[ "$actual" == "$expected" ]] || die "$label HEAD differs from its supplied full SHA" + if repo_git "$root" symbolic-ref -q HEAD >/dev/null 2>&1; then + die "$label must be detached" + fi + sparse=$(repo_git "$root" config --bool core.sparseCheckout 2>/dev/null || true) + [[ "$sparse" != true ]] || die "$label cannot be sparse" + status=$(repo_git "$root" status --porcelain=v1 --untracked-files=all --ignore-submodules=none) || + die "cannot inspect $label status" + [[ -z "$status" ]] || die "$label must be clean" + gitlinks=$(repo_git "$root" ls-tree -r "$expected" 2>/dev/null | awk '$1 == "160000" { print; exit }') || + die "cannot inspect $label submodule state" + [[ -z "$gitlinks" ]] || die "$label cannot contain submodule state" + printf -v "$common_result_name" '%s' "$canonical_common" + printf -v "$object_result_name" '%s' "$canonical_object" +} + +require_input_file() { + local path=$1 label=$2 parent canonical_parent expected + [[ "$path" == /* && -f "$path" && ! -L "$path" ]] || + die "$label must be an absolute regular non-symlink file" + parent=${path%/*} + [[ -n "$parent" ]] || parent=/ + canonical_parent=$(cd -- "$parent" && pwd -P) || die "cannot resolve $label parent" + [[ "$canonical_parent" == "$parent" ]] || die "$label path must already be canonical" + if [[ "$parent" == / ]]; then expected="/${path##*/}"; else expected="$parent/${path##*/}"; fi + [[ "$expected" == "$path" ]] || die "$label path must already be canonical" + ! is_beneath "$path" "$GATE_ROOT" || die "$label must be outside the gate repository" + ! is_beneath "$path" "$SUBJECT_ROOT" || die "$label must be outside the subject repository" +} + +require_exact_subject_blob() { + local input=$1 relative=$2 label=$3 entry metadata mode type recorded + entry=$(subject_git ls-tree "$CANDIDATE_SHA" -- "$relative" 2>/dev/null) || + die "cannot inspect candidate $label blob" + [[ -n "$entry" && "$entry" != *$'\n'* && "$entry" == *$'\t'* ]] || + die "candidate $label blob is missing or ambiguous" + metadata=${entry%%$'\t'*} + recorded=${entry#*$'\t'} + read -r mode type _ <<<"$metadata" + [[ "$recorded" == "$relative" && "$type" == blob && "$mode" == 100644 ]] || + die "candidate $label path is not a regular Git blob" + cmp -s "$input" <(subject_git show "$CANDIDATE_SHA:$relative" 2>/dev/null) || + die "$label bytes differ from the exact candidate blob" +} + +extract_top_level_unsigned() { + local path=$1 wanted=$2 + awk -v wanted="$wanted" ' + BEGIN { RS = "\034"; count = 0 } + { + s = $0 + depth = 0 + n = length(s) + for (i = 1; i <= n; i++) { + c = substr(s, i, 1) + if (c == "{") { depth++; continue } + if (c == "}" || c == "]") { depth--; continue } + if (c == "[") { depth++; continue } + if (c != "\"") continue + + start = i + 1 + escaped = 0 + for (j = start; j <= n; j++) { + q = substr(s, j, 1) + if (escaped) { escaped = 0; continue } + if (q == "\\") { escaped = 1; continue } + if (q == "\"") break + } + if (j > n) exit 1 + if (depth != 1) { i = j; continue } + key = substr(s, start, j - start) + k = j + 1 + while (k <= n && substr(s, k, 1) ~ /[ \t\r\n]/) k++ + if (substr(s, k, 1) != ":") { i = j; continue } + k++ + while (k <= n && substr(s, k, 1) ~ /[ \t\r\n]/) k++ + if (key == wanted) { + value_start = k + while (k <= n && substr(s, k, 1) ~ /[0-9]/) k++ + if (k == value_start) exit 1 + value = substr(s, value_start, k - value_start) + while (k <= n && substr(s, k, 1) ~ /[ \t\r\n]/) k++ + if (substr(s, k, 1) != "," && substr(s, k, 1) != "}") exit 1 + count++ + found = value + } + i = j + } + } + END { if (count == 1) print found; else exit 1 } + ' "$path" +} + +temporary_files=() +remove_temporary_files() { + local path + for path in ${temporary_files[@]+"${temporary_files[@]}"}; do + [[ -z "$path" ]] || rm -f -- "$path" 2>/dev/null || true + done +} + +cleanup_exit() { + local status=$? + trap - EXIT HUP INT TERM + remove_temporary_files + exit "$status" +} + +cleanup_signal() { + local status=$1 + trap - EXIT HUP INT TERM + remove_temporary_files + exit "$status" +} + +trap cleanup_exit EXIT +trap 'cleanup_signal 129' HUP +trap 'cleanup_signal 130' INT +trap 'cleanup_signal 143' TERM + +new_temporary_file() { + local result_name=$1 label=$2 path + path=$(mktemp "$TEMP_ROOT/.edgezero-publication-$label.XXXXXX" 2>/dev/null) || + die "cannot create temporary response file" + temporary_files+=("$path") + printf -v "$result_name" '%s' "$path" +} + +curl_get() { + local label=$1 url=$2 result_name=$3 link_name=$4 body metadata line link + local -a metadata_lines=() + new_temporary_file body body + new_temporary_file metadata metadata + + if ! printf '%s\n' \ + 'header = "Accept: application/vnd.github+json"' \ + 'header = "X-GitHub-Api-Version: 2026-03-10"' \ + 'header = "User-Agent: edgezero-build-container-gate/1"' \ + "header = \"Authorization: Bearer $publication_github_token\"" | + env -i PATH="$PATH" LC_ALL=C curl \ + --disable --silent --show-error --connect-timeout 10 --max-time 30 --max-redirs 0 \ + --request GET --config - --output "$body" \ + --write-out '%{http_code}\n%header{x-github-api-version-selected}\n%header{content-type}\nlink=%header{link}' \ + "$url" >"$metadata" 2>/dev/null; then + die "$label request failed" + fi + + while IFS= read -r line || [[ -n "$line" ]]; do + metadata_lines+=("$line") + done <"$metadata" + [[ "${#metadata_lines[@]}" -eq 4 ]] || die "$label response metadata is malformed" + [[ "${metadata_lines[0]}" == 200 ]] || die "$label response did not return HTTP 200" + [[ "${metadata_lines[1]}" == "$API_VERSION" ]] || + die "$label response selected an unexpected API version" + case "${metadata_lines[2]}" in + application/json | "$CONTENT_TYPE") ;; + *) die "$label response has an unexpected content type" ;; + esac + [[ "${metadata_lines[3]}" == link=* ]] || die "$label Link metadata is malformed" + link=${metadata_lines[3]#link=} + printf -v "$result_name" '%s' "$body" + printf -v "$link_name" '%s' "$link" +} + +validate_review_time() { + local value=$1 epoch round_trip + [[ "$value" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$ ]] || + return 1 + epoch=$(jq -nr --arg value "$value" '$value | fromdateiso8601' 2>/dev/null) || return 1 + round_trip=$(jq -nr --argjson epoch "$epoch" \ + '$epoch | strftime("%Y-%m-%dT%H:%M:%SZ")' 2>/dev/null) || return 1 + [[ "$round_trip" == "$value" ]] +} + +validate_run_response() { + local body=$1 raw_id raw_attempt values event workflow_path head_sha head_branch status conclusion extra + jq -e -s 'length == 1 and (.[0] | type) == "object"' "$body" >/dev/null 2>&1 || + die "run response is not exactly one JSON object" + raw_id=$(extract_top_level_unsigned "$body" id 2>/dev/null) || + die "run response id is not one exact JSON integer" + raw_attempt=$(extract_top_level_unsigned "$body" run_attempt 2>/dev/null) || + die "run response attempt is not one exact JSON integer" + is_positive_decimal_at_most "$raw_id" "$U64_MAX" || die "run response id is out of range" + is_positive_decimal_at_most "$raw_attempt" "$U32_MAX" || die "run response attempt is out of range" + [[ "$raw_id" == "$RUN_ID" && "$raw_attempt" == "$RUN_ATTEMPT" ]] || + die "run response identifiers differ from the evidence" + + values=$(jq -er -s ' + .[0] as $run + | if (($run.event | type) == "string") + and (($run.path | type) == "string") + and (($run.head_sha | type) == "string") + and (($run.head_branch | type) == "string") + and (($run.status | type) == "string") + and (($run.conclusion == null) or (($run.conclusion | type) == "string")) + then [$run.event, $run.path, $run.head_sha, $run.head_branch, + $run.status, ($run.conclusion // "")] | @tsv + else error("invalid run fields") + end + ' "$body" 2>/dev/null) || die "run response fields are malformed" + IFS=$'\t' read -r event workflow_path head_sha head_branch status conclusion extra <<<"$values" + [[ -z "${extra:-}" ]] || die "run response fields are malformed" + [[ "$event" == push ]] || die "publication run event is not push" + [[ "$workflow_path" == ".github/workflows/publish-build-container.yml@$RELEASE_TAG" ]] || + die "publication run path is not exact" + [[ "$head_sha" == "$SOURCE_REVISION" ]] || die "publication run source revision differs" + [[ "$head_branch" == "$RELEASE_TAG" ]] || die "publication run release tag differs" + + if [[ "$status" == completed ]]; then + [[ "$conclusion" == success ]] || die "publication run did not succeed" + return 0 + fi + case "$status" in queued | in_progress | waiting | requested | pending) ;; *) + die "publication run has an unknown status" ;; + esac + [[ -z "$conclusion" ]] || die "incomplete publication run has a conclusion" + return 10 +} + +validate_jobs_response() { + local body=$1 raw_total_count + raw_total_count=$(extract_top_level_unsigned "$body" total_count 2>/dev/null) || + die "publisher jobs total count is not one exact JSON integer" + [[ "$raw_total_count" == 2 ]] || die "publisher jobs total count is not exactly two" + jq -e -s --arg source "$SOURCE_REVISION" --arg attempt "$RUN_ATTEMPT" ' + def valid_context_step: + (.steps | type) == "array" + and ([.steps[] + | select(type == "object" and .name == "assert-exact-publisher-context")] | length) == 1 + and ([.steps[] + | select(type == "object" and .name == "assert-exact-publisher-context")][0].conclusion + == "success"); + length == 1 + and (.[0] | type) == "object" + and (.[0].total_count | type) == "number" + and .[0].total_count == 2 + and .[0].total_count == (.[0].total_count | floor) + and (.[0].jobs | type) == "array" + and (.[0].jobs | length) == 2 + and ([.[0].jobs[].name] | sort) == ["build-and-verify", "update-pin"] + and all(.[0].jobs[]; + (type == "object") + and .status == "completed" + and .conclusion == "success" + and .head_sha == $source + and ((has("run_attempt") | not) + or ((.run_attempt | type) == "number" + and .run_attempt == (.run_attempt | floor) + and (.run_attempt | tostring) == $attempt)) + and valid_context_step) + ' "$body" >/dev/null 2>&1 || die "publisher jobs response is not exact" +} + +validate_protocol_keys() { + local path=$1 + jq -ne --stream ' + [inputs | select(length == 2)] as $events + | (($events | length) == 8) + and all($events[]; ((.[0] | length) == 1)) + and ([$events[] | .[0][0]] as $keys + | ($keys | length) == ($keys | unique | length) + and ($keys | sort) == (["challenge", "image-digest", "png-sha256", + "release-tag", "reviewed-at", "run-attempt", "run-id", "source-revision"] | sort)) + ' "$path" >/dev/null 2>&1 +} + +validate_approvals_response() { + local body=$1 reviews review_file comment protocol_file values + local challenge digest screenshot tag reviewed_at attempt run_id source extra canonical + local current_count=0 + jq -e -s 'length == 1 and (.[0] | type) == "array"' "$body" >/dev/null 2>&1 || + die "approvals response is not exactly one JSON array" + new_temporary_file reviews reviews + jq -j -s --arg prefix "$COMMENT_PREFIX" ' + .[0][] + | select(type == "object" + and (.comment | type) == "string" + and (.comment | startswith($prefix))) + | @json, "\u0000" + ' "$body" >"$reviews" 2>/dev/null || die "cannot enumerate approval comments" + + while IFS= read -r -d '' review_file; do + new_temporary_file protocol_file protocol + comment=$(jq -er '.comment' <<<"$review_file" 2>/dev/null) || + die "cannot extract a protocol approval comment" + printf '%s' "${comment#"$COMMENT_PREFIX"}" >"$protocol_file" + validate_protocol_keys "$protocol_file" || + die "approval history contains duplicate or malformed protocol keys" + values=$(jq -er ' + if type == "object" + and all(.[]; type == "string") + then [.challenge, ."image-digest", ."png-sha256", ."release-tag", + ."reviewed-at", ."run-attempt", ."run-id", ."source-revision"] | @tsv + else error("invalid protocol fields") + end + ' "$protocol_file" 2>/dev/null) || die "approval protocol fields are malformed" + IFS=$'\t' read -r challenge digest screenshot tag reviewed_at attempt run_id source extra <<<"$values" + [[ -z "${extra:-}" ]] || die "approval protocol fields are malformed" + [[ "$challenge" =~ ^[0-9a-f]{64}$ ]] || die "approval challenge is malformed" + [[ "$digest" =~ ^sha256:[0-9a-f]{64}$ ]] || die "approval image digest is malformed" + [[ "$screenshot" =~ ^sha256:[0-9a-f]{64}$ ]] || die "approval screenshot digest is malformed" + [[ "$tag" =~ ^build-container-v[1-9][0-9]*$ ]] || die "approval release tag is malformed" + validate_review_time "$reviewed_at" || die "approval review time is malformed" + is_positive_decimal_at_most "$attempt" "$U32_MAX" || die "approval attempt is malformed" + is_positive_decimal_at_most "$run_id" "$U64_MAX" || die "approval run id is malformed" + is_sha "$source" || die "approval source revision is malformed" + [[ "$run_id" == "$RUN_ID" ]] || die "approval protocol belongs to another run" + decimal_greater_than "$attempt" "$RUN_ATTEMPT" && die "approval protocol claims a future attempt" + canonical="$COMMENT_PREFIX{\"challenge\":\"$challenge\",\"image-digest\":\"$digest\",\"png-sha256\":\"$screenshot\",\"release-tag\":\"$tag\",\"reviewed-at\":\"$reviewed_at\",\"run-attempt\":\"$attempt\",\"run-id\":\"$run_id\",\"source-revision\":\"$source\"}" + [[ "$comment" == "$canonical" ]] || die "approval protocol comment is not canonical" + + if [[ "$attempt" == "$RUN_ATTEMPT" ]]; then + current_count=$((current_count + 1)) + [[ "$comment" == "$EXPECTED_COMMENT" ]] || + die "current approval protocol differs from the release evidence" + jq -e --arg state approved --arg environment "$ENVIRONMENT" \ + --arg login "$APPROVER_LOGIN" --arg comment "$EXPECTED_COMMENT" ' + .state == $state + and .comment == $comment + and (.environments | type) == "array" + and (.environments | length) == 1 + and (.environments[0] | type) == "object" + and .environments[0].name == $environment + and (.user | type) == "object" + and .user.login == $login + ' <<<"$review_file" >/dev/null 2>&1 || + die "current approval review metadata differs from the release evidence" + fi + done <"$reviews" + [[ "$current_count" -eq 1 ]] || die "approval history lacks one unique current approval" +} + +GATE_ROOT= +SUBJECT_ROOT= +GATE_SHA= +CANDIDATE_SHA= +IMAGE_JSON= +EVIDENCE_JSON= +seen_flags=' ' + +while (($#)); do + (($# >= 2)) || usage + flag=$1 + value=$2 + shift 2 + case "$flag" in + --gate-root | --subject-root | --gate-sha | --candidate-sha | --image-json | --evidence-json) ;; + *) usage ;; + esac + [[ -n "$value" ]] || usage + [[ "$seen_flags" != *" $flag "* ]] || usage + seen_flags+="$flag " + case "$flag" in + --gate-root) GATE_ROOT=$value ;; + --subject-root) SUBJECT_ROOT=$value ;; + --gate-sha) GATE_SHA=$value ;; + --candidate-sha) CANDIDATE_SHA=$value ;; + --image-json) IMAGE_JSON=$value ;; + --evidence-json) EVIDENCE_JSON=$value ;; + esac +done + +for required in --gate-root --subject-root --gate-sha --candidate-sha --image-json --evidence-json; do + [[ "$seen_flags" == *" $required "* ]] || usage +done + +is_sha "$GATE_SHA" || die "gate SHA is not a full nonzero lowercase SHA" +is_sha "$CANDIDATE_SHA" || die "candidate SHA is not a full nonzero lowercase SHA" +for tool in env git jq curl sleep mktemp cmp awk rm dirname bash; do + command -v "$tool" >/dev/null 2>&1 || tool_die "publication verifier requires $tool" +done + +[[ -z "${GIT_ALTERNATE_OBJECT_DIRECTORIES+x}" ]] || + die "publication verifier cannot use environment-provided Git object alternates" +unset GIT_ALTERNATE_OBJECT_DIRECTORIES + +[[ -n "${GITHUB_TOKEN:-}" ]] || die "GitHub token is absent" +unset publication_github_token +publication_github_token=$GITHUB_TOKEN +readonly publication_github_token +unset GITHUB_TOKEN +[[ "$publication_github_token" != *$'\n'* && "$publication_github_token" != *$'\r'* && + "$publication_github_token" != *'"'* && "$publication_github_token" != *\\* ]] || + die "GitHub token cannot be encoded safely" + +GATE_ROOT=$(canonical_root "$GATE_ROOT" "gate root") +SUBJECT_ROOT=$(canonical_root "$SUBJECT_ROOT" "subject root") +[[ "$GATE_ROOT" != "$SUBJECT_ROOT" ]] || die "gate and subject roots must differ" +require_checkout "$GATE_ROOT" "$GATE_SHA" "gate checkout" \ + GATE_COMMON_DIRECTORY GATE_OBJECT_DIRECTORY +require_checkout "$SUBJECT_ROOT" "$CANDIDATE_SHA" "subject checkout" \ + SUBJECT_COMMON_DIRECTORY SUBJECT_OBJECT_DIRECTORY +[[ "$GATE_COMMON_DIRECTORY" != "$SUBJECT_COMMON_DIRECTORY" ]] || + die "gate and subject roots must use separate Git repositories" +[[ "$GATE_OBJECT_DIRECTORY" != "$SUBJECT_OBJECT_DIRECTORY" ]] || + die "gate and subject roots must use separate Git object stores" + +SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P) +[[ "$SCRIPT_DIR/verify-build-container-publication.sh" == "$GATE_ROOT/.github/docker/build-app-cli/verify-build-container-publication.sh" ]] || + die "publication verifier must execute from the canonical gate checkout" +PIN_CHECK="$GATE_ROOT/$PIN_CHECK_PATH" +[[ -f "$PIN_CHECK" && ! -L "$PIN_CHECK" && -x "$PIN_CHECK" ]] || + die "gate pin checker is missing, linked, or not executable" + +require_input_file "$IMAGE_JSON" "image record" +require_input_file "$EVIDENCE_JSON" "release evidence" +require_exact_subject_blob "$IMAGE_JSON" "$IMAGE_RECORD_PATH" "image record" +require_exact_subject_blob "$EVIDENCE_JSON" "$EVIDENCE_RECORD_PATH" "release evidence" + +PAIR_OUTPUT=$(cd -- "$GATE_ROOT" && bash "$PIN_CHECK" validate-pair "$IMAGE_JSON" "$EVIDENCE_JSON" 2>/dev/null) || + die "gate pin checker rejected the release record pair" +[[ "$PAIR_OUTPUT" == 'image release record pair is valid' ]] || + die "gate pin checker returned unexpected output" + +IMAGE_VALUES=$(jq -er '[.tag, .digest, ."image-source-revision"] | @tsv' "$IMAGE_JSON" 2>/dev/null) || + die "cannot parse validated image values" +IFS=$'\t' read -r RELEASE_TAG IMAGE_DIGEST SOURCE_REVISION IMAGE_EXTRA <<<"$IMAGE_VALUES" +[[ -z "${IMAGE_EXTRA:-}" ]] || die "validated image values are malformed" +EVIDENCE_VALUES=$(jq -er '[."approval-challenge", ."approver-login", ."reviewed-at", + ."run-attempt", ."run-id", ."screenshot-sha256"] | @tsv' "$EVIDENCE_JSON" 2>/dev/null) || + die "cannot parse validated evidence values" +IFS=$'\t' read -r APPROVAL_CHALLENGE APPROVER_LOGIN REVIEWED_AT RUN_ATTEMPT RUN_ID \ + SCREENSHOT_SHA256 EVIDENCE_EXTRA <<<"$EVIDENCE_VALUES" +[[ -z "${EVIDENCE_EXTRA:-}" ]] || die "validated evidence values are malformed" +EXPECTED_COMMENT="$COMMENT_PREFIX{\"challenge\":\"$APPROVAL_CHALLENGE\",\"image-digest\":\"$IMAGE_DIGEST\",\"png-sha256\":\"$SCREENSHOT_SHA256\",\"release-tag\":\"$RELEASE_TAG\",\"reviewed-at\":\"$REVIEWED_AT\",\"run-attempt\":\"$RUN_ATTEMPT\",\"run-id\":\"$RUN_ID\",\"source-revision\":\"$SOURCE_REVISION\"}" + +TEMP_ROOT=${IMAGE_JSON%/*} +[[ -n "$TEMP_ROOT" ]] || TEMP_ROOT=/ + +RUN_BODY= +RUN_LINK= +run_complete=false +for poll in {1..30}; do + curl_get "publication run" "$API_ROOT/$RUN_ID" RUN_BODY RUN_LINK + [[ -z "$RUN_LINK" ]] || die "publication run response has an unexpected Link header" + if validate_run_response "$RUN_BODY"; then + run_complete=true + break + else + status=$? + [[ "$status" -eq 10 ]] || exit "$status" + fi + ((poll < 30)) || die "publication run did not complete after thirty polls" + env -i PATH="$PATH" LC_ALL=C sleep 10 2>/dev/null || tool_die "cannot sleep between run polls" +done +[[ "$run_complete" == true ]] || die "publication run did not complete" + +JOBS_BODY= +JOBS_LINK= +curl_get "publisher jobs" \ + "$API_ROOT/$RUN_ID/attempts/$RUN_ATTEMPT/jobs?per_page=100&page=1" JOBS_BODY JOBS_LINK +[[ -z "$JOBS_LINK" ]] || die "publisher jobs response requires pagination" +validate_jobs_response "$JOBS_BODY" + +APPROVALS_BODY= +APPROVALS_LINK= +curl_get "approval history" "$API_ROOT/$RUN_ID/approvals" APPROVALS_BODY APPROVALS_LINK +[[ -z "$APPROVALS_LINK" ]] || die "approval history response has an unexpected Link header" +validate_approvals_response "$APPROVALS_BODY" + +FINAL_RUN_BODY= +FINAL_RUN_LINK= +curl_get "final publication run" "$API_ROOT/$RUN_ID" FINAL_RUN_BODY FINAL_RUN_LINK +[[ -z "$FINAL_RUN_LINK" ]] || die "final publication run response has an unexpected Link header" +if validate_run_response "$FINAL_RUN_BODY"; then + : +else + status=$? + [[ "$status" -ne 10 ]] || die "publication run changed before final verification" + exit "$status" +fi diff --git a/.github/docker/build-app-cli/verify-gate-rotation-lock.sh b/.github/docker/build-app-cli/verify-gate-rotation-lock.sh new file mode 100755 index 00000000..45d18ed5 --- /dev/null +++ b/.github/docker/build-app-cli/verify-gate-rotation-lock.sh @@ -0,0 +1,1200 @@ +#!/usr/bin/env bash +# shellcheck disable=SC2016 # jq programs must remain literal single-quoted expressions. +set +x +a +set -euo pipefail + +TOKEN=${GITHUB_TOKEN-} +export -n TOKEN +unset GITHUB_TOKEN GH_TOKEN + +export LC_ALL=C +export BASH_ENV= +export ENV= +export GIT_CONFIG_NOSYSTEM=1 +export GIT_CONFIG_GLOBAL=/dev/null +export GIT_NO_REPLACE_OBJECTS=1 +unset GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE GIT_OBJECT_DIRECTORY +unset GIT_ALTERNATE_OBJECT_DIRECTORIES GIT_COMMON_DIR GIT_CEILING_DIRECTORIES +unset GIT_REPLACE_REF_BASE + +readonly API_BASE=https://api.github.com/repos/stackpop/edgezero +readonly API_VERSION=2026-03-10 +readonly ROTATION_WORKFLOW=rotate-build-container-gate.yml +readonly ROTATION_PATH=.github/workflows/rotate-build-container-gate.yml@main +readonly ROTATION_ENVIRONMENT=build-container-gate-rotation-lock +readonly COMMENT_PREFIX='edgezero-gate-rotation-v1 ' +readonly POLICY_PREFIX='edgezero-gate-rotation-policy-v1 ' +readonly GATE_MANIFEST=.github/docker/build-app-cli/gate-paths.txt +readonly HELPER_PATH=.github/docker/build-app-cli/verify-gate-rotation-lock.sh +readonly U64_MAX=18446744073709551615 +readonly U32_MAX=4294967295 +readonly MAX_HISTORY_ITEMS=10000 +readonly MAX_PAGES=100 +readonly MAX_RECORD_BYTES=16384 +readonly MAX_MANIFEST_BYTES=65536 + +usage() { + printf '%s\n' \ + "usage: verify-gate-rotation-lock.sh waiting \\" \ + " --gate-root \\" \ + " --old-gate-sha \\" \ + " --dispatch-sha \\" \ + " --run-id \\" \ + " --run-attempt \\" \ + ' --run-actor-login ' \ + '' \ + "usage: verify-gate-rotation-lock.sh publisher \\" \ + " --gate-root \\" \ + " --gate-sha \\" \ + " --source-revision \\" \ + ' --publisher-prerequisite-json ' >&2 + exit 2 +} + +die() { + printf '::error::%s\n' "$*" >&2 + exit 1 +} + +tool_die() { + printf '::error::%s\n' "$*" >&2 + exit 2 +} + +is_sha() { + [[ "$1" =~ ^[0-9a-f]{40}$ && "$1" != 0000000000000000000000000000000000000000 ]] +} + +is_login() { + [[ "$1" =~ ^[A-Za-z0-9][A-Za-z0-9-]{0,38}(\[bot\])?$ ]] +} + +is_digest() { + [[ "$1" =~ ^sha256:[0-9a-f]{64}$ ]] +} + +is_positive_decimal_at_most() { + local value=$1 maximum=$2 + [[ "$value" =~ ^[1-9][0-9]*$ ]] || return 1 + ((${#value} < ${#maximum})) && return 0 + ((${#value} == ${#maximum})) && [[ "$value" < "$maximum" || "$value" == "$maximum" ]] +} + +is_nonnegative_decimal_at_most() { + local value=$1 maximum=$2 + [[ "$value" == 0 ]] && return 0 + is_positive_decimal_at_most "$value" "$maximum" +} + +decimal_greater_than() { + local left=$1 right=$2 + ((${#left} > ${#right})) && return 0 + ((${#left} == ${#right})) && [[ "$left" > "$right" ]] +} + +is_beneath() { + [[ "$1" == "$2" || "$1" == "$2/"* ]] +} + +safe_jq() { + env -i PATH="$PATH" LC_ALL=C jq "$@" +} + +safe_date_epoch() { + env -i PATH="$PATH" LC_ALL=C date -u '+%s' +} + +repo_git() { + env -i PATH="$PATH" LC_ALL=C HOME=/dev/null TMPDIR=/tmp \ + GIT_CONFIG_NOSYSTEM=1 GIT_CONFIG_GLOBAL=/dev/null GIT_TERMINAL_PROMPT=0 \ + GIT_NO_REPLACE_OBJECTS=1 GIT_OPTIONAL_LOCKS=0 \ + git --no-replace-objects -c core.fsmonitor=false -c core.hooksPath=/dev/null \ + -C "$GATE_ROOT" "$@" +} + +canonical_root() { + local supplied=$1 canonical + [[ "$supplied" == /* && -d "$supplied" && ! -L "$supplied" ]] || + die "gate root must be an absolute, non-symlink directory" + canonical=$(cd -- "$supplied" && pwd -P) || die "cannot resolve gate root" + [[ "$canonical" == "$supplied" ]] || die "gate root must already be canonical" + printf '%s\n' "$canonical" +} + +require_checkout() { + local expected=$1 top git_directory common_directory replacements shallow actual + local sparse status gitlinks partial promisor + [[ "$(repo_git rev-parse --is-inside-work-tree 2>/dev/null)" == true ]] || + die "gate root is not a Git worktree" + top=$(repo_git rev-parse --show-toplevel 2>/dev/null) || die "cannot resolve gate top level" + [[ "$top" == "$GATE_ROOT" ]] || die "gate root must be the exact repository top level" + git_directory=$(repo_git rev-parse --absolute-git-dir 2>/dev/null) || + die "cannot resolve gate Git directory" + common_directory=$(repo_git rev-parse --path-format=absolute --git-common-dir 2>/dev/null) || + die "cannot resolve gate common Git directory" + [[ ! -e "$git_directory/info/grafts" && ! -L "$git_directory/info/grafts" && + ! -e "$common_directory/info/grafts" && ! -L "$common_directory/info/grafts" ]] || + die "gate checkout cannot contain legacy grafts" + [[ ! -e "$git_directory/objects/info/alternates" && + ! -L "$git_directory/objects/info/alternates" && + ! -e "$common_directory/objects/info/alternates" && + ! -L "$common_directory/objects/info/alternates" ]] || + die "gate checkout cannot use object alternates" + replacements=$(repo_git for-each-ref --format='%(refname)' refs/replace/ 2>/dev/null) || + die "cannot inspect gate replacement refs" + [[ -z "$replacements" ]] || die "gate checkout cannot contain replacement refs" + shallow=$(repo_git rev-parse --is-shallow-repository 2>/dev/null) || + die "cannot inspect gate history depth" + [[ "$shallow" == false ]] || die "gate checkout must contain full history" + partial=$(repo_git config --get extensions.partialclone 2>/dev/null || true) + [[ -z "$partial" ]] || die "gate checkout cannot be partial" + promisor=$(repo_git config --get-regexp '^remote\..*\.promisor$' 2>/dev/null || true) + [[ -z "$promisor" ]] || die "gate checkout cannot use a promisor remote" + actual=$(repo_git rev-parse --verify HEAD 2>/dev/null) || die "gate checkout HEAD is absent" + [[ "$actual" == "$expected" ]] || die "gate checkout HEAD differs from its supplied SHA" + if repo_git symbolic-ref -q HEAD >/dev/null 2>&1; then + die "gate checkout must be detached" + fi + sparse=$(repo_git config --bool core.sparseCheckout 2>/dev/null || true) + [[ "$sparse" != true ]] || die "gate checkout cannot be sparse" + status=$(repo_git status --porcelain=v1 --untracked-files=all --ignore-submodules=none) || + die "cannot inspect gate checkout status" + [[ -z "$status" ]] || die "gate checkout must be clean" + gitlinks=$(repo_git ls-tree -r "$expected" 2>/dev/null | awk '$1 == "160000" { print; exit }') || + die "cannot inspect gate submodule state" + [[ -z "$gitlinks" ]] || die "gate checkout cannot contain submodule state" +} + +require_input_file() { + local path=$1 parent canonical_parent expected size + [[ "$path" == /* && -f "$path" && ! -L "$path" ]] || + die "publisher prerequisite must be an absolute regular non-symlink file" + parent=${path%/*} + [[ -n "$parent" ]] || parent=/ + canonical_parent=$(cd -- "$parent" && pwd -P) || die "cannot resolve prerequisite parent" + [[ "$canonical_parent" == "$parent" ]] || die "prerequisite path must already be canonical" + if [[ "$parent" == / ]]; then expected="/${path##*/}"; else expected="$parent/${path##*/}"; fi + [[ "$expected" == "$path" ]] || die "prerequisite path must already be canonical" + ! is_beneath "$path" "$GATE_ROOT" || die "publisher prerequisite must be outside the gate repository" + size=$(wc -c <"$path" | tr -d '[:space:]') || die "cannot size publisher prerequisite" + [[ "$size" =~ ^[0-9]+$ && "$size" -gt 0 && "$size" -le "$MAX_RECORD_BYTES" ]] || + die "publisher prerequisite is empty or oversized" +} + +temporary_files=() +remove_temporary_files() { + local path + for path in ${temporary_files[@]+"${temporary_files[@]}"}; do + [[ -z "$path" ]] || rm -f -- "$path" 2>/dev/null || true + done + [[ -z "${TEMP_ROOT:-}" ]] || rm -rf -- "$TEMP_ROOT" 2>/dev/null || true +} + +cleanup_exit() { + local status=$? + trap - EXIT HUP INT TERM + remove_temporary_files + exit "$status" +} + +cleanup_signal() { + local status=$1 + trap - EXIT HUP INT TERM + remove_temporary_files + exit "$status" +} + +trap cleanup_exit EXIT +trap 'cleanup_signal 129' HUP +trap 'cleanup_signal 130' INT +trap 'cleanup_signal 143' TERM + +new_temporary_file() { + local result_name=$1 label=$2 path + path=$(mktemp "$TEMP_ROOT/.edgezero-rotation-$label.XXXXXX" 2>/dev/null) || + tool_die "cannot create a temporary file" + temporary_files+=("$path") + printf -v "$result_name" '%s' "$path" +} + +hash_file() { + local path=$1 output + output=$(env -i PATH="$PATH" LC_ALL=C sha256sum "$path" 2>/dev/null) || + tool_die "cannot hash trusted bytes" + [[ "$output" =~ ^[0-9a-f]{64}[[:space:]] ]] || tool_die "sha256sum returned malformed output" + printf '%s' "${output%%[[:space:]]*}" +} + +valid_content_type() { + [[ "$1" == application/json || "$1" == 'application/json; charset=utf-8' ]] +} + +curl_get() { + local label=$1 url=$2 result_name=$3 link_name=$4 response_body response_metadata line response_link + local -a metadata_lines=() + new_temporary_file response_body body + new_temporary_file response_metadata metadata + if ! printf '%s\n' \ + 'header = "Accept: application/vnd.github+json"' \ + 'header = "X-GitHub-Api-Version: 2026-03-10"' \ + 'header = "User-Agent: edgezero-build-container-gate/1"' \ + "header = \"Authorization: Bearer $TOKEN\"" | + env -i PATH="$PATH" LC_ALL=C curl \ + --disable --silent --show-error --connect-timeout 10 --max-time 30 --max-redirs 0 \ + --request GET --config - --output "$response_body" \ + --write-out '%{http_code}\n%header{x-github-api-version-selected}\n%header{content-type}\nlink=%header{link}' \ + "$url" >"$response_metadata" 2>/dev/null; then + die "$label request failed" + fi + while IFS= read -r line || [[ -n "$line" ]]; do metadata_lines+=("$line"); done <"$response_metadata" + [[ "${#metadata_lines[@]}" -eq 4 ]] || die "$label response metadata is malformed" + [[ "${metadata_lines[0]}" == 200 ]] || die "$label response did not return HTTP 200" + [[ "${metadata_lines[1]}" == "$API_VERSION" ]] || + die "$label response selected an unexpected API version" + valid_content_type "${metadata_lines[2]}" || die "$label response has an unexpected content type" + [[ "${metadata_lines[3]}" == link=* ]] || die "$label Link metadata is malformed" + response_link=${metadata_lines[3]#link=} + safe_jq -e -s 'length == 1' "$response_body" >/dev/null 2>&1 || + die "$label response is not exactly one JSON value" + printf -v "$result_name" '%s' "$response_body" + printf -v "$link_name" '%s' "$response_link" +} + +write_number_stream() { + local body=$1 output=$2 paths numbers path_count number_count + new_temporary_file paths number-paths + new_temporary_file numbers number-tokens + safe_jq -rc --stream \ + 'select(length == 2 and (.[1] | type) == "number") | (.[0] | @json)' \ + "$body" >"$paths" 2>/dev/null || die "cannot enumerate JSON number paths" + awk ' + BEGIN { in_string = 0; escaped = 0 } + { + s = $0 + for (i = 1; i <= length(s); i++) { + c = substr(s, i, 1) + if (in_string) { + if (escaped) { escaped = 0; continue } + if (c == "\\") { escaped = 1; continue } + if (c == "\"") in_string = 0 + continue + } + if (c == "\"") { in_string = 1; continue } + if (c ~ /[-0-9]/) { + token = c + for (j = i + 1; j <= length(s); j++) { + q = substr(s, j, 1) + if (q !~ /[0-9eE+.-]/) break + token = token q + } + print token + i = j - 1 + } + } + } + END { if (in_string || escaped) exit 1 } + ' "$body" >"$numbers" || die "cannot preserve raw JSON numbers" + path_count=$(wc -l <"$paths" | tr -d '[:space:]') + number_count=$(wc -l <"$numbers" | tr -d '[:space:]') + [[ "$path_count" == "$number_count" ]] || die "JSON numeric token stream is ambiguous" + paste "$paths" "$numbers" >"$output" || tool_die "cannot pair JSON number tokens" +} + +number_at_path() { + local stream=$1 path=$2 result_name=$3 matches count value + new_temporary_file matches number-match + awk -F '\t' -v wanted="$path" '$1 == wanted { print $2 }' "$stream" >"$matches" || + die "cannot select a JSON number" + count=$(wc -l <"$matches" | tr -d '[:space:]') + [[ "$count" == 1 ]] || die "JSON number is missing or duplicated at $path" + value=$(<"$matches") + printf -v "$result_name" '%s' "$value" +} + +validate_time() { + local value=$1 result_name=$2 epoch round_trip + [[ "$value" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$ ]] || return 1 + epoch=$(safe_jq -nr --arg value "$value" '$value | fromdateiso8601' 2>/dev/null) || return 1 + [[ "$epoch" =~ ^[0-9]+$ ]] || return 1 + round_trip=$(safe_jq -nr --argjson epoch "$epoch" \ + '$epoch | strftime("%Y-%m-%dT%H:%M:%SZ")' 2>/dev/null) || return 1 + [[ "$round_trip" == "$value" ]] || return 1 + printf -v "$result_name" '%s' "$epoch" +} + +commit_exists() { + [[ "$(repo_git cat-file -t "$1" 2>/dev/null)" == commit ]] +} + +valid_manifest_path() { + local path=$1 + [[ -n "$path" && "$path" =~ ^[A-Za-z0-9._/+-]+$ && "$path" != /* && + "$path" != -* && "$path" != */ && "$path" != . && "$path" != .. && + "$path" != ../* && "$path" != */../* && "$path" != */.. && + "$path" != */./* && "$path" != */. && "$path" != *//* && "$path" != *\\* ]] +} + +tree_entry_or_absent() { + local revision=$1 path=$2 result_name=$3 tree_entry metadata recorded mode type object + tree_entry=$(repo_git ls-tree "$revision" -- "$path" 2>/dev/null) || + die "cannot inspect manifested path $path" + if [[ -z "$tree_entry" ]]; then + printf -v "$result_name" '%s' '' + return + fi + [[ "$tree_entry" != *$'\n'* && "$tree_entry" == *$'\t'* ]] || + die "manifested path is ambiguous: $path" + metadata=${tree_entry%%$'\t'*} + recorded=${tree_entry#*$'\t'} + read -r mode type object <<<"$metadata" + [[ "$recorded" == "$path" && "$type" == blob && + ("$mode" == 100644 || "$mode" == 100755) && "$object" =~ ^[0-9a-f]{40,64}$ ]] || + die "manifested path is not a regular Git blob: $path" + printf -v "$result_name" '%s' "$tree_entry" +} + +extract_manifest() { + local revision=$1 output=$2 entry size last previous current + tree_entry_or_absent "$revision" "$GATE_MANIFEST" entry + [[ -n "$entry" ]] || die "gate manifest is absent at $revision" + repo_git show "$revision:$GATE_MANIFEST" >"$output" 2>/dev/null || + die "cannot read gate manifest at $revision" + size=$(wc -c <"$output" | tr -d '[:space:]') || die "cannot size gate manifest" + [[ "$size" =~ ^[0-9]+$ && "$size" -gt 0 && "$size" -le "$MAX_MANIFEST_BYTES" ]] || + die "gate manifest is empty or oversized" + last=$(tail -c 1 "$output" | od -An -tx1 | tr -d '[:space:]') || + die "cannot inspect gate manifest terminator" + [[ "$last" == 0a ]] || die "gate manifest must be LF-terminated" + previous= + while IFS= read -r current; do + valid_manifest_path "$current" || die "gate manifest contains an invalid path" + [[ -z "$previous" || "$previous" < "$current" ]] || + die "gate manifest must be byte-sorted and unique" + previous=$current + done <"$output" + grep -Fqx -e "$GATE_MANIFEST" "$output" || die "gate manifest must contain itself" + grep -Fqx -e "$HELPER_PATH" "$output" || die "gate manifest must contain the rotation helper" +} + +compare_final_gate_tree() { + local old_gate=$1 new_gate=$2 final_gate=$3 final_head=$4 + local old_manifest new_manifest union path expected actual + new_temporary_file old_manifest old-manifest + new_temporary_file new_manifest new-manifest + new_temporary_file union manifest-union + extract_manifest "$old_gate" "$old_manifest" + extract_manifest "$new_gate" "$new_manifest" + { cat "$old_manifest"; cat "$new_manifest"; } | LC_ALL=C sort -u >"$union" + while IFS= read -r path; do + tree_entry_or_absent "$final_gate" "$path" expected + tree_entry_or_absent "$final_head" "$path" actual + [[ "$actual" == "$expected" ]] || die "final head differs from the final gate at $path" + done <"$union" +} + +compare_active_gate_tree() { + local active_gate=$1 main_sha=$2 manifest path expected actual + new_temporary_file manifest active-gate-manifest + extract_manifest "$active_gate" "$manifest" + while IFS= read -r path; do + tree_entry_or_absent "$active_gate" "$path" expected + tree_entry_or_absent "$main_sha" "$path" actual + [[ "$actual" == "$expected" ]] || + die "protected main differs from the active gate at $path" + done <"$manifest" +} + +parse_prerequisite() { + local raw values extra history_state canonical issue_id expected_url + safe_jq -e ' + type == "object" + and (keys == ["evidence-sha256", "evidence-url", "gate-sha", + "previous-value-sha256", "rotation-history", "schema-version", + "source-pr", "source-revision"]) + and (."evidence-sha256" | type) == "string" + and (."evidence-url" | type) == "string" + and (."gate-sha" | type) == "string" + and (."previous-value-sha256" | type) == "string" + and (."rotation-history" | type) == "object" + and ."schema-version" == 2 + and (."source-pr" | type) == "string" + and (."source-revision" | type) == "string" + ' "$PREREQUISITE_JSON" >/dev/null 2>&1 || + die "publisher prerequisite is not a schema-version-2 release-bound record" + values=$(safe_jq -er '[."evidence-sha256", ."evidence-url", ."gate-sha", + ."previous-value-sha256", ."source-pr", ."source-revision", + ."rotation-history".state] | @tsv' "$PREREQUISITE_JSON" 2>/dev/null) || + die "cannot parse publisher prerequisite" + IFS=$'\t' read -r PR_OUTER_EVIDENCE PR_EVIDENCE_URL PR_GATE PR_PREVIOUS PR_SOURCE_PR \ + PR_SOURCE history_state extra <<<"$values" + [[ -z "${extra:-}" ]] || die "publisher prerequisite fields are malformed" + is_digest "$PR_OUTER_EVIDENCE" || die "publisher prerequisite evidence digest is malformed" + is_digest "$PR_PREVIOUS" || die "publisher prerequisite predecessor digest is malformed" + is_sha "$PR_GATE" || die "publisher prerequisite gate SHA is malformed" + is_sha "$PR_SOURCE" || die "publisher prerequisite source revision is malformed" + is_positive_decimal_at_most "$PR_SOURCE_PR" "$U64_MAX" || + die "publisher prerequisite source PR is malformed" + expected_url="https://github.com/stackpop/edgezero/pull/$PR_SOURCE_PR#issuecomment-" + [[ "$PR_EVIDENCE_URL" == "$expected_url"* ]] || + die "publisher prerequisite evidence URL has the wrong repository or PR" + issue_id=${PR_EVIDENCE_URL#"$expected_url"} + is_positive_decimal_at_most "$issue_id" "$U64_MAX" || + die "publisher prerequisite evidence URL comment id is malformed" + [[ "$PR_GATE" == "$GATE_SHA" ]] || die "publisher prerequisite gate differs from active gate" + [[ "$PR_SOURCE" == "$SOURCE_REVISION" ]] || + die "publisher prerequisite source differs from tag source" + + case "$history_state" in + bootstrap-no-rotation) + safe_jq -e '."rotation-history" == {"state":"bootstrap-no-rotation"}' \ + "$PREREQUISITE_JSON" >/dev/null 2>&1 || + die "bootstrap rotation history is not exact" + PR_HISTORY_STATE=bootstrap + canonical="{\"evidence-sha256\":\"$PR_OUTER_EVIDENCE\",\"evidence-url\":\"$PR_EVIDENCE_URL\",\"gate-sha\":\"$PR_GATE\",\"previous-value-sha256\":\"$PR_PREVIOUS\",\"rotation-history\":{\"state\":\"bootstrap-no-rotation\"},\"schema-version\":2,\"source-pr\":\"$PR_SOURCE_PR\",\"source-revision\":\"$PR_SOURCE\"}" + ;; + verified) + safe_jq -e ' + (."rotation-history" | keys) == ["created-at", "evidence-sha256", + "history-sha256", "run-attempt", "run-id", "run-number", "state"] + and all(."rotation-history"[]; type == "string") + ' "$PREREQUISITE_JSON" >/dev/null 2>&1 || + die "verified rotation history shape is not exact" + values=$(safe_jq -er '[."rotation-history"."created-at", + ."rotation-history"."evidence-sha256", ."rotation-history"."history-sha256", + ."rotation-history"."run-attempt", ."rotation-history"."run-id", + ."rotation-history"."run-number"] | @tsv' "$PREREQUISITE_JSON" 2>/dev/null) || + die "cannot parse verified rotation history" + IFS=$'\t' read -r PR_CREATED PR_RECEIPT_DIGEST PR_HISTORY_DIGEST PR_RUN_ATTEMPT \ + PR_RUN_ID PR_RUN_NUMBER extra <<<"$values" + [[ -z "${extra:-}" ]] || die "verified rotation history fields are malformed" + validate_time "$PR_CREATED" ignored_epoch || die "rotation history creation time is malformed" + is_digest "$PR_RECEIPT_DIGEST" || die "rotation receipt digest is malformed" + is_digest "$PR_HISTORY_DIGEST" || die "complete history digest is malformed" + is_positive_decimal_at_most "$PR_RUN_ATTEMPT" "$U32_MAX" || + die "rotation history attempt is malformed" + is_positive_decimal_at_most "$PR_RUN_ID" "$U64_MAX" || + die "rotation history run id is malformed" + is_positive_decimal_at_most "$PR_RUN_NUMBER" "$U64_MAX" || + die "rotation history run number is malformed" + PR_HISTORY_STATE=verified + canonical="{\"evidence-sha256\":\"$PR_OUTER_EVIDENCE\",\"evidence-url\":\"$PR_EVIDENCE_URL\",\"gate-sha\":\"$PR_GATE\",\"previous-value-sha256\":\"$PR_PREVIOUS\",\"rotation-history\":{\"created-at\":\"$PR_CREATED\",\"evidence-sha256\":\"$PR_RECEIPT_DIGEST\",\"history-sha256\":\"$PR_HISTORY_DIGEST\",\"run-attempt\":\"$PR_RUN_ATTEMPT\",\"run-id\":\"$PR_RUN_ID\",\"run-number\":\"$PR_RUN_NUMBER\",\"state\":\"verified\"},\"schema-version\":2,\"source-pr\":\"$PR_SOURCE_PR\",\"source-revision\":\"$PR_SOURCE\"}" + ;; + *) die "publisher prerequisite rotation history state is invalid" ;; + esac + raw=$(<"$PREREQUISITE_JSON") + [[ "${#raw}" -eq "$(wc -c <"$PREREQUISITE_JSON" | tr -d '[:space:]')" ]] || + die "publisher prerequisite contains trailing or non-text bytes" + [[ "$raw" == "$canonical" ]] || die "publisher prerequisite bytes are not exact JCS" +} + +list_url() { + local kind=$1 page=$2 + case "$kind" in + history) + printf '%s/actions/workflows/%s/runs?event=workflow_dispatch&per_page=100&page=%s' \ + "$API_BASE" "$ROTATION_WORKFLOW" "$page" + ;; + jobs) + printf '%s/actions/runs/%s/attempts/%s/jobs?per_page=100&page=%s' \ + "$API_BASE" "$SELECTED_ID" "$SELECTED_ATTEMPT" "$page" + ;; + *) die "internal list kind is invalid" ;; + esac +} + +validate_link_header() { + local kind=$1 link=$2 current=$3 total=$4 needs_next=$5 + local remaining part url suffix relation target expected last seen=' ' + [[ -n "$link" ]] || { [[ "$needs_next" == false ]]; return; } + remaining=$link + while :; do + if [[ "$remaining" == *,* ]]; then + part=${remaining%%,*} + remaining=${remaining#*,} + [[ "$remaining" == ' '* ]] || die "$kind Link relations are not canonically separated" + remaining=${remaining# } + else + part=$remaining + remaining= + fi + [[ "$part" == '<'*'>; rel="'*'"' ]] || die "$kind Link relation is malformed" + url=${part#<} + url=${url%%>*} + suffix=${part#*>} + case "$suffix" in + '; rel="first"') relation=first ;; + '; rel="prev"') relation=prev ;; + '; rel="next"') relation=next ;; + '; rel="last"') relation=last ;; + *) die "$kind Link relation is unsupported" ;; + esac + [[ "$seen" != *" $relation "* ]] || die "$kind Link relation is duplicated" + seen+="$relation " + last=$(((total + 99) / 100)) + ((last > 0)) || last=1 + case "$relation" in + first) target=1 ;; + prev) + ((current > 1)) || die "$kind Link has impossible prev relation" + target=$((current - 1)) + ;; + next) + [[ "$needs_next" == true ]] || die "$kind Link has an unexpected next relation" + target=$((current + 1)) + ;; + last) target=$last ;; + esac + expected=$(list_url "$kind" "$target") + [[ "$url" == "$expected" ]] || die "$kind Link target differs from the closed query contract" + [[ -n "$remaining" ]] || break + done + if [[ "$needs_next" == true ]]; then + [[ "$seen" == *' next '* ]] || die "$kind pagination is missing its next relation" + fi +} + +enumerate_history() { + local snapshot=$1 page=1 accumulated=0 declared_total='' raw_total body link stream + local page_length i id attempt number created created_epoch round_trip padded rows sorted + local seen_ids seen_numbers need_next digest separator row number_path raw index field date_rows + local -a page_ids=() page_attempts=() page_numbers=() page_created=() + new_temporary_file rows history-rows + new_temporary_file sorted history-sorted + new_temporary_file seen_ids history-ids + new_temporary_file seen_numbers history-numbers + : >"$rows"; : >"$seen_ids"; : >"$seen_numbers" + while :; do + ((page <= MAX_PAGES)) || die "rotation history exceeds the page bound" + curl_get "rotation history page $page" "$(list_url history "$page")" body link + safe_jq -e 'type == "object" and (.workflow_runs | type) == "array"' "$body" \ + >/dev/null 2>&1 || die "rotation history page shape is malformed" + new_temporary_file stream history-numbers + write_number_stream "$body" "$stream" + raw_total= + page_ids=() + page_attempts=() + page_numbers=() + while IFS=$'\t' read -r number_path raw; do + case "$number_path" in + '["total_count"]') + [[ -z "$raw_total" ]] || die "rotation history total count is duplicated" + raw_total=$raw + ;; + '["workflow_runs",'*',"id"]'|'["workflow_runs",'*',"run_attempt"]'|'["workflow_runs",'*',"run_number"]') + if [[ "$number_path" =~ ^\[\"workflow_runs\",([0-9]+),\"(id|run_attempt|run_number)\"\]$ ]]; then + index=${BASH_REMATCH[1]} + field=${BASH_REMATCH[2]} + else + die "rotation history numeric path is malformed" + fi + case "$field" in + id) + [[ -z "${page_ids[index]:-}" ]] || die "rotation run id is duplicated" + page_ids[index]=$raw + ;; + run_attempt) + [[ -z "${page_attempts[index]:-}" ]] || die "rotation run attempt is duplicated" + page_attempts[index]=$raw + ;; + run_number) + [[ -z "${page_numbers[index]:-}" ]] || die "rotation run number is duplicated" + page_numbers[index]=$raw + ;; + esac + ;; + esac + done <"$stream" + [[ -n "$raw_total" ]] || die "rotation history total count is missing" + is_nonnegative_decimal_at_most "$raw_total" "$MAX_HISTORY_ITEMS" || + die "rotation history total count is malformed or too large" + if [[ -z "$declared_total" ]]; then declared_total=$raw_total; else + [[ "$declared_total" == "$raw_total" ]] || die "rotation history total count changed between pages" + fi + page_length=$(safe_jq -er '.workflow_runs | length' "$body" 2>/dev/null) || + die "cannot count rotation history page" + [[ "$page_length" =~ ^[0-9]+$ && "$page_length" -le 100 ]] || + die "rotation history page exceeds 100 items" + if ((page == MAX_PAGES && page_length == 100)); then + die "rotation history page bound ended on a full page" + fi + new_temporary_file date_rows history-dates + safe_jq -r ' + .workflow_runs[] + | .created_at as $created + | if (($created | type) == "string") + and ($created | test("^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$")) + then ($created | fromdateiso8601) as $epoch + | [$created, ($epoch | tostring), ($epoch | strftime("%Y-%m-%dT%H:%M:%SZ"))] | @tsv + else error("malformed creation time") + end + ' "$body" >"$date_rows" 2>/dev/null || die "rotation run creation time is malformed" + page_created=() + i=0 + while IFS=$'\t' read -r created created_epoch round_trip; do + [[ "$round_trip" == "$created" && "$created_epoch" =~ ^[0-9]+$ ]] || + die "rotation run creation time is malformed" + page_created[i]="$created"$'\t'"$created_epoch" + i=$((i + 1)) + done <"$date_rows" + [[ "$i" -eq "$page_length" ]] || die "rotation run creation time count differs" + for ((i = 0; i < page_length; i++)); do + id=${page_ids[$i]:-} + attempt=${page_attempts[$i]:-} + number=${page_numbers[$i]:-} + is_positive_decimal_at_most "$id" "$U64_MAX" || die "rotation run id is malformed" + is_positive_decimal_at_most "$attempt" "$U32_MAX" || die "rotation run attempt is malformed" + is_positive_decimal_at_most "$number" "$U64_MAX" || die "rotation run number is malformed" + IFS=$'\t' read -r created created_epoch <<<"${page_created[$i]}" + ((created_epoch <= NOW_EPOCH)) || die "rotation run creation time is in the future" + ! grep -Fqx -e "$id" "$seen_ids" || die "rotation history contains a duplicate run id" + ! grep -Fqx -e "$number" "$seen_numbers" || die "rotation history contains a duplicate run number" + printf '%s\n' "$id" >>"$seen_ids" + printf '%s\n' "$number" >>"$seen_numbers" + padded=$(printf '%020s' "$number" | tr ' ' 0) + printf '%s\t%s\t%s\t%s\t%s\n' "$padded" "$attempt" "$id" "$number" "$created" >>"$rows" + done + accumulated=$((accumulated + page_length)) + ((accumulated <= declared_total)) || die "rotation history contains more runs than total_count" + if ((accumulated < declared_total)); then + [[ "$page_length" -eq 100 ]] || die "rotation history is truncated before total_count" + ((page < MAX_PAGES)) || die "rotation history is truncated at page 100" + need_next=true + else + need_next=false + fi + validate_link_header history "$link" "$page" "$declared_total" "$need_next" + [[ "$need_next" == true ]] || break + page=$((page + 1)) + done + [[ "$accumulated" == "$declared_total" ]] || die "rotation history count is incomplete" + LC_ALL=C sort "$rows" >"$sorted" || tool_die "cannot sort rotation history" + printf '[' >"$snapshot" + separator= + ENUM_SELECTED_ID= + ENUM_SELECTED_ATTEMPT= + ENUM_SELECTED_NUMBER= + ENUM_SELECTED_CREATED= + while IFS=$'\t' read -r _ attempt id number created; do + row="{\"run-attempt\":\"$attempt\",\"run-id\":\"$id\",\"run-number\":\"$number\"}" + printf '%s%s' "$separator" "$row" >>"$snapshot" + separator=, + ENUM_SELECTED_ID=$id + ENUM_SELECTED_ATTEMPT=$attempt + ENUM_SELECTED_NUMBER=$number + ENUM_SELECTED_CREATED=$created + done <"$sorted" + printf ']' >>"$snapshot" + ENUM_COUNT=$declared_total + digest=$(hash_file "$snapshot") + ENUM_DIGEST="sha256:$digest" +} + +validate_run_detail() { + local body=$1 mode=$2 expected_id=$3 expected_attempt=$4 expected_number=$5 + local expected_created=$6 result_name=$7 stream id attempt number values extra created_epoch + local event path head branch actor repository head_repository status conclusion identity + safe_jq -e ' + type == "object" + and (.created_at | type) == "string" + and (.event | type) == "string" + and (.path | type) == "string" + and (.head_sha | type) == "string" + and (.head_branch | type) == "string" + and (.actor.login | type) == "string" + and (.repository.full_name | type) == "string" + and (.head_repository.full_name | type) == "string" + and (.status | type) == "string" + and ((.conclusion == null) or ((.conclusion | type) == "string")) + ' "$body" >/dev/null 2>&1 || die "rotation run detail shape is malformed" + if [[ "$mode" == waiting ]]; then + safe_jq -e '.status == "in_progress" and .conclusion == null' "$body" \ + >/dev/null 2>&1 || die "waiting run status or conclusion is invalid" + else + safe_jq -e '.status == "completed" and .conclusion == "success"' "$body" \ + >/dev/null 2>&1 || die "selected rotation run status or conclusion is invalid" + fi + new_temporary_file stream run-numbers + write_number_stream "$body" "$stream" + number_at_path "$stream" '["id"]' id + number_at_path "$stream" '["run_attempt"]' attempt + number_at_path "$stream" '["run_number"]' number + is_positive_decimal_at_most "$id" "$U64_MAX" || die "rotation run detail id is malformed" + is_positive_decimal_at_most "$attempt" "$U32_MAX" || die "rotation run detail attempt is malformed" + is_positive_decimal_at_most "$number" "$U64_MAX" || die "rotation run detail number is malformed" + [[ "$id" == "$expected_id" && "$attempt" == "$expected_attempt" ]] || + die "rotation run detail identifiers differ" + [[ -z "$expected_number" || "$number" == "$expected_number" ]] || + die "rotation run detail run number differs" + values=$(safe_jq -er '[.created_at, .event, .path, .head_sha, .head_branch, + .actor.login, .repository.full_name, .head_repository.full_name, .status, + (.conclusion // "")] | @tsv' "$body" 2>/dev/null) || + die "cannot parse rotation run detail fields" + IFS=$'\t' read -r RUN_DETAIL_CREATED event path head branch actor repository head_repository \ + status conclusion extra <<<"$values" + [[ -z "${extra:-}" ]] || die "rotation run detail fields are malformed" + validate_time "$RUN_DETAIL_CREATED" created_epoch || die "rotation run detail creation time is malformed" + ((created_epoch <= NOW_EPOCH)) || die "rotation run detail creation time is in the future" + [[ -z "$expected_created" || "$RUN_DETAIL_CREATED" == "$expected_created" ]] || + die "rotation run detail creation time differs from history" + [[ "$event" == workflow_dispatch ]] || die "rotation run event is not workflow_dispatch" + [[ "$path" == "$ROTATION_PATH" ]] || die "rotation run path is not exact protected main" + is_sha "$head" || die "rotation run dispatch head is malformed" + [[ "$branch" == main ]] || die "rotation run branch is not main" + is_login "$actor" || die "rotation run actor login is malformed" + [[ "$repository" == stackpop/edgezero && "$head_repository" == stackpop/edgezero ]] || + die "rotation run repository identity differs" + if [[ "$mode" == waiting ]]; then + [[ "$head" == "$DISPATCH_SHA" ]] || die "waiting run dispatch SHA differs" + [[ "$actor" == "$RUN_ACTOR_LOGIN" ]] || die "waiting run actor differs" + [[ "$status" == in_progress && -z "$conclusion" ]] || + die "waiting run is not executing without a conclusion" + else + [[ "$status" == completed && "$conclusion" == success ]] || + die "selected rotation run is not completed successfully" + fi + RUN_DETAIL_HEAD=$head + RUN_DETAIL_ACTOR=$actor + RUN_DETAIL_NUMBER=$number + identity="$id|$attempt|$number|$RUN_DETAIL_CREATED|$event|$path|$head|$branch|$actor|$repository|$head_repository|$status|$conclusion" + printf -v "$result_name" '%s' "$identity" +} + +enumerate_jobs() { + local page=1 accumulated=0 declared_total='' raw_total body link stream page_length i + local id attempt head status conclusion steps_length j name step_status step_conclusion completed + local completed_epoch need_next seen_ids target_count=0 + new_temporary_file seen_ids job-ids + : >"$seen_ids" + while :; do + ((page <= MAX_PAGES)) || die "rotation jobs exceed the page bound" + curl_get "rotation jobs page $page" "$(list_url jobs "$page")" body link + safe_jq -e 'type == "object" and (.jobs | type) == "array"' "$body" >/dev/null 2>&1 || + die "rotation jobs page shape is malformed" + new_temporary_file stream job-numbers + write_number_stream "$body" "$stream" + number_at_path "$stream" '["total_count"]' raw_total + is_nonnegative_decimal_at_most "$raw_total" "$MAX_HISTORY_ITEMS" || + die "rotation jobs total count is malformed or too large" + if [[ -z "$declared_total" ]]; then declared_total=$raw_total; else + [[ "$declared_total" == "$raw_total" ]] || die "rotation jobs total count changed between pages" + fi + page_length=$(safe_jq -er '.jobs | length' "$body" 2>/dev/null) || die "cannot count rotation jobs" + [[ "$page_length" =~ ^[0-9]+$ && "$page_length" -le 100 ]] || + die "rotation jobs page exceeds 100 items" + if ((page == MAX_PAGES && page_length == 100)); then + die "rotation jobs page bound ended on a full page" + fi + for ((i = 0; i < page_length; i++)); do + number_at_path "$stream" "[\"jobs\",$i,\"id\"]" id + number_at_path "$stream" "[\"jobs\",$i,\"run_attempt\"]" attempt + is_positive_decimal_at_most "$id" "$U64_MAX" || die "rotation job id is malformed" + is_positive_decimal_at_most "$attempt" "$U32_MAX" || die "rotation job attempt is malformed" + [[ "$attempt" == "$SELECTED_ATTEMPT" ]] || die "rotation job belongs to another attempt" + ! grep -Fqx -e "$id" "$seen_ids" || die "rotation jobs contain a duplicate id" + printf '%s\n' "$id" >>"$seen_ids" + values=$(safe_jq -er --argjson index "$i" \ + '[.jobs[$index].head_sha, .jobs[$index].status, .jobs[$index].conclusion] | @tsv' \ + "$body" 2>/dev/null) || die "rotation job fields are malformed" + IFS=$'\t' read -r head status conclusion extra <<<"$values" + [[ -z "${extra:-}" ]] || die "rotation job fields are malformed" + [[ "$head" == "$RUN_DETAIL_HEAD" && "$status" == completed && "$conclusion" == success ]] || + die "rotation job identity or result differs" + steps_length=$(safe_jq -er --argjson index "$i" '.jobs[$index].steps | length' \ + "$body" 2>/dev/null) || die "rotation job steps are malformed" + for ((j = 0; j < steps_length; j++)); do + name=$(safe_jq -er --argjson i "$i" --argjson j "$j" \ + '.jobs[$i].steps[$j].name | select(type == "string")' "$body" 2>/dev/null) || + die "rotation job step name is malformed" + [[ "$name" == assert-exact-rotation-context ]] || continue + target_count=$((target_count + 1)) + values=$(safe_jq -er --argjson i "$i" --argjson j "$j" \ + '[.jobs[$i].steps[$j].status, .jobs[$i].steps[$j].conclusion, + .jobs[$i].steps[$j].completed_at] | @tsv' "$body" 2>/dev/null) || + die "rotation context step fields are malformed" + IFS=$'\t' read -r step_status step_conclusion completed extra <<<"$values" + [[ -z "${extra:-}" && "$step_status" == completed && "$step_conclusion" == success ]] || + die "rotation context step did not complete successfully" + validate_time "$completed" completed_epoch || die "rotation context completion time is malformed" + STEP_COMPLETED_EPOCH=$completed_epoch + done + done + accumulated=$((accumulated + page_length)) + ((accumulated <= declared_total)) || die "rotation jobs exceed total_count" + if ((accumulated < declared_total)); then + [[ "$page_length" -eq 100 ]] || die "rotation jobs are truncated before total_count" + ((page < MAX_PAGES)) || die "rotation jobs are truncated at page 100" + need_next=true + else + need_next=false + fi + validate_link_header jobs "$link" "$page" "$declared_total" "$need_next" + [[ "$need_next" == true ]] || break + page=$((page + 1)) + done + [[ "$accumulated" == "$declared_total" ]] || die "rotation jobs count is incomplete" + [[ "$target_count" -eq 1 ]] || die "rotation jobs lack one unique context step" +} + +parse_receipt_comment() { + local comment=$1 reviewer=$2 bound_epoch=$3 run_created=$4 + local first second first_json second_json values extra canonical_first canonical_second + local evidence head_one id_one new_gate old_gate result reviewed + local audited dispatch gate head_two attempt id_two policy release_state required + local digest audited_epoch reviewed_epoch created_epoch + [[ "$comment" == *$'\n'* ]] || die "rotation approval comment is not exactly two lines" + first=${comment%%$'\n'*} + second=${comment#*$'\n'} + [[ "$second" != *$'\n'* && "$first" == "$COMMENT_PREFIX"* && "$second" == "$POLICY_PREFIX"* ]] || + die "rotation approval comment is not canonical two-line protocol" + new_temporary_file first_json receipt + new_temporary_file second_json policy-receipt + printf '%s' "${first#"$COMMENT_PREFIX"}" >"$first_json" + printf '%s' "${second#"$POLICY_PREFIX"}" >"$second_json" + safe_jq -e ' + type == "object" + and (keys == ["evidence-sha256", "head-sha", "lock-run-id", "new-gate-sha", + "old-gate-sha", "result", "reviewed-at"]) + and all(.[]; type == "string") + ' "$first_json" >/dev/null 2>&1 || die "rotation approval evidence shape is malformed" + safe_jq -e ' + type == "object" + and (keys == ["audited-at", "dispatch-sha", "gate-sha", "head-sha", + "lock-run-attempt", "lock-run-id", "policy-sha256", "release-state", + "required-workflow-sha"]) + and all(.[]; type == "string") + ' "$second_json" >/dev/null 2>&1 || die "rotation policy receipt shape is malformed" + values=$(safe_jq -er '[."evidence-sha256", ."head-sha", ."lock-run-id", + ."new-gate-sha", ."old-gate-sha", .result, ."reviewed-at"] | @tsv' \ + "$first_json" 2>/dev/null) || die "cannot parse rotation approval evidence" + IFS=$'\t' read -r evidence head_one id_one new_gate old_gate result reviewed extra <<<"$values" + [[ -z "${extra:-}" ]] || die "rotation approval evidence fields are malformed" + values=$(safe_jq -er '[."audited-at", ."dispatch-sha", ."gate-sha", ."head-sha", + ."lock-run-attempt", ."lock-run-id", ."policy-sha256", ."release-state", + ."required-workflow-sha"] | @tsv' "$second_json" 2>/dev/null) || + die "cannot parse rotation policy receipt" + IFS=$'\t' read -r audited dispatch gate head_two attempt id_two policy release_state required \ + extra <<<"$values" + [[ -z "${extra:-}" ]] || die "rotation policy receipt fields are malformed" + is_digest "$evidence" || die "rotation receipt evidence digest is malformed" + is_digest "$policy" || die "rotation policy evidence digest is malformed" + if ! { + is_sha "$head_one" && is_sha "$head_two" && is_sha "$new_gate" && is_sha "$old_gate" && + is_sha "$dispatch" && is_sha "$gate" && is_sha "$required" + }; then + die "rotation receipt contains a malformed SHA" + fi + if ! { + is_positive_decimal_at_most "$id_one" "$U64_MAX" && + is_positive_decimal_at_most "$id_two" "$U64_MAX" + }; then + die "rotation receipt run id is malformed" + fi + is_positive_decimal_at_most "$attempt" "$U32_MAX" || die "rotation receipt attempt is malformed" + [[ "$id_one" == "$SELECTED_ID" && "$id_two" == "$SELECTED_ID" && + "$attempt" == "$SELECTED_ATTEMPT" ]] || die "rotation receipt run identity differs" + [[ "$head_one" == "$head_two" ]] || die "rotation receipt final heads differ" + [[ "$dispatch" == "$RUN_DETAIL_HEAD" ]] || die "rotation receipt dispatch SHA differs from run" + [[ "$release_state" == enabled ]] || die "rotation receipt release state is not enabled" + [[ "$new_gate" != "$old_gate" ]] || die "rotation receipt does not describe a gate change" + case "$result" in + activated) RECEIPT_FINAL_GATE=$new_gate ;; + rolled-back) RECEIPT_FINAL_GATE=$old_gate ;; + *) die "rotation result is invalid" ;; + esac + [[ "$gate" == "$RECEIPT_FINAL_GATE" && "$required" == "$RECEIPT_FINAL_GATE" ]] || + die "rotation receipt gate pointers differ from its result" + if [[ "$MODE" == publisher ]]; then + [[ "$RECEIPT_FINAL_GATE" == "$EXPECTED_FINAL_GATE" ]] || + die "rotation receipt final gate differs from active gate" + fi + if [[ "$MODE" == waiting ]]; then + [[ "$old_gate" == "$OLD_GATE_SHA" ]] || die "rotation receipt old gate differs from waiting gate" + fi + digest=$(hash_file "$second_json") + [[ "$evidence" == "sha256:$digest" ]] || die "rotation receipt evidence digest differs" + canonical_first="{\"evidence-sha256\":\"$evidence\",\"head-sha\":\"$head_one\",\"lock-run-id\":\"$id_one\",\"new-gate-sha\":\"$new_gate\",\"old-gate-sha\":\"$old_gate\",\"result\":\"$result\",\"reviewed-at\":\"$reviewed\"}" + canonical_second="{\"audited-at\":\"$audited\",\"dispatch-sha\":\"$dispatch\",\"gate-sha\":\"$gate\",\"head-sha\":\"$head_two\",\"lock-run-attempt\":\"$attempt\",\"lock-run-id\":\"$id_two\",\"policy-sha256\":\"$policy\",\"release-state\":\"$release_state\",\"required-workflow-sha\":\"$required\"}" + [[ "$(<"$first_json")" == "$canonical_first" && "$(<"$second_json")" == "$canonical_second" ]] || + die "rotation approval comment bytes are not exact JCS" + validate_time "$run_created" created_epoch || die "rotation run creation time is malformed" + validate_time "$audited" audited_epoch || die "rotation audit time is malformed" + validate_time "$reviewed" reviewed_epoch || die "rotation review time is malformed" + ((created_epoch <= audited_epoch && audited_epoch <= reviewed_epoch && reviewed_epoch <= bound_epoch)) || + die "rotation receipt timestamps are not ordered" + ((bound_epoch - audited_epoch <= 900 && bound_epoch - reviewed_epoch <= 900)) || + die "rotation receipt is stale" + RECEIPT_EVIDENCE=$evidence + RECEIPT_HEAD=$head_one + RECEIPT_OLD_GATE=$old_gate + RECEIPT_NEW_GATE=$new_gate +} + +validate_approvals() { + local body=$1 link=$2 bound_epoch=$3 run_created=$4 reviews item comment reviewer state + local environments count=0 + [[ -z "$link" ]] || die "rotation approvals unexpectedly require pagination" + safe_jq -e 'type == "array"' "$body" >/dev/null 2>&1 || + die "rotation approvals response is not an array" + new_temporary_file reviews reviews + safe_jq -j --arg first "$COMMENT_PREFIX" --arg second "$POLICY_PREFIX" ' + .[] + | select(type == "object" and (.comment | type) == "string" + and ((.comment | startswith($first)) or (.comment | startswith($second)))) + | @json, "\u0000" + ' "$body" >"$reviews" 2>/dev/null || die "cannot enumerate rotation approval comments" + while IFS= read -r -d '' item; do + count=$((count + 1)) + comment=$(safe_jq -er '.comment | select(type == "string")' <<<"$item" 2>/dev/null) || + die "rotation approval comment is malformed" + values=$(safe_jq -er '[.state, .user.login, (.environments | length), + .environments[0].name] | @tsv' <<<"$item" 2>/dev/null) || + die "rotation approval metadata is malformed" + IFS=$'\t' read -r state reviewer environments environment extra <<<"$values" + [[ -z "${extra:-}" && "$state" == approved && "$environments" == 1 && + "$environment" == "$ROTATION_ENVIRONMENT" ]] || + die "rotation approval metadata is not exact" + is_login "$reviewer" || die "rotation approval reviewer login is malformed" + [[ "$reviewer" != "$RUN_DETAIL_ACTOR" ]] || + die "rotation approval reviewer must differ from the run actor" + parse_receipt_comment "$comment" "$reviewer" "$bound_epoch" "$run_created" + done <"$reviews" + [[ "$count" -eq 1 ]] || die "rotation approvals lack one unique protocol review" +} + +validate_main_ref() { + local body=$1 link=$2 result_name=$3 values sha type ref extra + [[ -z "$link" ]] || die "main ref response has an unexpected Link header" + safe_jq -e 'type == "object" and (.ref | type) == "string" + and (.object | type) == "object" and (.object.sha | type) == "string" + and (.object.type | type) == "string"' "$body" >/dev/null 2>&1 || + die "main ref response shape is malformed" + values=$(safe_jq -er '[.ref, .object.sha, .object.type] | @tsv' "$body" 2>/dev/null) || + die "cannot parse main ref response" + IFS=$'\t' read -r ref sha type extra <<<"$values" + [[ -z "${extra:-}" && "$ref" == refs/heads/main && "$type" == commit ]] || + die "main ref identity is not exact" + is_sha "$sha" || die "main ref commit SHA is malformed" + commit_exists "$sha" || die "main ref commit is unavailable locally" + printf -v "$result_name" '%s' "$sha" +} + +validate_publisher_main() { + local body link + curl_get "protected main ref" "$API_BASE/git/ref/heads/main" body link + MAIN_SHA= + validate_main_ref "$body" "$link" MAIN_SHA + repo_git merge-base --is-ancestor "$SOURCE_REVISION" "$MAIN_SHA" 2>/dev/null || + die "release source is not an ancestor of protected main" + compare_active_gate_tree "$GATE_SHA" "$MAIN_SHA" +} + +MODE=${1:-} +case "$MODE" in waiting | publisher) shift ;; *) usage ;; esac + +GATE_ROOT= +OLD_GATE_SHA= +DISPATCH_SHA= +RUN_ID= +RUN_ATTEMPT= +RUN_ACTOR_LOGIN= +GATE_SHA= +SOURCE_REVISION= +PREREQUISITE_JSON= +seen_flags=' ' + +while (($#)); do + (($# >= 2)) || usage + flag=$1 + value=$2 + shift 2 + case "$MODE:$flag" in + waiting:--gate-root|waiting:--old-gate-sha|waiting:--dispatch-sha|\ + waiting:--run-id|waiting:--run-attempt|waiting:--run-actor-login|\ + publisher:--gate-root|publisher:--gate-sha|publisher:--source-revision|\ + publisher:--publisher-prerequisite-json) ;; + *) usage ;; + esac + [[ -n "$value" ]] || usage + [[ "$seen_flags" != *" $flag "* ]] || usage + seen_flags+="$flag " + case "$flag" in + --gate-root) GATE_ROOT=$value ;; + --old-gate-sha) OLD_GATE_SHA=$value ;; + --dispatch-sha) DISPATCH_SHA=$value ;; + --run-id) RUN_ID=$value ;; + --run-attempt) RUN_ATTEMPT=$value ;; + --run-actor-login) RUN_ACTOR_LOGIN=$value ;; + --gate-sha) GATE_SHA=$value ;; + --source-revision) SOURCE_REVISION=$value ;; + --publisher-prerequisite-json) PREREQUISITE_JSON=$value ;; + esac +done + +if [[ "$MODE" == waiting ]]; then + required_flags='--gate-root --old-gate-sha --dispatch-sha --run-id --run-attempt --run-actor-login' +else + required_flags='--gate-root --gate-sha --source-revision --publisher-prerequisite-json' +fi +for required in $required_flags; do [[ "$seen_flags" == *" $required "* ]] || usage; done + +[[ -n "$TOKEN" ]] || die "GitHub token is absent" +[[ "$TOKEN" != *$'\n'* && "$TOKEN" != *$'\r'* && "$TOKEN" != *'"'* && "$TOKEN" != *\\* ]] || + die "GitHub token cannot be encoded safely" + +for tool in env git jq curl awk sed sort mktemp cmp sha256sum date wc tail od grep paste tr rm cat dirname; do + command -v "$tool" >/dev/null 2>&1 || tool_die "rotation verifier requires $tool" +done +safe_jq --version >/dev/null 2>&1 || tool_die "rotation verifier cannot execute jq" +NOW_EPOCH=$(safe_date_epoch 2>/dev/null) || tool_die "rotation verifier cannot read UTC time" +[[ "$NOW_EPOCH" =~ ^[0-9]+$ ]] || tool_die "rotation verifier received malformed UTC time" + +if [[ "$MODE" == waiting ]]; then + is_sha "$OLD_GATE_SHA" || die "old gate SHA is malformed" + is_sha "$DISPATCH_SHA" || die "dispatch SHA is malformed" + is_positive_decimal_at_most "$RUN_ID" "$U64_MAX" || die "run id is malformed" + is_positive_decimal_at_most "$RUN_ATTEMPT" "$U32_MAX" || die "run attempt is malformed" + is_login "$RUN_ACTOR_LOGIN" || die "run actor login is malformed" + CHECKOUT_SHA=$OLD_GATE_SHA +else + is_sha "$GATE_SHA" || die "gate SHA is malformed" + is_sha "$SOURCE_REVISION" || die "source revision is malformed" + CHECKOUT_SHA=$GATE_SHA +fi + +GATE_ROOT=$(canonical_root "$GATE_ROOT") +require_checkout "$CHECKOUT_SHA" +SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P) +[[ "$SCRIPT_DIR/verify-gate-rotation-lock.sh" == "$GATE_ROOT/$HELPER_PATH" ]] || + die "rotation verifier must execute from the canonical gate checkout" +TEMP_ROOT=$(mktemp -d /tmp/edgezero-rotation-lock.XXXXXX 2>/dev/null) || + tool_die "cannot create rotation verifier temporary directory" + +if [[ "$MODE" == waiting ]]; then + commit_exists "$DISPATCH_SHA" || die "dispatch commit is unavailable locally" + repo_git merge-base --is-ancestor "$OLD_GATE_SHA" "$DISPATCH_SHA" 2>/dev/null || + die "old gate is not an ancestor of dispatch" +else + require_input_file "$PREREQUISITE_JSON" + parse_prerequisite + commit_exists "$SOURCE_REVISION" || die "source revision is unavailable locally" + repo_git merge-base --is-ancestor "$GATE_SHA" "$SOURCE_REVISION" 2>/dev/null || + die "source revision does not descend from active gate" +fi + +if [[ "$MODE" == waiting ]]; then + SELECTED_ID=$RUN_ID + SELECTED_ATTEMPT=$RUN_ATTEMPT + RUN_BODY= + RUN_LINK= + curl_get "waiting rotation run" "$API_BASE/actions/runs/$RUN_ID" RUN_BODY RUN_LINK + [[ -z "$RUN_LINK" ]] || die "waiting run response has an unexpected Link header" + validate_run_detail "$RUN_BODY" waiting "$RUN_ID" "$RUN_ATTEMPT" '' '' WAITING_IDENTITY + SELECTED_NUMBER=$RUN_DETAIL_NUMBER + EXPECTED_FINAL_GATE=$OLD_GATE_SHA + APPROVAL_BODY= + APPROVAL_LINK= + curl_get "waiting rotation approvals" "$API_BASE/actions/runs/$RUN_ID/approvals" \ + APPROVAL_BODY APPROVAL_LINK + validate_approvals "$APPROVAL_BODY" "$APPROVAL_LINK" "$NOW_EPOCH" "$RUN_DETAIL_CREATED" + MAIN_BODY= + MAIN_LINK= + curl_get "protected main ref" "$API_BASE/git/ref/heads/main" MAIN_BODY MAIN_LINK + validate_main_ref "$MAIN_BODY" "$MAIN_LINK" MAIN_SHA + [[ "$MAIN_SHA" == "$RECEIPT_HEAD" ]] || die "waiting receipt head differs from current main" + if ! { + commit_exists "$RECEIPT_OLD_GATE" && commit_exists "$RECEIPT_NEW_GATE" && + commit_exists "$RECEIPT_HEAD" + }; then + die "rotation receipt commits are unavailable locally" + fi + repo_git merge-base --is-ancestor "$RUN_DETAIL_HEAD" "$RECEIPT_HEAD" 2>/dev/null || + die "dispatch is not an ancestor of final head" + repo_git merge-base --is-ancestor "$RECEIPT_NEW_GATE" "$RECEIPT_HEAD" 2>/dev/null || + die "new gate is not an ancestor of final head" + compare_final_gate_tree "$RECEIPT_OLD_GATE" "$RECEIPT_NEW_GATE" \ + "$RECEIPT_FINAL_GATE" "$RECEIPT_HEAD" + exit 0 +fi + +HISTORY_ONE= +new_temporary_file HISTORY_ONE history-one +enumerate_history "$HISTORY_ONE" +FIRST_COUNT=$ENUM_COUNT +FIRST_DIGEST=$ENUM_DIGEST +FIRST_ID=$ENUM_SELECTED_ID +FIRST_ATTEMPT=$ENUM_SELECTED_ATTEMPT +FIRST_NUMBER=$ENUM_SELECTED_NUMBER +FIRST_CREATED=$ENUM_SELECTED_CREATED + +if [[ "$FIRST_COUNT" == 0 ]]; then + [[ "$PR_HISTORY_STATE" == bootstrap ]] || die "empty history requires bootstrap prerequisite" + validate_publisher_main + HISTORY_TWO= + new_temporary_file HISTORY_TWO history-two + enumerate_history "$HISTORY_TWO" + [[ "$ENUM_COUNT" == 0 ]] || die "rotation appeared during bootstrap history verification" + cmp -s "$HISTORY_ONE" "$HISTORY_TWO" || die "bootstrap history changed between snapshots" + exit 0 +fi + +[[ "$PR_HISTORY_STATE" == verified ]] || die "nonempty history requires verified prerequisite" +[[ "$FIRST_ID" == "$PR_RUN_ID" && "$FIRST_ATTEMPT" == "$PR_RUN_ATTEMPT" && + "$FIRST_NUMBER" == "$PR_RUN_NUMBER" && "$FIRST_CREATED" == "$PR_CREATED" && + "$FIRST_DIGEST" == "$PR_HISTORY_DIGEST" ]] || + die "publisher prerequisite does not match current rotation history" + +SELECTED_ID=$FIRST_ID +SELECTED_ATTEMPT=$FIRST_ATTEMPT +SELECTED_NUMBER=$FIRST_NUMBER +RUN_BODY= +RUN_LINK= +curl_get "selected rotation run" "$API_BASE/actions/runs/$SELECTED_ID" RUN_BODY RUN_LINK +[[ -z "$RUN_LINK" ]] || die "selected rotation run has an unexpected Link header" +validate_run_detail "$RUN_BODY" publisher "$SELECTED_ID" "$SELECTED_ATTEMPT" \ + "$SELECTED_NUMBER" "$FIRST_CREATED" FIRST_RUN_IDENTITY + +enumerate_jobs + +EXPECTED_FINAL_GATE=$GATE_SHA +APPROVAL_BODY= +APPROVAL_LINK= +curl_get "selected rotation approvals" "$API_BASE/actions/runs/$SELECTED_ID/approvals" \ + APPROVAL_BODY APPROVAL_LINK +validate_approvals "$APPROVAL_BODY" "$APPROVAL_LINK" "$STEP_COMPLETED_EPOCH" "$RUN_DETAIL_CREATED" +[[ "$RECEIPT_EVIDENCE" == "$PR_RECEIPT_DIGEST" ]] || + die "publisher prerequisite rotation receipt digest differs" + +validate_publisher_main +if ! { + commit_exists "$RECEIPT_OLD_GATE" && commit_exists "$RECEIPT_NEW_GATE" && + commit_exists "$RECEIPT_HEAD" +}; then + die "rotation receipt commits are unavailable locally" +fi +repo_git merge-base --is-ancestor "$RUN_DETAIL_HEAD" "$RECEIPT_HEAD" 2>/dev/null || + die "rotation dispatch is not an ancestor of final head" +repo_git merge-base --is-ancestor "$RECEIPT_NEW_GATE" "$RECEIPT_HEAD" 2>/dev/null || + die "new gate is not an ancestor of final head" +repo_git merge-base --is-ancestor "$RECEIPT_HEAD" "$MAIN_SHA" 2>/dev/null || + die "rotation final head is not an ancestor of protected main" +compare_final_gate_tree "$RECEIPT_OLD_GATE" "$RECEIPT_NEW_GATE" \ + "$RECEIPT_FINAL_GATE" "$RECEIPT_HEAD" + +HISTORY_TWO= +new_temporary_file HISTORY_TWO history-two +enumerate_history "$HISTORY_TWO" +[[ "$ENUM_COUNT" == "$FIRST_COUNT" && "$ENUM_DIGEST" == "$FIRST_DIGEST" && + "$ENUM_SELECTED_ID" == "$FIRST_ID" && "$ENUM_SELECTED_ATTEMPT" == "$FIRST_ATTEMPT" && + "$ENUM_SELECTED_NUMBER" == "$FIRST_NUMBER" && "$ENUM_SELECTED_CREATED" == "$FIRST_CREATED" ]] || + die "rotation history selection changed between snapshots" +cmp -s "$HISTORY_ONE" "$HISTORY_TWO" || die "rotation history changed between snapshots" + +RUN_BODY_TWO= +RUN_LINK_TWO= +curl_get "selected rotation run repeat" "$API_BASE/actions/runs/$SELECTED_ID" \ + RUN_BODY_TWO RUN_LINK_TWO +[[ -z "$RUN_LINK_TWO" ]] || die "repeated selected run has an unexpected Link header" +validate_run_detail "$RUN_BODY_TWO" publisher "$SELECTED_ID" "$SELECTED_ATTEMPT" \ + "$SELECTED_NUMBER" "$FIRST_CREATED" SECOND_RUN_IDENTITY +[[ "$SECOND_RUN_IDENTITY" == "$FIRST_RUN_IDENTITY" ]] || + die "selected rotation run detail changed during verification" diff --git a/.github/docker/build-app-cli/verify-published-image.sh b/.github/docker/build-app-cli/verify-published-image.sh new file mode 100755 index 00000000..89786d47 --- /dev/null +++ b/.github/docker/build-app-cli/verify-published-image.sh @@ -0,0 +1,469 @@ +#!/usr/bin/env bash +set -euo pipefail + +die() { + printf 'verify-published-image: %s\n' "$*" >&2 + exit 1 +} + +ref= +local_image_id= +source_sha= +protocol= +build_metadata= +while (($#)); do + (($# >= 2)) || die "missing value for $1" + case "$1" in + --ref) + [[ -z "$ref" ]] || die "duplicate --ref" + ref=$2 + ;; + --source-sha) + [[ -z "$source_sha" ]] || die "duplicate --source-sha" + source_sha=$2 + ;; + --protocol) + [[ -z "$protocol" ]] || die "duplicate --protocol" + protocol=$2 + ;; + --local-image-id) + [[ -z "$local_image_id" ]] || die "duplicate --local-image-id" + local_image_id=$2 + ;; + --build-metadata) + [[ -z "$build_metadata" ]] || die "duplicate --build-metadata" + build_metadata=$2 + ;; + *) die "unknown argument: $1" ;; + esac + shift 2 +done + +repository=ghcr.io/stackpop/edgezero-build-app-cli +digest_pattern='sha256:[0-9a-f]{64}' +if [[ -n "$ref" && -n "$local_image_id" ]] || [[ -z "$ref" && -z "$local_image_id" ]]; then + die "exactly one of --ref or --local-image-id is required" +fi +if [[ -n "$ref" ]]; then + [[ "$ref" =~ ^${repository}@${digest_pattern}$ ]] || die "ref must be the fixed repository at a digest" + digest=${ref#*@} + runtime_ref=$ref +else + [[ "$local_image_id" =~ ^${digest_pattern}$ ]] || die "local image ID must be an immutable sha256 ID" + [[ -z "$build_metadata" ]] || die "build metadata is only valid with --ref" + runtime_ref=$local_image_id +fi +[[ "$source_sha" =~ ^[0-9a-f]{40}$ ]] || die "source SHA must be full lowercase hex" +[[ "$protocol" == 1 ]] || die "protocol must be exactly 1" +command -v docker >/dev/null 2>&1 || die "docker is required" +command -v jq >/dev/null 2>&1 || die "jq is required" +command -v timeout >/dev/null 2>&1 || die "GNU timeout is required" + +script_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P) || die "cannot resolve verifier directory" +fixtures="$script_dir/fixtures/provenance" +[[ -d "$fixtures/valid" && ! -L "$fixtures" && ! -L "$fixtures/valid" ]] || + die "trusted provenance fixtures are unavailable" + +work=$(mktemp -d "/tmp/edgezero-image-verify.XXXXXX") +active_container= +docker_config="$work/docker-config" +cleanup() { + if [[ -n "$active_container" ]]; then + DOCKER_CONFIG="$docker_config" docker rm --force "$active_container" >/dev/null 2>&1 || true + fi + rm -rf -- "$work" +} +trap cleanup EXIT + +if [[ -n "$build_metadata" ]]; then + [[ -f "$build_metadata" && ! -L "$build_metadata" ]] || die "build metadata is not a regular file" + jq -e 'type == "object"' "$build_metadata" >/dev/null 2>&1 || die "build metadata is not a JSON object" + metadata_digest_count=$(jq --stream -r ' + select(length == 2 and (.[0] | length) == 1 and .[0][0] == "containerimage.digest") | .[1] + ' "$build_metadata" | wc -l | tr -d ' ') + [[ "$metadata_digest_count" == 1 ]] || die "build metadata digest is missing or duplicated" + metadata_digest=$(jq -er '.["containerimage.digest"] | select(type == "string")' "$build_metadata") || + die "build metadata digest is not a string" + [[ "$metadata_digest" == "$digest" ]] || die "build metadata digest differs from the supplied ref" +fi + +mkdir -- "$docker_config" +raw="$work/manifest.json" +image="$work/image.json" +if [[ -n "$ref" ]]; then + DOCKER_CONFIG="$docker_config" docker buildx imagetools inspect "$ref" --raw >"$raw" || + die "anonymous manifest inspection failed" + + jq -e --arg digest_pattern "^${digest_pattern}$" ' + type == "object" and + .schemaVersion == 2 and + (has("manifests") | not) and + ( + (.mediaType == "application/vnd.oci.image.manifest.v1+json" and + .config.mediaType == "application/vnd.oci.image.config.v1+json" and + ([.layers[].mediaType | test("^application/vnd[.]oci[.]image[.]layer[.]v1[.]tar([+]gzip|[+]zstd)?$")] | all)) or + (.mediaType == "application/vnd.docker.distribution.manifest.v2+json" and + .config.mediaType == "application/vnd.docker.container.image.v1+json" and + ([.layers[].mediaType == "application/vnd.docker.image.rootfs.diff.tar.gzip"] | all)) + ) and + (.config | type == "object") and + (.config.digest | type == "string" and test($digest_pattern)) and + (.config.size | type == "number" and floor == . and . > 0) and + (.layers | type == "array" and length > 0) and + ([.layers[] | + type == "object" and + (.digest | type == "string" and test($digest_pattern)) and + (.size | type == "number" and floor == . and . > 0) + ] | all) + ' "$raw" >/dev/null || die "registry object is not an accepted leaf image manifest" + + DOCKER_CONFIG="$docker_config" docker buildx imagetools inspect "$ref" \ + --format '{{json .Image}}' >"$image" || die "anonymous image-config inspection failed" + jq -e \ + --arg source_sha "$source_sha" \ + --arg protocol "$protocol" ' + type == "object" and + .architecture == "amd64" and + .os == "linux" and + .config.Entrypoint == ["/usr/bin/env"] and + .config.User == "1001:1001" and + .config.Labels["org.opencontainers.image.source"] == "https://github.com/stackpop/edgezero" and + .config.Labels["org.opencontainers.image.revision"] == $source_sha and + .config.Labels["io.edgezero.provenance-protocol"] == $protocol + ' "$image" >/dev/null || die "image platform, config, or labels differ" + + DOCKER_CONFIG="$docker_config" docker pull "$ref" >/dev/null || die "anonymous digest pull failed" +else + DOCKER_CONFIG="$docker_config" docker image inspect "$local_image_id" >"$image" || + die "local image inspection failed" + jq -e \ + --arg image_id "$local_image_id" \ + --arg source_sha "$source_sha" \ + --arg protocol "$protocol" ' + type == "array" and length == 1 and + .[0].Id == $image_id and + .[0].Architecture == "amd64" and + .[0].Os == "linux" and + .[0].Config.Entrypoint == ["/usr/bin/env"] and + .[0].Config.User == "1001:1001" and + .[0].Config.Labels["org.opencontainers.image.source"] == "https://github.com/stackpop/edgezero" and + .[0].Config.Labels["org.opencontainers.image.revision"] == $source_sha and + .[0].Config.Labels["io.edgezero.provenance-protocol"] == $protocol + ' "$image" >/dev/null || die "local image identity, platform, config, or labels differ" +fi + +base_env="$work/base.env" +cat >"$base_env" <<'EOF' +HOME=/work/home +PATH=/usr/local/bin:/usr/local/cargo/bin:/usr/bin:/bin +TMPDIR=/work/tmp +EOF +chmod 0600 "$base_env" + +probe_env="$work/probe.env" +cat >"$probe_env" <<'EOF' +EDGEZERO_ENV_BACKSLASH=back\slash +EDGEZERO_ENV_DOLLAR=dollar$value +EDGEZERO_ENV_EMPTY= +EDGEZERO_ENV_EQUALS=left=right +EDGEZERO_ENV_HASH=hash#value +EDGEZERO_ENV_LITERAL=${EDGEZERO_ENV_DOLLAR} +EDGEZERO_ENV_NONASCII=café +EDGEZERO_ENV_QUOTE=quote"value +EDGEZERO_ENV_SPACE=two words +HOME=/work/home +PATH=/usr/local/bin:/usr/local/cargo/bin:/usr/bin:/bin +TMPDIR=/work/tmp +EOF +chmod 0600 "$probe_env" + +base_names=$'HOME\nPATH\nTMPDIR' +# These are the required literal env(1) split operands (intentional SC2016). +# shellcheck disable=SC2016 +base_split='-i HOME=${HOME} PATH=${PATH} TMPDIR=${TMPDIR}' +probe_names=$'EDGEZERO_ENV_BACKSLASH\nEDGEZERO_ENV_DOLLAR\nEDGEZERO_ENV_EMPTY\nEDGEZERO_ENV_EQUALS\nEDGEZERO_ENV_HASH\nEDGEZERO_ENV_LITERAL\nEDGEZERO_ENV_NONASCII\nEDGEZERO_ENV_QUOTE\nEDGEZERO_ENV_SPACE\nHOME\nPATH\nTMPDIR' +# shellcheck disable=SC2016 +probe_split='-i EDGEZERO_ENV_BACKSLASH=${EDGEZERO_ENV_BACKSLASH} EDGEZERO_ENV_DOLLAR=${EDGEZERO_ENV_DOLLAR} EDGEZERO_ENV_EMPTY=${EDGEZERO_ENV_EMPTY} EDGEZERO_ENV_EQUALS=${EDGEZERO_ENV_EQUALS} EDGEZERO_ENV_HASH=${EDGEZERO_ENV_HASH} EDGEZERO_ENV_LITERAL=${EDGEZERO_ENV_LITERAL} EDGEZERO_ENV_NONASCII=${EDGEZERO_ENV_NONASCII} EDGEZERO_ENV_QUOTE=${EDGEZERO_ENV_QUOTE} EDGEZERO_ENV_SPACE=${EDGEZERO_ENV_SPACE} HOME=${HOME} PATH=${PATH} TMPDIR=${TMPDIR}' + +assert_env_file_contract() { + local env_file=$1 split=$2 expected_names expected_split=-i name mode links actual_names + case "$split" in + "$base_split") expected_names=$base_names ;; + "$probe_split") expected_names=$probe_names ;; + *) die "environment split string is not an approved profile" ;; + esac + case "$env_file" in + "$work"/*.env) ;; + *) die "environment file is outside the private verifier root" ;; + esac + [[ -f "$env_file" && ! -L "$env_file" ]] || die "environment file is not regular" + mode=$(stat -c '%a' "$env_file" 2>/dev/null || stat -f '%Lp' "$env_file") + links=$(stat -c '%h' "$env_file" 2>/dev/null || stat -f '%l' "$env_file") + [[ "$mode" == 600 && "$links" == 1 ]] || die "environment file mode or link count differs" + if ! actual_names=$(jq -Rrs -e ' + if contains("\u0000") then error("NUL in environment file") + elif (endswith("\n") | not) then error("missing final newline") + else + split("\n")[:-1] as $lines | + if ($lines | length) == 0 or + any($lines[]; (test("^[A-Z][A-Z0-9_]*=") | not)) + then error("invalid environment line") + else [$lines[] | capture("^(?[A-Z][A-Z0-9_]*)=").name] | join("\n") + end + end + ' "$env_file"); then + die "environment file serialization is invalid" + fi + [[ "$actual_names" == "$expected_names" ]] || die "environment file name set or order differs" + while IFS= read -r name; do + expected_split+=" $name=\${$name}" + done <<<"$expected_names" + [[ "$split" == "$expected_split" ]] || die "environment placeholders differ from file names" +} + +sequence=0 +last_output= +last_stderr= +container_mounts=() +run_container() { + local env_file=$1 split=$2 memory=$3 pids=$4 wall=$5 + shift 5 + local expected_process actual_process + sequence=$((sequence + 1)) + local name="edgezero-image-verify-$$-$sequence" container status=0 + container=$name + last_output="$work/container-$sequence.stdout" + last_stderr="$work/container-$sequence.stderr" + assert_env_file_contract "$env_file" "$split" + if ! DOCKER_CONFIG="$docker_config" docker create \ + --name "$name" \ + --platform linux/amd64 \ + --user 1001:1001 \ + --read-only \ + --cap-drop=ALL \ + --security-opt=no-new-privileges \ + --network=none \ + --memory "$memory" \ + --memory-swap "$memory" \ + --pids-limit "$pids" \ + --tmpfs /work/home:rw,noexec,nosuid,nodev,mode=0700,uid=1001,gid=1001 \ + --tmpfs /work/tmp:rw,noexec,nosuid,nodev,mode=0700,uid=1001,gid=1001 \ + --env-file "$env_file" \ + ${container_mounts[@]+"${container_mounts[@]}"} \ + --entrypoint /usr/bin/env \ + "$runtime_ref" \ + -S "$split" \ + "$@" >/dev/null; then + rm -f -- "$env_file" + return 1 + fi + active_container=$container + expected_process=$(jq -cn --arg split "$split" --args \ + '{Path: "/usr/bin/env", Args: (["-S", $split] + $ARGS.positional)}' -- "$@") || + die "cannot encode expected container process" + if ! actual_process=$(DOCKER_CONFIG="$docker_config" docker container inspect "$container" | + jq -ce ' + if type == "array" and length == 1 and + (.[0].Path | type) == "string" and (.[0].Args | type) == "array" + then .[0] | {Path, Args} + else error("container process shape differs") + end + '); then + die "cannot inspect persisted container process" + fi + [[ "$actual_process" == "$expected_process" ]] || die "persisted container process differs" + rm -f -- "$env_file" + [[ ! -e "$env_file" && ! -L "$env_file" ]] || die "environment file survived container creation" + timeout --signal=TERM --kill-after=10s "$wall" \ + env DOCKER_CONFIG="$docker_config" docker start --attach "$container" \ + >"$last_output" 2>"$last_stderr" || status=$? + if ! DOCKER_CONFIG="$docker_config" docker rm --force "$container" >/dev/null; then + return 1 + fi + active_container= + return "$status" +} + +run_required() { + if ! run_container "$@"; then + [[ ! -s "$last_stderr" ]] || cat "$last_stderr" >&2 + die "container verification failed" + fi +} + +copy_base_env() { + local destination=$1 + cp -- "$base_env" "$destination" + chmod 0600 "$destination" +} + +assert_single_output() { + local directory=$1 basename=$2 expected_mode=$3 path mode links count + path="$directory/$basename" + count=$(find "$directory" -mindepth 1 -maxdepth 1 -print | wc -l | tr -d ' ') + [[ "$count" == 1 && -f "$path" && ! -L "$path" ]] || die "output directory shape differs" + mode=$(stat -c '%a' "$path" 2>/dev/null || stat -f '%Lp' "$path") + links=$(stat -c '%h' "$path" 2>/dev/null || stat -f '%l' "$path") + [[ "$mode" == "$expected_mode" && "$links" == 1 ]] || + die "output file mode or link count differs: $path (mode=$mode links=$links)" +} + +run_required "$probe_env" "$probe_split" 256m 32 60s /usr/bin/env +cat >"$work/probe.expected" <<'EOF' +EDGEZERO_ENV_BACKSLASH=back\slash +EDGEZERO_ENV_DOLLAR=dollar$value +EDGEZERO_ENV_EMPTY= +EDGEZERO_ENV_EQUALS=left=right +EDGEZERO_ENV_HASH=hash#value +EDGEZERO_ENV_LITERAL=${EDGEZERO_ENV_DOLLAR} +EDGEZERO_ENV_NONASCII=café +EDGEZERO_ENV_QUOTE=quote"value +EDGEZERO_ENV_SPACE=two words +HOME=/work/home +PATH=/usr/local/bin:/usr/local/cargo/bin:/usr/bin:/bin +TMPDIR=/work/tmp +EOF +cmp -s "$last_output" "$work/probe.expected" || die "post-env target bytes differ" + +copy_base_env "$work/toolchain.env" +run_required "$work/toolchain.env" "$base_split" \ + 2g 64 600s /usr/local/bin/verify-toolchain --root / +[[ ! -s "$last_output" ]] || die "toolchain verification produced unexpected stdout" + +copy_base_env "$work/self-test.env" +run_required "$work/self-test.env" "$base_split" \ + 2g 64 600s /usr/local/bin/edgezero-provenance-validator self-test \ + --fixtures /usr/local/share/edgezero/provenance-fixtures +[[ ! -s "$last_output" ]] || die "validator self-test produced unexpected stdout" + +fixture_expected="$fixtures/valid/expected.json" +fixture_binary="$fixtures/valid/elf-static/app-cli" +fixture_archive="$fixtures/valid/archive.tar" +for fixture in "$fixture_expected" "$fixture_binary" "$fixture_archive"; do + [[ -f "$fixture" && ! -L "$fixture" ]] || die "required host fixture is missing or linked" +done +[[ "$work" != *,* && "$fixtures" != *,* ]] || die "fixture or temporary path is not mount-safe" + +expected_dir="$work/expected" +mkdir -- "$expected_dir" +copy_base_env "$work/write-expected.env" +container_mounts=(--mount "type=bind,src=$expected_dir,dst=/work/expected") +run_required "$work/write-expected.env" "$base_split" \ + 256m 32 60s /usr/local/bin/edgezero-provenance-validator write-expected \ + --work-root /work \ + --app-repo-id 123456 \ + --source-revision 1111111111111111111111111111111111111111 \ + --app-cli-package edgezero-cli \ + --app-cli-bin edgezero \ + --workspace-id sha256:2222222222222222222222222222222222222222222222222222222222222222 \ + --platform-id sha256:3333333333333333333333333333333333333333333333333333333333333333 \ + --provenance-protocol 1 \ + --output /work/expected/expected.json +[[ ! -s "$last_output" ]] || die "expected writer produced unexpected stdout" +assert_single_output "$expected_dir" expected.json 644 +cmp -s "$expected_dir/expected.json" "$fixture_expected" || die "expected writer bytes differ from golden fixture" + +compiled_dir="$work/compiled" +mkdir -- "$compiled_dir" +copy_base_env "$work/compile-real.env" +container_mounts=(--mount "type=bind,src=$compiled_dir,dst=/work/compiled") +run_required "$work/compile-real.env" "$base_split" \ + 2g 64 600s /usr/local/rustup/toolchains/1.95.0-x86_64-unknown-linux-gnu/bin/rustc \ + --crate-name edgezero_image_runtime_smoke \ + --edition 2024 \ + -C opt-level=0 \ + /usr/local/share/edgezero/gnu-smoke.rs \ + -o /work/compiled/app-cli +[[ ! -s "$last_output" ]] || die "GNU smoke compilation produced unexpected stdout" +assert_single_output "$compiled_dir" app-cli 755 + +package_once() { + local binary=$1 output_dir=$2 env_file=$3 + mkdir -- "$output_dir" + copy_base_env "$env_file" + container_mounts=( + --mount "type=bind,src=$binary,dst=/work/input/app-cli,readonly" + --mount "type=bind,src=$expected_dir/expected.json,dst=/work/input/expected.json,readonly" + --mount "type=bind,src=$output_dir,dst=/work/packaged" + ) + run_required "$env_file" "$base_split" \ + 2g 64 600s /usr/local/bin/edgezero-provenance-validator package \ + --work-root /work \ + --binary /work/input/app-cli \ + --schema /usr/local/share/edgezero/provenance.schema.json \ + --expected /work/input/expected.json \ + --app-cli-version 0.1.0 \ + --archive /work/packaged/artifact.tar + [[ ! -s "$last_output" ]] || die "packager produced unexpected stdout" + assert_single_output "$output_dir" artifact.tar 644 +} + +package_one="$work/package-one" +package_two="$work/package-two" +package_real="$work/package-real" +package_once "$fixture_binary" "$package_one" "$work/package-one.env" +package_once "$fixture_binary" "$package_two" "$work/package-two.env" +package_once "$compiled_dir/app-cli" "$package_real" "$work/package-real.env" +cmp -s "$package_one/artifact.tar" "$package_two/artifact.tar" || die "package output is not deterministic" +cmp -s "$package_one/artifact.tar" "$fixture_archive" || die "package output differs from golden archive" + +validate_archive() { + local archive=$1 output_dir=$2 expectation=$3 expected_binary=$4 + local env_file="$work/validate-$((sequence + 1)).env" + [[ -f "$archive" && ! -L "$archive" ]] || die "archive fixture is missing or linked" + mkdir -- "$output_dir" + copy_base_env "$env_file" + container_mounts=( + --mount "type=bind,src=$archive,dst=/work/input/artifact.tar,readonly" + --mount "type=bind,src=$expected_dir/expected.json,dst=/work/input/expected.json,readonly" + --mount "type=bind,src=$output_dir,dst=/work/validated" + ) + if [[ "$expectation" == success ]]; then + run_required "$env_file" "$base_split" \ + 2g 64 600s /usr/local/bin/edgezero-provenance-validator validate \ + --work-root /work \ + --archive /work/input/artifact.tar \ + --schema /usr/local/share/edgezero/provenance.schema.json \ + --expected /work/input/expected.json \ + --output /work/validated/app-cli + [[ ! -s "$last_output" ]] || die "validator produced unexpected stdout" + assert_single_output "$output_dir" app-cli 755 + cmp -s "$output_dir/app-cli" "$expected_binary" || die "validated binary differs from packaged input" + else + if run_container "$env_file" "$base_split" \ + 2g 64 600s /usr/local/bin/edgezero-provenance-validator validate \ + --work-root /work \ + --archive /work/input/artifact.tar \ + --schema /usr/local/share/edgezero/provenance.schema.json \ + --expected /work/input/expected.json \ + --output /work/validated/app-cli; then + die "malformed archive fixture was accepted: $archive" + fi + [[ -z "$(find "$output_dir" -mindepth 1 -maxdepth 1 -print -quit)" ]] || + die "failed validation left an output file" + fi +} + +validate_archive "$package_one/artifact.tar" "$work/validated-generated" success "$fixture_binary" +validate_archive "$fixture_archive" "$work/validated-golden" success "$fixture_binary" +validated_real="$work/validated-real" +validate_archive "$package_real/artifact.tar" "$validated_real" success "$compiled_dir/app-cli" + +copy_base_env "$work/binary-smoke.env" +container_mounts=(--mount "type=bind,src=$validated_real/app-cli,dst=/work/bin/app-cli,readonly") +run_required "$work/binary-smoke.env" "$base_split" \ + 512m 64 60s /lib64/ld-linux-x86-64.so.2 \ + --inhibit-cache \ + --glibc-hwcaps-mask '' \ + --library-path /opt/edgezero/runtime-lib \ + /work/bin/app-cli \ + --help +printf 'edgezero image runtime smoke\n' >"$work/binary-smoke.expected" +cmp -s "$last_output" "$work/binary-smoke.expected" || die "controlled-loader binary smoke output differs" + +invalid_count=0 +while IFS= read -r invalid_archive; do + invalid_count=$((invalid_count + 1)) + validate_archive "$invalid_archive" "$work/invalid-$invalid_count" failure "$fixture_binary" +done < <(find "$fixtures/invalid" -mindepth 1 -maxdepth 1 -type f -name '*.tar' -print | LC_ALL=C sort) +[[ "$invalid_count" -gt 0 ]] || die "no malformed archive fixtures were exercised" diff --git a/.github/docker/build-app-cli/verify-release-prerequisites.sh b/.github/docker/build-app-cli/verify-release-prerequisites.sh new file mode 100755 index 00000000..dc517aa1 --- /dev/null +++ b/.github/docker/build-app-cli/verify-release-prerequisites.sh @@ -0,0 +1,1522 @@ +#!/usr/bin/env bash +# shellcheck disable=SC2016 +{ set +x; set +a; } 2>/dev/null +set -euo pipefail + +ALTERNATES_WERE_SET=false +[[ -z "${GIT_ALTERNATE_OBJECT_DIRECTORIES:-}" ]] || ALTERNATES_WERE_SET=true + +export LC_ALL=C +export BASH_ENV= +export ENV= +export GIT_CONFIG_NOSYSTEM=1 +export GIT_CONFIG_GLOBAL=/dev/null +export GIT_NO_REPLACE_OBJECTS=1 +export GIT_NO_LAZY_FETCH=1 +unset GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE GIT_OBJECT_DIRECTORY +unset GIT_ALTERNATE_OBJECT_DIRECTORIES GIT_COMMON_DIR GIT_CEILING_DIRECTORIES +unset GIT_REPLACE_REF_BASE + +readonly API=https://api.github.com +readonly API_VERSION=2026-03-10 +readonly WORKFLOW_PATH=.github/workflows/build-container-ci.yml +readonly ROTATION_PATH=.github/workflows/rotate-build-container-gate.yml@main +readonly ROTATION_ENVIRONMENT=build-container-gate-rotation-lock +readonly GATE_MANIFEST=.github/docker/build-app-cli/gate-paths.txt +readonly CODEOWNERS=.github/CODEOWNERS +readonly HELPER_PATH=.github/docker/build-app-cli/verify-release-prerequisites.sh +readonly U64_MAX=18446744073709551615 +readonly U32_MAX=4294967295 +readonly MAX_PAGES=100 +readonly MAX_ITEMS=10000 +readonly MAX_EVIDENCE_BYTES=1048576 +readonly MAX_PNG_BYTES=10485760 +readonly POLICY_PREFIX='edgezero-gate-rotation-policy-v1 ' +readonly ROTATION_PREFIX='edgezero-gate-rotation-v1 ' + +usage() { + printf '%s\n' \ + 'usage: verify-release-prerequisites.sh configuration ' \ + 'usage: verify-release-prerequisites.sh release ' \ + 'usage: verify-release-prerequisites.sh rotation-review ' \ + 'usage: verify-release-prerequisites.sh rotation-complete ' >&2 + exit 2 +} + +die() { + printf '::error::%s\n' "$*" >&2 + exit 1 +} + +tool_die() { + printf '::error::%s\n' "$*" >&2 + exit 2 +} + +for tool in git jq curl sha256sum stat od cmp awk sed grep sort uniq wc tr date openssl mktemp ln mv chmod id paste tail env rm dirname; do + command -v "$tool" >/dev/null 2>&1 || tool_die "required tool is unavailable: $tool" +done + +is_sha() { + [[ "$1" =~ ^[0-9a-f]{40}$ && "$1" != 0000000000000000000000000000000000000000 ]] +} + +is_login() { + [[ "$1" =~ ^[A-Za-z0-9][A-Za-z0-9-]{0,38}(\[bot\])?$ ]] +} + +is_positive_decimal_at_most() { + local value=$1 maximum=$2 + [[ "$value" =~ ^[1-9][0-9]*$ ]] || return 1 + ((${#value} < ${#maximum})) && return 0 + ((${#value} == ${#maximum})) && [[ "$value" < "$maximum" || "$value" == "$maximum" ]] +} + +is_nonnegative_decimal_at_most() { + [[ "$1" == 0 ]] || is_positive_decimal_at_most "$1" "$2" +} + +is_beneath() { + [[ "$1" == "$2" || "$1" == "$2/"* ]] +} + +safe_jq() { + env -i PATH="$PATH" LC_ALL=C HOME=/dev/null jq "$@" +} + +repo_git() { + env -i PATH="$PATH" LC_ALL=C HOME=/dev/null TMPDIR=/tmp \ + GIT_CONFIG_NOSYSTEM=1 GIT_CONFIG_GLOBAL=/dev/null GIT_TERMINAL_PROMPT=0 \ + GIT_NO_REPLACE_OBJECTS=1 GIT_NO_LAZY_FETCH=1 GIT_OPTIONAL_LOCKS=0 \ + git --no-replace-objects -c core.fsmonitor=false -c core.hooksPath=/dev/null \ + -C "$GATE_ROOT" "$@" +} + +TEMP_ROOT= +temporary_files=() +AUDIT_TOKEN= +PROBE_TOKEN= +APP_JWT= +AUDIT_REVOKED=true +PROBE_REVOKED=true + +remove_temporary_files() { + local path + for path in ${temporary_files[@]+"${temporary_files[@]}"}; do + [[ -z "$path" ]] || rm -f -- "$path" 2>/dev/null || true + done + [[ -z "$TEMP_ROOT" ]] || rm -rf -- "$TEMP_ROOT" 2>/dev/null || true +} + +best_effort_revoke() { + local kind=$1 token=$2 + [[ -n "$token" && -n "$TEMP_ROOT" && -d "$TEMP_ROOT" ]] || return 0 + ( + trap - EXIT HUP INT TERM + api_request "$kind" "$token" DELETE /installation/token 204 ignored + ) >/dev/null 2>/dev/null || true +} + +cleanup_credentials() { + [[ "$AUDIT_REVOKED" == true ]] || best_effort_revoke installation-audit "$AUDIT_TOKEN" + [[ "$PROBE_REVOKED" == true ]] || best_effort_revoke publisher-probe "$PROBE_TOKEN" + APP_JWT='' AUDIT_TOKEN='' PROBE_TOKEN='' +} + +cleanup_exit() { + local status=$1 + trap - EXIT HUP INT TERM + cleanup_credentials + remove_temporary_files + exit "$status" +} + +cleanup_signal() { + local status=$1 + trap - EXIT HUP INT TERM + cleanup_credentials + remove_temporary_files + exit "$status" +} + +trap 'cleanup_exit $?' EXIT +trap 'cleanup_signal 129' HUP +trap 'cleanup_signal 130' INT +trap 'cleanup_signal 143' TERM + +new_temp() { + local result_name=$1 label=$2 path + path=$(mktemp "$TEMP_ROOT/.edgezero-release-$label.XXXXXX" 2>/dev/null) || + tool_die 'cannot create private temporary file' + temporary_files+=("$path") + printf -v "$result_name" '%s' "$path" +} + +file_size() { + local path=$1 size + if size=$(env -i PATH="$PATH" LC_ALL=C stat -f '%z' -- "$path" 2>/dev/null); then : + elif size=$(env -i PATH="$PATH" LC_ALL=C stat -c '%s' -- "$path" 2>/dev/null); then : + else tool_die 'cannot inspect file size' + fi + [[ "$size" =~ ^[0-9]+$ ]] || tool_die 'file size result is malformed' + printf '%s' "$size" +} + +file_mode() { + local path=$1 mode + if mode=$(env -i PATH="$PATH" LC_ALL=C stat -f '%Lp' -- "$path" 2>/dev/null); then : + elif mode=$(env -i PATH="$PATH" LC_ALL=C stat -c '%a' -- "$path" 2>/dev/null); then : + else tool_die 'cannot inspect file mode' + fi + printf '%s' "$mode" +} + +file_uid() { + local path=$1 uid + if uid=$(env -i PATH="$PATH" LC_ALL=C stat -f '%u' -- "$path" 2>/dev/null); then : + elif uid=$(env -i PATH="$PATH" LC_ALL=C stat -c '%u' -- "$path" 2>/dev/null); then : + else tool_die 'cannot inspect file owner' + fi + printf '%s' "$uid" +} + +canonical_file() { + local path=$1 label=$2 parent name canonical_parent expected + [[ "$path" == /* && -f "$path" && ! -L "$path" ]] || + die "$label must be an absolute regular non-symlink file" + parent=${path%/*}; name=${path##*/}; [[ -n "$parent" ]] || parent=/ + canonical_parent=$(cd -- "$parent" && pwd -P) || die "cannot resolve $label parent" + [[ "$canonical_parent" == "$parent" ]] || die "$label path must already be canonical" + if [[ "$parent" == / ]]; then expected="/$name"; else expected="$parent/$name"; fi + [[ "$expected" == "$path" ]] || die "$label path must already be canonical" +} + +validate_output_path() { + local path=$1 label=$2 parent name canonical_parent expected mode top + [[ "$path" == /* && ! -e "$path" && ! -L "$path" ]] || die "$label must be an absent absolute path" + parent=${path%/*}; name=${path##*/}; [[ -n "$parent" ]] || parent=/ + [[ -d "$parent" && ! -L "$parent" ]] || die "$label parent must be a directory" + canonical_parent=$(cd -- "$parent" && pwd -P) || die "cannot resolve $label parent" + [[ "$canonical_parent" == "$parent" ]] || die "$label parent must already be canonical" + if [[ "$parent" == / ]]; then expected="/$name"; else expected="$parent/$name"; fi + [[ "$expected" == "$path" ]] || die "$label path must already be canonical" + mode=$(file_mode "$parent") + [[ "$mode" == 700 ]] || die "$label parent must have mode 0700" + ! is_beneath "$path" "$GATE_ROOT" || die "$label must be outside the gate repository" + top=$(env -i PATH="$PATH" LC_ALL=C HOME=/dev/null GIT_CONFIG_NOSYSTEM=1 \ + GIT_CONFIG_GLOBAL=/dev/null git -C "$parent" rev-parse --show-toplevel 2>/dev/null || true) + [[ -z "$top" ]] || die "$label must be outside every Git repository" +} + +validate_raw_json_keys() { + safe_jq -ne --stream '[inputs | select(length == 2) | (.[0] | tojson)] as $paths | ($paths | length) == ($paths | unique | length)' "$1" >/dev/null 2>&1 || + die "$2 contains duplicate or malformed JSON keys" +} + +hash_file() { + local output + output=$(env -i PATH="$PATH" LC_ALL=C sha256sum "$1" 2>/dev/null) || tool_die 'cannot hash trusted bytes' + [[ "$output" =~ ^[0-9a-f]{64}[[:space:]] ]] || tool_die 'SHA-256 output is malformed' + printf '%s' "${output%%[[:space:]]*}" +} + +hash_bytes() { + local output + output=$(printf '%s' "$1" | env -i PATH="$PATH" LC_ALL=C sha256sum 2>/dev/null) || tool_die 'cannot hash trusted bytes' + [[ "$output" =~ ^[0-9a-f]{64}[[:space:]] ]] || tool_die 'SHA-256 output is malformed' + printf '%s' "${output%%[[:space:]]*}" +} + +validate_utc() { + local value=$1 label=$2 result_name=$3 epoch round_trip + [[ "$value" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$ ]] || die "$label is not canonical UTC" + epoch=$(safe_jq -nr --arg value "$value" '$value | fromdateiso8601' 2>/dev/null) || die "$label is not a valid UTC instant" + [[ "$epoch" =~ ^[0-9]+$ ]] || die "$label epoch is malformed" + round_trip=$(safe_jq -nr --argjson epoch "$epoch" '$epoch | strftime("%Y-%m-%dT%H:%M:%SZ")' 2>/dev/null) || die "$label cannot be normalized" + [[ "$round_trip" == "$value" ]] || die "$label is not a real calendar instant" + printf -v "$result_name" '%s' "$epoch" +} + +validate_png() { + local path=$1 label=$2 size signature + canonical_file "$path" "$label" + size=$(file_size "$path") + ((size >= 8 && size <= MAX_PNG_BYTES)) || die "$label size is outside its allowed bounds" + signature=$(env -i PATH="$PATH" LC_ALL=C od -An -tx1 -N8 -- "$path" 2>/dev/null | tr -d '[:space:]') || tool_die "cannot inspect $label" + [[ "$signature" == 89504e470d0a1a0a ]] || die "$label does not have the PNG signature" +} + +validate_key_file() { + local path=$1 mode uid current_uid + canonical_file "$path" 'App private-key file' + mode=$(file_mode "$path"); uid=$(file_uid "$path"); current_uid=$(id -u) + [[ "$mode" == 600 && "$uid" == "$current_uid" ]] || die 'App private-key file must be owner-only mode 0600' +} + +canonical_root() { + local supplied=$1 canonical + [[ "$supplied" == /* && -d "$supplied" && ! -L "$supplied" ]] || die 'gate root must be an absolute non-symlink directory' + canonical=$(cd -- "$supplied" && pwd -P) || die 'cannot resolve gate root' + [[ "$canonical" == "$supplied" ]] || die 'gate root must already be canonical' +} + +require_checkout() { + local expected=$1 top git_dir common_dir replacements shallow partial promisor sparse actual status gitlinks + [[ "$ALTERNATES_WERE_SET" == false ]] || die 'gate checkout cannot use environment object alternates' + [[ "$(repo_git rev-parse --is-inside-work-tree 2>/dev/null)" == true ]] || die 'gate root is not a Git worktree' + top=$(repo_git rev-parse --show-toplevel 2>/dev/null) || die 'cannot resolve gate top level' + [[ "$top" == "$GATE_ROOT" ]] || die 'gate root must be the exact repository top level' + git_dir=$(repo_git rev-parse --absolute-git-dir 2>/dev/null) || die 'cannot resolve gate Git directory' + common_dir=$(repo_git rev-parse --path-format=absolute --git-common-dir 2>/dev/null) || die 'cannot resolve gate common directory' + [[ ! -e "$git_dir/info/grafts" && ! -L "$git_dir/info/grafts" && ! -e "$common_dir/info/grafts" && ! -L "$common_dir/info/grafts" ]] || die 'gate checkout cannot contain grafts' + [[ ! -e "$git_dir/objects/info/alternates" && ! -L "$git_dir/objects/info/alternates" && ! -e "$common_dir/objects/info/alternates" && ! -L "$common_dir/objects/info/alternates" ]] || die 'gate checkout cannot use object alternates' + replacements=$(repo_git for-each-ref --format='%(refname)' refs/replace/ 2>/dev/null) || die 'cannot inspect replacement refs' + [[ -z "$replacements" ]] || die 'gate checkout cannot contain replacement refs' + shallow=$(repo_git rev-parse --is-shallow-repository 2>/dev/null) || die 'cannot inspect history depth' + [[ "$shallow" == false ]] || die 'gate checkout must contain full history' + partial=$(repo_git config --get extensions.partialclone 2>/dev/null || true) + promisor=$(repo_git config --get-regexp '^remote\..*\.promisor$' 2>/dev/null || true) + [[ -z "$partial$promisor" ]] || die 'gate checkout cannot use partial or promisor objects' + sparse=$(repo_git config --bool core.sparseCheckout 2>/dev/null || true) + [[ "$sparse" != true ]] || die 'gate checkout cannot be sparse' + actual=$(repo_git rev-parse --verify HEAD 2>/dev/null) || die 'gate checkout HEAD is absent' + [[ "$actual" == "$expected" ]] || die 'gate checkout HEAD differs from supplied gate SHA' + repo_git symbolic-ref -q HEAD >/dev/null 2>&1 && die 'gate checkout must be detached' + status=$(repo_git status --porcelain=v1 --untracked-files=all --ignore-submodules=none) || die 'cannot inspect checkout status' + [[ -z "$status" ]] || die 'gate checkout must be clean' + gitlinks=$(repo_git ls-tree -r "$expected" 2>/dev/null | awk '$1 == "160000" { print; exit }') || die 'cannot inspect submodule state' + [[ -z "$gitlinks" ]] || die 'gate checkout cannot contain submodule state' +} + +require_gate_helper_source() { + local source=${BASH_SOURCE[0]} parent canonical entry + [[ -f "$source" && ! -L "$source" ]] || die 'auditor source must be a regular non-symlink file' + parent=$(cd -- "$(dirname -- "$source")" && pwd -P) || die 'cannot resolve auditor source directory' + canonical="$parent/${source##*/}" + [[ "$canonical" == "$GATE_ROOT/$HELPER_PATH" ]] || die 'auditor must execute from the verified gate path' + entry=$(repo_git ls-tree "$GATE_SHA" -- "$HELPER_PATH") || die 'cannot inspect auditor gate entry' + [[ "$entry" =~ ^100755[[:space:]]blob[[:space:]][0-9a-f]{40,64}$'\t'"$HELPER_PATH"$ ]] || + die 'auditor is not the executable gate-owned blob' +} + +commit_exists() { + [[ "$(repo_git cat-file -t "$1" 2>/dev/null)" == commit ]] +} + +require_ancestor() { + repo_git merge-base --is-ancestor "$1" "$2" 2>/dev/null || die "$3 ancestry is invalid" +} + +publish_file() { + local destination=$1 content=$2 label=$3 parent staged + parent=${destination%/*} + staged=$(mktemp "$parent/.edgezero-$label.XXXXXX" 2>/dev/null) || tool_die "cannot stage $label output" + temporary_files+=("$staged") + chmod 0600 "$staged" || tool_die "cannot protect $label output" + printf '%s' "$content" >"$staged" || die "cannot write $label output" + ln "$staged" "$destination" 2>/dev/null || die "$label output appeared before publication" + rm -f -- "$staged" || die "cannot remove staged $label output" +} + +MODE=${1:-} +case "$MODE" in configuration|release|rotation-review|rotation-complete) shift ;; *) usage ;; esac + +GATE_ROOT='' GATE_SHA='' OLD_GATE_SHA='' NEW_GATE_SHA='' CANDIDATE_PR='' EVIDENCE_URL='' +SOURCE_REVISION='' MERGE_GROUP_SHA='' MERGE_GROUP_RUN_ID='' MERGE_GROUP_RUN_ATTEMPT='' +SMOKE_RUN_ID='' SMOKE_RUN_ATTEMPT='' PUSH_RUN_ID='' PUSH_RUN_ATTEMPT='' +EXPECTED_APP_ID='' EXPECTED_INSTALLATION_ID='' EXPECTED_TEAM_ID='' EXPECTED_BOT_ID='' EXPECTED_BOT_LOGIN='' +PACKAGE_AUDITOR_LOGIN='' PACKAGE_STATE='' DISPATCH_SHA='' FINAL_HEAD_SHA='' LOCK_RUN_ID='' LOCK_RUN_ATTEMPT='' +OPERATOR_LOGIN='' ROTATION_RESULT='' POLICY_REVIEW_JSON='' POLICY_REVIEW_PNG='' ADMIN_PNG='' +ADMIN_REVIEWER='' ADMIN_REVIEWED_AT='' EVIDENCE_OUT='' PREREQUISITE_OUT='' APPROVAL_COMMENT_OUT='' + +case "$MODE" in + configuration) + allowed=' --gate-root --gate-sha --smoke-run-id --smoke-run-attempt --expected-app-id --expected-installation-id --expected-team-id --expected-bot-id --expected-bot-login --policy-token-review-json --policy-token-review-png --administrator-bypass-png --administrator-bypass-reviewer --administrator-bypass-reviewed-at --evidence-out ' + expected_count=15 ;; + release) + allowed=' --gate-root --gate-sha --candidate-pr --evidence-url --source-revision --merge-group-sha --merge-group-run-id --merge-group-run-attempt --smoke-run-id --smoke-run-attempt --push-run-id --push-run-attempt --expected-app-id --expected-installation-id --expected-team-id --expected-bot-id --expected-bot-login --package-auditor-login --package-state --policy-token-review-json --policy-token-review-png --administrator-bypass-png --administrator-bypass-reviewer --administrator-bypass-reviewed-at --evidence-out --publisher-prerequisite-out ' + expected_count=26 ;; + rotation-review) + allowed=' --gate-root --old-gate-sha --new-gate-sha --dispatch-sha --final-head-sha --lock-run-id --lock-run-attempt --operator-login --result --policy-token-review-json --policy-token-review-png --evidence-out --approval-comment-out ' + expected_count=13 ;; + rotation-complete) + allowed=' --gate-root --gate-sha --lock-run-id --lock-run-attempt --policy-token-review-json --policy-token-review-png --evidence-out --publisher-prerequisite-out ' + expected_count=8 ;; +esac + +seen='|' +count=0 +while (($#)); do + (($# >= 2)) || usage + flag=$1; value=$2; shift 2 + [[ -n "$value" ]] || usage + case "$allowed" in *" $flag "*) ;; *) usage ;; esac + case "$seen" in *"|$flag|"*) usage ;; esac + seen="$seen$flag|"; count=$((count + 1)) + case "$flag" in + --gate-root) GATE_ROOT=$value ;; --gate-sha) GATE_SHA=$value ;; --old-gate-sha) OLD_GATE_SHA=$value ;; + --new-gate-sha) NEW_GATE_SHA=$value ;; --candidate-pr) CANDIDATE_PR=$value ;; --evidence-url) EVIDENCE_URL=$value ;; + --source-revision) SOURCE_REVISION=$value ;; --merge-group-sha) MERGE_GROUP_SHA=$value ;; + --merge-group-run-id) MERGE_GROUP_RUN_ID=$value ;; --merge-group-run-attempt) MERGE_GROUP_RUN_ATTEMPT=$value ;; + --smoke-run-id) SMOKE_RUN_ID=$value ;; --smoke-run-attempt) SMOKE_RUN_ATTEMPT=$value ;; + --push-run-id) PUSH_RUN_ID=$value ;; --push-run-attempt) PUSH_RUN_ATTEMPT=$value ;; + --expected-app-id) EXPECTED_APP_ID=$value ;; --expected-installation-id) EXPECTED_INSTALLATION_ID=$value ;; + --expected-team-id) EXPECTED_TEAM_ID=$value ;; --expected-bot-id) EXPECTED_BOT_ID=$value ;; + --expected-bot-login) EXPECTED_BOT_LOGIN=$value ;; --package-auditor-login) PACKAGE_AUDITOR_LOGIN=$value ;; + --package-state) PACKAGE_STATE=$value ;; --dispatch-sha) DISPATCH_SHA=$value ;; --final-head-sha) FINAL_HEAD_SHA=$value ;; + --lock-run-id) LOCK_RUN_ID=$value ;; --lock-run-attempt) LOCK_RUN_ATTEMPT=$value ;; + --operator-login) OPERATOR_LOGIN=$value ;; --result) ROTATION_RESULT=$value ;; + --policy-token-review-json) POLICY_REVIEW_JSON=$value ;; --policy-token-review-png) POLICY_REVIEW_PNG=$value ;; + --administrator-bypass-png) ADMIN_PNG=$value ;; --administrator-bypass-reviewer) ADMIN_REVIEWER=$value ;; + --administrator-bypass-reviewed-at) ADMIN_REVIEWED_AT=$value ;; --evidence-out) EVIDENCE_OUT=$value ;; + --publisher-prerequisite-out) PREREQUISITE_OUT=$value ;; --approval-comment-out) APPROVAL_COMMENT_OUT=$value ;; + esac +done +((count == expected_count)) || usage + +if [[ "$MODE" == rotation-review ]]; then GATE_SHA=$OLD_GATE_SHA; fi +is_sha "$GATE_SHA" || usage +canonical_root "$GATE_ROOT" +require_checkout "$GATE_SHA" +require_gate_helper_source + +for value in "$LOCK_RUN_ID" "$MERGE_GROUP_RUN_ID" "$SMOKE_RUN_ID" "$PUSH_RUN_ID" "$CANDIDATE_PR" "$EXPECTED_APP_ID" "$EXPECTED_INSTALLATION_ID" "$EXPECTED_TEAM_ID" "$EXPECTED_BOT_ID"; do + [[ -z "$value" ]] || is_positive_decimal_at_most "$value" "$U64_MAX" || usage +done +for value in "$LOCK_RUN_ATTEMPT" "$MERGE_GROUP_RUN_ATTEMPT" "$SMOKE_RUN_ATTEMPT" "$PUSH_RUN_ATTEMPT"; do + [[ -z "$value" ]] || is_positive_decimal_at_most "$value" "$U32_MAX" || usage +done +for value in "$EXPECTED_BOT_LOGIN" "$PACKAGE_AUDITOR_LOGIN" "$OPERATOR_LOGIN" "$ADMIN_REVIEWER"; do + [[ -z "$value" ]] || is_login "$value" || usage +done +for value in "$SOURCE_REVISION" "$MERGE_GROUP_SHA" "$NEW_GATE_SHA" "$DISPATCH_SHA" "$FINAL_HEAD_SHA"; do + [[ -z "$value" ]] || is_sha "$value" || usage +done +[[ -z "$PACKAGE_STATE" || "$PACKAGE_STATE" == absent || "$PACKAGE_STATE" == public-linked ]] || usage +[[ -z "$ROTATION_RESULT" || "$ROTATION_RESULT" == activated || "$ROTATION_RESULT" == rolled-back ]] || usage +if [[ -n "$EVIDENCE_URL" ]]; then + [[ "$EVIDENCE_URL" =~ ^https://github\.com/stackpop/edgezero/pull/([1-9][0-9]*)#issuecomment-([1-9][0-9]*)$ ]] || usage + [[ "${BASH_REMATCH[1]}" == "$CANDIDATE_PR" ]] || usage + is_positive_decimal_at_most "${BASH_REMATCH[2]}" "$U64_MAX" || usage +fi + +for revision in "$SOURCE_REVISION" "$MERGE_GROUP_SHA" "$NEW_GATE_SHA" "$DISPATCH_SHA" "$FINAL_HEAD_SHA"; do + [[ -z "$revision" ]] || commit_exists "$revision" || die 'required commit object is unavailable' +done +[[ -z "$SOURCE_REVISION" ]] || require_ancestor "$GATE_SHA" "$SOURCE_REVISION" 'release source' +[[ -z "$DISPATCH_SHA" || -z "$FINAL_HEAD_SHA" ]] || require_ancestor "$DISPATCH_SHA" "$FINAL_HEAD_SHA" 'rotation head' + +canonical_file "$POLICY_REVIEW_JSON" 'policy-token review JSON' +canonical_file "$POLICY_REVIEW_PNG" 'policy-token review PNG' +validate_png "$POLICY_REVIEW_PNG" 'policy-token review PNG' +validate_output_path "$EVIDENCE_OUT" 'evidence output' +if [[ -n "$PREREQUISITE_OUT" ]]; then validate_output_path "$PREREQUISITE_OUT" 'publisher prerequisite output'; fi +if [[ -n "$APPROVAL_COMMENT_OUT" ]]; then validate_output_path "$APPROVAL_COMMENT_OUT" 'approval comment output'; fi +[[ -z "$PREREQUISITE_OUT" || "$PREREQUISITE_OUT" != "$EVIDENCE_OUT" ]] || die 'output paths must be distinct' +[[ -z "$APPROVAL_COMMENT_OUT" || "$APPROVAL_COMMENT_OUT" != "$EVIDENCE_OUT" ]] || die 'output paths must be distinct' + +if [[ "$MODE" == configuration || "$MODE" == release ]]; then + validate_png "$ADMIN_PNG" 'administrator-bypass PNG' + [[ ! "$POLICY_REVIEW_PNG" -ef "$ADMIN_PNG" ]] || die 'review PNG inputs must be distinct files' + validate_utc "$ADMIN_REVIEWED_AT" 'administrator-bypass review time' ADMIN_REVIEW_EPOCH +fi + +validate_raw_json_keys "$POLICY_REVIEW_JSON" 'policy-token review' +safe_jq -e ' + type == "object" + and keys == ["expires-at","organization-grants","repository-grants","resource-owner","reviewed-at","reviewer-login","schema-version","screenshot-sha256","selected-repositories","subject-login","token-id"] + and ."organization-grants" == {"administration":"write","members":"read","other-displayed":"none"} + and ."repository-grants" == {"actions":"read","administration":"write","checks":"read","contents":"read","environments":"read","metadata":"read","other-displayed":"none","pull-requests":"read","variables":"read"} + and ."resource-owner" == "stackpop" + and ."selected-repositories" == ["stackpop/edgezero"] + and ."schema-version" == 1 and (."schema-version" | type) == "number" + and all(."expires-at",."reviewed-at",."reviewer-login",."screenshot-sha256",."subject-login",."token-id"; type == "string") +' "$POLICY_REVIEW_JSON" >/dev/null 2>&1 || die 'policy-token review has the wrong shape or grants' +POLICY_EXPIRES=$(safe_jq -er '."expires-at"' "$POLICY_REVIEW_JSON") +POLICY_REVIEWED=$(safe_jq -er '."reviewed-at"' "$POLICY_REVIEW_JSON") +POLICY_REVIEWER=$(safe_jq -er '."reviewer-login"' "$POLICY_REVIEW_JSON") +POLICY_SCREENSHOT=$(safe_jq -er '."screenshot-sha256"' "$POLICY_REVIEW_JSON") +POLICY_LOGIN=$(safe_jq -er '."subject-login"' "$POLICY_REVIEW_JSON") +POLICY_TOKEN_ID=$(safe_jq -er '."token-id"' "$POLICY_REVIEW_JSON") +if ! is_login "$POLICY_LOGIN" || ! is_login "$POLICY_REVIEWER" || [[ "$POLICY_LOGIN" == "$POLICY_REVIEWER" ]]; then + die 'policy-token review identities are invalid' +fi +is_positive_decimal_at_most "$POLICY_TOKEN_ID" "$U64_MAX" || die 'policy-token review token id is invalid' +[[ "$POLICY_SCREENSHOT" =~ ^sha256:[0-9a-f]{64}$ ]] || die 'policy-token review screenshot digest is invalid' +[[ "$POLICY_SCREENSHOT" == "sha256:$(hash_file "$POLICY_REVIEW_PNG")" ]] || die 'policy-token review PNG digest differs' +validate_utc "$POLICY_REVIEWED" 'policy-token review time' POLICY_REVIEW_EPOCH +validate_utc "$POLICY_EXPIRES" 'policy-token expiration' POLICY_EXPIRES_EPOCH +NOW_EPOCH=$(env -i PATH="$PATH" LC_ALL=C date -u +%s 2>/dev/null) || tool_die 'cannot read current time' +[[ "$NOW_EPOCH" =~ ^[0-9]+$ ]] || tool_die 'current time is malformed' +((POLICY_REVIEW_EPOCH <= NOW_EPOCH && POLICY_EXPIRES_EPOCH > NOW_EPOCH)) || die 'policy-token review is future or expired' +if [[ "$MODE" == configuration || "$MODE" == release ]]; then + [[ "$ADMIN_REVIEWER" != "$POLICY_LOGIN" ]] || die 'administrator-bypass reviewer must differ from verifier' + ((ADMIN_REVIEW_EPOCH <= NOW_EPOCH)) || die 'administrator-bypass review is future' + ADMIN_DIGEST="sha256:$(hash_file "$ADMIN_PNG")" +fi + +POLICY_CANONICAL=$(safe_jq -cS . "$POLICY_REVIEW_JSON" 2>/dev/null) || die 'cannot canonicalize policy-token review' +[[ "$(file_size "$POLICY_REVIEW_JSON")" == "${#POLICY_CANONICAL}" && "$POLICY_CANONICAL" == "$(<"$POLICY_REVIEW_JSON")" ]] || + die 'policy-token review is not exact JCS' + +# Credentials are not read until every CLI, checkout, path, and reviewed-input check above succeeds. +POLICY_TOKEN=${EDGEZERO_RELEASE_POLICY_AUDIT_TOKEN:-} +[[ -n "$POLICY_TOKEN" ]] || die 'policy-audit credential is missing' +PACKAGE_TOKEN= +APP_KEY_FILE= +if [[ "$MODE" == release ]]; then + PACKAGE_TOKEN=${EDGEZERO_RELEASE_PACKAGE_AUDIT_TOKEN:-} + [[ -n "$PACKAGE_TOKEN" ]] || die 'package-audit credential is missing' + [[ "$PACKAGE_TOKEN" != "$POLICY_TOKEN" ]] || die 'policy and package credentials must be distinct' +fi +if [[ "$MODE" == configuration || "$MODE" == release ]]; then + APP_KEY_FILE=${EDGEZERO_RELEASE_APP_PRIVATE_KEY_FILE:-} + [[ -n "$APP_KEY_FILE" ]] || die 'App private-key path is missing' + validate_key_file "$APP_KEY_FILE" +fi +unset EDGEZERO_RELEASE_POLICY_AUDIT_TOKEN EDGEZERO_RELEASE_PACKAGE_AUDIT_TOKEN EDGEZERO_RELEASE_APP_PRIVATE_KEY_FILE + +TEMP_ROOT=$(mktemp -d /tmp/.edgezero-release-audit.XXXXXX 2>/dev/null) || tool_die 'cannot create private temporary directory' +chmod 0700 "$TEMP_ROOT" || tool_die 'cannot protect private temporary directory' + +valid_credential() { + ((${#1} >= 8 && ${#1} <= 512)) && [[ "$1" =~ ^[A-Za-z0-9_.-]+$ ]] +} + +valid_credential "$POLICY_TOKEN" || die 'policy-audit credential has an invalid transport form' +[[ -z "$PACKAGE_TOKEN" ]] || valid_credential "$PACKAGE_TOKEN" || die 'package-audit credential has an invalid transport form' + +id_is_discovered() { + local needle=$1 haystack=$2 + case "$haystack" in *"|$needle|"*) return 0 ;; *) return 1 ;; esac +} + +ORG_RULESET_IDS='|' +REPO_RULESET_IDS='|' + +allow_policy_path() { + local method=$1 path=$2 id suffix sha check run attempt + [[ "$method" == GET ]] || return 1 + case "$path" in + /user|/orgs/stackpop/actions/permissions|/repos/stackpop/edgezero|/repos/stackpop/edgezero/actions/permissions|/repos/stackpop/edgezero/immutable-releases|/repos/stackpop/edgezero/environments/build-container-release|/repos/stackpop/edgezero/environments/build-container-release/deployment_protection_rules|/repos/stackpop/edgezero/git/ref/heads/main) + return 0 ;; + "/orgs/stackpop/memberships/$POLICY_LOGIN"|"/orgs/stackpop/teams/edgezero-build-container-releasers/memberships/$POLICY_LOGIN") + return 0 ;; + /orgs/stackpop/rulesets\?per_page=100\&page=*|/repos/stackpop/edgezero/rulesets\?per_page=100\&page=*|/repos/stackpop/edgezero/environments/build-container-release/deployment-branch-policies\?per_page=100\&page=*|/repos/stackpop/edgezero/actions/workflows/rotate-build-container-gate.yml/runs\?event=workflow_dispatch\&per_page=100\&page=*) + suffix=${path##*page=}; is_positive_decimal_at_most "$suffix" "$U64_MAX" ; return ;; + /orgs/stackpop/rulesets/*) + id=${path##*/}; is_positive_decimal_at_most "$id" "$U64_MAX" && id_is_discovered "$id" "$ORG_RULESET_IDS"; return ;; + /repos/stackpop/edgezero/rulesets/*) + id=${path##*/}; is_positive_decimal_at_most "$id" "$U64_MAX" && id_is_discovered "$id" "$REPO_RULESET_IDS"; return ;; + /users/*) + [[ "$path" == "/users/$ACTIVE_BOT_LOGIN" ]]; return ;; + /repos/stackpop/edgezero/pulls/*) + id=${path##*/}; is_positive_decimal_at_most "$id" "$U64_MAX" && [[ "$id" == "$ACTIVE_CANDIDATE_PR" ]]; return ;; + /repos/stackpop/edgezero/actions/variables/*) + case "${path##*/}" in EDGEZERO_BUILD_CONTAINER_GATE_SHA|EDGEZERO_BUILD_CONTAINER_RELEASE_STATE|EDGEZERO_BUILD_CONTAINER_PUBLISHER_APP_ID|EDGEZERO_BUILD_CONTAINER_PUBLISHER_BOT_ID|EDGEZERO_BUILD_CONTAINER_PUBLISHER_BOT_LOGIN|EDGEZERO_BUILD_CONTAINER_PUBLISHER_PREREQUISITE) return 0 ;; *) return 1 ;; esac ;; + /repos/stackpop/edgezero/environments/build-container-release/variables/*) + case "${path##*/}" in EDGEZERO_BUILD_CONTAINER_APP_ID|EDGEZERO_BUILD_CONTAINER_APP_INSTALLATION_ID|EDGEZERO_BUILD_CONTAINER_RELEASE_TEAM_ID) return 0 ;; *) return 1 ;; esac ;; + /repos/stackpop/edgezero/environments/build-container-release/secrets/EDGEZERO_BUILD_CONTAINER_APP_PRIVATE_KEY) + return 0 ;; + /repos/stackpop/edgezero/commits/*/check-runs\?*) + suffix=${path#'/repos/stackpop/edgezero/commits/'}; sha=${suffix%%/*}; suffix=${suffix#*/check-runs\?} + is_sha "$sha" || return 1 + case "$sha" in "$ACTIVE_CANDIDATE_HEAD"|"$MERGE_GROUP_SHA") ;; *) return 1 ;; esac + case "$suffix" in + check_name=build-container-release-preflight\&filter=latest\&app_id=15368\&per_page=100\&page=1) check=build-container-release-preflight ;; + check_name=build-container-local\&filter=latest\&app_id=15368\&per_page=100\&page=1) check=build-container-local ;; + check_name=build-container-pin\&filter=latest\&app_id=15368\&per_page=100\&page=1) check=build-container-pin ;; + *) return 1 ;; + esac + [[ "$check" == build-container-release-preflight && "$sha" == "$ACTIVE_CANDIDATE_HEAD" ]] || + [[ "$check" != build-container-release-preflight && "$sha" == "$MERGE_GROUP_SHA" ]] + return ;; + /repos/stackpop/edgezero/actions/runs/*) + suffix=${path#'/repos/stackpop/edgezero/actions/runs/'} + run=${suffix%%/*} + is_positive_decimal_at_most "$run" "$U64_MAX" || return 1 + case "$run" in "$SMOKE_RUN_ID"|"$MERGE_GROUP_RUN_ID"|"$PUSH_RUN_ID"|"$LOCK_RUN_ID") ;; *) return 1 ;; esac + [[ "$suffix" == "$run" ]] && return 0 + [[ "$suffix" == "$run/approvals" && "$run" == "$LOCK_RUN_ID" ]] && return 0 + if [[ "$suffix" =~ ^$run/attempts/([1-9][0-9]*)/jobs\?per_page=100\&page=([1-9][0-9]*)$ ]]; then + attempt=${BASH_REMATCH[1]}; id=${BASH_REMATCH[2]} + is_positive_decimal_at_most "$attempt" "$U32_MAX" && is_positive_decimal_at_most "$id" "$U64_MAX" || return 1 + case "$run:$attempt" in + "$SMOKE_RUN_ID:$SMOKE_RUN_ATTEMPT"|"$MERGE_GROUP_RUN_ID:$MERGE_GROUP_RUN_ATTEMPT"|"$PUSH_RUN_ID:$PUSH_RUN_ATTEMPT"|"$LOCK_RUN_ID:$LOCK_RUN_ATTEMPT") return 0 ;; + esac + fi + return 1 ;; + esac + return 1 +} + +allow_request() { + local credential=$1 method=$2 path=$3 page + case "$credential" in + policy) allow_policy_path "$method" "$path" ;; + package) + [[ "$method" == GET ]] || return 1 + case "$path" in + /user|"/orgs/stackpop/memberships/$PACKAGE_AUDITOR_LOGIN"|/orgs/stackpop/packages/container/edgezero-build-app-cli) return 0 ;; + /orgs/stackpop/packages\?package_type=container\&per_page=100\&page=*) page=${path##*page=}; is_positive_decimal_at_most "$page" "$U64_MAX" ;; + *) return 1 ;; + esac ;; + app-jwt) + case "$method:$path" in + GET:/app|"GET:/app/installations/$EXPECTED_INSTALLATION_ID"|"POST:/app/installations/$EXPECTED_INSTALLATION_ID/access_tokens") return 0 ;; + *) return 1 ;; + esac ;; + installation-audit|publisher-probe) + case "$method:$path" in + DELETE:/installation/token) return 0 ;; + GET:/installation/repositories\?per_page=100\&page=*) page=${path##*page=}; is_positive_decimal_at_most "$page" "$U64_MAX" ; return ;; + GET:/repos/stackpop/edgezero) [[ "$credential" == publisher-probe ]] ; return ;; + *) return 1 ;; + esac ;; + *) return 1 ;; + esac +} + +header_value() { + local file=$1 wanted=$2 result_name=$3 count value + count=$(awk -v wanted="$wanted" ' + BEGIN { IGNORECASE=1; count=0 } + { sub(/\r$/, "") } + index(tolower($0), tolower(wanted) ":") == 1 { count++ } + END { print count } + ' "$file") || tool_die 'cannot inspect response headers' + ((count <= 1)) || die "response repeats $wanted header" + value=$(awk -v wanted="$wanted" ' + { sub(/\r$/, "") } + index(tolower($0), tolower(wanted) ":") == 1 { + value=substr($0, index($0, ":") + 1); sub(/^[[:space:]]+/, "", value); sub(/[[:space:]]+$/, "", value); print value + } + ' "$file") || tool_die 'cannot read response header' + printf -v "$result_name" '%s' "$value" +} + +LAST_LINK= +LAST_SCOPES= +api_request() { + local credential=$1 token=$2 method=$3 path=$4 expected_status=$5 result_name=$6 data=${7:-} + local response_body response_headers status count selected content_type link scopes normalized_content_type + allow_request "$credential" "$method" "$path" || die 'API request is outside its credential allowlist' + valid_credential "$token" || die 'API credential has an invalid transport form' + new_temp response_body api-body; new_temp response_headers api-headers + if [[ -n "$data" ]]; then + if ! printf '%s\n' \ + 'header = "Accept: application/vnd.github+json"' \ + 'header = "X-GitHub-Api-Version: 2026-03-10"' \ + 'header = "User-Agent: edgezero-build-container-gate/1"' \ + "header = \"Authorization: Bearer $token\"" | + env -i PATH="$PATH" LC_ALL=C curl --disable --silent --show-error \ + --connect-timeout 10 --max-time 30 --max-redirs 0 --request "$method" \ + --output "$response_body" --dump-header "$response_headers" --config - --data-binary "@$data" \ + "$API$path" 2>/dev/null; then + die 'GitHub API request failed' + fi + else + if ! printf '%s\n' \ + 'header = "Accept: application/vnd.github+json"' \ + 'header = "X-GitHub-Api-Version: 2026-03-10"' \ + 'header = "User-Agent: edgezero-build-container-gate/1"' \ + "header = \"Authorization: Bearer $token\"" | + env -i PATH="$PATH" LC_ALL=C curl --disable --silent --show-error \ + --connect-timeout 10 --max-time 30 --max-redirs 0 --request "$method" \ + --output "$response_body" --dump-header "$response_headers" --config - "$API$path" 2>/dev/null; then + die 'GitHub API request failed' + fi + fi + count=$(grep -Ec '^HTTP/[0-9.]+ [0-9]{3}([^0-9]|$)' "$response_headers" 2>/dev/null || true) + [[ "$count" == 1 ]] || die 'response contains an ambiguous HTTP status history' + status=$(sed -n 's/^HTTP\/[0-9.]* \([0-9][0-9][0-9]\).*$/\1/p' "$response_headers" | tr -d '\r') + [[ "$status" == "$expected_status" ]] || die 'GitHub API response has an unexpected status' + header_value "$response_headers" X-GitHub-Api-Version-Selected selected + [[ "$selected" == "$API_VERSION" ]] || die 'GitHub API selected an unexpected version' + header_value "$response_headers" Content-Type content_type + header_value "$response_headers" Link link + header_value "$response_headers" X-OAuth-Scopes scopes + if [[ "$expected_status" == 204 ]]; then + [[ ! -s "$response_body" ]] || die 'HTTP 204 response contains a body' + else + normalized_content_type=$(printf '%s' "$content_type" | tr '[:upper:]' '[:lower:]') + case "$normalized_content_type" in application/json|'application/json; charset=utf-8') ;; *) die 'GitHub API response has an unexpected media type' ;; esac + safe_jq -e -s 'length == 1' "$response_body" >/dev/null 2>&1 || die 'GitHub API response is not exactly one JSON value' + validate_raw_json_keys "$response_body" 'GitHub API response' + fi + LAST_LINK=$link + LAST_SCOPES=$scopes + printf -v "$result_name" '%s' "$response_body" +} + +policy_get() { api_request policy "$POLICY_TOKEN" GET "$1" 200 "$2"; } +package_get() { + api_request package "$PACKAGE_TOKEN" GET "$1" 200 "$2" + local normalized + normalized=$(printf '%s' "$LAST_SCOPES" | tr ',' '\n' | sed 's/^[[:space:]]*//;s/[[:space:]]*$//' | sed '/^$/d' | sort -u | paste -sd, -) + [[ "$normalized" == read:org,read:packages ]] || die 'package token scopes are not exactly read:org and read:packages' +} + +write_number_stream() { + local body=$1 output=$2 paths numbers path_count number_count + new_temp paths number-paths; new_temp numbers number-tokens + safe_jq -rc --stream 'select(length == 2 and (.[1] | type) == "number") | (.[0] | @json)' "$body" >"$paths" 2>/dev/null || die 'cannot enumerate JSON number paths' + awk ' + BEGIN { in_string=0; escaped=0 } + { + s=$0 + for (i=1; i<=length(s); i++) { + c=substr(s,i,1) + if (in_string) { + if (escaped) { escaped=0; continue } + if (c=="\\") { escaped=1; continue } + if (c=="\"") in_string=0 + continue + } + if (c=="\"") { in_string=1; continue } + if (c ~ /[-0-9]/) { + token=c + for (j=i+1; j<=length(s); j++) { q=substr(s,j,1); if (q !~ /[0-9eE+.-]/) break; token=token q } + print token; i=j-1 + } + } + } + END { if (in_string || escaped) exit 1 } + ' "$body" >"$numbers" || die 'cannot preserve raw JSON numbers' + path_count=$(wc -l <"$paths" | tr -d '[:space:]'); number_count=$(wc -l <"$numbers" | tr -d '[:space:]') + [[ "$path_count" == "$number_count" ]] || die 'JSON numeric token stream is ambiguous' + paste "$paths" "$numbers" >"$output" || tool_die 'cannot pair JSON number tokens' +} + +number_at_path() { + local body=$1 path=$2 maximum=$3 result_name=$4 allow_zero=${5:-false} stream matches matches_count raw_value + new_temp stream number-stream; new_temp matches number-match + write_number_stream "$body" "$stream" + awk -F '\t' -v wanted="$path" '$1 == wanted { print $2 }' "$stream" >"$matches" || die 'cannot select JSON number' + matches_count=$(wc -l <"$matches" | tr -d '[:space:]') + [[ "$matches_count" == 1 ]] || die "JSON number is missing or duplicated at $path" + raw_value=$(<"$matches") + if [[ "$allow_zero" == true ]]; then + is_nonnegative_decimal_at_most "$raw_value" "$maximum" || die "JSON number is not a canonical nonnegative integer at $path" + else + is_positive_decimal_at_most "$raw_value" "$maximum" || die "JSON number is not a canonical positive integer at $path" + fi + printf -v "$result_name" '%s' "$raw_value" +} + +validate_link() { + local link=$1 base=$2 page=$3 expect_next=$4 declared_total=$5 item_count=$6 result_name=$7 + local part target rel target_page seen='|' canonical_without_page expected_without_page last_page observed_last='' + if [[ -z "$link" ]]; then + [[ "$expect_next" == false ]] || die 'paginated response omits required next relation' + printf -v "$result_name" '%s' '' + return + fi + while [[ -n "$link" ]]; do + part=${link%%,*}; if [[ "$link" == *,* ]]; then link=${link#*,}; else link=; fi + part=${part#"${part%%[![:space:]]*}"}; part=${part%"${part##*[![:space:]]}"} + [[ "$part" =~ ^\<(https://api\.github\.com[^\>]*)\>\;[[:space:]]rel=\"(next|prev|first|last)\"$ ]] || die 'Link header relation is malformed' + target=${BASH_REMATCH[1]}; rel=${BASH_REMATCH[2]} + case "$seen" in *"|$rel|"*) die 'Link header repeats a relation' ;; esac; seen="$seen$rel|" + [[ "$target" =~ ^(https://api\.github\.com.*page=)([1-9][0-9]*)$ ]] || die 'Link target page is not canonical' + target_page=${BASH_REMATCH[2]}; canonical_without_page=${BASH_REMATCH[1]} + is_positive_decimal_at_most "$target_page" "$MAX_PAGES" || die 'Link target page exceeds the pagination bound' + expected_without_page="https://api.github.com${base%page=*}page=" + [[ "$canonical_without_page" == "$expected_without_page" ]] || die 'Link target changes path or non-page query bytes' + case "$rel" in + next) ((target_page == page + 1)) || die 'Link next page is not successive' ;; + prev) ((page > 1 && target_page == page - 1)) || die 'Link previous page is invalid' ;; + first) [[ "$target_page" == 1 ]] || die 'Link first page is invalid' ;; + last) + if [[ -n "$declared_total" ]]; then last_page=$(((declared_total + 99) / 100)); ((last_page > 0)) || last_page=1; [[ "$target_page" == "$last_page" ]] || die 'Link last page is inconsistent'; fi + observed_last=$target_page ;; + esac + done + case "$seen" in *'|next|'*) [[ "$expect_next" == true ]] || die 'Link contains next after list completion' ;; *) [[ "$expect_next" == false ]] || die 'Link omits next before list completion' ;; esac + ((item_count <= 100)) || die 'page exceeds the item bound' + printf -v "$result_name" '%s' "$observed_last" +} + +paginate() { + local credential=$1 base=$2 key=$3 declared=$4 result_name=$5 + local page=1 total_seen=0 expected_total='' item_count expect_next response combined next_combined digest page_digests='|' ids='|' id lines total_value + local linked_last='' page_last='' + new_temp combined pagination-combined; printf '[]' >"$combined" + while ((page <= MAX_PAGES)); do + case "$credential" in + policy) policy_get "${base}page=$page" response ;; + package) package_get "${base}page=$page" response ;; + installation-audit) api_request installation-audit "$AUDIT_TOKEN" GET "${base}page=$page" 200 response ;; + publisher-probe) api_request publisher-probe "$PROBE_TOKEN" GET "${base}page=$page" 200 response ;; + *) die 'pagination credential is invalid' ;; + esac + if [[ -z "$key" ]]; then + safe_jq -e 'type == "array"' "$response" >/dev/null 2>&1 || die 'paginated response is not an array' + item_count=$(safe_jq -r 'length' "$response") + new_temp next_combined pagination-next + safe_jq -c -s '.[0] + .[1]' "$combined" "$response" >"$next_combined" || die 'cannot combine paginated response' + else + safe_jq -e --arg key "$key" '.[$key] | type == "array"' "$response" >/dev/null 2>&1 || die 'paginated response has the wrong collection shape' + item_count=$(safe_jq -r --arg key "$key" '.[$key] | length' "$response") + if [[ "$declared" == true ]]; then + safe_jq -e '.total_count | type == "number"' "$response" >/dev/null 2>&1 || die 'paginated response omits total_count' + number_at_path "$response" '["total_count"]' "$U64_MAX" total_value true + if [[ -z "$expected_total" ]]; then expected_total=$total_value; else [[ "$expected_total" == "$total_value" ]] || die 'paginated total_count changed'; fi + fi + new_temp next_combined pagination-next + safe_jq -c -s --arg key "$key" '.[0] + .[1][$key]' "$combined" "$response" >"$next_combined" || die 'cannot combine paginated response' + fi + if [[ ! "$item_count" =~ ^[0-9]+$ ]] || ((item_count > 100)); then die 'paginated page size is invalid'; fi + digest=$(hash_file "$response"); case "$page_digests" in *"|$digest|"*) die 'paginated response repeats a page payload' ;; esac; page_digests="$page_digests$digest|" + lines=$(safe_jq -r "${key:+.\"$key\" | }.[] | if (.id | type) == \"number\" then (.id | tostring) else error(\"id\") end" "$response" 2>/dev/null) || die 'paginated item id is missing or mistyped' + while IFS= read -r id || [[ -n "$id" ]]; do + [[ -z "$id" ]] && continue + is_positive_decimal_at_most "$id" "$U64_MAX" || die 'paginated item id is invalid' + case "$ids" in *"|$id|"*) die 'paginated response repeats an item id' ;; esac; ids="$ids$id|" + done <<<"$lines" + mv "$next_combined" "$combined" || tool_die 'cannot advance pagination state' + total_seen=$((total_seen + item_count)); ((total_seen <= MAX_ITEMS)) || die 'paginated result exceeds item bound' + if [[ -n "$expected_total" ]]; then + ((total_seen <= expected_total)) || die 'paginated result exceeds total_count' + if ((total_seen < expected_total)); then expect_next=true; else expect_next=false; fi + elif ((item_count == 100)); then expect_next=true + else expect_next=false + fi + ((page < MAX_PAGES)) || { [[ "$expect_next" == false && "$item_count" -lt 100 ]] || die 'pagination is truncated at page 100'; } + page_last='' + validate_link "$LAST_LINK" "$base" "$page" "$expect_next" "$expected_total" "$item_count" page_last + if [[ -n "$page_last" ]]; then + if [[ -z "$linked_last" ]]; then linked_last=$page_last + elif [[ "$linked_last" != "$page_last" ]]; then die 'Link last relation changed between pages' + fi + fi + [[ "$expect_next" == true ]] || break + page=$((page + 1)) + done + [[ -z "$expected_total" || "$total_seen" == "$expected_total" ]] || die 'paginated result is incomplete' + [[ -z "$linked_last" || "$linked_last" == "$page" ]] || die 'Link last relation does not identify the completed page' + printf -v "$result_name" '%s' "$combined" +} + +one_page_list() { + local path=$1 key=$2 expected_count=$3 result_name=$4 response count + policy_get "$path" response + [[ -z "$LAST_LINK" ]] || die 'one-page endpoint returned a continuation' + safe_jq -e --arg key "$key" '.[$key] | type == "array"' "$response" >/dev/null 2>&1 || die 'one-page response has the wrong shape' + number_at_path "$response" '["total_count"]' "$U64_MAX" count + [[ "$count" == "$expected_count" ]] || die 'one-page response total_count differs' + [[ "$(safe_jq -r --arg key "$key" '.[$key] | length' "$response")" == "$expected_count" ]] || die 'one-page response length differs' + printf -v "$result_name" '%s' "$response" +} + +extract_variable() { + local body=$1 name=$2 result_name=$3 extracted + extracted=$(safe_jq -er --arg name "$name" 'select(type == "object" and .name == $name and (.value | type) == "string" and ((.value | test("[\\r\\n]")) | not)) | .value' "$body" 2>/dev/null) || + die "repository variable $name is invalid" + printf -v "$result_name" '%s' "$extracted" +} + +verify_named_time_record() { + local body=$1 name=$2 expected_value=$3 result_name=$4 value created updated created_epoch updated_epoch canonical + safe_jq -e --arg name "$name" 'type == "object" and .name == $name and (.created_at | type) == "string" and (.updated_at | type) == "string" and (.value | type) == "string"' "$body" >/dev/null 2>&1 || + die "environment variable $name record is invalid" + value=$(safe_jq -er '.value' "$body"); [[ -z "$expected_value" || "$value" == "$expected_value" ]] || die "environment variable $name differs" + created=$(safe_jq -er '.created_at' "$body"); updated=$(safe_jq -er '.updated_at' "$body") + validate_utc "$created" "$name creation time" created_epoch; validate_utc "$updated" "$name update time" updated_epoch + ((created_epoch <= updated_epoch)) || die "environment variable $name timestamps are inconsistent" + canonical=$(safe_jq -cnS --arg name "$name" --arg value "$value" --arg created "$created" --arg updated "$updated" '{created_at:$created,name:$name,updated_at:$updated,value:$value}') + printf -v "$result_name" '%s' "$canonical" +} + +verify_secret_time_record() { + local body=$1 name=$2 result_name=$3 created updated created_epoch updated_epoch canonical + safe_jq -e --arg name "$name" 'type == "object" and .name == $name and (.created_at | type) == "string" and (.updated_at | type) == "string" and (has("value") | not)' "$body" >/dev/null 2>&1 || + die "environment secret $name metadata is invalid" + created=$(safe_jq -er '.created_at' "$body"); updated=$(safe_jq -er '.updated_at' "$body") + validate_utc "$created" "$name creation time" created_epoch; validate_utc "$updated" "$name update time" updated_epoch + ((created_epoch <= updated_epoch)) || die "environment secret $name timestamps are inconsistent" + canonical=$(safe_jq -cnS --arg name "$name" --arg created "$created" --arg updated "$updated" '{created_at:$created,name:$name,updated_at:$updated}') + printf -v "$result_name" '%s' "$canonical" +} + +validate_record_string() { + local record=$1 expected_gate=$2 result_name=$3 file canonical state source source_pr evidence_url rotation previous evidence + new_temp file prerequisite-record; printf '%s' "$record" >"$file" + validate_raw_json_keys "$file" 'publisher prerequisite' + safe_jq -e ' + type == "object" + and keys == ["evidence-sha256","evidence-url","gate-sha","previous-value-sha256","rotation-history","schema-version","source-pr","source-revision"] + and ."schema-version" == 2 and (."schema-version" | type) == "number" + and (."evidence-sha256" | type) == "string" and (."gate-sha" | type) == "string" + and ((."previous-value-sha256" == null) or ((."previous-value-sha256" | type) == "string")) + and ((."source-revision" == null) or ((."source-revision" | type) == "string")) + and ((."source-pr" == null) or ((."source-pr" | type) == "string")) + and ((."evidence-url" == null) or ((."evidence-url" | type) == "string")) + and ((."rotation-history" == {"state":"bootstrap-no-rotation"}) or + ((."rotation-history" | type) == "object" + and (."rotation-history" | keys) == ["created-at","evidence-sha256","history-sha256","run-attempt","run-id","run-number","state"] + and ."rotation-history".state == "verified" + and all(."rotation-history"."created-at",."rotation-history"."evidence-sha256",."rotation-history"."history-sha256",."rotation-history"."run-attempt",."rotation-history"."run-id",."rotation-history"."run-number"; type == "string"))) + ' "$file" >/dev/null 2>&1 || die 'publisher prerequisite has the wrong schema-version-2 shape' + canonical=$(safe_jq -cS . "$file" 2>/dev/null) || die 'cannot canonicalize publisher prerequisite' + [[ "$canonical" == "$record" ]] || die 'publisher prerequisite is not exact JCS' + evidence=$(safe_jq -er '."evidence-sha256"' "$file"); [[ "$evidence" =~ ^sha256:[0-9a-f]{64}$ ]] || die 'publisher prerequisite evidence digest is invalid' + gate=$(safe_jq -er '."gate-sha"' "$file"); is_sha "$gate" || die 'publisher prerequisite gate SHA is invalid' + [[ -z "$expected_gate" || "$gate" == "$expected_gate" ]] || die 'publisher prerequisite gate SHA differs' + previous=$(safe_jq -r 'if ."previous-value-sha256" == null then "null" else ."previous-value-sha256" end' "$file") + [[ "$previous" == null || "$previous" =~ ^sha256:[0-9a-f]{64}$ ]] || die 'publisher prerequisite predecessor digest is invalid' + source=$(safe_jq -r 'if ."source-revision" == null then "null" else ."source-revision" end' "$file") + source_pr=$(safe_jq -r 'if ."source-pr" == null then "null" else ."source-pr" end' "$file") + evidence_url=$(safe_jq -r 'if ."evidence-url" == null then "null" else ."evidence-url" end' "$file") + if [[ "$source" == null ]]; then + [[ "$source_pr" == null && "$evidence_url" == null ]] || die 'publisher prerequisite source tuple is partial' + else + is_sha "$source" || die 'publisher prerequisite source SHA is invalid' + is_positive_decimal_at_most "$source_pr" "$U64_MAX" || die 'publisher prerequisite source PR is invalid' + [[ "$evidence_url" =~ ^https://github\.com/stackpop/edgezero/pull/([1-9][0-9]*)#issuecomment-([1-9][0-9]*)$ ]] || die 'publisher prerequisite evidence URL is invalid' + if [[ "${BASH_REMATCH[1]}" != "$source_pr" ]] || ! is_positive_decimal_at_most "${BASH_REMATCH[2]}" "$U64_MAX"; then + die 'publisher prerequisite evidence URL identity differs' + fi + commit_exists "$source" || die 'publisher prerequisite source commit is unavailable' + require_ancestor "$gate" "$source" 'publisher prerequisite source' + fi + state=$(safe_jq -er '."rotation-history".state' "$file") + if [[ "$state" == verified ]]; then + rotation=$(safe_jq -cS '."rotation-history"' "$file") + created=$(safe_jq -er '."created-at"' <<<"$rotation"); validate_utc "$created" 'rotation history creation time' unused_epoch + for pair in "run-attempt:$U32_MAX" "run-id:$U64_MAX" "run-number:$U64_MAX"; do + name=${pair%%:*}; maximum=${pair#*:}; value=$(safe_jq -er --arg name "$name" '.[$name]' <<<"$rotation") + is_positive_decimal_at_most "$value" "$maximum" || die "rotation history $name is invalid" + done + for name in evidence-sha256 history-sha256; do value=$(safe_jq -er --arg name "$name" '.[$name]' <<<"$rotation"); [[ "$value" =~ ^sha256:[0-9a-f]{64}$ ]] || die "rotation history $name is invalid"; done + else + rotation='{"state":"bootstrap-no-rotation"}' + fi + printf -v "$result_name" '%s' "$rotation" +} + +validate_manifest_and_codeowners() { + local gate=$1 main=$2 manifest entry size previous='' path line expected old_entry new_entry + new_temp manifest gate-manifest + repo_git show "$gate:$GATE_MANIFEST" >"$manifest" 2>/dev/null || die 'gate path manifest is absent' + size=$(file_size "$manifest"); ((size > 0 && size <= 65536)) || die 'gate path manifest size is invalid' + [[ "$(tail -c 1 "$manifest" | od -An -tuC | tr -d ' ')" == 10 ]] || die 'gate path manifest must end in one LF' + while IFS= read -r path; do + [[ -n "$path" && "$path" =~ ^[A-Za-z0-9._/+-]+$ && "$path" != /* && "$path" != -* && "$path" != *//* && "$path" != */../* ]] || die 'gate path manifest contains an invalid path' + [[ -z "$previous" || "$previous" < "$path" ]] || die 'gate path manifest is not uniquely sorted' + previous=$path + entry=$(repo_git ls-tree "$gate" -- "$path") || die 'cannot inspect gate path' + [[ "$entry" =~ ^100(644|755)[[:space:]]blob[[:space:]][0-9a-f]{40,64}$'\t'"$path"$ ]] || die 'gate path is not a regular blob' + line="/$path @stackpop/edgezero-build-container-gate-reviewers" + [[ "$(repo_git show "$gate:$CODEOWNERS" | grep -Fxc "$line")" == 1 ]] || die 'CODEOWNERS does not exactly cover a gate path' + if [[ -n "$main" ]]; then + old_entry=$(repo_git ls-tree "$gate" -- "$path"); new_entry=$(repo_git ls-tree "$main" -- "$path") + [[ "$old_entry" == "$new_entry" ]] || die 'protected main gate path differs from active gate' + fi + done <"$manifest" +} + +verify_policy_actor() { + local body user_id + policy_get /user body + safe_jq -e --arg login "$POLICY_LOGIN" 'type == "object" and .login == $login and .type == "User" and (.id | type) == "number"' "$body" >/dev/null 2>&1 || die 'policy token identity is invalid' + number_at_path "$body" '["id"]' "$U64_MAX" user_id + policy_get "/orgs/stackpop/memberships/$POLICY_LOGIN" body + safe_jq -e '.state == "active" and .role == "admin"' "$body" >/dev/null 2>&1 || die 'policy actor is not an active organization owner' + policy_get "/orgs/stackpop/teams/edgezero-build-container-releasers/memberships/$POLICY_LOGIN" body + safe_jq -e '.state == "active" and (.role == "member" or .role == "maintainer")' "$body" >/dev/null 2>&1 || die 'policy actor is not an active releaser' + POLICY_USER_ID=$user_id +} + +verify_rulesets() { + local final_gate=$1 body list ids id detail detail_id name include org_count=0 main_count=0 image_create=0 image_immutable=0 action_create=0 action_immutable=0 pin_count=0 + paginate policy '/orgs/stackpop/rulesets?per_page=100&' '' false list + ids=$(safe_jq -r '.[] | select((.id|type)=="number") | .id | tostring' "$list") + while IFS= read -r id || [[ -n "$id" ]]; do [[ -z "$id" ]] && continue; is_positive_decimal_at_most "$id" "$U64_MAX" || die 'organization ruleset id is invalid'; case "$ORG_RULESET_IDS" in *"|$id|"*) die 'organization ruleset id repeats' ;; esac; ORG_RULESET_IDS="$ORG_RULESET_IDS$id|"; done <<<"$ids" + while IFS= read -r id || [[ -n "$id" ]]; do + [[ -z "$id" ]] && continue; policy_get "/orgs/stackpop/rulesets/$id" detail + number_at_path "$detail" '["id"]' "$U64_MAX" detail_id; [[ "$detail_id" == "$id" ]] || die 'organization ruleset detail id differs' + name=$(safe_jq -er '.name' "$detail" 2>/dev/null) || die 'organization ruleset name is invalid' + if [[ "$name" == edgezero-build-container-required-workflow ]]; then + safe_jq -e --arg gate "$final_gate" --argjson repo "$REPOSITORY_ID" ' + .source_type == "Organization" and .source == "stackpop" and .target == "branch" and .enforcement == "active" + and .bypass_actors == [] + and .conditions == {"ref_name":{"exclude":[],"include":["refs/heads/main"]},"repository_id":{"repository_ids":[$repo]}} + and .rules == [{"parameters":{"do_not_enforce_on_create":false,"workflows":[{"path":".github/workflows/build-container-ci.yml","repository_id":$repo,"sha":$gate}]},"type":"workflows"}] + ' "$detail" >/dev/null 2>&1 || die 'required-workflow ruleset differs from the exact contract' + REQUIRED_WORKFLOW_ID=$id; org_count=$((org_count + 1)) + fi + done <<<"$ids" + ((org_count == 1)) || die 'required-workflow ruleset is missing or duplicated' + + paginate policy '/repos/stackpop/edgezero/rulesets?per_page=100&' '' false list + ids=$(safe_jq -r '.[] | select((.id|type)=="number") | .id | tostring' "$list") + while IFS= read -r id || [[ -n "$id" ]]; do [[ -z "$id" ]] && continue; is_positive_decimal_at_most "$id" "$U64_MAX" || die 'repository ruleset id is invalid'; case "$REPO_RULESET_IDS" in *"|$id|"*) die 'repository ruleset id repeats' ;; esac; REPO_RULESET_IDS="$REPO_RULESET_IDS$id|"; done <<<"$ids" + while IFS= read -r id || [[ -n "$id" ]]; do + [[ -z "$id" ]] && continue; policy_get "/repos/stackpop/edgezero/rulesets/$id" detail + number_at_path "$detail" '["id"]' "$U64_MAX" detail_id; [[ "$detail_id" == "$id" ]] || die 'repository ruleset detail id differs' + name=$(safe_jq -er '.name' "$detail" 2>/dev/null) || die 'repository ruleset name is invalid' + case "$name" in + edgezero-build-container-main) + safe_jq -e ' + .source_type == "Repository" and .source == "stackpop/edgezero" and .target == "branch" and .enforcement == "active" and .bypass_actors == [] + and .conditions == {"ref_name":{"exclude":[],"include":["refs/heads/main"]}} + and .rules == [ + {"parameters":{"allowed_merge_methods":["squash"],"dismiss_stale_reviews_on_push":true,"require_code_owner_review":true,"require_last_push_approval":true,"required_approving_review_count":2,"required_review_thread_resolution":true},"type":"pull_request"}, + {"parameters":{"check_response_timeout_minutes":60,"grouping_strategy":"ALLGREEN","max_entries_to_build":1,"max_entries_to_merge":1,"merge_method":"SQUASH","min_entries_to_merge":1,"min_entries_to_merge_wait_minutes":0},"type":"merge_queue"}] + ' "$detail" >/dev/null 2>&1 || die 'protected-main ruleset differs from the exact contract'; MAIN_RULESET_ID=$id; main_count=$((main_count + 1)) ;; + edgezero-build-container-tag-creation|edgezero-action-version-tag-creation) + if [[ "$name" == edgezero-build-container-tag-creation ]]; then include='refs/tags/build-container-v*'; image_create=$((image_create + 1)); IMAGE_CREATION_ID=$id; else include='refs/tags/v*'; action_create=$((action_create + 1)); ACTION_CREATION_ID=$id; fi + safe_jq -e --arg include "$include" --argjson team "$ACTIVE_TEAM_ID" '.source_type == "Repository" and .source == "stackpop/edgezero" and .target == "tag" and .enforcement == "active" and .conditions == {"ref_name":{"exclude":[],"include":[$include]}} and .bypass_actors == [{"actor_id":$team,"actor_type":"Team","bypass_mode":"always"}] and .rules == [{"type":"creation"}]' "$detail" >/dev/null 2>&1 || die "$name differs from the exact contract" ;; + edgezero-build-container-tag-immutability|edgezero-action-version-tag-immutability) + if [[ "$name" == edgezero-build-container-tag-immutability ]]; then include='refs/tags/build-container-v*'; image_immutable=$((image_immutable + 1)); IMAGE_IMMUTABILITY_ID=$id; else include='refs/tags/v*'; action_immutable=$((action_immutable + 1)); ACTION_IMMUTABILITY_ID=$id; fi + safe_jq -e --arg include "$include" '.source_type == "Repository" and .source == "stackpop/edgezero" and .target == "tag" and .enforcement == "active" and .conditions == {"ref_name":{"exclude":[],"include":[$include]}} and .bypass_actors == [] and .rules == [{"parameters":{"update_allows_fetch_and_merge":false},"type":"update"},{"type":"deletion"}]' "$detail" >/dev/null 2>&1 || die "$name differs from the exact contract" ;; + edgezero-build-container-pin-branches) + safe_jq -e --argjson app "$ACTIVE_APP_ID" '.source_type == "Repository" and .source == "stackpop/edgezero" and .target == "branch" and .enforcement == "active" and .conditions == {"ref_name":{"exclude":[],"include":["refs/heads/edgezero-build-container-pin/*"]}} and .bypass_actors == [{"actor_id":$app,"actor_type":"Integration","bypass_mode":"always"}] and .rules == [{"type":"creation"},{"parameters":{"update_allows_fetch_and_merge":false},"type":"update"},{"type":"deletion"}]' "$detail" >/dev/null 2>&1 || die 'pin-branch ruleset differs from the exact contract'; PIN_RULESET_ID=$id; pin_count=$((pin_count + 1)) ;; + esac + done <<<"$ids" + ((main_count == 1 && image_create == 1 && image_immutable == 1 && action_create == 1 && action_immutable == 1 && pin_count == 1)) || die 'required repository rulesets are missing or duplicated' +} + +ACTIVE_CANDIDATE_PR=$CANDIDATE_PR +ACTIVE_CANDIDATE_HEAD= +ACTIVE_BOT_LOGIN=$EXPECTED_BOT_LOGIN +ACTIVE_APP_ID=$EXPECTED_APP_ID +ACTIVE_TEAM_ID=$EXPECTED_TEAM_ID +REPOSITORY_ID= +REQUIRED_WORKFLOW_ID= +MAIN_RULESET_ID= +IMAGE_CREATION_ID= +IMAGE_IMMUTABILITY_ID= +ACTION_CREATION_ID= +ACTION_IMMUTABILITY_ID= +PIN_RULESET_ID= +CURRENT_PREREQUISITE= +CURRENT_ROTATION_HISTORY= +MAIN_SHA= +ENV_SNAPSHOTS= +IMMUTABLE_ENFORCED_BY_OWNER= + +verify_common_policy() { + local final_gate=$1 prerequisite_gate=$2 body value policies protection app_record installation_record team_record secret_record bot_id main_ref + verify_policy_actor + + policy_get /orgs/stackpop/actions/permissions body + safe_jq -e 'type == "object" and .sha_pinning_required == false and (.sha_pinning_required | type) == "boolean"' "$body" >/dev/null 2>&1 || die 'organization Actions SHA-pinning policy differs' + policy_get /repos/stackpop/edgezero/actions/permissions body + safe_jq -e 'type == "object" and .sha_pinning_required == false and (.sha_pinning_required | type) == "boolean"' "$body" >/dev/null 2>&1 || die 'repository Actions SHA-pinning policy differs' + + policy_get /repos/stackpop/edgezero body + safe_jq -e 'type == "object" and .full_name == "stackpop/edgezero" and .owner.login == "stackpop" and .default_branch == "main" and .archived == false and (.id | type) == "number"' "$body" >/dev/null 2>&1 || die 'repository identity is invalid' + number_at_path "$body" '["id"]' "$U64_MAX" REPOSITORY_ID + + policy_get /repos/stackpop/edgezero/environments/build-container-release/variables/EDGEZERO_BUILD_CONTAINER_APP_ID body + verify_named_time_record "$body" EDGEZERO_BUILD_CONTAINER_APP_ID "$EXPECTED_APP_ID" app_record + ACTIVE_APP_ID=$(safe_jq -er '.value' "$body"); is_positive_decimal_at_most "$ACTIVE_APP_ID" "$U64_MAX" || die 'environment App id is invalid' + policy_get /repos/stackpop/edgezero/environments/build-container-release/variables/EDGEZERO_BUILD_CONTAINER_APP_INSTALLATION_ID body + verify_named_time_record "$body" EDGEZERO_BUILD_CONTAINER_APP_INSTALLATION_ID "$EXPECTED_INSTALLATION_ID" installation_record + ACTIVE_INSTALLATION_ID=$(safe_jq -er '.value' "$body"); is_positive_decimal_at_most "$ACTIVE_INSTALLATION_ID" "$U64_MAX" || die 'environment installation id is invalid' + policy_get /repos/stackpop/edgezero/environments/build-container-release/variables/EDGEZERO_BUILD_CONTAINER_RELEASE_TEAM_ID body + verify_named_time_record "$body" EDGEZERO_BUILD_CONTAINER_RELEASE_TEAM_ID "$EXPECTED_TEAM_ID" team_record + ACTIVE_TEAM_ID=$(safe_jq -er '.value' "$body"); is_positive_decimal_at_most "$ACTIVE_TEAM_ID" "$U64_MAX" || die 'environment team id is invalid' + policy_get /repos/stackpop/edgezero/environments/build-container-release/secrets/EDGEZERO_BUILD_CONTAINER_APP_PRIVATE_KEY body + verify_secret_time_record "$body" EDGEZERO_BUILD_CONTAINER_APP_PRIVATE_KEY secret_record + ENV_SNAPSHOTS=$(safe_jq -cnS --argjson app "$app_record" --argjson installation "$installation_record" --argjson team "$team_record" --argjson secret "$secret_record" '[$app,$installation,$team,$secret]') + + policy_get /repos/stackpop/edgezero/actions/variables/EDGEZERO_BUILD_CONTAINER_GATE_SHA body + extract_variable "$body" EDGEZERO_BUILD_CONTAINER_GATE_SHA value + [[ "$value" == "$final_gate" ]] || die 'active gate variable differs from final gate' + policy_get /repos/stackpop/edgezero/actions/variables/EDGEZERO_BUILD_CONTAINER_RELEASE_STATE body + extract_variable "$body" EDGEZERO_BUILD_CONTAINER_RELEASE_STATE value + [[ "$value" == enabled ]] || die 'build-container release state is not enabled' + policy_get /repos/stackpop/edgezero/actions/variables/EDGEZERO_BUILD_CONTAINER_PUBLISHER_APP_ID body + extract_variable "$body" EDGEZERO_BUILD_CONTAINER_PUBLISHER_APP_ID value + [[ "$value" == "$ACTIVE_APP_ID" ]] || die 'publisher App variable differs from environment App id' + policy_get /repos/stackpop/edgezero/actions/variables/EDGEZERO_BUILD_CONTAINER_PUBLISHER_BOT_ID body + extract_variable "$body" EDGEZERO_BUILD_CONTAINER_PUBLISHER_BOT_ID ACTIVE_BOT_ID + is_positive_decimal_at_most "$ACTIVE_BOT_ID" "$U64_MAX" || die 'publisher bot id is invalid' + [[ -z "$EXPECTED_BOT_ID" || "$ACTIVE_BOT_ID" == "$EXPECTED_BOT_ID" ]] || die 'publisher bot id differs from expected id' + policy_get /repos/stackpop/edgezero/actions/variables/EDGEZERO_BUILD_CONTAINER_PUBLISHER_BOT_LOGIN body + extract_variable "$body" EDGEZERO_BUILD_CONTAINER_PUBLISHER_BOT_LOGIN ACTIVE_BOT_LOGIN + if ! is_login "$ACTIVE_BOT_LOGIN" || [[ "$ACTIVE_BOT_LOGIN" != *'[bot]' ]]; then die 'publisher bot login is invalid'; fi + [[ -z "$EXPECTED_BOT_LOGIN" || "$ACTIVE_BOT_LOGIN" == "$EXPECTED_BOT_LOGIN" ]] || die 'publisher bot login differs from expected login' + policy_get /repos/stackpop/edgezero/actions/variables/EDGEZERO_BUILD_CONTAINER_PUBLISHER_PREREQUISITE body + extract_variable "$body" EDGEZERO_BUILD_CONTAINER_PUBLISHER_PREREQUISITE CURRENT_PREREQUISITE + validate_record_string "$CURRENT_PREREQUISITE" "$prerequisite_gate" CURRENT_ROTATION_HISTORY + + verify_rulesets "$final_gate" + + policy_get /repos/stackpop/edgezero/environments/build-container-release body + safe_jq -e --argjson team "$ACTIVE_TEAM_ID" ' + type == "object" + and .deployment_branch_policy == {"custom_branch_policies":true,"protected_branches":false} + and (.protection_rules | type) == "array" and (.protection_rules | length) == 1 + and .protection_rules[0].type == "required_reviewers" + and .protection_rules[0].prevent_self_review == true + and (.protection_rules[0].reviewers | type) == "array" and (.protection_rules[0].reviewers | length) > 0 + and any(.protection_rules[0].reviewers[]; .type == "Team" and .reviewer.id == $team) + ' "$body" >/dev/null 2>&1 || die 'release environment reviewer policy differs' + one_page_list '/repos/stackpop/edgezero/environments/build-container-release/deployment-branch-policies?per_page=100&page=1' branch_policies 1 policies + safe_jq -e '.branch_policies == [{"id":31,"name":"build-container-v*","type":"tag"}] or (.branch_policies | length == 1 and .[0].name == "build-container-v*" and .[0].type == "tag" and (.[0].id | type) == "number")' "$policies" >/dev/null 2>&1 || die 'release environment tag policy differs' + number_at_path "$policies" '["branch_policies",0,"id"]' "$U64_MAX" value + policy_get /repos/stackpop/edgezero/environments/build-container-release/deployment_protection_rules protection + safe_jq -e 'type == "object" and .total_count == 0 and (.total_count | type) == "number" and .custom_deployment_protection_rules == []' "$protection" >/dev/null 2>&1 || die 'release environment has a custom protection rule' + number_at_path "$protection" '["total_count"]' "$U64_MAX" value true; [[ "$value" == 0 ]] || die 'release environment protection-rule count differs' + + policy_get /repos/stackpop/edgezero/immutable-releases body + safe_jq -e 'type == "object" and .enabled == true and (.enabled | type) == "boolean" and (.enforced_by_owner | type) == "boolean"' "$body" >/dev/null 2>&1 || die 'immutable releases are not enabled' + IMMUTABLE_ENFORCED_BY_OWNER=$(safe_jq -r '.enforced_by_owner' "$body") + + policy_get "/users/$ACTIVE_BOT_LOGIN" body + safe_jq -e --arg login "$ACTIVE_BOT_LOGIN" --argjson id "$ACTIVE_BOT_ID" 'type == "object" and .login == $login and .id == $id and .type == "Bot"' "$body" >/dev/null 2>&1 || die 'publisher bot public identity differs' + number_at_path "$body" '["id"]' "$U64_MAX" bot_id + [[ "$bot_id" == "$ACTIVE_BOT_ID" ]] || die 'publisher bot numeric identity differs' + + policy_get /repos/stackpop/edgezero/git/ref/heads/main main_ref + safe_jq -e 'type == "object" and .ref == "refs/heads/main" and .object.type == "commit" and (.object.sha | type) == "string"' "$main_ref" >/dev/null 2>&1 || die 'protected-main ref response is invalid' + MAIN_SHA=$(safe_jq -er '.object.sha' "$main_ref") + if ! is_sha "$MAIN_SHA" || ! commit_exists "$MAIN_SHA"; then die 'protected-main SHA is unavailable'; fi + require_ancestor "$final_gate" "$MAIN_SHA" 'protected main' + validate_manifest_and_codeowners "$final_gate" "$MAIN_SHA" +} + +verify_exact_check_run() { + local sha=$1 name=$2 expected_run=${3:-} body id app_id + one_page_list "/repos/stackpop/edgezero/commits/$sha/check-runs?check_name=$name&filter=latest&app_id=15368&per_page=100&page=1" check_runs 1 body + safe_jq -e --arg sha "$sha" --arg name "$name" --arg run "$expected_run" ' + .check_runs[0].name == $name and .check_runs[0].head_sha == $sha + and .check_runs[0].status == "completed" and .check_runs[0].conclusion == "success" + and .check_runs[0].app.id == 15368 + and ($run == "" or .check_runs[0].details_url == ("https://github.com/stackpop/edgezero/actions/runs/" + $run)) + ' "$body" >/dev/null 2>&1 || die "$name check-run identity differs" + number_at_path "$body" '["check_runs",0,"id"]' "$U64_MAX" id + number_at_path "$body" '["check_runs",0,"app","id"]' "$U64_MAX" app_id; [[ "$app_id" == 15368 ]] || die "$name check-run App id differs" +} + +verify_run_detail() { + local run_id=$1 attempt=$2 event=$3 head=$4 path=$5 result_name=$6 response api_id api_attempt + policy_get "/repos/stackpop/edgezero/actions/runs/$run_id" response + safe_jq -e --arg event "$event" --arg head "$head" --arg path "$path" ' + type == "object" and .event == $event and .head_sha == $head and .path == $path + and .status == "completed" and .conclusion == "success" + and (.created_at | type) == "string" and (.id | type) == "number" and (.run_attempt | type) == "number" + ' "$response" >/dev/null 2>&1 || die 'workflow run identity or result differs' + number_at_path "$response" '["id"]' "$U64_MAX" api_id; [[ "$api_id" == "$run_id" ]] || die 'workflow run id differs' + number_at_path "$response" '["run_attempt"]' "$U32_MAX" api_attempt; [[ "$api_attempt" == "$attempt" ]] || die 'workflow run attempt differs' + printf -v "$result_name" '%s' "$response" +} + +verify_two_stable_jobs() { + local run_id=$1 attempt=$2 head=$3 required_step=$4 body + one_page_list "/repos/stackpop/edgezero/actions/runs/$run_id/attempts/$attempt/jobs?per_page=100&page=1" jobs 2 body + safe_jq -e --arg head "$head" --arg step "$required_step" ' + ([.jobs[].name] | sort) == ["build-container-local","build-container-pin"] + and all(.jobs[]; .head_sha == $head and .conclusion == "success") + and ($step == "" or all(.jobs[]; ([.steps[] | select(.name == $step and .conclusion == "success")] | length) == 1)) + ' "$body" >/dev/null 2>&1 || die 'stable required jobs differ from the exact contract' +} + +verify_smoke() { + local run body title candidate_pr candidate_head created created_epoch snapshots updated completed completed_epoch + policy_get "/repos/stackpop/edgezero/actions/runs/$SMOKE_RUN_ID" run + safe_jq -e ' + type == "object" and .event == "workflow_dispatch" and .path == ".github/workflows/build-container-ci.yml" + and .status == "completed" and .conclusion == "success" and (.head_sha | type) == "string" + and (.display_title | type) == "string" and (.created_at | type) == "string" + ' "$run" >/dev/null 2>&1 || die 'credential-smoke run identity differs' + number_at_path "$run" '["id"]' "$U64_MAX" id; [[ "$id" == "$SMOKE_RUN_ID" ]] || die 'credential-smoke run id differs' + number_at_path "$run" '["run_attempt"]' "$U32_MAX" attempt; [[ "$attempt" == "$SMOKE_RUN_ATTEMPT" ]] || die 'credential-smoke attempt differs' + SMOKE_HEAD=$(safe_jq -er '.head_sha' "$run") + if ! is_sha "$SMOKE_HEAD" || ! commit_exists "$SMOKE_HEAD"; then die 'credential-smoke head is unavailable'; fi + require_ancestor "$GATE_SHA" "$SMOKE_HEAD" 'credential-smoke gate' + validate_manifest_and_codeowners "$GATE_SHA" "$SMOKE_HEAD" + title=$(safe_jq -er '.display_title' "$run") + [[ "$title" =~ ^build-container-release-preflight[[:space:]]pr=([1-9][0-9]*)[[:space:]]repo=stackpop/edgezero[[:space:]]sha=([0-9a-f]{40})$ ]] || die 'credential-smoke title is invalid' + candidate_pr=${BASH_REMATCH[1]}; candidate_head=${BASH_REMATCH[2]} + if ! is_positive_decimal_at_most "$candidate_pr" "$U64_MAX" || ! commit_exists "$candidate_head"; then die 'credential-smoke candidate identity is invalid'; fi + [[ -z "$ACTIVE_CANDIDATE_PR" || "$candidate_pr" == "$ACTIVE_CANDIDATE_PR" ]] || die 'credential-smoke candidate PR differs' + [[ -z "$ACTIVE_CANDIDATE_HEAD" || "$candidate_head" == "$ACTIVE_CANDIDATE_HEAD" ]] || die 'credential-smoke candidate head differs' + ACTIVE_CANDIDATE_PR=$candidate_pr; ACTIVE_CANDIDATE_HEAD=$candidate_head + policy_get "/repos/stackpop/edgezero/pulls/$ACTIVE_CANDIDATE_PR" body + safe_jq -e --argjson pr "$ACTIVE_CANDIDATE_PR" --arg head "$ACTIVE_CANDIDATE_HEAD" ' + .number == $pr and .base.ref == "main" and .base.repo.full_name == "stackpop/edgezero" + and .head.repo.full_name == "stackpop/edgezero" and .head.sha == $head + ' "$body" >/dev/null 2>&1 || die 'credential-smoke candidate PR identity differs' + verify_exact_check_run "$ACTIVE_CANDIDATE_HEAD" build-container-release-preflight "$SMOKE_RUN_ID" + one_page_list "/repos/stackpop/edgezero/actions/runs/$SMOKE_RUN_ID/attempts/$SMOKE_RUN_ATTEMPT/jobs?per_page=100&page=1" jobs 1 body + safe_jq -e --arg head "$SMOKE_HEAD" ' + .jobs[0].name == "build-container-release-preflight" and .jobs[0].head_sha == $head and .jobs[0].conclusion == "success" + and ([.jobs[0].steps[] | select(.name == "assert-exact-g-dispatch-context" and .conclusion == "success")] | length) == 1 + and (.jobs[0].completed_at | type) == "string" + ' "$body" >/dev/null 2>&1 || die 'credential-smoke job differs from the exact contract' + number_at_path "$body" '["jobs",0,"id"]' "$U64_MAX" SMOKE_JOB_ID + created=$(safe_jq -er '.created_at' "$run"); validate_utc "$created" 'credential-smoke creation time' created_epoch + completed=$(safe_jq -er '.jobs[0].completed_at' "$body"); validate_utc "$completed" 'credential-smoke completion time' completed_epoch + ((created_epoch <= completed_epoch)) || die 'credential-smoke timestamps are inconsistent' + snapshots=$(safe_jq -cr '.[]' <<<"$ENV_SNAPSHOTS") + while IFS= read -r record || [[ -n "$record" ]]; do + [[ -z "$record" ]] && continue; updated=$(safe_jq -er '.updated_at' <<<"$record"); validate_utc "$updated" 'credential snapshot update time' updated_epoch + ((updated_epoch < created_epoch)) || die 'credential record was not fixed before smoke creation' + done <<<"$snapshots" + SMOKE_CREATED=$created; SMOKE_COMPLETED=$completed +} + +base64url() { + env -i PATH="$PATH" LC_ALL=C openssl base64 -A 2>/dev/null | tr '+/' '-_' | tr -d '=' +} + +mint_app_jwt() { + local header payload signing signature iat exp + iat=$((NOW_EPOCH - 30)); exp=$((NOW_EPOCH + 540)) + header=$(printf '%s' '{"alg":"RS256","typ":"JWT"}' | base64url) || tool_die 'cannot encode App JWT header' + payload=$(printf '{"exp":%s,"iat":%s,"iss":%s}' "$exp" "$iat" "$EXPECTED_APP_ID" | base64url) || tool_die 'cannot encode App JWT payload' + signing="$header.$payload" + signature=$(printf '%s' "$signing" | env -i PATH="$PATH" LC_ALL=C openssl dgst -sha256 -sign "$APP_KEY_FILE" 2>/dev/null | base64url) || die 'cannot sign App JWT' + APP_JWT="$signing.$signature" + valid_credential "$APP_JWT" || die 'signed App JWT is malformed' +} + +revoke_token() { + local kind=$1 token=$2 ignored='' + api_request "$kind" "$token" DELETE /installation/token 204 ignored + : "$ignored" +} + +verify_app_probes() { + local body request token repos expires expires_epoch app_id installation_id + mint_app_jwt + api_request app-jwt "$APP_JWT" GET /app 200 body + safe_jq -e --arg slug "${ACTIVE_BOT_LOGIN%\[bot\]}" '.slug == $slug and (.id | type) == "number"' "$body" >/dev/null 2>&1 || die 'GitHub App identity differs' + number_at_path "$body" '["id"]' "$U64_MAX" app_id; [[ "$app_id" == "$EXPECTED_APP_ID" ]] || die 'GitHub App id differs' + api_request app-jwt "$APP_JWT" GET "/app/installations/$EXPECTED_INSTALLATION_ID" 200 body + safe_jq -e ' + .account.login == "stackpop" and .account.type == "Organization" and .repository_selection == "selected" + and .suspended_at == null and .permissions == {"contents":"write","metadata":"read","pull_requests":"write"} + and (.id | type) == "number" + ' "$body" >/dev/null 2>&1 || die 'GitHub App installation differs' + number_at_path "$body" '["id"]' "$U64_MAX" installation_id; [[ "$installation_id" == "$EXPECTED_INSTALLATION_ID" ]] || die 'GitHub App installation id differs' + + new_temp request app-request; printf '%s' '{"permissions":{"metadata":"read"}}' >"$request" + api_request app-jwt "$APP_JWT" POST "/app/installations/$EXPECTED_INSTALLATION_ID/access_tokens" 201 body "$request" + safe_jq -e 'type == "object" and (.token | type) == "string" and (.token | test("^[A-Za-z0-9_.-]{8,512}$"))' "$body" >/dev/null 2>&1 || die 'metadata-audit token is malformed' + AUDIT_TOKEN=$(safe_jq -er '.token' "$body"); AUDIT_REVOKED=false + safe_jq -e '.permissions == {"metadata":"read"} and (.expires_at | type) == "string" and ((.expires_at | test("[\\r\\n]")) | not)' "$body" >/dev/null 2>&1 || die 'installation metadata-audit token response differs' + expires=$(safe_jq -er '.expires_at' "$body"); validate_utc "$expires" 'metadata-audit token expiration' expires_epoch; ((expires_epoch > NOW_EPOCH)) || die 'metadata-audit token is expired' + paginate installation-audit '/installation/repositories?per_page=100&' repositories true repos + safe_jq -e --argjson repo "$REPOSITORY_ID" 'length == 1 and .[0].id == $repo and .[0].full_name == "stackpop/edgezero"' "$repos" >/dev/null 2>&1 || die 'installation-wide repository selection differs' + revoke_token installation-audit "$AUDIT_TOKEN"; AUDIT_REVOKED=true + + new_temp request app-request; printf '{"repository_ids":[%s],"permissions":{"contents":"write","pull_requests":"write"}}' "$REPOSITORY_ID" >"$request" + api_request app-jwt "$APP_JWT" POST "/app/installations/$EXPECTED_INSTALLATION_ID/access_tokens" 201 body "$request" + safe_jq -e 'type == "object" and (.token | type) == "string" and (.token | test("^[A-Za-z0-9_.-]{8,512}$"))' "$body" >/dev/null 2>&1 || die 'publisher-probe token is malformed' + PROBE_TOKEN=$(safe_jq -er '.token' "$body"); PROBE_REVOKED=false + safe_jq -e '.permissions == {"contents":"write","metadata":"read","pull_requests":"write"} and (.expires_at | type) == "string" and ((.expires_at | test("[\\r\\n]")) | not)' "$body" >/dev/null 2>&1 || die 'publisher-probe token response differs' + [[ "$PROBE_TOKEN" != "$AUDIT_TOKEN" ]] || die 'App probe tokens are not distinct' + paginate publisher-probe '/installation/repositories?per_page=100&' repositories true repos + safe_jq -e --argjson repo "$REPOSITORY_ID" 'length == 1 and .[0].id == $repo and .[0].full_name == "stackpop/edgezero"' "$repos" >/dev/null 2>&1 || die 'publisher-probe repository selection differs' + api_request publisher-probe "$PROBE_TOKEN" GET /repos/stackpop/edgezero 200 body + safe_jq -e --argjson repo "$REPOSITORY_ID" '.id == $repo and .full_name == "stackpop/edgezero"' "$body" >/dev/null 2>&1 || die 'publisher-probe repository identity differs' + revoke_token publisher-probe "$PROBE_TOKEN"; PROBE_REVOKED=true + APP_PROBE_EVIDENCE=$(safe_jq -cnS --arg app "$EXPECTED_APP_ID" --arg installation "$EXPECTED_INSTALLATION_ID" --arg repo "$REPOSITORY_ID" '{"app-id":$app,"installation-id":$installation,"installation-metadata-audit-revoked":true,"publisher-probe-revoked":true,"repository-id":$repo}') + APP_JWT=; AUDIT_TOKEN=; PROBE_TOKEN= +} + +verify_package() { + local body + package_get /user body + safe_jq -e --arg login "$PACKAGE_AUDITOR_LOGIN" '.login == $login and .type == "User" and (.id | type) == "number"' "$body" >/dev/null 2>&1 || die 'package token identity differs' + package_get "/orgs/stackpop/memberships/$PACKAGE_AUDITOR_LOGIN" body + safe_jq -e '.state == "active" and .role == "admin"' "$body" >/dev/null 2>&1 || die 'package auditor is not an active organization owner' + if [[ "$PACKAGE_STATE" == absent ]]; then + paginate package '/orgs/stackpop/packages?package_type=container&per_page=100&' '' false body + safe_jq -e 'all(.[]; .name != "edgezero-build-app-cli")' "$body" >/dev/null 2>&1 || die 'container package is not absent' + else + package_get /orgs/stackpop/packages/container/edgezero-build-app-cli body + safe_jq -e --argjson repo "$REPOSITORY_ID" '.name == "edgezero-build-app-cli" and .package_type == "container" and .visibility == "public" and .repository.id == $repo and .repository.full_name == "stackpop/edgezero"' "$body" >/dev/null 2>&1 || die 'container package is not public and repository-linked' + fi +} + +build_policy_evidence() { + local mode=$1 gate=$2 admin=$3 app_probes=$4 release=$5 rotation=$6 + safe_jq -cnS \ + --arg audited "$AUDITED_AT" --arg mode "$mode" --arg gate "$gate" \ + --arg policy_login "$POLICY_LOGIN" --arg policy_user_id "$POLICY_USER_ID" \ + --arg policy_token_id "$POLICY_TOKEN_ID" --arg policy_reviewed "$POLICY_REVIEWED" \ + --arg policy_expires "$POLICY_EXPIRES" --arg policy_png "$POLICY_SCREENSHOT" \ + --arg repository_id "$REPOSITORY_ID" --arg required_id "$REQUIRED_WORKFLOW_ID" \ + --arg required_sha "$gate" --arg main_id "$MAIN_RULESET_ID" --arg image_create "$IMAGE_CREATION_ID" \ + --arg image_immutable "$IMAGE_IMMUTABILITY_ID" --arg action_create "$ACTION_CREATION_ID" \ + --arg action_immutable "$ACTION_IMMUTABILITY_ID" --arg pin_id "$PIN_RULESET_ID" \ + --arg main_sha "$MAIN_SHA" --arg immutable_owner "$IMMUTABLE_ENFORCED_BY_OWNER" \ + --argjson snapshots "$ENV_SNAPSHOTS" --argjson admin "$admin" --argjson app "$app_probes" \ + --argjson release "$release" --argjson rotation "$rotation" ' + { + "audited-at":$audited, + "credentials":{ + "app-probes":$app, + "environment-snapshots":$snapshots, + "policy-auditor":{"expires-at":$policy_expires,"login":$policy_login,"reviewed-at":$policy_reviewed,"screenshot-sha256":$policy_png,"token-id":$policy_token_id,"user-id":$policy_user_id} + }, + "environment":{"administrator-bypass":$admin,"name":"build-container-release","release-state":"enabled","tag-policy":{"name":"build-container-v*","type":"tag"}}, + "gate-sha":$gate, + "mode":$mode, + "policy":{ + "action-tag-rulesets":{"creation-id":$action_create,"immutability-id":$action_immutable}, + "image-tag-rulesets":{"creation-id":$image_create,"immutability-id":$image_immutable}, + "immutable-releases":{"enabled":true,"enforced-by-owner":($immutable_owner == "true")}, + "main-ref":$main_sha, + "main-ruleset-id":$main_id, + "pin-branch-ruleset-id":$pin_id, + "repository-id":$repository_id, + "required-workflow-ruleset-id":$required_id, + "required-workflow-sha":$required_sha, + "sha-pinning-required":false + }, + "release":$release, + "rotation":$rotation, + "schema-version":1 + } + ' +} + +admin_evidence() { + if [[ "$MODE" == configuration || "$MODE" == release ]]; then + safe_jq -cnS --arg basename "${ADMIN_PNG##*/}" --arg head "$ACTIVE_CANDIDATE_HEAD" --arg digest "$ADMIN_DIGEST" --arg reviewer "$ADMIN_REVIEWER" --arg reviewed "$ADMIN_REVIEWED_AT" '{allowed:false,basename:$basename,"candidate-head-sha":$head,"reviewed-at":$reviewed,reviewer:$reviewer,sha256:$digest,verification:"manual-ui"}' + else + printf 'null' + fi +} + +verify_release_source() { + local body request changed canonical + policy_get "/repos/stackpop/edgezero/pulls/$CANDIDATE_PR" body + safe_jq -e --argjson pr "$CANDIDATE_PR" --arg head "$ACTIVE_CANDIDATE_HEAD" --arg source "$SOURCE_REVISION" ' + .number == $pr and .state == "closed" and .merged == true and .merge_commit_sha == $source + and .head.sha == $head and .head.repo.full_name == "stackpop/edgezero" + and .base.ref == "main" and .base.repo.full_name == "stackpop/edgezero" + ' "$body" >/dev/null 2>&1 || die 'release candidate PR is not the exact merged source' + require_ancestor "$SOURCE_REVISION" "$MAIN_SHA" 'release source on protected main' + changed=$(repo_git diff --name-only "$GATE_SHA" "$SOURCE_REVISION" --) || die 'cannot inspect release-source paths' + [[ "$changed" == .github/docker/build-app-cli/release-request.json ]] || die 'release source changes more than the isolated release request' + new_temp request release-request + repo_git show "$SOURCE_REVISION:.github/docker/build-app-cli/release-request.json" >"$request" 2>/dev/null || die 'release request is absent' + validate_raw_json_keys "$request" 'release request' + safe_jq -e --arg gate "$GATE_SHA" 'type == "object" and keys == ["gate-sha","provenance-protocol","release-tag"] and ."gate-sha" == $gate and ."provenance-protocol" == 1 and (."provenance-protocol" | type) == "number" and (."release-tag" | type) == "string" and (."release-tag" | test("^build-container-v[1-9][0-9]*$"))' "$request" >/dev/null 2>&1 || die 'release request has the wrong exact shape' + canonical=$(safe_jq -cS . "$request") + [[ "$(file_size "$request")" == "${#canonical}" && "$canonical" == "$(<"$request")" ]] || die 'release request is not exact JCS' + RELEASE_TAG=$(safe_jq -er '."release-tag"' "$request") + + verify_run_detail "$MERGE_GROUP_RUN_ID" "$MERGE_GROUP_RUN_ATTEMPT" merge_group "$MERGE_GROUP_SHA" "$WORKFLOW_PATH" body + verify_two_stable_jobs "$MERGE_GROUP_RUN_ID" "$MERGE_GROUP_RUN_ATTEMPT" "$MERGE_GROUP_SHA" '' + verify_exact_check_run "$MERGE_GROUP_SHA" build-container-local + verify_exact_check_run "$MERGE_GROUP_SHA" build-container-pin + verify_run_detail "$PUSH_RUN_ID" "$PUSH_RUN_ATTEMPT" push "$SOURCE_REVISION" "$WORKFLOW_PATH" body + verify_two_stable_jobs "$PUSH_RUN_ID" "$PUSH_RUN_ATTEMPT" "$SOURCE_REVISION" assert-exact-main-push-context +} + +rotation_history_snapshot() { + local result_name=$1 list count index id attempt number created line rows records='[]' next seen_ids='|' seen_numbers='|' unused_epoch='' + paginate policy '/repos/stackpop/edgezero/actions/workflows/rotate-build-container-gate.yml/runs?event=workflow_dispatch&per_page=100&' workflow_runs true list + count=$(safe_jq -r 'length' "$list") + if [[ ! "$count" =~ ^[0-9]+$ ]] || ((count == 0)); then die 'rotation history is empty'; fi + new_temp rows rotation-rows; : >"$rows" + index=0 + while ((index < count)); do + number_at_path "$list" "[$index,\"id\"]" "$U64_MAX" id + number_at_path "$list" "[$index,\"run_attempt\"]" "$U32_MAX" attempt + number_at_path "$list" "[$index,\"run_number\"]" "$U64_MAX" number + created=$(safe_jq -er --argjson index "$index" '.[$index].created_at | select(type == "string" and ((test("[\\r\\n]")) | not))' "$list" 2>/dev/null) || die 'rotation history creation time is missing' + validate_utc "$created" 'rotation run creation time' unused_epoch + ((unused_epoch <= NOW_EPOCH)) || die 'rotation run creation time is future' + case "$seen_ids" in *"|$id|"*) die 'rotation history repeats a run id' ;; esac; seen_ids="$seen_ids$id|" + case "$seen_numbers" in *"|$number|"*) die 'rotation history repeats a run number' ;; esac; seen_numbers="$seen_numbers$number|" + line=$(safe_jq -cnS --arg attempt "$attempt" --arg id "$id" --arg number "$number" '{"run-attempt":$attempt,"run-id":$id,"run-number":$number}') + printf '%02d\t%s\t%s\t%s\t%s\n' "${#number}" "$number" "$id" "$created" "$line" >>"$rows" + index=$((index + 1)) + done + sort -t $'\t' -k1,1n -k2,2 "$rows" >"$rows.sorted" || tool_die 'cannot order rotation history' + temporary_files+=("$rows.sorted") + while IFS=$'\t' read -r _ number id created line; do + next=$(safe_jq -cnS --argjson records "$records" --argjson line "$line" '$records + [$line]') + records=$next + SELECTED_RUN_NUMBER=$number; SELECTED_RUN_ID=$id; SELECTED_RUN_ATTEMPT=$attempt; SELECTED_CREATED_AT=$created + done <"$rows.sorted" + printf -v "$result_name" '%s' "$records" +} + +parse_rotation_approval() { + local approvals=$1 completed_at=$2 run_actor=$3 comment reviewer api_reviewed first second first_json second_json canonical + local first_file second_file + local run_created_epoch audited_epoch reviewed_epoch completed_epoch evidence_digest policy_digest + safe_jq -e --arg prefix "$ROTATION_PREFIX" --arg environment "$ROTATION_ENVIRONMENT" ' + type == "array" + and ([.[] | select((.comment | type) == "string" and (.comment | startswith($prefix)))]) as $records + | ($records | length) == 1 + and ($records[0].user | type) == "object" + and ($records[0].user.login | type) == "string" + and (($records[0].user.login | test("[\\r\\n]")) | not) + and ($records[0].reviewed_at | type) == "string" + and (($records[0].reviewed_at | test("[\\r\\n]")) | not) + and (($records[0].comment | contains("\r")) | not) + and ($records[0].comment | split("\n") | length) == 2 + and ($records[0].comment | endswith("\n") | not) + and $records[0].state == "approved" + and $records[0].environment_name == $environment + ' "$approvals" >/dev/null 2>&1 || die 'rotation approval record is missing, duplicated, or malformed' + comment=$(safe_jq -er --arg prefix "$ROTATION_PREFIX" '.[] | select(.comment | startswith($prefix)) | .comment' "$approvals") + reviewer=$(safe_jq -er --arg prefix "$ROTATION_PREFIX" '.[] | select(.comment | startswith($prefix)) | .user.login' "$approvals") + api_reviewed=$(safe_jq -er --arg prefix "$ROTATION_PREFIX" '.[] | select(.comment | startswith($prefix)) | .reviewed_at' "$approvals") + if ! is_login "$reviewer" || [[ "$reviewer" == "$run_actor" || "$reviewer" == "$POLICY_LOGIN" ]]; then + die 'rotation reviewer identity is not independent' + fi + [[ "$comment" != *$'\n'$'\n'* && "$(printf '%s' "$comment" | grep -c '^')" == 2 ]] || die 'rotation approval must contain exactly two lines' + first=${comment%%$'\n'*}; second=${comment#*$'\n'} + [[ "$first" == "$ROTATION_PREFIX"* && "$second" == "$POLICY_PREFIX"* ]] || die 'rotation approval prefixes are invalid' + first_json=${first#"$ROTATION_PREFIX"}; second_json=${second#"$POLICY_PREFIX"} + new_temp first_file rotation-receipt; new_temp second_file rotation-policy + printf '%s' "$first_json" >"$first_file"; printf '%s' "$second_json" >"$second_file" + validate_raw_json_keys "$first_file" 'rotation receipt'; validate_raw_json_keys "$second_file" 'rotation policy receipt' + canonical=$(safe_jq -cS . "$first_file"); [[ "$canonical" == "$first_json" ]] || die 'rotation receipt is not exact JCS' + canonical=$(safe_jq -cS . "$second_file"); [[ "$canonical" == "$second_json" ]] || die 'rotation policy receipt is not exact JCS' + safe_jq -e ' + type == "object" and keys == ["evidence-sha256","head-sha","lock-run-id","new-gate-sha","old-gate-sha","result","reviewed-at"] + and all(."evidence-sha256",."head-sha",."lock-run-id",."new-gate-sha",."old-gate-sha",.result,."reviewed-at"; type == "string") + ' "$first_file" >/dev/null 2>&1 || die 'rotation receipt shape is invalid' + safe_jq -e ' + type == "object" and keys == ["audited-at","dispatch-sha","gate-sha","head-sha","lock-run-attempt","lock-run-id","policy-sha256","release-state","required-workflow-sha"] + and all(."audited-at",."dispatch-sha",."gate-sha",."head-sha",."lock-run-attempt",."lock-run-id",."policy-sha256",."release-state",."required-workflow-sha"; type == "string") + ' "$second_file" >/dev/null 2>&1 || die 'rotation policy receipt shape is invalid' + evidence_digest=$(safe_jq -er '."evidence-sha256"' "$first_file") + [[ "$evidence_digest" == "sha256:$(hash_bytes "$second_json")" ]] || die 'rotation nested evidence digest differs' + policy_digest=$(safe_jq -er '."policy-sha256"' "$second_file"); [[ "$policy_digest" =~ ^sha256:[0-9a-f]{64}$ ]] || die 'rotation policy evidence digest is invalid' + RECEIPT_HEAD=$(safe_jq -er '."head-sha"' "$first_file"); RECEIPT_OLD_GATE=$(safe_jq -er '."old-gate-sha"' "$first_file"); RECEIPT_NEW_GATE=$(safe_jq -er '."new-gate-sha"' "$first_file"); RECEIPT_RESULT=$(safe_jq -er '.result' "$first_file") + RECEIPT_REVIEWED_AT=$(safe_jq -er '."reviewed-at"' "$first_file"); RECEIPT_EVIDENCE_DIGEST=$evidence_digest + RECEIPT_DISPATCH=$(safe_jq -er '."dispatch-sha"' "$second_file"); RECEIPT_GATE=$(safe_jq -er '."gate-sha"' "$second_file"); RECEIPT_REQUIRED_GATE=$(safe_jq -er '."required-workflow-sha"' "$second_file"); RECEIPT_AUDITED_AT=$(safe_jq -er '."audited-at"' "$second_file") + [[ "$(safe_jq -er '."head-sha"' "$second_file")" == "$RECEIPT_HEAD" ]] || die 'rotation receipt head values differ' + [[ "$(safe_jq -er '."lock-run-id"' "$first_file")" == "$LOCK_RUN_ID" && "$(safe_jq -er '."lock-run-id"' "$second_file")" == "$LOCK_RUN_ID" ]] || die 'rotation receipt run id differs' + [[ "$(safe_jq -er '."lock-run-attempt"' "$second_file")" == "$LOCK_RUN_ATTEMPT" ]] || die 'rotation receipt attempt differs' + [[ "$(safe_jq -er '."release-state"' "$second_file")" == enabled ]] || die 'rotation receipt release state differs' + [[ "$api_reviewed" == "$RECEIPT_REVIEWED_AT" ]] || die 'rotation approval timestamp differs from comment' + validate_utc "$SELECTED_CREATED_AT" 'rotation creation time' run_created_epoch + validate_utc "$RECEIPT_AUDITED_AT" 'rotation audit time' audited_epoch + validate_utc "$RECEIPT_REVIEWED_AT" 'rotation review time' reviewed_epoch + validate_utc "$completed_at" 'rotation completion time' completed_epoch + ((run_created_epoch <= audited_epoch && audited_epoch <= reviewed_epoch && reviewed_epoch <= completed_epoch && completed_epoch - audited_epoch <= 900 && completed_epoch - reviewed_epoch <= 900)) || die 'rotation receipt time ordering or freshness differs' + [[ "$RECEIPT_RESULT" == activated || "$RECEIPT_RESULT" == rolled-back ]] || die 'rotation receipt result is invalid' + for value in "$RECEIPT_HEAD" "$RECEIPT_OLD_GATE" "$RECEIPT_NEW_GATE" "$RECEIPT_DISPATCH" "$RECEIPT_GATE" "$RECEIPT_REQUIRED_GATE"; do is_sha "$value" || die 'rotation receipt SHA is invalid'; done +} + +verify_rotation_complete() { + local first_history second_history first_detail second_detail jobs approvals run_actor completed_at selected_id selected_attempt selected_number selected_created selected_head + local id attempt number first_identity second_identity + rotation_history_snapshot first_history + selected_id=$SELECTED_RUN_ID; selected_attempt=$SELECTED_RUN_ATTEMPT; selected_number=$SELECTED_RUN_NUMBER; selected_created=$SELECTED_CREATED_AT + [[ "$selected_id" == "$LOCK_RUN_ID" ]] || die 'selected rotation is not the requested latest run' + [[ "$selected_attempt" == "$LOCK_RUN_ATTEMPT" ]] || die 'selected rotation attempt differs' + policy_get "/repos/stackpop/edgezero/actions/runs/$LOCK_RUN_ID" first_detail + safe_jq -e --arg head "$(safe_jq -er '.head_sha' "$first_detail")" --arg path "$ROTATION_PATH" '.event == "workflow_dispatch" and .path == $path and .status == "completed" and .conclusion == "success" and (.actor.login | type) == "string" and (.run_number | type) == "number" and (.run_attempt | type) == "number"' "$first_detail" >/dev/null 2>&1 || die 'selected rotation run is not completed successfully' + number_at_path "$first_detail" '["id"]' "$U64_MAX" id; [[ "$id" == "$LOCK_RUN_ID" ]] || die 'selected rotation detail id differs' + number_at_path "$first_detail" '["run_attempt"]' "$U32_MAX" attempt; [[ "$attempt" == "$LOCK_RUN_ATTEMPT" ]] || die 'selected rotation detail attempt differs' + [[ "$attempt" == "$selected_attempt" ]] || die 'selected rotation history attempt differs from detail' + number_at_path "$first_detail" '["run_number"]' "$U64_MAX" number; [[ "$number" == "$selected_number" ]] || die 'selected rotation detail number differs' + [[ "$(safe_jq -er '.created_at' "$first_detail")" == "$selected_created" ]] || die 'selected rotation detail creation time differs' + selected_head=$(safe_jq -er '.head_sha' "$first_detail"); is_sha "$selected_head" || die 'selected rotation head SHA is invalid' + run_actor=$(safe_jq -er '.actor.login' "$first_detail"); is_login "$run_actor" || die 'rotation run actor is invalid' + one_page_list "/repos/stackpop/edgezero/actions/runs/$LOCK_RUN_ID/attempts/$LOCK_RUN_ATTEMPT/jobs?per_page=100&page=1" jobs 2 jobs + safe_jq -e --arg head "$selected_head" ' + ([.jobs[].name] | sort) == ["acquire-rotation-lock","wait-for-rotation-review"] + and all(.jobs[]; .conclusion == "success" and .head_sha == $head) + and ([.jobs[] | select(.name == "wait-for-rotation-review") | .steps[] | select(.name == "assert-exact-rotation-context" and .conclusion == "success")] | length) == 1 + ' "$jobs" >/dev/null 2>&1 || die 'rotation jobs differ from the exact contract' + completed_at=$(safe_jq -er '.jobs[] | select(.name == "wait-for-rotation-review") | .completed_at' "$jobs") + policy_get "/repos/stackpop/edgezero/actions/runs/$LOCK_RUN_ID/approvals" approvals + [[ -z "$LAST_LINK" ]] || die 'rotation approvals endpoint returned continuation' + parse_rotation_approval "$approvals" "$completed_at" "$run_actor" + [[ "$RECEIPT_DISPATCH" == "$selected_head" ]] || die 'rotation dispatch SHA differs from run' + [[ "$RECEIPT_HEAD" == "$MAIN_SHA" && "$RECEIPT_GATE" == "$GATE_SHA" && "$RECEIPT_REQUIRED_GATE" == "$GATE_SHA" ]] || die 'rotation final pointers differ from active policy' + require_ancestor "$RECEIPT_DISPATCH" "$RECEIPT_HEAD" 'rotation receipt head' + validate_manifest_and_codeowners "$GATE_SHA" "$RECEIPT_HEAD" + + rotation_history_snapshot second_history + [[ "$first_history" == "$second_history" && "$SELECTED_RUN_ID" == "$selected_id" && "$SELECTED_RUN_ATTEMPT" == "$selected_attempt" && "$SELECTED_RUN_NUMBER" == "$selected_number" && "$SELECTED_CREATED_AT" == "$selected_created" ]] || die 'rotation history changed during audit' + policy_get "/repos/stackpop/edgezero/actions/runs/$LOCK_RUN_ID" second_detail + first_identity=$(safe_jq -cS '{conclusion,created_at,event,head_sha,id,path,run_attempt,run_number,status}' "$first_detail") + second_identity=$(safe_jq -cS '{conclusion,created_at,event,head_sha,id,path,run_attempt,run_number,status}' "$second_detail") + [[ "$first_identity" == "$second_identity" ]] || die 'selected rotation detail changed during audit' + ROTATION_HISTORY_DIGEST="sha256:$(hash_bytes "$second_history")" + ROTATION_CREATED_AT=$SELECTED_CREATED_AT +} + +AUDITED_AT=$(safe_jq -nr --argjson epoch "$NOW_EPOCH" '$epoch | strftime("%Y-%m-%dT%H:%M:%SZ")') || tool_die 'cannot format audit time' +ADMIN_JSON=null +APP_PROBE_EVIDENCE=null +RELEASE_EVIDENCE=null +ROTATION_EVIDENCE=null + +case "$MODE" in + configuration) + verify_common_policy "$GATE_SHA" "$GATE_SHA" + verify_smoke + ADMIN_JSON=$(admin_evidence) + verify_app_probes + RELEASE_EVIDENCE=$(safe_jq -cnS --arg pr "$ACTIVE_CANDIDATE_PR" --arg head "$ACTIVE_CANDIDATE_HEAD" --arg smoke "$SMOKE_RUN_ID" --arg attempt "$SMOKE_RUN_ATTEMPT" --arg job "$SMOKE_JOB_ID" --arg created "$SMOKE_CREATED" --arg completed "$SMOKE_COMPLETED" '{"candidate-head":$head,"candidate-pr":$pr,"credential-smoke":{"completed-at":$completed,"created-at":$created,"job-id":$job,"run-attempt":$attempt,"run-id":$smoke,"run-url":("https://github.com/stackpop/edgezero/actions/runs/"+$smoke)}}') + EVIDENCE=$(build_policy_evidence configuration "$GATE_SHA" "$ADMIN_JSON" "$APP_PROBE_EVIDENCE" "$RELEASE_EVIDENCE" null) + ;; + release) + verify_common_policy "$GATE_SHA" "$GATE_SHA" + verify_smoke + ADMIN_JSON=$(admin_evidence) + verify_app_probes + verify_release_source + verify_package + RELEASE_EVIDENCE=$(safe_jq -cnS --arg pr "$CANDIDATE_PR" --arg url "$EVIDENCE_URL" --arg source "$SOURCE_REVISION" --arg tag "$RELEASE_TAG" --arg merge_sha "$MERGE_GROUP_SHA" --arg merge_id "$MERGE_GROUP_RUN_ID" --arg merge_attempt "$MERGE_GROUP_RUN_ATTEMPT" --arg smoke_id "$SMOKE_RUN_ID" --arg smoke_attempt "$SMOKE_RUN_ATTEMPT" --arg smoke_job "$SMOKE_JOB_ID" --arg push_id "$PUSH_RUN_ID" --arg push_attempt "$PUSH_RUN_ATTEMPT" --arg package "$PACKAGE_STATE" --arg package_login "$PACKAGE_AUDITOR_LOGIN" '{"candidate-pr":$pr,"evidence-url":$url,"merge-group":{"run-attempt":$merge_attempt,"run-id":$merge_id,"run-url":("https://github.com/stackpop/edgezero/actions/runs/"+$merge_id),sha:$merge_sha},"package":{"auditor-login":$package_login,state:$package},"push":{"run-attempt":$push_attempt,"run-id":$push_id,"run-url":("https://github.com/stackpop/edgezero/actions/runs/"+$push_id)},"release-tag":$tag,"smoke":{"job-id":$smoke_job,"run-attempt":$smoke_attempt,"run-id":$smoke_id,"run-url":("https://github.com/stackpop/edgezero/actions/runs/"+$smoke_id)},"source-revision":$source}') + EVIDENCE=$(build_policy_evidence release "$GATE_SHA" "$ADMIN_JSON" "$APP_PROBE_EVIDENCE" "$RELEASE_EVIDENCE" null) + ;; + rotation-review) + if [[ "$ROTATION_RESULT" == activated ]]; then FINAL_GATE=$NEW_GATE_SHA; else FINAL_GATE=$OLD_GATE_SHA; fi + verify_common_policy "$FINAL_GATE" "$OLD_GATE_SHA" + [[ "$MAIN_SHA" == "$FINAL_HEAD_SHA" ]] || die 'rotation final head differs from protected main' + policy_get "/repos/stackpop/edgezero/actions/runs/$LOCK_RUN_ID" LOCK_DETAIL + safe_jq -e --arg dispatch "$DISPATCH_SHA" --arg path "$ROTATION_PATH" --arg actor "$OPERATOR_LOGIN" ' + .event == "workflow_dispatch" and .path == $path and .head_sha == $dispatch + and .status == "in_progress" and .conclusion == null and (.actor.login | type) == "string" + ' "$LOCK_DETAIL" >/dev/null 2>&1 || die 'waiting rotation run identity differs' + number_at_path "$LOCK_DETAIL" '["id"]' "$U64_MAX" id; [[ "$id" == "$LOCK_RUN_ID" ]] || die 'waiting rotation run id differs' + number_at_path "$LOCK_DETAIL" '["run_attempt"]' "$U32_MAX" attempt; [[ "$attempt" == "$LOCK_RUN_ATTEMPT" ]] || die 'waiting rotation attempt differs' + require_ancestor "$DISPATCH_SHA" "$FINAL_HEAD_SHA" 'rotation final head' + validate_manifest_and_codeowners "$FINAL_GATE" "$FINAL_HEAD_SHA" + ROTATION_EVIDENCE=$(safe_jq -cnS --arg old "$OLD_GATE_SHA" --arg new "$NEW_GATE_SHA" --arg dispatch "$DISPATCH_SHA" --arg head "$FINAL_HEAD_SHA" --arg id "$LOCK_RUN_ID" --arg attempt "$LOCK_RUN_ATTEMPT" --arg operator "$OPERATOR_LOGIN" --arg actor "$(safe_jq -er '.actor.login' "$LOCK_DETAIL")" --arg result "$ROTATION_RESULT" '{"dispatch-sha":$dispatch,"final-head-sha":$head,"lock-run-actor":$actor,"lock-run-attempt":$attempt,"lock-run-id":$id,"lock-run-url":("https://github.com/stackpop/edgezero/actions/runs/"+$id),"new-gate-sha":$new,"old-gate-sha":$old,"operator-login":$operator,result:$result}') + EVIDENCE=$(build_policy_evidence rotation-review "$FINAL_GATE" null null null "$ROTATION_EVIDENCE") + ;; + rotation-complete) + verify_common_policy "$GATE_SHA" '' + verify_rotation_complete + ROTATION_EVIDENCE=$(safe_jq -cnS --arg head "$RECEIPT_HEAD" --arg id "$LOCK_RUN_ID" --arg attempt "$LOCK_RUN_ATTEMPT" --arg number "$SELECTED_RUN_NUMBER" --arg history "$ROTATION_HISTORY_DIGEST" --arg evidence "$RECEIPT_EVIDENCE_DIGEST" --arg created "$ROTATION_CREATED_AT" --arg result "$RECEIPT_RESULT" '{"completed-lock":{"created-at":$created,"evidence-sha256":$evidence,"history-sha256":$history,"run-attempt":$attempt,"run-id":$id,"run-number":$number,"run-url":("https://github.com/stackpop/edgezero/actions/runs/"+$id)},"final-head-sha":$head,result:$result}') + EVIDENCE=$(build_policy_evidence rotation-complete "$GATE_SHA" null null null "$ROTATION_EVIDENCE") + ;; +esac + +EVIDENCE_SIZE=${#EVIDENCE} +((EVIDENCE_SIZE > 0 && EVIDENCE_SIZE <= MAX_EVIDENCE_BYTES)) || die 'canonical evidence size is outside its allowed bounds' +safe_jq -e 'type == "object"' <<<"$EVIDENCE" >/dev/null 2>&1 || die 'canonical evidence is not a JSON object' +EVIDENCE_DIGEST="sha256:$(hash_bytes "$EVIDENCE")" + +if [[ "$MODE" == release ]]; then + PREVIOUS_DIGEST="sha256:$(hash_bytes "$CURRENT_PREREQUISITE")" + PREREQUISITE=$(safe_jq -cnS --arg evidence "$EVIDENCE_DIGEST" --arg url "$EVIDENCE_URL" --arg gate "$GATE_SHA" --arg previous "$PREVIOUS_DIGEST" --argjson rotation "$CURRENT_ROTATION_HISTORY" --arg pr "$CANDIDATE_PR" --arg source "$SOURCE_REVISION" '{"evidence-sha256":$evidence,"evidence-url":$url,"gate-sha":$gate,"previous-value-sha256":$previous,"rotation-history":$rotation,"schema-version":2,"source-pr":$pr,"source-revision":$source}') +elif [[ "$MODE" == rotation-complete ]]; then + PREVIOUS_DIGEST="sha256:$(hash_bytes "$CURRENT_PREREQUISITE")" + VERIFIED_HISTORY=$(safe_jq -cnS --arg created "$ROTATION_CREATED_AT" --arg evidence "$RECEIPT_EVIDENCE_DIGEST" --arg history "$ROTATION_HISTORY_DIGEST" --arg attempt "$LOCK_RUN_ATTEMPT" --arg id "$LOCK_RUN_ID" --arg number "$SELECTED_RUN_NUMBER" '{"created-at":$created,"evidence-sha256":$evidence,"history-sha256":$history,"run-attempt":$attempt,"run-id":$id,"run-number":$number,state:"verified"}') + PREREQUISITE=$(safe_jq -cnS --arg evidence "$EVIDENCE_DIGEST" --arg gate "$GATE_SHA" --arg previous "$PREVIOUS_DIGEST" --argjson rotation "$VERIFIED_HISTORY" '{"evidence-sha256":$evidence,"evidence-url":null,"gate-sha":$gate,"previous-value-sha256":$previous,"rotation-history":$rotation,"schema-version":2,"source-pr":null,"source-revision":null}') +elif [[ "$MODE" == rotation-review ]]; then + POLICY_JSON=$(safe_jq -cnS --arg audited "$AUDITED_AT" --arg dispatch "$DISPATCH_SHA" --arg gate "$FINAL_GATE" --arg head "$FINAL_HEAD_SHA" --arg attempt "$LOCK_RUN_ATTEMPT" --arg id "$LOCK_RUN_ID" --arg digest "$EVIDENCE_DIGEST" '{"audited-at":$audited,"dispatch-sha":$dispatch,"gate-sha":$gate,"head-sha":$head,"lock-run-attempt":$attempt,"lock-run-id":$id,"policy-sha256":$digest,"release-state":"enabled","required-workflow-sha":$gate}') + ROTATION_JSON=$(safe_jq -cnS --arg evidence "sha256:$(hash_bytes "$POLICY_JSON")" --arg head "$FINAL_HEAD_SHA" --arg id "$LOCK_RUN_ID" --arg new "$NEW_GATE_SHA" --arg old "$OLD_GATE_SHA" --arg result "$ROTATION_RESULT" --arg reviewed "$AUDITED_AT" '{"evidence-sha256":$evidence,"head-sha":$head,"lock-run-id":$id,"new-gate-sha":$new,"old-gate-sha":$old,result:$result,"reviewed-at":$reviewed}') + APPROVAL_COMMENT="$ROTATION_PREFIX$ROTATION_JSON"$'\n'"$POLICY_PREFIX$POLICY_JSON" +fi + +publish_file "$EVIDENCE_OUT" "$EVIDENCE" evidence +if [[ "$MODE" == release || "$MODE" == rotation-complete ]]; then + if ! publish_file "$PREREQUISITE_OUT" "$PREREQUISITE" prerequisite; then rm -f -- "$EVIDENCE_OUT"; exit 1; fi +elif [[ "$MODE" == rotation-review ]]; then + if ! publish_file "$APPROVAL_COMMENT_OUT" "$APPROVAL_COMMENT" approval-comment; then rm -f -- "$EVIDENCE_OUT"; exit 1; fi +fi diff --git a/.github/docker/build-app-cli/verify-toolchain.sh b/.github/docker/build-app-cli/verify-toolchain.sh new file mode 100755 index 00000000..a51e4f19 --- /dev/null +++ b/.github/docker/build-app-cli/verify-toolchain.sh @@ -0,0 +1,213 @@ +#!/usr/bin/env bash +set -euo pipefail + +die() { + printf 'verify-toolchain: %s\n' "$*" >&2 + exit 1 +} + +root= +while (($#)); do + (($# >= 2)) || die "missing value for $1" + case "$1" in + --root) + [[ -z "$root" ]] || die "duplicate --root" + root=$2 + ;; + *) die "unknown argument: $1" ;; + esac + shift 2 +done + +[[ -n "$root" ]] || die "--root is required" +root=$(cd -- "$root" 2>/dev/null && pwd -P) || die "root is not a directory" +if [[ "$root" == "/" ]]; then + prefix='' +else + prefix=${root%/} +fi + +rustc="$prefix/usr/local/cargo/bin/rustc" +rustup="$prefix/usr/local/cargo/bin/rustup" +fastly="$prefix/usr/local/bin/fastly" +sccache="$prefix/usr/local/bin/sccache" +validator="$prefix/usr/local/bin/edgezero-provenance-validator" +env_bin="$prefix/usr/bin/env" +id_bin="$prefix/usr/bin/id" +touch_bin="$prefix/usr/bin/touch" +readelf_bin="$prefix/usr/bin/x86_64-linux-gnu-readelf" +sha256sum_bin="$prefix/usr/bin/sha256sum" +fixture="$prefix/usr/local/share/edgezero/wasm-smoke.rs" +loader_dir="$prefix/lib64" +loader="$loader_dir/ld-linux-x86-64.so.2" +runtime_lib="$prefix/opt/edgezero/runtime-lib" +rustup_home="$prefix/usr/local/rustup" +rustup_toolchain=1.95.0-x86_64-unknown-linux-gnu + +for executable in \ + "$rustup" "$fastly" "$sccache" "$validator" "$env_bin" "$id_bin" "$touch_bin" \ + "$readelf_bin" "$sha256sum_bin"; do + [[ -f "$executable" && ! -L "$executable" && -x "$executable" ]] || + die "required executable is missing, linked, or not executable: $executable" +done +[[ -L "$rustc" && "$(readlink "$rustc")" == "rustup" && "$rustc" -ef "$rustup" && -x "$rustc" ]] || + die "rustc is not the exact rustup proxy alias" +[[ -f "$fixture" && ! -L "$fixture" ]] || die "wasm smoke fixture is missing or linked" +[[ -d "$loader_dir" && ! -L "$loader_dir" ]] || die "/lib64 is not a real directory" +[[ -f "$loader" && ! -L "$loader" ]] || die "dynamic interpreter is missing or linked" +[[ -d "$runtime_lib" && ! -L "$runtime_lib" ]] || die "runtime library root is not a real directory" +[[ ! -e "$prefix/etc/ld.so.preload" && ! -L "$prefix/etc/ld.so.preload" ]] || + die "/etc/ld.so.preload must be absent" + +shopt -s dotglob nullglob +loader_members=("$loader_dir"/*) +[[ "${#loader_members[@]}" == 1 && "${loader_members[0]}" == "$loader" ]] || + die "dynamic interpreter directory member set differs" +[[ "$(find "$loader" -links 1 -print)" == "$loader" ]] || die "dynamic interpreter is multiply linked" +runtime_members=("$runtime_lib"/*) +[[ "${#runtime_members[@]}" == 3 ]] || die "flat runtime-library member set differs" + +elf_field() { + local header=$1 field=$2 + sed -n "s|^[[:space:]]*$field:[[:space:]]*||p" <<<"$header" +} + +assert_shared_object() { + local path=$1 basename=$2 expected_hash=$3 expected_needed=$4 + local header program dynamic soname needed actual_hash interpreter + [[ -f "$path" && ! -L "$path" ]] || die "runtime object is missing, linked, or non-regular: $basename" + [[ "$(find "$path" -links 1 -print)" == "$path" ]] || die "runtime object is multiply linked: $basename" + actual_hash=$("$sha256sum_bin" "$path") || die "cannot hash runtime object: $basename" + [[ "$actual_hash" == "$expected_hash $path" ]] || die "runtime object digest differs: $basename" + header=$(LC_ALL=C "$readelf_bin" -h "$path") || die "cannot read ELF header: $basename" + [[ "$(elf_field "$header" Class)" == ELF64 ]] || die "runtime object class differs: $basename" + [[ "$(elf_field "$header" Data)" == "2's complement, little endian" ]] || + die "runtime object byte order differs: $basename" + case "$(elf_field "$header" 'OS/ABI')" in + 'UNIX - GNU' | 'UNIX - System V') ;; + *) die "runtime object OSABI differs: $basename" ;; + esac + [[ "$(elf_field "$header" 'ABI Version')" == 0 ]] || die "runtime object ABI version differs: $basename" + [[ "$(elf_field "$header" Type)" == 'DYN (Shared object file)' ]] || + die "runtime object type differs: $basename" + [[ "$(elf_field "$header" Machine)" == 'Advanced Micro Devices X86-64' ]] || + die "runtime object machine differs: $basename" + program=$(LC_ALL=C "$readelf_bin" -l "$path") || die "cannot read program headers: $basename" + interpreter=$(sed -n 's/.*Requesting program interpreter: \([^]]*\)].*/\1/p' <<<"$program") + [[ -z "$interpreter" || "$interpreter" == /lib64/ld-linux-x86-64.so.2 ]] || + die "runtime object carries unsupported PT_INTERP: $basename" + dynamic=$(LC_ALL=C "$readelf_bin" -d "$path") || die "cannot read dynamic tags: $basename" + soname=$(sed -n -e 's/^SONAME=//p' -e 's/.*(SONAME).*[[]\([^]]*\)[]].*/\1/p' <<<"$dynamic") + needed=$(sed -n -e 's/^NEEDED=//p' -e 's/.*(NEEDED).*[[]\([^]]*\)[]].*/\1/p' <<<"$dynamic") + [[ "$soname" == "$basename" ]] || die "runtime filename and SONAME differ: $basename" + [[ "$needed" == "$expected_needed" ]] || die "runtime dependency set differs: $basename" +} + +assert_shared_object \ + "$runtime_lib/libc.so.6" libc.so.6 \ + 6b4a45352fd0c540a9c7c718f35ce8c8e46a4e482f9d3885a910c32d1a0e1421 \ + ld-linux-x86-64.so.2 +assert_shared_object \ + "$runtime_lib/libgcc_s.so.1" libgcc_s.so.1 \ + 2bd1552c47799ef67e701e81d4383061fd76059868e446e63560f0dd0d5ec14e \ + libc.so.6 +assert_shared_object \ + "$runtime_lib/libm.so.6" libm.so.6 \ + 7f2ca87f652f56b094462474b076749e90e689d0ecb9cb63c7679820b271b4e7 \ + $'libc.so.6\nld-linux-x86-64.so.2' + +loader_header=$(LC_ALL=C "$readelf_bin" -h "$loader") || die "cannot read dynamic interpreter header" +[[ "$(elf_field "$loader_header" Class)" == ELF64 ]] || die "dynamic interpreter class differs" +[[ "$(elf_field "$loader_header" Data)" == "2's complement, little endian" ]] || + die "dynamic interpreter byte order differs" +[[ "$(elf_field "$loader_header" 'OS/ABI')" == 'UNIX - GNU' ]] || die "dynamic interpreter OSABI differs" +[[ "$(elf_field "$loader_header" 'ABI Version')" == 0 ]] || die "dynamic interpreter ABI version differs" +[[ "$(elf_field "$loader_header" Type)" == 'DYN (Shared object file)' ]] || + die "dynamic interpreter type differs" +[[ "$(elf_field "$loader_header" Machine)" == 'Advanced Micro Devices X86-64' ]] || + die "dynamic interpreter machine differs" +loader_hash=$("$sha256sum_bin" "$loader") || die "cannot hash dynamic interpreter" +[[ "$loader_hash" == \ + "02bcda52c1a5dfc236f94d9e5255b4a0e26347d8a372a5223b650e31f291ce3c $loader" ]] || + die "dynamic interpreter digest differs" +loader_program=$(LC_ALL=C "$readelf_bin" -l "$loader") || die "cannot read dynamic interpreter program headers" +[[ "$loader_program" != *'Requesting program interpreter:'* ]] || + die "dynamic interpreter unexpectedly carries PT_INTERP" +loader_dynamic=$(LC_ALL=C "$readelf_bin" -d "$loader") || die "cannot read dynamic interpreter tags" +loader_soname=$(sed -n -e 's/^SONAME=//p' -e 's/.*(SONAME).*[[]\([^]]*\)[]].*/\1/p' <<<"$loader_dynamic") +loader_needed=$(sed -n -e 's/^NEEDED=//p' -e 's/.*(NEEDED).*[[]\([^]]*\)[]].*/\1/p' <<<"$loader_dynamic") +[[ "$loader_soname" == ld-linux-x86-64.so.2 && -z "$loader_needed" ]] || + die "dynamic interpreter tags differ" + +assert_exact_semver() { + local label=$1 actual=$2 expected=$3 + [[ "$actual" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]] || + die "$label version is not an exact stable semantic version" + [[ "$actual" == "$expected" ]] || die "$label version differs" +} + +expected_rustc='rustc 1.95.0 (59807616e 2026-04-14) +binary: rustc +commit-hash: 59807616e1fa2540724bfbac14d7976d7e4a3860 +commit-date: 2026-04-14 +host: x86_64-unknown-linux-gnu +release: 1.95.0 +LLVM version: 22.1.2' +actual_rustc=$(RUSTUP_HOME="$rustup_home" RUSTUP_TOOLCHAIN="$rustup_toolchain" \ + "$rustc" --version --verbose) || die "rustc version command failed" +rust_version=$(sed -n 's/^release: //p' <<<"$actual_rustc") +assert_exact_semver Rust "$rust_version" 1.95.0 +[[ "$actual_rustc" == "$expected_rustc" ]] || die "rustc version output differs" + +expected_fastly='Fastly CLI version v15.1.0 (e58c0f5e) +Built with go version go1.26.3 linux/amd64 (2026-09-09)' +actual_fastly=$("$fastly" --quiet version) || die "Fastly version command failed" +fastly_version=$(sed -n 's/^Fastly CLI version v\([^ ]*\) .*/\1/p' <<<"$actual_fastly") +assert_exact_semver Fastly "$fastly_version" 15.1.0 +[[ "$actual_fastly" == "$expected_fastly" ]] || die "Fastly version output differs" + +actual_sccache=$("$sccache" --version) || die "sccache version command failed" +sccache_version=$(sed -n 's/^sccache //p' <<<"$actual_sccache") +assert_exact_semver sccache "$sccache_version" 0.10.0 +[[ "$actual_sccache" == "sccache 0.10.0" ]] || die "sccache version output differs" + +installed_targets=$(RUSTUP_HOME="$rustup_home" RUSTUP_TOOLCHAIN="$rustup_toolchain" \ + "$rustup" target list --installed) || die "rustup target inspection failed" +installed_targets=$(LC_ALL=C sort <<<"$installed_targets") +[[ "$installed_targets" == $'wasm32-wasip1\nx86_64-unknown-linux-gnu' ]] || + die "installed Rust target set differs" + +work=$(mktemp -d "${TMPDIR:-/tmp}/edgezero-toolchain.XXXXXX") +trap 'rm -rf "$work"' EXIT +RUSTUP_HOME="$rustup_home" RUSTUP_TOOLCHAIN="$rustup_toolchain" "$rustc" \ + --crate-name edgezero_wasm_smoke \ + --crate-type cdylib \ + --edition 2024 \ + --target wasm32-wasip1 \ + -C opt-level=0 \ + "$fixture" \ + -o "$work/wasm-smoke.wasm" || die "minimal wasm compile failed" +[[ -f "$work/wasm-smoke.wasm" && ! -L "$work/wasm-smoke.wasm" ]] || + die "minimal wasm compile did not create a regular output" +magic=$(od -An -tx1 -N4 "$work/wasm-smoke.wasm" | tr -d ' \n') +[[ "$magic" == "0061736d" ]] || die "minimal compile output lacks wasm magic" + +"$validator" self-test --fixtures /usr/local/share/edgezero/provenance-fixtures || + die "validator self-test failed" + +[[ "$("$id_bin" -u)" == "1001" ]] || die "runtime uid is not 1001" +[[ "$("$id_bin" -g)" == "1001" ]] || die "runtime gid is not 1001" + +root_probe="$prefix/.edgezero-read-only-probe.$$" +if "$touch_bin" "$root_probe" >/dev/null 2>&1; then + rm -f -- "$root_probe" + die "root filesystem is writable" +fi + +env_probe=$(printf 'space quote" slash\\ dollar$ hash# equals= unicode-\303\251') +# shellcheck disable=SC2016 # GNU env must expand this literal placeholder itself. +env_output=$(EDGEZERO_ENV_PROBE="$env_probe" \ + "$env_bin" -S '-i EDGEZERO_ENV_PROBE=${EDGEZERO_ENV_PROBE}') || + die "GNU env -S probe failed" +[[ "$env_output" == "EDGEZERO_ENV_PROBE=$env_probe" ]] || + die "GNU env lacks required expansion-before-clear semantics" diff --git a/.github/docker/build-app-cli/write-image-release-record.sh b/.github/docker/build-app-cli/write-image-release-record.sh new file mode 100755 index 00000000..535aec9b --- /dev/null +++ b/.github/docker/build-app-cli/write-image-release-record.sh @@ -0,0 +1,185 @@ +#!/usr/bin/env bash +set -euo pipefail + +export LC_ALL=C + +DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +readonly CHECK="$DIR/check-image-pin.sh" + +usage() { + cat >&2 <<'EOF' +usage: write-image-release-record.sh \ + --image-path \ + --evidence-path \ + --repository \ + --release-tag \ + --image-digest \ + --source-revision \ + --provenance-protocol \ + --approval-challenge <64-lowercase-hex> \ + --approver-login \ + --reviewed-at \ + --run-attempt \ + --run-id \ + --screenshot-sha256 +EOF + exit 2 +} + +die() { + printf '::error::%s\n' "$*" >&2 + exit 1 +} + +image_path= +evidence_path= +repository= +release_tag= +image_digest= +source_revision= +provenance_protocol= +approval_challenge= +approver_login= +reviewed_at= +run_attempt= +run_id= +screenshot_sha256= +seen_flags=" " + +while (($#)); do + (($# >= 2)) || usage + flag=$1 + value=$2 + shift 2 + case "$flag" in + --image-path | --evidence-path | --repository | --release-tag | --image-digest | \ + --source-revision | --provenance-protocol | --approval-challenge | --approver-login | \ + --reviewed-at | --run-attempt | --run-id | --screenshot-sha256) ;; + *) usage ;; + esac + [[ "$seen_flags" != *" $flag "* ]] || die "duplicate argument: $flag" + seen_flags+="$flag " + case "$flag" in + --image-path) image_path=$value ;; + --evidence-path) evidence_path=$value ;; + --repository) repository=$value ;; + --release-tag) release_tag=$value ;; + --image-digest) image_digest=$value ;; + --source-revision) source_revision=$value ;; + --provenance-protocol) provenance_protocol=$value ;; + --approval-challenge) approval_challenge=$value ;; + --approver-login) approver_login=$value ;; + --reviewed-at) reviewed_at=$value ;; + --run-attempt) run_attempt=$value ;; + --run-id) run_id=$value ;; + --screenshot-sha256) screenshot_sha256=$value ;; + esac +done + +for required in \ + --image-path --evidence-path --repository --release-tag --image-digest --source-revision \ + --provenance-protocol --approval-challenge --approver-login --reviewed-at --run-attempt \ + --run-id --screenshot-sha256; do + [[ "$seen_flags" == *" $required "* ]] || die "missing required argument: $required" +done + +[[ -n "$image_path" && -n "$evidence_path" ]] || die "record paths must not be empty" +[[ "$image_path" != "$evidence_path" ]] || die "image and evidence paths must differ" +command -v jq >/dev/null 2>&1 || die "write-image-release-record.sh requires jq" +command -v git >/dev/null 2>&1 || die "write-image-release-record.sh requires git" + +[[ "$image_path" == /* && "$evidence_path" == /* ]] || + die "record paths must be absolute" + +image_parent_arg=$(dirname -- "$image_path") +evidence_parent_arg=$(dirname -- "$evidence_path") +[[ ! -L "$image_parent_arg" && ! -L "$evidence_parent_arg" ]] || + die "record output parent must not be a symlink" +image_parent=$(cd -- "$image_parent_arg" 2>/dev/null && pwd -P) || + die "image output parent must already exist" +evidence_parent=$(cd -- "$evidence_parent_arg" 2>/dev/null && pwd -P) || + die "evidence output parent must already exist" +[[ -d "$image_parent" ]] || + die "image output parent must be a non-symlink directory" +[[ -d "$evidence_parent" ]] || + die "evidence output parent must be a non-symlink directory" +[[ "$image_parent" == "$evidence_parent" ]] || die "image and evidence outputs must share one parent" +[[ "$image_path" == "$image_parent/$(basename -- "$image_path")" && + "$evidence_path" == "$evidence_parent/$(basename -- "$evidence_path")" ]] || + die "record paths must be direct children of their canonical parent" + +if stat -f '%Lp' "$image_parent" >/dev/null 2>&1; then + parent_mode=$(stat -f '%Lp' "$image_parent") +else + parent_mode=$(stat -c '%a' "$image_parent") +fi +[[ "$parent_mode" == 700 ]] || die "record output parent must have mode 0700" + +inside_work_tree=$(env -i HOME="${HOME:-/}" PATH="$PATH" LC_ALL=C \ + git -C "$image_parent" rev-parse --is-inside-work-tree 2>/dev/null || true) +inside_git_dir=$(env -i HOME="${HOME:-/}" PATH="$PATH" LC_ALL=C \ + git -C "$image_parent" rev-parse --is-inside-git-dir 2>/dev/null || true) +if [[ "$inside_work_tree" == true || "$inside_git_dir" == true ]]; then + die "record output parent must be outside every Git repository" +fi + +[[ ! -e "$image_path" && ! -L "$image_path" ]] || die "image output already exists" +[[ ! -e "$evidence_path" && ! -L "$evidence_path" ]] || die "evidence output already exists" +[[ "$provenance_protocol" == 1 ]] || die "provenance protocol must be the exact integer spelling 1" + +image_json=$(jq -cnS \ + --arg repository "$repository" \ + --arg tag "$release_tag" \ + --arg digest "$image_digest" \ + --arg source "$source_revision" \ + --arg protocol "$provenance_protocol" \ + '{digest:$digest,"image-source-revision":$source,"provenance-protocol":($protocol | tonumber), + repository:$repository,tag:$tag}' 2>/dev/null) || die "provenance protocol is not numeric" +evidence_json=$(jq -cnS \ + --arg challenge "$approval_challenge" \ + --arg login "$approver_login" \ + --arg digest "$image_digest" \ + --arg tag "$release_tag" \ + --arg reviewed "$reviewed_at" \ + --arg attempt "$run_attempt" \ + --arg run_id "$run_id" \ + --arg screenshot "$screenshot_sha256" \ + --arg source "$source_revision" \ + '{"approval-challenge":$challenge,"approver-login":$login,"image-digest":$digest, + "release-tag":$tag,"reviewed-at":$reviewed,"run-attempt":$attempt,"run-id":$run_id, + "schema-version":1,"screenshot-sha256":$screenshot,"source-revision":$source}') + +image_tmp= +evidence_tmp= +image_published=false +evidence_published=false +cleanup() { + status=$? + trap - EXIT + if [[ "$evidence_published" == true && "$image_published" != true && + -n "$evidence_tmp" && -e "$evidence_path" && "$evidence_path" -ef "$evidence_tmp" ]]; then + rm -f -- "$evidence_path" + fi + if [[ "$image_published" == true && "$evidence_published" != true && + -n "$image_tmp" && -e "$image_path" && "$image_path" -ef "$image_tmp" ]]; then + rm -f -- "$image_path" + fi + [[ -z "$image_tmp" ]] || rm -f -- "$image_tmp" + [[ -z "$evidence_tmp" ]] || rm -f -- "$evidence_tmp" + exit "$status" +} +trap cleanup EXIT + +image_tmp=$(mktemp "$image_parent/.edgezero-image-record.XXXXXX") || die "cannot create image record" +evidence_tmp=$(mktemp "$evidence_parent/.edgezero-release-evidence.XXXXXX") || + die "cannot create release evidence" +printf '%s' "$image_json" >"$image_tmp" +printf '%s' "$evidence_json" >"$evidence_tmp" +chmod 0644 "$image_tmp" "$evidence_tmp" + +bash "$CHECK" validate-pair "$image_tmp" "$evidence_tmp" >/dev/null + +ln "$image_tmp" "$image_path" || die "image output appeared before publication" +image_published=true +ln "$evidence_tmp" "$evidence_path" || die "evidence output appeared before publication" +evidence_published=true diff --git a/.github/docker/build-app-cli/write-publisher-prerequisite.sh b/.github/docker/build-app-cli/write-publisher-prerequisite.sh new file mode 100755 index 00000000..b646c388 --- /dev/null +++ b/.github/docker/build-app-cli/write-publisher-prerequisite.sh @@ -0,0 +1,621 @@ +#!/usr/bin/env bash +# shellcheck disable=SC2016 +{ set +x; set +a; } 2>/dev/null +set -euo pipefail + +GIT_ALTERNATES_WERE_SET=false +[[ -z "${GIT_ALTERNATE_OBJECT_DIRECTORIES:-}" ]] || GIT_ALTERNATES_WERE_SET=true + +export LC_ALL=C +export BASH_ENV= +export ENV= +export GIT_CONFIG_NOSYSTEM=1 +export GIT_CONFIG_GLOBAL=/dev/null +export GIT_NO_REPLACE_OBJECTS=1 +unset GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE GIT_OBJECT_DIRECTORY +unset GIT_ALTERNATE_OBJECT_DIRECTORIES GIT_COMMON_DIR GIT_CEILING_DIRECTORIES +unset GIT_REPLACE_REF_BASE + +readonly API_VERSION=2026-03-10 +readonly API=https://api.github.com +readonly OWNER=stackpop +readonly REPOSITORY=stackpop/edgezero +readonly GATE_VARIABLE=EDGEZERO_BUILD_CONTAINER_GATE_SHA +readonly PREREQUISITE_VARIABLE=EDGEZERO_BUILD_CONTAINER_PUBLISHER_PREREQUISITE +readonly U64_MAX=18446744073709551615 +readonly U32_MAX=4294967295 + +usage() { + printf '%s\n' \ + "usage: write-publisher-prerequisite.sh \\" \ + " --gate-root \\" \ + " --gate-sha \\" \ + " --evidence-json \\" \ + " --publisher-prerequisite-json \\" \ + " --writer-token-review-json \\" \ + ' --writer-token-review-png ' >&2 + exit 2 +} + +die() { + printf '::error::%s\n' "$*" >&2 + exit 1 +} + +tool_die() { + printf '::error::%s\n' "$*" >&2 + exit 2 +} + +is_positive_decimal_at_most() { + local value=$1 maximum=$2 + [[ "$value" =~ ^[1-9][0-9]*$ ]] || return 1 + ((${#value} < ${#maximum})) && return 0 + ((${#value} == ${#maximum})) && [[ "$value" < "$maximum" || "$value" == "$maximum" ]] +} + +decimal_compare() { + local left=$1 right=$2 + if ((${#left} < ${#right})) || ((${#left} == ${#right})) && [[ "$left" < "$right" ]]; then + printf '%s' -1 + elif ((${#left} > ${#right})) || ((${#left} == ${#right})) && [[ "$left" > "$right" ]]; then + printf '%s' 1 + else + printf '%s' 0 + fi +} + +isolated_git() { + env -i PATH="$PATH" LC_ALL=C HOME=/dev/null TMPDIR=/tmp \ + GIT_CONFIG_NOSYSTEM=1 GIT_CONFIG_GLOBAL=/dev/null GIT_TERMINAL_PROMPT=0 \ + GIT_NO_REPLACE_OBJECTS=1 GIT_NO_LAZY_FETCH=1 GIT_OPTIONAL_LOCKS=0 \ + git --no-replace-objects -c core.fsmonitor=false -c core.hooksPath=/dev/null "$@" +} + +gate_git() { + isolated_git -C "$GATE_ROOT" "$@" +} + +clean_jq() { + env -i PATH="$PATH" LC_ALL=C HOME=/dev/null jq "$@" +} + +clean_cmp() { + env -i PATH="$PATH" LC_ALL=C HOME=/dev/null cmp "$@" +} + +temporary_files=() +remove_temporary_files() { + local path + for path in ${temporary_files[@]+"${temporary_files[@]}"}; do + [[ -z "$path" ]] || rm -f -- "$path" 2>/dev/null || true + done +} + +cleanup_exit() { + local status=$? + trap - EXIT HUP INT TERM + remove_temporary_files + exit "$status" +} + +cleanup_signal() { + local status=$1 + trap - EXIT HUP INT TERM + remove_temporary_files + exit "$status" +} + +trap cleanup_exit EXIT +trap 'cleanup_signal 129' HUP +trap 'cleanup_signal 130' INT +trap 'cleanup_signal 143' TERM + +file_size() { + local path=$1 size + if size=$(env -i PATH="$PATH" LC_ALL=C stat -f '%z' -- "$path" 2>/dev/null); then + : + elif size=$(env -i PATH="$PATH" LC_ALL=C stat -c '%s' -- "$path" 2>/dev/null); then + : + else + tool_die "cannot inspect input file size" + fi + [[ "$size" =~ ^[0-9]+$ ]] || tool_die "file size tool returned malformed output" + printf '%s' "$size" +} + +validate_input_file() { + local path=$1 label=$2 minimum=${3:-0} maximum=${4:-0} + local parent name canonical_parent expected size + [[ "$path" == /* && -f "$path" && ! -L "$path" ]] || + die "$label must be an absolute regular non-symlink file" + name=${path##*/} + parent=${path%/*} + [[ -n "$parent" ]] || parent=/ + canonical_parent=$(cd -- "$parent" && pwd -P) || die "cannot resolve $label parent" + if [[ "$canonical_parent" == / ]]; then expected="/$name"; else expected="$canonical_parent/$name"; fi + [[ "$expected" == "$path" ]] || die "$label path must already be canonical" + if ((minimum > 0 || maximum > 0)); then + size=$(file_size "$path") + ((size >= minimum && size <= maximum)) || die "$label size is outside its allowed bounds" + fi +} + +validate_raw_json_keys() { + local path=$1 label=$2 + clean_jq -ne --stream ' + [inputs | select(length == 2) | (.[0] | tojson)] as $paths + | ($paths | length) == ($paths | unique | length) + ' "$path" >/dev/null 2>&1 || die "$label contains duplicate or malformed JSON keys" +} + +validate_utc() { + local value=$1 label=$2 epoch round_trip + [[ "$value" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$ ]] || + die "$label is not canonical UTC" + epoch=$(clean_jq -nr --arg value "$value" '$value | fromdateiso8601' 2>/dev/null) || + die "$label is not a valid UTC instant" + round_trip=$(clean_jq -nr --argjson epoch "$epoch" '$epoch | strftime("%Y-%m-%dT%H:%M:%SZ")' 2>/dev/null) || + die "$label cannot be normalized" + [[ "$round_trip" == "$value" ]] || die "$label is not a real calendar instant" + printf '%s' "$epoch" +} + +sha256_file() { + local output digest + output=$(env -i PATH="$PATH" LC_ALL=C sha256sum "$1" 2>/dev/null) || + tool_die "cannot hash input file" + digest=${output%% *} + [[ "$digest" =~ ^[0-9a-f]{64}$ ]] || tool_die "SHA-256 tool returned malformed output" + printf '%s' "$digest" +} + +require_exact_string() { + local path=$1 expected=$2 label=$3 staged + staged=$(mktemp /tmp/.edgezero-publisher-canonical.XXXXXX 2>/dev/null) || + die "cannot stage canonical $label" + temporary_files+=("$staged") + printf '%s' "$expected" >"$staged" || die "cannot stage canonical $label" + clean_cmp -s "$path" "$staged" || die "$label is not exact JCS" +} + +extract_variable_value() { + local body=$1 name=$2 output=$3 label=$4 + clean_jq -e -j -s --arg name "$name" ' + if length == 1 and (.[0] | type) == "object" + and .[0].name == $name and (.[0].value | type) == "string" + then .[0].value + else error("invalid variable response") end + ' "$body" >"$output" 2>/dev/null || die "$label response is invalid" +} + +parse_record() { + local path=$1 label=$2 canonical + validate_raw_json_keys "$path" "$label" + clean_jq -e ' + type == "object" + and keys == ["evidence-sha256","evidence-url","gate-sha","previous-value-sha256","rotation-history","schema-version","source-pr","source-revision"] + and (."evidence-sha256" | type) == "string" + and ((."evidence-url" == null) or ((."evidence-url" | type) == "string")) + and (."gate-sha" | type) == "string" + and ((."previous-value-sha256" == null) or ((."previous-value-sha256" | type) == "string")) + and ((."source-pr" == null) or ((."source-pr" | type) == "string")) + and ((."source-revision" == null) or ((."source-revision" | type) == "string")) + and ."schema-version" == 2 + and ((."schema-version" | type) == "number") + and (."rotation-history" | type) == "object" + and ( + (."rotation-history" == {"state":"bootstrap-no-rotation"}) + or ( + (."rotation-history" | keys) == ["created-at","evidence-sha256","history-sha256","run-attempt","run-id","run-number","state"] + and (."rotation-history"."created-at" | type) == "string" + and (."rotation-history"."evidence-sha256" | type) == "string" + and (."rotation-history"."history-sha256" | type) == "string" + and (."rotation-history"."run-attempt" | type) == "string" + and (."rotation-history"."run-id" | type) == "string" + and (."rotation-history"."run-number" | type) == "string" + and ."rotation-history".state == "verified" + ) + ) + ' "$path" >/dev/null 2>&1 || die "$label has the wrong JSON shape or types" + + REC_EVIDENCE=$(clean_jq -er '."evidence-sha256"' "$path" 2>/dev/null) || die "$label evidence digest is absent" + REC_EVIDENCE_URL=$(clean_jq -r 'if ."evidence-url" == null then "null" else ."evidence-url" end' "$path") + REC_GATE=$(clean_jq -er '."gate-sha"' "$path" 2>/dev/null) || die "$label gate SHA is absent" + REC_PREVIOUS=$(clean_jq -r 'if ."previous-value-sha256" == null then "null" else ."previous-value-sha256" end' "$path") + REC_SOURCE_PR=$(clean_jq -r 'if ."source-pr" == null then "null" else ."source-pr" end' "$path") + REC_SOURCE=$(clean_jq -r 'if ."source-revision" == null then "null" else ."source-revision" end' "$path") + REC_STATE=$(clean_jq -er '."rotation-history".state' "$path" 2>/dev/null) || die "$label rotation state is absent" + [[ "$REC_EVIDENCE" =~ ^sha256:[0-9a-f]{64}$ ]] || die "$label evidence digest is not canonical" + [[ "$REC_GATE" =~ ^[0-9a-f]{40}$ ]] || die "$label gate SHA is not canonical" + [[ "$REC_PREVIOUS" == null || "$REC_PREVIOUS" =~ ^sha256:[0-9a-f]{64}$ ]] || + die "$label previous-value digest is not canonical" + [[ "$REC_SOURCE" == null || "$REC_SOURCE" =~ ^[0-9a-f]{40}$ ]] || + die "$label source revision is not canonical" + if [[ "$REC_SOURCE" == null ]]; then + [[ "$REC_SOURCE_PR" == null && "$REC_EVIDENCE_URL" == null ]] || + die "$label inert source tuple is partial" + else + [[ "$REC_SOURCE_PR" != null && "$REC_EVIDENCE_URL" != null ]] || + die "$label release source tuple is partial" + is_positive_decimal_at_most "$REC_SOURCE_PR" "$U64_MAX" || + die "$label source PR is not a positive u64" + [[ "$REC_EVIDENCE_URL" =~ ^https://github\.com/stackpop/edgezero/pull/([1-9][0-9]*)#issuecomment-([1-9][0-9]*)$ ]] || + die "$label evidence URL is not canonical" + [[ "${BASH_REMATCH[1]}" == "$REC_SOURCE_PR" ]] || + die "$label evidence URL PR differs from source PR" + is_positive_decimal_at_most "${BASH_REMATCH[2]}" "$U64_MAX" || + die "$label evidence comment id is not a positive u64" + fi + + if [[ "$REC_STATE" == bootstrap-no-rotation ]]; then + REC_CREATED='' + REC_ROTATION_EVIDENCE='' + REC_HISTORY_EVIDENCE='' + REC_RUN_ATTEMPT='' + REC_RUN_ID='' + REC_RUN_NUMBER='' + canonical=$(printf '{"evidence-sha256":"%s","evidence-url":%s,"gate-sha":"%s","previous-value-sha256":%s,"rotation-history":{"state":"bootstrap-no-rotation"},"schema-version":2,"source-pr":%s,"source-revision":%s}' \ + "$REC_EVIDENCE" "$([[ "$REC_EVIDENCE_URL" == null ]] && printf null || printf '"%s"' "$REC_EVIDENCE_URL")" \ + "$REC_GATE" "$([[ "$REC_PREVIOUS" == null ]] && printf null || printf '"%s"' "$REC_PREVIOUS")" \ + "$([[ "$REC_SOURCE_PR" == null ]] && printf null || printf '"%s"' "$REC_SOURCE_PR")" \ + "$([[ "$REC_SOURCE" == null ]] && printf null || printf '"%s"' "$REC_SOURCE")") + else + REC_CREATED=$(clean_jq -er '."rotation-history"."created-at"' "$path" 2>/dev/null) || die "$label rotation time is absent" + REC_ROTATION_EVIDENCE=$(clean_jq -er '."rotation-history"."evidence-sha256"' "$path" 2>/dev/null) || die "$label rotation digest is absent" + REC_HISTORY_EVIDENCE=$(clean_jq -er '."rotation-history"."history-sha256"' "$path" 2>/dev/null) || die "$label rotation history digest is absent" + REC_RUN_ATTEMPT=$(clean_jq -er '."rotation-history"."run-attempt"' "$path" 2>/dev/null) || die "$label run attempt is absent" + REC_RUN_ID=$(clean_jq -er '."rotation-history"."run-id"' "$path" 2>/dev/null) || die "$label run id is absent" + REC_RUN_NUMBER=$(clean_jq -er '."rotation-history"."run-number"' "$path" 2>/dev/null) || die "$label run number is absent" + validate_utc "$REC_CREATED" "$label rotation creation time" >/dev/null + [[ "$REC_ROTATION_EVIDENCE" =~ ^sha256:[0-9a-f]{64}$ ]] || die "$label rotation digest is not canonical" + [[ "$REC_HISTORY_EVIDENCE" =~ ^sha256:[0-9a-f]{64}$ ]] || die "$label rotation history digest is not canonical" + is_positive_decimal_at_most "$REC_RUN_ATTEMPT" "$U32_MAX" || die "$label run attempt is not a positive u32" + is_positive_decimal_at_most "$REC_RUN_ID" "$U64_MAX" || die "$label run id is not a positive u64" + is_positive_decimal_at_most "$REC_RUN_NUMBER" "$U64_MAX" || die "$label run number is not a positive u64" + canonical=$(printf '{"evidence-sha256":"%s","evidence-url":%s,"gate-sha":"%s","previous-value-sha256":%s,"rotation-history":{"created-at":"%s","evidence-sha256":"%s","history-sha256":"%s","run-attempt":"%s","run-id":"%s","run-number":"%s","state":"verified"},"schema-version":2,"source-pr":%s,"source-revision":%s}' \ + "$REC_EVIDENCE" "$([[ "$REC_EVIDENCE_URL" == null ]] && printf null || printf '"%s"' "$REC_EVIDENCE_URL")" \ + "$REC_GATE" "$([[ "$REC_PREVIOUS" == null ]] && printf null || printf '"%s"' "$REC_PREVIOUS")" \ + "$REC_CREATED" "$REC_ROTATION_EVIDENCE" "$REC_HISTORY_EVIDENCE" "$REC_RUN_ATTEMPT" "$REC_RUN_ID" "$REC_RUN_NUMBER" \ + "$([[ "$REC_SOURCE_PR" == null ]] && printf null || printf '"%s"' "$REC_SOURCE_PR")" \ + "$([[ "$REC_SOURCE" == null ]] && printf null || printf '"%s"' "$REC_SOURCE")") + fi + require_exact_string "$path" "$canonical" "$label" +} + +GATE_ROOT= +GATE_SHA= +EVIDENCE_JSON= +PREREQUISITE_JSON= +REVIEW_JSON= +REVIEW_PNG= +seen_flags=' ' + +while (($#)); do + (($# >= 2)) || usage + flag=$1 + value=$2 + shift 2 + case "$flag" in + --gate-root | --gate-sha | --evidence-json | --publisher-prerequisite-json | \ + --writer-token-review-json | --writer-token-review-png) ;; + *) usage ;; + esac + [[ -n "$value" ]] || usage + [[ "$seen_flags" != *" $flag "* ]] || usage + seen_flags+="$flag " + case "$flag" in + --gate-root) GATE_ROOT=$value ;; + --gate-sha) GATE_SHA=$value ;; + --evidence-json) EVIDENCE_JSON=$value ;; + --publisher-prerequisite-json) PREREQUISITE_JSON=$value ;; + --writer-token-review-json) REVIEW_JSON=$value ;; + --writer-token-review-png) REVIEW_PNG=$value ;; + esac +done + +for required in --gate-root --gate-sha --evidence-json --publisher-prerequisite-json \ + --writer-token-review-json --writer-token-review-png; do + [[ "$seen_flags" == *" $required "* ]] || usage +done + +for tool in env git jq curl date mktemp stat rm cmp od tr sha256sum; do + command -v "$tool" >/dev/null 2>&1 || tool_die "publisher prerequisite writer requires $tool" +done + +unset WRITER_TOKEN +[[ -n "${EDGEZERO_PUBLISHER_PREREQUISITE_WRITE_TOKEN:-}" ]] || die "publisher prerequisite write token is absent" +WRITER_TOKEN=$EDGEZERO_PUBLISHER_PREREQUISITE_WRITE_TOKEN +unset EDGEZERO_PUBLISHER_PREREQUISITE_WRITE_TOKEN +readonly WRITER_TOKEN +[[ "$WRITER_TOKEN" != *$'\n'* && "$WRITER_TOKEN" != *$'\r'* && "$WRITER_TOKEN" != *'"'* && "$WRITER_TOKEN" != *\\* ]] || + die "publisher prerequisite write token cannot be encoded safely" + +[[ "$GATE_SHA" =~ ^[0-9a-f]{40}$ ]] || die "gate SHA is not a full lowercase SHA" +[[ "$GATE_ROOT" == /* && -d "$GATE_ROOT" && ! -L "$GATE_ROOT" ]] || + die "gate root must be an absolute non-symlink directory" +CANONICAL_GATE_ROOT=$(cd -- "$GATE_ROOT" && pwd -P) || die "cannot resolve gate root" +[[ "$CANONICAL_GATE_ROOT" == "$GATE_ROOT" ]] || die "gate root must already be canonical" +[[ "$GIT_ALTERNATES_WERE_SET" == false ]] || die "gate checkout cannot use environment object alternates" +[[ "$(gate_git rev-parse --is-inside-work-tree 2>/dev/null)" == true ]] || die "gate root is not a Git worktree" +[[ "$(gate_git rev-parse --show-toplevel 2>/dev/null)" == "$GATE_ROOT" ]] || + die "gate root must be the exact repository top level" +GIT_DIRECTORY=$(gate_git rev-parse --absolute-git-dir 2>/dev/null) || die "cannot resolve gate Git directory" +COMMON_GIT_DIRECTORY=$(gate_git rev-parse --path-format=absolute --git-common-dir 2>/dev/null) || + die "cannot resolve gate common Git directory" +OBJECT_DIRECTORY=$(gate_git rev-parse --path-format=absolute --git-path objects 2>/dev/null) || + die "cannot resolve gate object directory" +if gate_git config --local --get-regexp \ + '^(extensions\.partial[Cc]lone|remote\..*\.(promisor|partial[Cc]lone[Ff]ilter))$' >/dev/null 2>&1; then + die "gate checkout cannot use partial or promisor object storage" +fi +for alternates_file in \ + "$OBJECT_DIRECTORY/info/alternates" \ + "$GIT_DIRECTORY/objects/info/alternates" \ + "$COMMON_GIT_DIRECTORY/objects/info/alternates"; do + [[ ! -e "$alternates_file" && ! -L "$alternates_file" ]] || + die "gate checkout cannot use object alternates" +done +[[ ! -e "$GIT_DIRECTORY/info/grafts" && ! -L "$GIT_DIRECTORY/info/grafts" && + ! -e "$COMMON_GIT_DIRECTORY/info/grafts" && ! -L "$COMMON_GIT_DIRECTORY/info/grafts" ]] || + die "gate checkout cannot contain legacy grafts" +REPLACEMENT_REFS=$(gate_git for-each-ref --format='%(refname)' refs/replace/) || die "cannot inspect replacement refs" +[[ -z "$REPLACEMENT_REFS" ]] || die "gate checkout cannot contain replacement refs" +[[ "$(gate_git rev-parse --is-shallow-repository 2>/dev/null)" == false ]] || die "gate checkout must contain full history" +[[ "$(gate_git config --bool core.sparseCheckout 2>/dev/null || true)" != true ]] || die "gate checkout cannot be sparse" +GATE_STATUS=$(gate_git status --porcelain=v1 --untracked-files=all --ignore-submodules=none) || die "cannot inspect gate checkout" +[[ -z "$GATE_STATUS" ]] || die "gate checkout must be clean" +[[ "$(gate_git rev-parse --verify HEAD 2>/dev/null)" == "$GATE_SHA" ]] || die "gate checkout HEAD differs from gate SHA" +if gate_git symbolic-ref -q HEAD >/dev/null 2>&1; then die "gate checkout must be detached"; fi + +SCRIPT_PATH=${BASH_SOURCE[0]} +[[ "$SCRIPT_PATH" == /* ]] || die "writer must be invoked by its absolute gate path" +EXPECTED_SCRIPT="$GATE_ROOT/.github/docker/build-app-cli/write-publisher-prerequisite.sh" +[[ "$SCRIPT_PATH" == "$EXPECTED_SCRIPT" && -f "$SCRIPT_PATH" && ! -L "$SCRIPT_PATH" ]] || + die "writer must execute from the supplied gate revision" + +validate_input_file "$EVIDENCE_JSON" "evidence JSON" 1 1048576 +validate_input_file "$PREREQUISITE_JSON" "publisher prerequisite JSON" +validate_input_file "$REVIEW_JSON" "writer token review JSON" +validate_input_file "$REVIEW_PNG" "writer token review PNG" 8 10485760 + +PNG_MAGIC=$(env -i PATH="$PATH" LC_ALL=C od -An -tx1 -N8 -- "$REVIEW_PNG" 2>/dev/null | tr -d '[:space:]') || + tool_die "cannot inspect writer token review PNG" +[[ "$PNG_MAGIC" == 89504e470d0a1a0a ]] || die "writer token review PNG signature is invalid" + +validate_raw_json_keys "$REVIEW_JSON" "writer token review" +clean_jq -e ' + type == "object" + and keys == ["expires-at","organization-grants","repository-grants","resource-owner","reviewed-at","reviewer-login","schema-version","screenshot-sha256","selected-repositories","subject-login","token-id"] + and ."organization-grants" == {"members":"read","other-displayed":"none"} + and ."repository-grants" == {"metadata":"read","other-displayed":"none","variables":"write"} + and ."resource-owner" == "stackpop" + and ."selected-repositories" == ["stackpop/edgezero"] + and ."schema-version" == 1 and (."schema-version" | type) == "number" + and (."expires-at" | type) == "string" + and (."reviewed-at" | type) == "string" + and (."reviewer-login" | type) == "string" + and (."screenshot-sha256" | type) == "string" + and (."subject-login" | type) == "string" + and (."token-id" | type) == "string" +' "$REVIEW_JSON" >/dev/null 2>&1 || die "writer token review has the wrong JSON shape or grants" + +REVIEW_EXPIRES=$(clean_jq -er '."expires-at"' "$REVIEW_JSON") +REVIEW_REVIEWED=$(clean_jq -er '."reviewed-at"' "$REVIEW_JSON") +REVIEW_REVIEWER=$(clean_jq -er '."reviewer-login"' "$REVIEW_JSON") +REVIEW_SCREENSHOT=$(clean_jq -er '."screenshot-sha256"' "$REVIEW_JSON") +REVIEW_SUBJECT=$(clean_jq -er '."subject-login"' "$REVIEW_JSON") +REVIEW_TOKEN_ID=$(clean_jq -er '."token-id"' "$REVIEW_JSON") +for login in "$REVIEW_REVIEWER" "$REVIEW_SUBJECT"; do + [[ "$login" =~ ^[A-Za-z0-9]([A-Za-z0-9-]{0,37}[A-Za-z0-9])?$ && "$login" != *--* ]] || + die "writer token review contains an invalid login" +done +[[ "$REVIEW_REVIEWER" != "$REVIEW_SUBJECT" ]] || die "writer token reviewer must differ from subject" +[[ "$REVIEW_SCREENSHOT" =~ ^sha256:[0-9a-f]{64}$ ]] || die "writer token screenshot digest is not canonical" +is_positive_decimal_at_most "$REVIEW_TOKEN_ID" "$U64_MAX" || die "writer token id is not a positive u64" +REVIEW_CANONICAL=$(printf '{"expires-at":"%s","organization-grants":{"members":"read","other-displayed":"none"},"repository-grants":{"metadata":"read","other-displayed":"none","variables":"write"},"resource-owner":"stackpop","reviewed-at":"%s","reviewer-login":"%s","schema-version":1,"screenshot-sha256":"%s","selected-repositories":["stackpop/edgezero"],"subject-login":"%s","token-id":"%s"}' \ + "$REVIEW_EXPIRES" "$REVIEW_REVIEWED" "$REVIEW_REVIEWER" "$REVIEW_SCREENSHOT" "$REVIEW_SUBJECT" "$REVIEW_TOKEN_ID") +require_exact_string "$REVIEW_JSON" "$REVIEW_CANONICAL" "writer token review" + +REVIEWED_EPOCH=$(validate_utc "$REVIEW_REVIEWED" "writer token review time") +EXPIRES_EPOCH=$(validate_utc "$REVIEW_EXPIRES" "writer token expiration") +NOW_EPOCH=$(env -i PATH="$PATH" LC_ALL=C date -u +%s 2>/dev/null) || tool_die "cannot read current UTC time" +[[ "$NOW_EPOCH" =~ ^[0-9]+$ ]] || tool_die "UTC time source returned malformed output" +((REVIEWED_EPOCH <= NOW_EPOCH)) || die "writer token review is in the future" +((EXPIRES_EPOCH > NOW_EPOCH)) || die "writer token is expired" +[[ "sha256:$(sha256_file "$REVIEW_PNG")" == "$REVIEW_SCREENSHOT" ]] || die "writer token screenshot digest differs" + +parse_record "$PREREQUISITE_JSON" "publisher prerequisite record" +NEW_EVIDENCE=$REC_EVIDENCE +NEW_GATE=$REC_GATE +NEW_PREVIOUS=$REC_PREVIOUS +NEW_SOURCE_PR=$REC_SOURCE_PR +NEW_SOURCE=$REC_SOURCE +NEW_STATE=$REC_STATE +NEW_CREATED=$REC_CREATED +NEW_ROTATION_EVIDENCE=$REC_ROTATION_EVIDENCE +NEW_HISTORY_EVIDENCE=$REC_HISTORY_EVIDENCE +NEW_RUN_ATTEMPT=$REC_RUN_ATTEMPT +NEW_RUN_ID=$REC_RUN_ID +NEW_RUN_NUMBER=$REC_RUN_NUMBER +[[ "$NEW_GATE" == "$GATE_SHA" ]] || die "publisher prerequisite gate differs from supplied gate" +[[ "sha256:$(sha256_file "$EVIDENCE_JSON")" == "$NEW_EVIDENCE" ]] || die "opaque evidence digest differs" +if [[ "$NEW_SOURCE" != null ]]; then + gate_git cat-file -e "$NEW_SOURCE^{commit}" 2>/dev/null || die "source revision is not a commit" + gate_git merge-base --is-ancestor "$GATE_SHA" "$NEW_SOURCE" 2>/dev/null || die "source revision does not descend from gate" +fi + +curl_get() { + local label=$1 url=$2 result_name=$3 body metadata line + local -a lines=() + body=$(mktemp /tmp/.edgezero-publisher-body.XXXXXX 2>/dev/null) || die "cannot create API response file" + temporary_files+=("$body") + metadata=$(mktemp /tmp/.edgezero-publisher-metadata.XXXXXX 2>/dev/null) || die "cannot create API metadata file" + temporary_files+=("$metadata") + if ! printf '%s\n' \ + 'header = "Accept: application/vnd.github+json"' \ + 'header = "X-GitHub-Api-Version: 2026-03-10"' \ + 'header = "User-Agent: edgezero-build-container-gate/1"' \ + "header = \"Authorization: Bearer $WRITER_TOKEN\"" | + env -i PATH="$PATH" LC_ALL=C curl \ + --disable --silent --show-error --connect-timeout 10 --max-time 30 --max-redirs 0 \ + --request GET --config - --output "$body" \ + --write-out '%{http_code}\n%header{x-github-api-version-selected}\n%header{content-type}' \ + "$url" >"$metadata" 2>/dev/null; then + die "$label request failed" + fi + while IFS= read -r line || [[ -n "$line" ]]; do lines+=("$line"); done <"$metadata" + [[ "${#lines[@]}" -eq 3 && "${lines[0]}" == 200 ]] || die "$label response did not return HTTP 200" + [[ "${lines[1]}" == "$API_VERSION" ]] || die "$label selected an unexpected API version" + [[ "${lines[2]}" =~ ^[Aa][Pp][Pp][Ll][Ii][Cc][Aa][Tt][Ii][Oo][Nn]/[Jj][Ss][Oo][Nn]([[:space:]]*\;[[:space:]]*[Cc][Hh][Aa][Rr][Ss][Ee][Tt][[:space:]]*=[[:space:]]*[Uu][Tt][Ff]-8)?$ ]] || + die "$label response has an unsupported media type" + clean_jq -e -s 'length == 1' "$body" >/dev/null 2>&1 || die "$label response is not one complete JSON value" + printf -v "$result_name" '%s' "$body" +} + +curl_patch() { + local request=$1 body metadata line + local -a lines=() + body=$(mktemp /tmp/.edgezero-publisher-body.XXXXXX 2>/dev/null) || die "cannot create PATCH response file" + temporary_files+=("$body") + metadata=$(mktemp /tmp/.edgezero-publisher-metadata.XXXXXX 2>/dev/null) || die "cannot create PATCH metadata file" + temporary_files+=("$metadata") + if ! printf '%s\n' \ + 'header = "Accept: application/vnd.github+json"' \ + 'header = "X-GitHub-Api-Version: 2026-03-10"' \ + 'header = "User-Agent: edgezero-build-container-gate/1"' \ + 'header = "Content-Type: application/json"' \ + "header = \"Authorization: Bearer $WRITER_TOKEN\"" | + env -i PATH="$PATH" LC_ALL=C curl \ + --disable --silent --show-error --connect-timeout 10 --max-time 30 --max-redirs 0 \ + --request PATCH --config - --data-binary "@$request" --output "$body" \ + --write-out '%{http_code}\n%header{x-github-api-version-selected}\n%header{content-type}' \ + "$API/repos/$REPOSITORY/actions/variables/$PREREQUISITE_VARIABLE" >"$metadata" 2>/dev/null; then + die "publisher prerequisite PATCH failed" + fi + while IFS= read -r line || [[ -n "$line" ]]; do lines+=("$line"); done <"$metadata" + [[ ("${#lines[@]}" -eq 2 || "${#lines[@]}" -eq 3) && "${lines[0]}" == 204 ]] || + die "publisher prerequisite PATCH did not return HTTP 204" + [[ "${lines[1]}" == "$API_VERSION" ]] || die "publisher prerequisite PATCH selected an unexpected API version" + [[ ! -s "$body" ]] || die "publisher prerequisite PATCH returned a nonempty body" +} + +USER_BODY= +curl_get "authenticated user" "$API/user" USER_BODY +USER_VALUES=$(clean_jq -er -s ' + if length == 1 and (.[0] | type) == "object" + and (.[0].login | type) == "string" and (.[0].id | type) == "number" + then [.[0].login, (.[0].id | tostring)] | @tsv + else error("invalid user") end +' "$USER_BODY" 2>/dev/null) || die "authenticated user response is invalid" +IFS=$'\t' read -r USER_LOGIN USER_ID USER_EXTRA <<<"$USER_VALUES" +[[ -z "${USER_EXTRA:-}" && "$USER_LOGIN" == "$REVIEW_SUBJECT" ]] || die "authenticated user login differs from review" +is_positive_decimal_at_most "$USER_ID" "$U64_MAX" || die "authenticated user id is not a positive u64" + +MEMBERSHIP_BODY= +curl_get "organization membership" "$API/orgs/$OWNER/memberships/$REVIEW_SUBJECT" MEMBERSHIP_BODY +clean_jq -e -s --arg login "$REVIEW_SUBJECT" ' + length == 1 and (.[0] | type) == "object" + and .[0].state == "active" and .[0].user.login == $login +' "$MEMBERSHIP_BODY" >/dev/null 2>&1 || die "organization membership is not active for reviewed subject" + +GATE_BODY= +curl_get "active gate variable" "$API/repos/$REPOSITORY/actions/variables/$GATE_VARIABLE" GATE_BODY +clean_jq -e -s --arg name "$GATE_VARIABLE" --arg value "$GATE_SHA" ' + length == 1 and (.[0] | type) == "object" + and .[0].name == $name and .[0].value == $value +' "$GATE_BODY" >/dev/null 2>&1 || die "active gate variable differs from supplied gate" + +CURRENT_BODY= +curl_get "current publisher prerequisite" "$API/repos/$REPOSITORY/actions/variables/$PREREQUISITE_VARIABLE" CURRENT_BODY +CURRENT_FILE=$(mktemp /tmp/.edgezero-publisher-current.XXXXXX 2>/dev/null) || die "cannot create current state file" +temporary_files+=("$CURRENT_FILE") +extract_variable_value "$CURRENT_BODY" "$PREREQUISITE_VARIABLE" "$CURRENT_FILE" \ + "current publisher prerequisite" +parse_record "$CURRENT_FILE" "current publisher prerequisite" +OLD_GATE=$REC_GATE +OLD_SOURCE_PR=$REC_SOURCE_PR +OLD_SOURCE=$REC_SOURCE +OLD_STATE=$REC_STATE +OLD_CREATED=$REC_CREATED +OLD_ROTATION_EVIDENCE=$REC_ROTATION_EVIDENCE +OLD_HISTORY_EVIDENCE=$REC_HISTORY_EVIDENCE +OLD_RUN_ATTEMPT=$REC_RUN_ATTEMPT +OLD_RUN_ID=$REC_RUN_ID +OLD_RUN_NUMBER=$REC_RUN_NUMBER + +gate_git cat-file -e "$OLD_GATE^{commit}" 2>/dev/null || die "current record gate is not a commit" +if [[ "$OLD_SOURCE" != null ]]; then + gate_git cat-file -e "$OLD_SOURCE^{commit}" 2>/dev/null || die "current source revision is not a commit" + gate_git merge-base --is-ancestor "$OLD_GATE" "$OLD_SOURCE" 2>/dev/null || die "current source does not descend from its gate" +fi + +if clean_cmp -s "$PREREQUISITE_JSON" "$CURRENT_FILE"; then + exit 0 +fi + +[[ "$NEW_PREVIOUS" == "sha256:$(sha256_file "$CURRENT_FILE")" ]] || die "previous-value digest differs from current bytes" + +HISTORY_EQUAL=false +if [[ "$OLD_STATE" == bootstrap-no-rotation && "$NEW_STATE" == bootstrap-no-rotation ]]; then + HISTORY_EQUAL=true +elif [[ "$OLD_STATE" == verified && "$NEW_STATE" == verified && + "$OLD_CREATED" == "$NEW_CREATED" && + "$OLD_ROTATION_EVIDENCE" == "$NEW_ROTATION_EVIDENCE" && + "$OLD_HISTORY_EVIDENCE" == "$NEW_HISTORY_EVIDENCE" && + "$OLD_RUN_ATTEMPT" == "$NEW_RUN_ATTEMPT" && + "$OLD_RUN_ID" == "$NEW_RUN_ID" && + "$OLD_RUN_NUMBER" == "$NEW_RUN_NUMBER" ]]; then + HISTORY_EQUAL=true +fi + +ROTATION_ADVANCED=false +if [[ "$OLD_STATE" == bootstrap-no-rotation ]]; then + if [[ "$NEW_STATE" == verified ]]; then ROTATION_ADVANCED=true; fi +elif [[ "$NEW_STATE" == bootstrap-no-rotation ]]; then + die "bootstrap rotation history cannot replace verified history" +elif [[ "$HISTORY_EQUAL" != true ]]; then + RUN_NUMBER_ORDER=$(decimal_compare "$OLD_RUN_NUMBER" "$NEW_RUN_NUMBER") + [[ "$RUN_NUMBER_ORDER" == -1 ]] || die "selected rotation run number did not advance" + [[ "$NEW_RUN_ID" != "$OLD_RUN_ID" ]] || die "forward rotation reused its run id" + [[ "$NEW_ROTATION_EVIDENCE" != "$OLD_ROTATION_EVIDENCE" ]] || die "forward rotation reused its receipt digest" + [[ "$NEW_HISTORY_EVIDENCE" != "$OLD_HISTORY_EVIDENCE" ]] || die "forward rotation reused its history digest" + ROTATION_ADVANCED=true +fi + +if [[ "$OLD_GATE" != "$NEW_GATE" ]]; then + [[ "$NEW_SOURCE" == null && "$NEW_STATE" == verified && "$ROTATION_ADVANCED" == true ]] || + die "gate change requires inert forward verified rotation state" +elif [[ "$OLD_SOURCE" == null && "$NEW_SOURCE" == null ]]; then + [[ "$NEW_STATE" == verified && "$ROTATION_ADVANCED" == true ]] || + die "inert refresh requires a forward verified rotation" +elif [[ "$OLD_SOURCE" != null && "$NEW_SOURCE" == null ]]; then + [[ "$NEW_STATE" == verified && "$ROTATION_ADVANCED" == true ]] || + die "same-gate source clear requires a forward verified rotation" +elif [[ "$OLD_SOURCE" == null && "$NEW_SOURCE" != null ]]; then + [[ "$HISTORY_EQUAL" == true ]] || die "source binding cannot change rotation history" +else + [[ "$HISTORY_EQUAL" == true ]] || die "source transition cannot change rotation history" + if [[ "$OLD_SOURCE" == "$NEW_SOURCE" ]]; then + [[ "$OLD_SOURCE_PR" == "$NEW_SOURCE_PR" ]] || die "same-source transition cannot change source PR" + fi + if [[ "$OLD_SOURCE" != "$NEW_SOURCE" ]]; then + gate_git merge-base --is-ancestor "$OLD_SOURCE" "$NEW_SOURCE" 2>/dev/null || + die "source transition is not forward" + fi +fi + +PATCH_FILE=$(mktemp /tmp/.edgezero-publisher-request.XXXXXX 2>/dev/null) || die "cannot create PATCH request" +temporary_files+=("$PATCH_FILE") +clean_jq -cjn --arg name "$PREREQUISITE_VARIABLE" --rawfile value "$PREREQUISITE_JSON" \ + '{name:$name,value:$value}' >"$PATCH_FILE" || die "cannot construct PATCH request" +curl_patch "$PATCH_FILE" + +READBACK_BODY= +curl_get "publisher prerequisite readback" "$API/repos/$REPOSITORY/actions/variables/$PREREQUISITE_VARIABLE" READBACK_BODY +READBACK_FILE=$(mktemp /tmp/.edgezero-publisher-readback.XXXXXX 2>/dev/null) || die "cannot create readback state file" +temporary_files+=("$READBACK_FILE") +extract_variable_value "$READBACK_BODY" "$PREREQUISITE_VARIABLE" "$READBACK_FILE" \ + "publisher prerequisite readback" +clean_cmp -s "$READBACK_FILE" "$PREREQUISITE_JSON" || + die "publisher prerequisite readback differs from requested bytes" diff --git a/.github/tools/edgezero-provenance-validator/Cargo.lock b/.github/tools/edgezero-provenance-validator/Cargo.lock new file mode 100644 index 00000000..d3c89915 --- /dev/null +++ b/.github/tools/edgezero-provenance-validator/Cargo.lock @@ -0,0 +1,792 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "getrandom", + "once_cell", + "serde", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "borrow-or-share" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc0b364ead1874514c8c2855ab558056ebfeb775653e7ae45ff72f28f8f3166c" + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytecount" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "data-encoding" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "edgezero-provenance-validator" +version = "0.1.0" +dependencies = [ + "jsonschema", + "serde", + "serde_json", + "sha2", +] + +[[package]] +name = "email_address" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449" +dependencies = [ + "serde", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "fancy-regex" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "476de73bddf2ef8490aa4ee8f1cf40b430bf1d56c48c22080e5186952cd580e6" +dependencies = [ + "bit-set", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "fluent-uri" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc74ac4d8359ae70623506d512209619e5cf8f347124910440dbc221714b328e" +dependencies = [ + "borrow-or-share", + "ref-cast", + "serde", +] + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "fraction" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e246562084dde8ebbcc943b261c406ce4f68e5032ec28029a251a47d6a295500" +dependencies = [ + "num", + "num-bigint", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi", + "wasip2", + "wasm-bindgen", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce57d20d1ea864ce2ac172ab472d409214f4fd359f0b2a2775abdf522e2af99e" +dependencies = [ + "cfg-if", + "wasm-bindgen", +] + +[[package]] +name = "jsonschema" +version = "0.54.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7070bf0681439ff992bb94947cb3a361f57c880561c1e9e90ddb5941c7ec2d04" +dependencies = [ + "ahash", + "bytecount", + "data-encoding", + "email_address", + "fancy-regex", + "fraction", + "getrandom", + "itoa", + "jsonschema-regex", + "jsonschema-value", + "num-cmp", + "num-traits", + "percent-encoding", + "referencing", + "regex", + "serde", + "serde_json", + "strum", + "unicode-general-category", + "uuid-simd", +] + +[[package]] +name = "jsonschema-regex" +version = "0.54.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6525f2f72c37c1a09f59a6d0d33f60ecf8beabd00be92fe8d49555cae15289c" +dependencies = [ + "regex-syntax", +] + +[[package]] +name = "jsonschema-value" +version = "0.54.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4309c6b52390b1ebdd2053e513e9f23d4bb18d0c3c2df8142eded3eb188a85bf" +dependencies = [ + "ahash", + "bytecount", + "fraction", + "getrandom", + "num-cmp", + "num-traits", + "serde_json", + "zmij", +] + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "micromap" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a86d3146ed3995b5913c414f6664344b9617457320782e64f0bb44afd49d74" + +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-cmp" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63335b2e2c34fae2fb0aa2cecfd9f0832a1e24b3b32ecec612c3426d46dc8aaa" + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-integer" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "outref" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "ref-cast" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e440fb4e4b4147295338efb76001ab9e4efc0e5839df2c47fc5ac2381d365c3" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "referencing" +version = "0.54.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0304d4734d208eaf24528093715e2cb5260c977b1be1eb81cd487f601e3ff35d" +dependencies = [ + "ahash", + "fluent-uri", + "getrandom", + "hashbrown", + "itoa", + "micromap", + "parking_lot", + "percent-encoding", + "serde_json", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "smallvec" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9be42f50aa861c555654aa3a37f52f4b1074bacf4e48fe0ef7fa584e80f1f0f" + +[[package]] +name = "strum" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-general-category" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b993bddc193ae5bd0d623b49ec06ac3e9312875fdae725a975c51db1cc1677f" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "uuid-simd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b082222b4f6619906941c17eb2297fff4c2fb96cb60164170522942a200bd8" +dependencies = [ + "outref", + "vsimd", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "vsimd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.5", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "zerocopy" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/.github/tools/edgezero-provenance-validator/Cargo.toml b/.github/tools/edgezero-provenance-validator/Cargo.toml new file mode 100644 index 00000000..7870e83c --- /dev/null +++ b/.github/tools/edgezero-provenance-validator/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "edgezero-provenance-validator" +version = "0.1.0" +edition = "2024" +rust-version = "1.95" +publish = false +license = "Apache-2.0" + +[workspace] + +[dependencies] +jsonschema = { version = "=0.54.0", default-features = false } +serde = { version = "=1.0.228", features = ["derive"] } +serde_json = "=1.0.150" +sha2 = "=0.10.9" diff --git a/.github/tools/edgezero-provenance-validator/src/archive.rs b/.github/tools/edgezero-provenance-validator/src/archive.rs new file mode 100644 index 00000000..a01b0c47 --- /dev/null +++ b/.github/tools/edgezero-provenance-validator/src/archive.rs @@ -0,0 +1,1386 @@ +use crate::Result; +use crate::json_contract::{BINARY_LIMIT, METADATA_LIMIT}; +use sha2::{Digest, Sha256}; +use std::io::{Read, Seek, SeekFrom, Write}; + +pub const BLOCK_SIZE: usize = 512; +const IO_CHUNK_SIZE: usize = 8 * 1024; +const METADATA_NAME: &[u8] = b"app-cli-meta.json"; +const BINARY_NAME: &[u8] = b"app-cli-bin"; +const METADATA_MODE: &[u8; 8] = b"0000644\0"; +const BINARY_MODE: &[u8; 8] = b"0000755\0"; +const ZERO_ID: &[u8; 8] = b"0000000\0"; +const ZERO_MTIME: &[u8; 12] = b"00000000000\0"; +const ZERO_BLOCK: [u8; BLOCK_SIZE] = [0; BLOCK_SIZE]; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ParsedArchive { + pub metadata: Vec, + pub binary_offset: u64, + pub binary_size: u64, +} + +pub fn encode( + writer: &mut W, + metadata: &mut M, + metadata_size: u64, + binary: &mut B, + binary_size: u64, +) -> Result<()> { + validate_sizes(metadata_size, binary_size)?; + let metadata_header = header(METADATA_NAME, METADATA_MODE, metadata_size)?; + let binary_header = header(BINARY_NAME, BINARY_MODE, binary_size)?; + + write_all(writer, &metadata_header)?; + copy_complete(metadata, writer, metadata_size)?; + write_padding(writer, metadata_size)?; + write_all(writer, &binary_header)?; + copy_complete(binary, writer, binary_size)?; + write_padding(writer, binary_size)?; + write_all(writer, &ZERO_BLOCK)?; + write_all(writer, &ZERO_BLOCK)?; + writer.flush().map_err(|error| error.to_string()) +} + +pub fn parse(reader: &mut R) -> Result { + require( + checked_seek(reader, SeekFrom::Current(0))? == 0, + "archive parsing must begin at offset zero", + )?; + + let metadata_size = read_header(reader, METADATA_NAME, METADATA_MODE)?; + require( + (1..=METADATA_LIMIT as u64).contains(&metadata_size), + "metadata payload is outside protocol bounds", + )?; + let metadata_len = usize::try_from(metadata_size).map_err(|_| "metadata size overflow")?; + let mut metadata = vec![0; metadata_len]; + read_exact(reader, &mut metadata)?; + read_zero_padding(reader, metadata_size)?; + + let binary_size = read_header(reader, BINARY_NAME, BINARY_MODE)?; + validate_sizes(metadata_size, binary_size)?; + let binary_offset = checked_seek(reader, SeekFrom::Current(0))?; + let binary_end = binary_offset + .checked_add(binary_size) + .ok_or("binary offset overflow")?; + checked_seek(reader, SeekFrom::Start(binary_end))?; + read_zero_padding(reader, binary_size)?; + + let mut end = [0; BLOCK_SIZE * 2]; + read_exact(reader, &mut end)?; + require( + end.iter().all(|byte| *byte == 0), + "invalid archive end blocks", + )?; + let mut trailing = [0]; + require( + read_retry(reader, &mut trailing)? == 0, + "archive has trailing data", + )?; + + Ok(ParsedArchive { + metadata, + binary_offset, + binary_size, + }) +} + +pub(crate) fn checked_seek(reader: &mut R, position: SeekFrom) -> Result { + let expected = match position { + SeekFrom::Start(expected) => Some(expected), + SeekFrom::Current(0) => None, + _ => return Err("unsupported seek operation".into()), + }; + let actual = loop { + match reader.seek(position) { + Ok(actual) => break actual, + Err(error) if error.kind() == std::io::ErrorKind::Interrupted => continue, + Err(error) => return Err(error.to_string()), + } + }; + if let Some(expected) = expected { + require( + actual == expected, + &format!("seek returned unexpected position: expected {expected}, got {actual}"), + )?; + } + Ok(actual) +} + +fn validate_sizes(metadata_size: u64, binary_size: u64) -> Result<()> { + require( + (1..=METADATA_LIMIT as u64).contains(&metadata_size), + "metadata payload is outside protocol bounds", + )?; + require(binary_size != 0, "binary payload is empty")?; + let payload_size = metadata_size + .checked_add(binary_size) + .ok_or("payload size overflow")?; + require( + payload_size <= BINARY_LIMIT, + "archive payloads exceed protocol bounds", + )?; + archive_size(metadata_size, binary_size)?; + Ok(()) +} + +fn archive_size(metadata_size: u64, binary_size: u64) -> Result { + let metadata_blocks = padded_size(metadata_size)?; + let binary_blocks = padded_size(binary_size)?; + (BLOCK_SIZE as u64) + .checked_add(metadata_blocks) + .and_then(|size| size.checked_add(BLOCK_SIZE as u64)) + .and_then(|size| size.checked_add(binary_blocks)) + .and_then(|size| size.checked_add((BLOCK_SIZE * 2) as u64)) + .ok_or_else(|| "archive size overflow".into()) +} + +fn padded_size(size: u64) -> Result { + let mask = BLOCK_SIZE as u64 - 1; + size.checked_add(mask) + .map(|value| value & !mask) + .ok_or_else(|| "payload padding overflow".into()) +} + +fn padding_size(size: u64) -> Result { + usize::try_from(padded_size(size)? - size).map_err(|_| "padding size overflow".into()) +} + +fn header(name: &[u8], mode: &[u8; 8], size: u64) -> Result<[u8; BLOCK_SIZE]> { + require(name.len() < 100, "archive member name is too long")?; + let size_field = format!("{size:011o}\0"); + require(size_field.len() == 12, "archive member size does not fit")?; + + let mut header = [0; BLOCK_SIZE]; + header[..name.len()].copy_from_slice(name); + header[100..108].copy_from_slice(mode); + header[108..116].copy_from_slice(ZERO_ID); + header[116..124].copy_from_slice(ZERO_ID); + header[124..136].copy_from_slice(size_field.as_bytes()); + header[136..148].copy_from_slice(ZERO_MTIME); + header[148..156].fill(b' '); + header[156] = b'0'; + header[257..263].copy_from_slice(b"ustar\0"); + header[263..265].copy_from_slice(b"00"); + header[329..337].copy_from_slice(ZERO_ID); + header[337..345].copy_from_slice(ZERO_ID); + + let checksum: u64 = header.iter().map(|byte| u64::from(*byte)).sum(); + let checksum_field = format!("{checksum:06o}\0 "); + require(checksum_field.len() == 8, "archive checksum does not fit")?; + header[148..156].copy_from_slice(checksum_field.as_bytes()); + Ok(header) +} + +fn read_header(reader: &mut R, name: &[u8], mode: &[u8; 8]) -> Result { + let mut actual = [0; BLOCK_SIZE]; + read_exact(reader, &mut actual)?; + let size = parse_octal(&actual[124..136])?; + let expected = header(name, mode, size)?; + require(actual == expected, "noncanonical ustar header")?; + Ok(size) +} + +fn parse_octal(field: &[u8]) -> Result { + require( + field.len() == 12 && field[11] == 0, + "invalid octal field padding", + )?; + field[..11].iter().try_fold(0u64, |value, byte| { + require((b'0'..=b'7').contains(byte), "invalid octal digit")?; + value + .checked_mul(8) + .and_then(|value| value.checked_add(u64::from(*byte - b'0'))) + .ok_or_else(|| "octal field overflow".into()) + }) +} + +fn copy_complete(reader: &mut R, writer: &mut W, size: u64) -> Result<()> { + copy_exact(reader, writer, size)?; + let mut extra = [0]; + require( + read_retry(reader, &mut extra)? == 0, + "payload input is longer than its declared size", + ) +} + +pub(crate) fn copy_exact( + reader: &mut R, + writer: &mut W, + mut remaining: u64, +) -> Result<()> { + let mut buffer = [0; IO_CHUNK_SIZE]; + while remaining != 0 { + let limit = usize::try_from(remaining.min(buffer.len() as u64)) + .map_err(|_| "payload size overflow")?; + let read = read_retry(reader, &mut buffer[..limit])?; + require(read != 0, "payload input is shorter than its declared size")?; + write_all(writer, &buffer[..read])?; + remaining = remaining + .checked_sub(read as u64) + .ok_or("payload size underflow")?; + } + Ok(()) +} + +pub(crate) fn verify_binary_sha256( + reader: &mut R, + parsed: &ParsedArchive, + expected: &str, +) -> Result<()> { + checked_seek(reader, SeekFrom::Start(parsed.binary_offset))?; + let mut digest = Sha256::new(); + let mut remaining = parsed.binary_size; + let mut buffer = [0; IO_CHUNK_SIZE]; + while remaining != 0 { + let limit = usize::try_from(remaining.min(buffer.len() as u64)) + .map_err(|_| "binary size overflow")?; + let read = read_retry(reader, &mut buffer[..limit])?; + require(read != 0, "staged binary is shorter than declared")?; + digest.update(&buffer[..read]); + remaining = remaining + .checked_sub(read as u64) + .ok_or("binary size underflow")?; + } + require( + format!("sha256:{:x}", digest.finalize()) == expected, + "staged binary digest changed", + ) +} + +fn write_padding(writer: &mut W, size: u64) -> Result<()> { + write_all(writer, &ZERO_BLOCK[..padding_size(size)?]) +} + +fn read_zero_padding(reader: &mut R, size: u64) -> Result<()> { + let mut padding = [0; BLOCK_SIZE]; + let length = padding_size(size)?; + read_exact(reader, &mut padding[..length])?; + require( + padding[..length].iter().all(|byte| *byte == 0), + "nonzero payload padding", + ) +} + +fn read_exact(reader: &mut R, mut output: &mut [u8]) -> Result<()> { + while !output.is_empty() { + let limit = output.len().min(IO_CHUNK_SIZE); + let read = read_retry(reader, &mut output[..limit])?; + require(read != 0, "unexpected end of archive")?; + output = &mut output[read..]; + } + Ok(()) +} + +fn read_retry(reader: &mut R, output: &mut [u8]) -> Result { + loop { + match reader.read(output) { + Err(error) if error.kind() == std::io::ErrorKind::Interrupted => continue, + result => return result.map_err(|error| error.to_string()), + } + } +} + +fn write_all(writer: &mut W, bytes: &[u8]) -> Result<()> { + writer.write_all(bytes).map_err(|error| error.to_string()) +} + +fn require(valid: bool, reason: &str) -> Result<()> { + if valid { Ok(()) } else { Err(reason.into()) } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::extract::{cleanup_owned_path, extract_binary}; + use std::{ + fs, + io::{self, Cursor, SeekFrom, Write}, + path::{Path, PathBuf}, + sync::atomic::{AtomicU64, Ordering}, + }; + + const FIXTURE_ROOT: &str = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../docker/build-app-cli/fixtures/provenance" + ); + const METADATA: &[u8] = + include_bytes!("../../../docker/build-app-cli/fixtures/provenance/valid/static-meta.json"); + const BINARY: &[u8] = include_bytes!( + "../../../docker/build-app-cli/fixtures/provenance/valid/elf-static/app-cli" + ); + const GOLDEN: &[u8] = + include_bytes!("../../../docker/build-app-cli/fixtures/provenance/valid/archive.tar"); + + static NEXT_TEMP: AtomicU64 = AtomicU64::new(0); + + struct TempDir(PathBuf); + + impl TempDir { + fn new() -> Self { + let sequence = NEXT_TEMP.fetch_add(1, Ordering::Relaxed); + let path = std::env::temp_dir().join(format!( + "edgezero-provenance-archive-{}-{sequence}", + std::process::id() + )); + fs::create_dir(&path).unwrap(); + Self(path.canonicalize().unwrap()) + } + + fn path(&self) -> &Path { + &self.0 + } + } + + impl Drop for TempDir { + fn drop(&mut self) { + fs::remove_dir_all(&self.0).unwrap(); + } + } + + fn encoded(metadata: &[u8], binary: &[u8]) -> Result> { + let mut archive = Vec::new(); + encode( + &mut archive, + &mut Cursor::new(metadata), + metadata.len() as u64, + &mut Cursor::new(binary), + binary.len() as u64, + )?; + Ok(archive) + } + + #[test] + fn encoder_is_byte_exact_deterministic_and_normalized() { + let first = encoded(METADATA, BINARY).unwrap(); + let second = encoded(METADATA, BINARY).unwrap(); + + assert_eq!(first, second); + assert_eq!(first, GOLDEN); + assert_eq!(first.len(), 10 * BLOCK_SIZE); + + let metadata_padding = 512 + METADATA.len()..3 * BLOCK_SIZE; + assert!(first[metadata_padding].iter().all(|byte| *byte == 0)); + let binary_padding = 4 * BLOCK_SIZE + BINARY.len()..8 * BLOCK_SIZE; + assert!(first[binary_padding].iter().all(|byte| *byte == 0)); + assert_eq!(&first[8 * BLOCK_SIZE..], &[0; 2 * BLOCK_SIZE]); + } + + #[test] + fn golden_headers_have_every_protocol_field_exact() { + assert_header( + &GOLDEN[..BLOCK_SIZE], + b"app-cli-meta.json", + b"0000644\0", + METADATA.len() as u64, + ); + assert_header( + &GOLDEN[3 * BLOCK_SIZE..4 * BLOCK_SIZE], + b"app-cli-bin", + b"0000755\0", + BINARY.len() as u64, + ); + } + + fn assert_header(header: &[u8], name: &[u8], mode: &[u8], size: u64) { + let mut expected_name = [0; 100]; + expected_name[..name.len()].copy_from_slice(name); + assert_eq!(&header[..100], &expected_name); + assert_eq!(&header[100..108], mode); + assert_eq!(&header[108..116], b"0000000\0"); + assert_eq!(&header[116..124], b"0000000\0"); + assert_eq!(&header[124..136], format!("{size:011o}\0").as_bytes()); + assert_eq!(&header[136..148], b"00000000000\0"); + assert!(header[148..154].iter().all(u8::is_ascii_digit)); + assert_eq!(&header[154..156], b"\0 "); + assert_eq!(header[156], b'0'); + assert!(header[157..257].iter().all(|byte| *byte == 0)); + assert_eq!(&header[257..263], b"ustar\0"); + assert_eq!(&header[263..265], b"00"); + assert!(header[265..329].iter().all(|byte| *byte == 0)); + assert_eq!(&header[329..337], b"0000000\0"); + assert_eq!(&header[337..345], b"0000000\0"); + assert!(header[345..].iter().all(|byte| *byte == 0)); + + let mut checksum_input = header.to_vec(); + checksum_input[148..156].fill(b' '); + let checksum: u64 = checksum_input.iter().map(|byte| u64::from(*byte)).sum(); + assert_eq!(&header[148..156], format!("{checksum:06o}\0 ").as_bytes()); + } + + #[test] + fn parser_accepts_the_golden_archive_without_loading_the_binary() { + let mut archive = Cursor::new(GOLDEN); + let parsed = parse(&mut archive).unwrap(); + + assert_eq!(parsed.metadata, METADATA); + assert_eq!(parsed.binary_offset, 4 * BLOCK_SIZE as u64); + assert_eq!(parsed.binary_size, BINARY.len() as u64); + assert_eq!(archive.stream_position().unwrap(), GOLDEN.len() as u64); + } + + #[test] + fn parser_rejects_every_malformed_fixture_category() { + for name in [ + "archive-base256.tar", + "archive-checksum.tar", + "archive-duplicate.tar", + "archive-embedded-nul.tar", + "archive-end-block.tar", + "archive-extra-end-block.tar", + "archive-extra.tar", + "archive-gnu.tar", + "archive-header.tar", + "archive-link.tar", + "archive-octal-digit.tar", + "archive-octal-padding.tar", + "archive-order.tar", + "archive-max-size.tar", + "archive-padding.tar", + "archive-pax.tar", + "archive-size-limit.tar", + "archive-size.tar", + "archive-sparse.tar", + "archive-special.tar", + "archive-trailing.tar", + "archive-traversal.tar", + ] { + let path = Path::new(FIXTURE_ROOT).join("invalid").join(name); + let mut archive = Cursor::new(fs::read(path).unwrap()); + assert!(parse(&mut archive).is_err(), "accepted {name}"); + } + } + + #[test] + fn size_bound_fixtures_fail_before_payload_io() { + let size = fs::read( + Path::new(FIXTURE_ROOT) + .join("invalid") + .join("archive-size.tar"), + ) + .unwrap(); + assert_eq!(&size[124..136], b"00000000000\0"); + let mut size = RejectIoPast::new(size, BLOCK_SIZE as u64); + assert_eq!( + parse(&mut size).unwrap_err(), + "metadata payload is outside protocol bounds" + ); + assert!(!size.attempted_forbidden_io); + + let size_limit = fs::read( + Path::new(FIXTURE_ROOT) + .join("invalid") + .join("archive-size-limit.tar"), + ) + .unwrap(); + assert_eq!( + &size_limit[3 * BLOCK_SIZE + 124..3 * BLOCK_SIZE + 136], + b"04000000000\0" + ); + let mut size_limit = RejectIoPast::new(size_limit, (4 * BLOCK_SIZE) as u64); + assert_eq!( + parse(&mut size_limit).unwrap_err(), + "archive payloads exceed protocol bounds" + ); + assert!(!size_limit.attempted_forbidden_io); + } + + #[test] + fn parser_rejects_metadata_size_65537_before_payload_io() { + let bytes = mutate_header(0, |header| { + header[124..136].copy_from_slice(b"00000200001\0") + }); + let mut archive = RejectIoPast::new(bytes, BLOCK_SIZE as u64); + + assert_eq!( + parse(&mut archive).unwrap_err(), + "metadata payload is outside protocol bounds" + ); + assert!(!archive.attempted_forbidden_io); + } + + #[test] + fn parser_rejects_binary_offset_overflow_before_seek_or_payload_read() { + let mut archive = NearMaxBinaryOffset::new(GOLDEN.to_vec()); + + assert_eq!(parse(&mut archive).unwrap_err(), "binary offset overflow"); + assert!(archive.returned_adversarial_offset); + assert!(!archive.attempted_binary_io); + } + + #[test] + fn parser_rejects_a_successful_absolute_seek_to_the_wrong_position() { + let mut archive = WrongParserSkip::new(GOLDEN.to_vec()); + let expected = (4 * BLOCK_SIZE + BINARY.len()) as u64; + + assert_eq!( + parse(&mut archive).unwrap_err(), + format!( + "seek returned unexpected position: expected {expected}, got {}", + expected + 1 + ) + ); + assert!(archive.returned_wrong_position); + assert!(!archive.attempted_read_after_wrong_position); + } + + #[test] + fn embedded_nul_fixture_preserves_the_canonical_name_before_garbage() { + let fixture = fs::read( + Path::new(FIXTURE_ROOT) + .join("invalid") + .join("archive-embedded-nul.tar"), + ) + .unwrap(); + let header = &fixture[..BLOCK_SIZE]; + let name = &header[..100]; + + assert_eq!(&name[..METADATA_NAME.len()], METADATA_NAME); + assert_eq!(name[METADATA_NAME.len()], 0); + assert!( + name[METADATA_NAME.len() + 1..] + .iter() + .any(|byte| *byte != 0) + ); + + let mut checksum_input = header.to_vec(); + checksum_input[148..156].fill(b' '); + let checksum: u64 = checksum_input.iter().map(|byte| u64::from(*byte)).sum(); + assert_eq!(&header[148..156], format!("{checksum:06o}\0 ").as_bytes()); + } + + #[test] + fn parser_rejects_all_exact_header_and_member_variants() { + let mut variants = vec![ + mutate_header(0, |header| set_name(header, b"renamed-meta.json")), + mutate_header(3 * BLOCK_SIZE, |header| { + header[156] = b'1'; + header[157..163].copy_from_slice(b"target"); + }), + mutate_header(3 * BLOCK_SIZE, |header| header[156] = b'4'), + mutate_header(3 * BLOCK_SIZE, |header| header[156] = b'5'), + mutate_header(3 * BLOCK_SIZE, |header| header[156] = b'6'), + mutate_header(3 * BLOCK_SIZE, |header| { + header[124..136].copy_from_slice(b"00000000000\0") + }), + mutate_header(3 * BLOCK_SIZE, |header| { + header[124..136].copy_from_slice(b"04000000000\0") + }), + ]; + for offset in [ + 100, 108, 116, 136, 157, 257, 263, 265, 297, 329, 337, 345, 500, + ] { + variants.push(mutate_header(0, |header| header[offset] ^= 1)); + } + + for bytes in variants { + assert!(parse(&mut Cursor::new(bytes)).is_err()); + } + } + + fn mutate_header(offset: usize, change: impl FnOnce(&mut [u8])) -> Vec { + let mut archive = GOLDEN.to_vec(); + let header = &mut archive[offset..offset + BLOCK_SIZE]; + change(header); + header[148..156].fill(b' '); + let checksum: u64 = header.iter().map(|byte| u64::from(*byte)).sum(); + header[148..156].copy_from_slice(format!("{checksum:06o}\0 ").as_bytes()); + archive + } + + fn set_name(header: &mut [u8], name: &[u8]) { + header[..100].fill(0); + header[..name.len()].copy_from_slice(name); + } + + #[test] + fn encoder_rejects_bounds_overflow_and_inexact_inputs() { + let mut output = Vec::new(); + assert_eq!( + encode( + &mut output, + &mut Cursor::new([b'm']), + 1, + &mut Cursor::new([b'b']), + u64::MAX, + ) + .unwrap_err(), + "payload size overflow" + ); + assert!(output.is_empty()); + + for (metadata_size, binary_size) in + [(0, 1), (65_537, 1), (1, 0), (1, 536_870_912), (u64::MAX, 1)] + { + let mut output = Vec::new(); + assert!( + encode( + &mut output, + &mut Cursor::new([b'm']), + metadata_size, + &mut Cursor::new([b'b']), + binary_size, + ) + .is_err() + ); + assert!(output.is_empty(), "wrote before rejecting size bounds"); + } + + for (metadata, metadata_size, binary, binary_size) in [ + (&b"metadata"[..], 7, &b"binary"[..], 6), + (&b"metadata"[..], 9, &b"binary"[..], 6), + (&b"metadata"[..], 8, &b"binary"[..], 5), + (&b"metadata"[..], 8, &b"binary"[..], 7), + ] { + let mut output = Vec::new(); + assert!( + encode( + &mut output, + &mut Cursor::new(metadata), + metadata_size, + &mut Cursor::new(binary), + binary_size, + ) + .is_err() + ); + } + } + + #[test] + fn size_bounds_accept_both_inclusive_limits_without_allocating_payloads() { + let metadata_size = METADATA_LIMIT as u64; + let binary_size = BINARY_LIMIT - metadata_size; + + validate_sizes(metadata_size, binary_size).unwrap(); + assert_eq!( + validate_sizes(metadata_size, binary_size + 1).unwrap_err(), + "archive payloads exceed protocol bounds" + ); + } + + #[test] + fn review_staged_archive_rejects_same_size_binary_payload_corruption() { + use sha2::{Digest, Sha256}; + + let mut bytes = Vec::new(); + encode( + &mut bytes, + &mut Cursor::new(METADATA), + METADATA.len() as u64, + &mut Cursor::new(BINARY), + BINARY.len() as u64, + ) + .unwrap(); + let parsed = parse(&mut Cursor::new(&bytes)).unwrap(); + bytes[usize::try_from(parsed.binary_offset).unwrap()] ^= 1; + let parsed = parse(&mut Cursor::new(&bytes)).unwrap(); + let expected = format!("sha256:{:x}", Sha256::digest(BINARY)); + + let error = verify_binary_sha256(&mut Cursor::new(bytes), &parsed, &expected).unwrap_err(); + assert_eq!(error, "staged binary digest changed"); + } + + #[test] + fn owned_path_cleanup_removes_files_accepts_absence_and_reports_failure() { + let parent = TempDir::new(); + let path = parent.path().join("owned"); + fs::write(&path, b"temporary").unwrap(); + + cleanup_owned_path(&path).unwrap(); + assert!(!path.exists()); + cleanup_owned_path(&path).unwrap(); + + fs::create_dir(&path).unwrap(); + let error = cleanup_owned_path(&path).unwrap_err(); + assert!(error.starts_with("failed to remove owned path")); + assert!(path.is_dir()); + } + + #[cfg(unix)] + #[test] + fn extractor_atomically_publishes_one_executable_unlinked_binary() { + use std::os::unix::fs::{MetadataExt, PermissionsExt}; + + let parent = TempDir::new(); + let mut archive = Cursor::new(GOLDEN); + let output = extract_binary(&mut archive, parent.path()).unwrap(); + + assert_eq!(output, parent.path().join("app-cli")); + assert_eq!(fs::read(&output).unwrap(), BINARY); + let metadata = fs::symlink_metadata(&output).unwrap(); + assert!(metadata.file_type().is_file()); + assert_eq!(metadata.permissions().mode() & 0o7777, 0o755); + assert_eq!(metadata.nlink(), 1); + assert_eq!(fs::read_dir(parent.path()).unwrap().count(), 1); + } + + #[test] + fn extractor_rejects_a_nonempty_output_parent_without_changes() { + let parent = TempDir::new(); + fs::write(parent.path().join("occupied"), b"sentinel").unwrap(); + let mut archive = Cursor::new(GOLDEN); + + assert!(extract_binary(&mut archive, parent.path()).is_err()); + assert_eq!( + fs::read(parent.path().join("occupied")).unwrap(), + b"sentinel" + ); + assert_eq!(fs::read_dir(parent.path()).unwrap().count(), 1); + } + + #[test] + fn extraction_failure_removes_the_temporary_output() { + let parent = TempDir::new(); + let mut archive = FailAtBinary::new(GOLDEN.to_vec()); + + assert!(extract_binary(&mut archive, parent.path()).is_err()); + assert_eq!(fs::read_dir(parent.path()).unwrap().count(), 0); + } + + #[cfg(unix)] + #[test] + fn extraction_reports_primary_and_cleanup_errors() { + let parent = TempDir::new(); + let temporary = parent.path().join(".app-cli.tmp"); + let mut archive = ReplaceTempWithDirectory::new(GOLDEN.to_vec(), temporary.clone()); + + let error = extract_binary(&mut archive, parent.path()).unwrap_err(); + assert!(error.contains("controlled binary read failure")); + assert!(error.contains("cleanup failed: owned path identity changed")); + assert!(temporary.is_dir()); + assert!(!parent.path().join("app-cli").exists()); + } + + #[cfg(unix)] + #[test] + fn extraction_never_removes_a_replacement_at_its_owned_temporary_path() { + let parent = TempDir::new(); + let temporary = parent.path().join(".app-cli.tmp"); + let mut archive = ReplaceTempWithFile::new(GOLDEN.to_vec(), temporary.clone()); + + let error = extract_binary(&mut archive, parent.path()).unwrap_err(); + assert!(error.contains("controlled binary read failure")); + assert!(error.contains("cleanup failed: owned path identity changed")); + assert_eq!(fs::read(temporary).unwrap(), b"sentinel"); + assert!(!parent.path().join("app-cli").exists()); + } + + #[cfg(unix)] + #[test] + fn extraction_never_publishes_a_same_shape_temporary_replacement() { + let parent = TempDir::new(); + let temporary = parent.path().join(".app-cli.tmp"); + let mut archive = ReplaceTempAfterBinaryRead::new(GOLDEN.to_vec(), temporary.clone()); + + let error = extract_binary(&mut archive, parent.path()).unwrap_err(); + assert!(error.contains("owned path identity changed")); + assert_eq!(fs::read(temporary).unwrap(), vec![b'x'; BINARY.len()]); + assert!(!parent.path().join("app-cli").exists()); + } + + #[cfg(unix)] + #[test] + fn final_name_collision_preserves_sentinel_and_cleans_temporary_output() { + let parent = TempDir::new(); + let final_path = parent.path().join("app-cli"); + let temporary_path = parent.path().join(".app-cli.tmp"); + let mut archive = CreateFinalAtBinaryRead::new(GOLDEN.to_vec(), final_path.clone()); + + assert!(extract_binary(&mut archive, parent.path()).is_err()); + assert!(archive.created_final); + assert_eq!(fs::read(final_path).unwrap(), b"sentinel"); + assert!(!temporary_path.exists()); + assert_eq!(fs::read_dir(parent.path()).unwrap().count(), 1); + } + + #[cfg(unix)] + #[test] + fn extractor_rejects_a_successful_binary_seek_to_the_wrong_position() { + let parent = TempDir::new(); + let mut archive = WrongExtractionRewind::new(GOLDEN.to_vec()); + let expected = (4 * BLOCK_SIZE) as u64; + + assert_eq!( + extract_binary(&mut archive, parent.path()).unwrap_err(), + format!( + "seek returned unexpected position: expected {expected}, got {}", + expected + 1 + ) + ); + assert!(archive.returned_wrong_position); + assert!(fs::read_dir(parent.path()).unwrap().next().is_none()); + } + + #[cfg(unix)] + #[test] + fn interrupted_seeks_are_retried_for_parser_queries_and_extraction() { + let mut parser = InterruptInitialQuery::new(GOLDEN.to_vec()); + parse(&mut parser).unwrap(); + assert!(parser.interrupted); + + let parent = TempDir::new(); + let mut extractor = InterruptExtractionRewind::new(GOLDEN.to_vec()); + let output = extract_binary(&mut extractor, parent.path()).unwrap(); + assert!(extractor.interrupted); + assert_eq!(fs::read(output).unwrap(), BINARY); + } + + #[test] + fn extraction_never_removes_a_temporary_file_it_did_not_create() { + let parent = TempDir::new(); + let temporary = parent.path().join(".app-cli.tmp"); + let mut archive = CreateTempOnRead::new(GOLDEN.to_vec(), temporary.clone()); + + assert!(extract_binary(&mut archive, parent.path()).is_err()); + assert_eq!(fs::read(temporary).unwrap(), b"sentinel"); + assert!(!parent.path().join("app-cli").exists()); + } + + #[cfg(unix)] + #[test] + fn extractor_rejects_invalid_output_parent_shapes() { + use std::os::unix::fs::symlink; + + let lexical = TempDir::new(); + let lexical_path = lexical.path().join("."); + assert_eq!( + extract_binary(&mut Cursor::new(GOLDEN), &lexical_path).unwrap_err(), + "output parent is not canonical" + ); + + let symlink_target = TempDir::new(); + let symlink_holder = TempDir::new(); + let symlink_path = symlink_holder.path().join("parent-link"); + symlink(symlink_target.path(), &symlink_path).unwrap(); + assert_eq!( + extract_binary(&mut Cursor::new(GOLDEN), &symlink_path).unwrap_err(), + "output parent is not canonical" + ); + + let missing = lexical.path().join("missing"); + assert!(extract_binary(&mut Cursor::new(GOLDEN), &missing).is_err()); + + let regular_file = lexical.path().join("regular-file"); + fs::write(®ular_file, b"not a directory").unwrap(); + assert_eq!( + extract_binary(&mut Cursor::new(GOLDEN), ®ular_file).unwrap_err(), + "output parent is not a directory" + ); + } + + #[test] + fn encoder_parser_and_extractor_never_request_unbounded_io() { + const MAX_CHUNK: usize = 8 * 1024; + let metadata_bytes = vec![b'm'; 32 * 1024]; + let binary = vec![b'x'; 256 * 1024]; + let mut metadata = LimitedReader::new(Cursor::new(&metadata_bytes), MAX_CHUNK); + let mut binary_reader = LimitedReader::new(Cursor::new(&binary), MAX_CHUNK); + let mut output = LimitedWriter::new(Vec::new(), MAX_CHUNK); + encode( + &mut output, + &mut metadata, + metadata_bytes.len() as u64, + &mut binary_reader, + binary.len() as u64, + ) + .unwrap(); + assert!(metadata.max_requested <= MAX_CHUNK); + assert!(binary_reader.max_requested <= MAX_CHUNK); + let bytes = output.inner; + + let mut archive = LimitedReader::new(Cursor::new(bytes.clone()), MAX_CHUNK); + let parsed = parse(&mut archive).unwrap(); + assert_eq!(parsed.metadata, metadata_bytes); + assert_eq!(parsed.binary_size, binary.len() as u64); + assert!(archive.max_requested <= MAX_CHUNK); + + let parent = TempDir::new(); + let mut archive = LimitedReader::new(Cursor::new(bytes), MAX_CHUNK); + let output = extract_binary(&mut archive, parent.path()).unwrap(); + assert_eq!(fs::metadata(output).unwrap().len(), binary.len() as u64); + assert!(archive.max_requested <= MAX_CHUNK); + } + + struct LimitedReader { + inner: R, + limit: usize, + max_requested: usize, + } + + impl LimitedReader { + fn new(inner: R, limit: usize) -> Self { + Self { + inner, + limit, + max_requested: 0, + } + } + } + + impl Read for LimitedReader { + fn read(&mut self, buffer: &mut [u8]) -> io::Result { + self.max_requested = self.max_requested.max(buffer.len()); + if buffer.len() > self.limit { + return Err(io::Error::other("oversized read request")); + } + self.inner.read(buffer) + } + } + + impl Seek for LimitedReader { + fn seek(&mut self, position: SeekFrom) -> io::Result { + self.inner.seek(position) + } + } + + struct RejectIoPast { + inner: Cursor>, + read_limit: u64, + attempted_forbidden_io: bool, + } + + impl RejectIoPast { + fn new(bytes: Vec, read_limit: u64) -> Self { + Self { + inner: Cursor::new(bytes), + read_limit, + attempted_forbidden_io: false, + } + } + } + + impl Read for RejectIoPast { + fn read(&mut self, buffer: &mut [u8]) -> io::Result { + let remaining = self.read_limit.saturating_sub(self.inner.position()); + if remaining == 0 { + self.attempted_forbidden_io = true; + return Err(io::Error::other("read beyond rejection boundary")); + } + let limit = + usize::try_from(remaining.min(buffer.len() as u64)).map_err(io::Error::other)?; + self.inner.read(&mut buffer[..limit]) + } + } + + impl Seek for RejectIoPast { + fn seek(&mut self, position: SeekFrom) -> io::Result { + if position != SeekFrom::Current(0) { + self.attempted_forbidden_io = true; + return Err(io::Error::other("seek beyond rejection boundary")); + } + self.inner.seek(position) + } + } + + struct NearMaxBinaryOffset { + inner: Cursor>, + returned_adversarial_offset: bool, + attempted_binary_io: bool, + } + + impl NearMaxBinaryOffset { + fn new(bytes: Vec) -> Self { + Self { + inner: Cursor::new(bytes), + returned_adversarial_offset: false, + attempted_binary_io: false, + } + } + } + + impl Read for NearMaxBinaryOffset { + fn read(&mut self, buffer: &mut [u8]) -> io::Result { + if self.returned_adversarial_offset { + self.attempted_binary_io = true; + return Err(io::Error::other("binary payload read attempted")); + } + self.inner.read(buffer) + } + } + + impl Seek for NearMaxBinaryOffset { + fn seek(&mut self, position: SeekFrom) -> io::Result { + if position == SeekFrom::Current(0) && self.inner.position() == (4 * BLOCK_SIZE) as u64 + { + self.returned_adversarial_offset = true; + return Ok(u64::MAX); + } + if self.returned_adversarial_offset { + self.attempted_binary_io = true; + return Err(io::Error::other("binary payload seek attempted")); + } + self.inner.seek(position) + } + } + + struct WrongParserSkip { + inner: Cursor>, + returned_wrong_position: bool, + attempted_read_after_wrong_position: bool, + } + + impl WrongParserSkip { + fn new(bytes: Vec) -> Self { + Self { + inner: Cursor::new(bytes), + returned_wrong_position: false, + attempted_read_after_wrong_position: false, + } + } + } + + impl Read for WrongParserSkip { + fn read(&mut self, buffer: &mut [u8]) -> io::Result { + if self.returned_wrong_position { + self.attempted_read_after_wrong_position = true; + return Err(io::Error::other("read after wrong seek position")); + } + self.inner.read(buffer) + } + } + + impl Seek for WrongParserSkip { + fn seek(&mut self, position: SeekFrom) -> io::Result { + if let SeekFrom::Start(expected) = position { + self.returned_wrong_position = true; + return Ok(expected + 1); + } + self.inner.seek(position) + } + } + + #[cfg(unix)] + struct WrongExtractionRewind { + inner: Cursor>, + returned_wrong_position: bool, + } + + #[cfg(unix)] + impl WrongExtractionRewind { + fn new(bytes: Vec) -> Self { + Self { + inner: Cursor::new(bytes), + returned_wrong_position: false, + } + } + } + + #[cfg(unix)] + impl Read for WrongExtractionRewind { + fn read(&mut self, buffer: &mut [u8]) -> io::Result { + self.inner.read(buffer) + } + } + + #[cfg(unix)] + impl Seek for WrongExtractionRewind { + fn seek(&mut self, position: SeekFrom) -> io::Result { + if position == SeekFrom::Start((4 * BLOCK_SIZE) as u64) { + self.returned_wrong_position = true; + return Ok((4 * BLOCK_SIZE + 1) as u64); + } + self.inner.seek(position) + } + } + + struct InterruptInitialQuery { + inner: Cursor>, + interrupted: bool, + } + + impl InterruptInitialQuery { + fn new(bytes: Vec) -> Self { + Self { + inner: Cursor::new(bytes), + interrupted: false, + } + } + } + + impl Read for InterruptInitialQuery { + fn read(&mut self, buffer: &mut [u8]) -> io::Result { + self.inner.read(buffer) + } + } + + impl Seek for InterruptInitialQuery { + fn seek(&mut self, position: SeekFrom) -> io::Result { + if !self.interrupted && position == SeekFrom::Current(0) { + self.interrupted = true; + return Err(io::Error::from(io::ErrorKind::Interrupted)); + } + self.inner.seek(position) + } + } + + #[cfg(unix)] + struct InterruptExtractionRewind { + inner: Cursor>, + interrupted: bool, + } + + #[cfg(unix)] + impl InterruptExtractionRewind { + fn new(bytes: Vec) -> Self { + Self { + inner: Cursor::new(bytes), + interrupted: false, + } + } + } + + #[cfg(unix)] + impl Read for InterruptExtractionRewind { + fn read(&mut self, buffer: &mut [u8]) -> io::Result { + self.inner.read(buffer) + } + } + + #[cfg(unix)] + impl Seek for InterruptExtractionRewind { + fn seek(&mut self, position: SeekFrom) -> io::Result { + if !self.interrupted && position == SeekFrom::Start((4 * BLOCK_SIZE) as u64) { + self.interrupted = true; + return Err(io::Error::from(io::ErrorKind::Interrupted)); + } + self.inner.seek(position) + } + } + + struct LimitedWriter { + inner: W, + limit: usize, + } + + impl LimitedWriter { + fn new(inner: W, limit: usize) -> Self { + Self { inner, limit } + } + } + + impl Write for LimitedWriter { + fn write(&mut self, buffer: &[u8]) -> io::Result { + if buffer.len() > self.limit { + return Err(io::Error::other("oversized write request")); + } + self.inner.write(buffer) + } + + fn flush(&mut self) -> io::Result<()> { + self.inner.flush() + } + } + + struct FailAtBinary { + inner: Cursor>, + } + + impl FailAtBinary { + fn new(bytes: Vec) -> Self { + Self { + inner: Cursor::new(bytes), + } + } + } + + impl Read for FailAtBinary { + fn read(&mut self, buffer: &mut [u8]) -> io::Result { + if self.inner.position() == 4 * BLOCK_SIZE as u64 { + return Err(io::Error::other("controlled binary read failure")); + } + self.inner.read(buffer) + } + } + + impl Seek for FailAtBinary { + fn seek(&mut self, position: SeekFrom) -> io::Result { + self.inner.seek(position) + } + } + + #[cfg(unix)] + struct ReplaceTempWithDirectory { + inner: Cursor>, + temporary_path: PathBuf, + replaced: bool, + } + + #[cfg(unix)] + impl ReplaceTempWithDirectory { + fn new(bytes: Vec, temporary_path: PathBuf) -> Self { + Self { + inner: Cursor::new(bytes), + temporary_path, + replaced: false, + } + } + } + + #[cfg(unix)] + impl Read for ReplaceTempWithDirectory { + fn read(&mut self, buffer: &mut [u8]) -> io::Result { + if !self.replaced && self.inner.position() == (4 * BLOCK_SIZE) as u64 { + fs::remove_file(&self.temporary_path)?; + fs::create_dir(&self.temporary_path)?; + self.replaced = true; + return Err(io::Error::other("controlled binary read failure")); + } + self.inner.read(buffer) + } + } + + #[cfg(unix)] + impl Seek for ReplaceTempWithDirectory { + fn seek(&mut self, position: SeekFrom) -> io::Result { + self.inner.seek(position) + } + } + + #[cfg(unix)] + struct ReplaceTempWithFile { + inner: Cursor>, + temporary_path: PathBuf, + replaced: bool, + } + + #[cfg(unix)] + impl ReplaceTempWithFile { + fn new(bytes: Vec, temporary_path: PathBuf) -> Self { + Self { + inner: Cursor::new(bytes), + temporary_path, + replaced: false, + } + } + } + + #[cfg(unix)] + impl Read for ReplaceTempWithFile { + fn read(&mut self, buffer: &mut [u8]) -> io::Result { + if !self.replaced && self.inner.position() == (4 * BLOCK_SIZE) as u64 { + fs::remove_file(&self.temporary_path)?; + fs::write(&self.temporary_path, b"sentinel")?; + self.replaced = true; + return Err(io::Error::other("controlled binary read failure")); + } + self.inner.read(buffer) + } + } + + #[cfg(unix)] + impl Seek for ReplaceTempWithFile { + fn seek(&mut self, position: SeekFrom) -> io::Result { + self.inner.seek(position) + } + } + + #[cfg(unix)] + struct ReplaceTempAfterBinaryRead { + inner: Cursor>, + temporary_path: PathBuf, + replaced: bool, + } + + #[cfg(unix)] + impl ReplaceTempAfterBinaryRead { + fn new(bytes: Vec, temporary_path: PathBuf) -> Self { + Self { + inner: Cursor::new(bytes), + temporary_path, + replaced: false, + } + } + } + + #[cfg(unix)] + impl Read for ReplaceTempAfterBinaryRead { + fn read(&mut self, buffer: &mut [u8]) -> io::Result { + use std::os::unix::fs::PermissionsExt; + + let read = self.inner.read(buffer)?; + if !self.replaced && self.inner.position() == (4 * BLOCK_SIZE + BINARY.len()) as u64 { + fs::remove_file(&self.temporary_path)?; + fs::write(&self.temporary_path, vec![b'x'; BINARY.len()])?; + fs::set_permissions(&self.temporary_path, fs::Permissions::from_mode(0o755))?; + self.replaced = true; + } + Ok(read) + } + } + + #[cfg(unix)] + impl Seek for ReplaceTempAfterBinaryRead { + fn seek(&mut self, position: SeekFrom) -> io::Result { + self.inner.seek(position) + } + } + + #[cfg(unix)] + struct CreateFinalAtBinaryRead { + inner: Cursor>, + final_path: PathBuf, + created_final: bool, + } + + #[cfg(unix)] + impl CreateFinalAtBinaryRead { + fn new(bytes: Vec, final_path: PathBuf) -> Self { + Self { + inner: Cursor::new(bytes), + final_path, + created_final: false, + } + } + } + + #[cfg(unix)] + impl Read for CreateFinalAtBinaryRead { + fn read(&mut self, buffer: &mut [u8]) -> io::Result { + if !self.created_final && self.inner.position() == (4 * BLOCK_SIZE) as u64 { + fs::write(&self.final_path, b"sentinel")?; + self.created_final = true; + } + self.inner.read(buffer) + } + } + + #[cfg(unix)] + impl Seek for CreateFinalAtBinaryRead { + fn seek(&mut self, position: SeekFrom) -> io::Result { + self.inner.seek(position) + } + } + + struct CreateTempOnRead { + inner: Cursor>, + path: PathBuf, + created: bool, + } + + impl CreateTempOnRead { + fn new(bytes: Vec, path: PathBuf) -> Self { + Self { + inner: Cursor::new(bytes), + path, + created: false, + } + } + } + + impl Read for CreateTempOnRead { + fn read(&mut self, buffer: &mut [u8]) -> io::Result { + if !self.created { + fs::write(&self.path, b"sentinel")?; + self.created = true; + } + self.inner.read(buffer) + } + } + + impl Seek for CreateTempOnRead { + fn seek(&mut self, position: SeekFrom) -> io::Result { + self.inner.seek(position) + } + } +} diff --git a/.github/tools/edgezero-provenance-validator/src/command.rs b/.github/tools/edgezero-provenance-validator/src/command.rs new file mode 100644 index 00000000..7badec85 --- /dev/null +++ b/.github/tools/edgezero-provenance-validator/src/command.rs @@ -0,0 +1,184 @@ +use crate::{Result, require}; +use std::{collections::BTreeMap, ffi::OsString}; + +#[derive(Debug, PartialEq, Eq)] +pub(crate) enum Command { + WriteExpected { + work_root: String, + app_repo_id: String, + source_revision: String, + app_cli_package: String, + app_cli_bin: String, + workspace_id: String, + platform_id: String, + provenance_protocol: String, + output: String, + }, + WriteReleaseRequest { + work_root: String, + gate_sha: String, + provenance_protocol: String, + release_tag: String, + output: String, + }, + Package { + work_root: String, + binary: String, + schema: String, + expected: String, + app_cli_version: String, + archive: String, + }, + Validate { + work_root: String, + archive: String, + schema: String, + expected: String, + output: String, + }, + SelfTest { + fixtures: String, + }, +} + +pub(crate) fn parse(arguments: I) -> Result +where + I: IntoIterator, + S: Into, +{ + let mut arguments = arguments + .into_iter() + .map(|argument| { + argument + .into() + .into_string() + .map_err(|_| "arguments must be UTF-8".to_string()) + }) + .collect::>>()? + .into_iter(); + let name = arguments.next().ok_or("missing command")?; + match name.as_str() { + "write-expected" => { + let mut flags = flags( + arguments, + &[ + "--work-root", + "--app-repo-id", + "--source-revision", + "--app-cli-package", + "--app-cli-bin", + "--workspace-id", + "--platform-id", + "--provenance-protocol", + "--output", + ], + )?; + Ok(Command::WriteExpected { + work_root: take(&mut flags, "--work-root"), + app_repo_id: take(&mut flags, "--app-repo-id"), + source_revision: take(&mut flags, "--source-revision"), + app_cli_package: take(&mut flags, "--app-cli-package"), + app_cli_bin: take(&mut flags, "--app-cli-bin"), + workspace_id: take(&mut flags, "--workspace-id"), + platform_id: take(&mut flags, "--platform-id"), + provenance_protocol: take(&mut flags, "--provenance-protocol"), + output: take(&mut flags, "--output"), + }) + } + "write-release-request" => { + let mut flags = flags( + arguments, + &[ + "--work-root", + "--gate-sha", + "--provenance-protocol", + "--release-tag", + "--output", + ], + )?; + Ok(Command::WriteReleaseRequest { + work_root: take(&mut flags, "--work-root"), + gate_sha: take(&mut flags, "--gate-sha"), + provenance_protocol: take(&mut flags, "--provenance-protocol"), + release_tag: take(&mut flags, "--release-tag"), + output: take(&mut flags, "--output"), + }) + } + "package" => { + let mut flags = flags( + arguments, + &[ + "--work-root", + "--binary", + "--schema", + "--expected", + "--app-cli-version", + "--archive", + ], + )?; + Ok(Command::Package { + work_root: take(&mut flags, "--work-root"), + binary: take(&mut flags, "--binary"), + schema: take(&mut flags, "--schema"), + expected: take(&mut flags, "--expected"), + app_cli_version: take(&mut flags, "--app-cli-version"), + archive: take(&mut flags, "--archive"), + }) + } + "validate" => { + let mut flags = flags( + arguments, + &[ + "--work-root", + "--archive", + "--schema", + "--expected", + "--output", + ], + )?; + Ok(Command::Validate { + work_root: take(&mut flags, "--work-root"), + archive: take(&mut flags, "--archive"), + schema: take(&mut flags, "--schema"), + expected: take(&mut flags, "--expected"), + output: take(&mut flags, "--output"), + }) + } + "self-test" => { + let mut flags = flags(arguments, &["--fixtures"])?; + Ok(Command::SelfTest { + fixtures: take(&mut flags, "--fixtures"), + }) + } + _ => Err(format!("unknown command: {name}")), + } +} + +fn flags( + mut arguments: impl Iterator, + expected: &[&str], +) -> Result> { + let mut parsed = BTreeMap::new(); + while let Some(flag) = arguments.next() { + require( + flag.starts_with("--") && !flag.contains('='), + "malformed flag", + )?; + require(expected.contains(&flag.as_str()), "unknown or mixed flag")?; + let value = arguments + .next() + .ok_or_else(|| format!("missing value for {flag}"))?; + require( + parsed.insert(flag.clone(), value).is_none(), + "duplicate flag", + )?; + } + for flag in expected { + require(parsed.contains_key(*flag), &format!("missing flag: {flag}"))?; + } + Ok(parsed) +} + +fn take(flags: &mut BTreeMap, name: &str) -> String { + flags.remove(name).expect("required flag was checked") +} diff --git a/.github/tools/edgezero-provenance-validator/src/elf.rs b/.github/tools/edgezero-provenance-validator/src/elf.rs new file mode 100644 index 00000000..8d5dd821 --- /dev/null +++ b/.github/tools/edgezero-provenance-validator/src/elf.rs @@ -0,0 +1,2395 @@ +use crate::{ + Result, + json_contract::{BINARY_LIMIT, INTERPRETER, METADATA_LIMIT}, +}; +use sha2::{Digest, Sha256}; +use std::{ + collections::{BTreeMap, HashMap, HashSet}, + fs::{self, File}, + io::{Read, Seek, SeekFrom}, + path::{Path, PathBuf}, +}; + +const ELF_HEADER_SIZE: usize = 64; +const PROGRAM_HEADER_SIZE: usize = 56; +const DYNAMIC_ENTRY_SIZE: u64 = 16; +const IO_CHUNK_SIZE: usize = 8 * 1024; +// A nonempty needed name occupies at least two quotes and one payload byte in +// canonical JSON, so a larger list cannot fit in protocol metadata. +const MIN_CANONICAL_NEEDED_ITEM_BYTES: usize = 3; +const MAX_NEEDED_ENTRIES: usize = METADATA_LIMIT / MIN_CANONICAL_NEEDED_ITEM_BYTES; +const ELFCLASS64: u8 = 2; +const ELFDATA2LSB: u8 = 1; +const EV_CURRENT: u32 = 1; +const ELFOSABI_SYSV: u8 = 0; +const ELFOSABI_GNU: u8 = 3; +const ET_EXEC: u16 = 2; +const ET_DYN: u16 = 3; +const EM_X86_64: u16 = 62; +const PN_XNUM: u16 = 0xffff; +const PT_LOAD: u32 = 1; +const PT_DYNAMIC: u32 = 2; +const PT_INTERP: u32 = 3; +const PF_R: u32 = 4; +const DT_NULL: u64 = 0; +const DT_NEEDED: u64 = 1; +const DT_STRTAB: u64 = 5; +const DT_STRSZ: u64 = 10; +const DT_SONAME: u64 = 14; +const DT_RPATH: u64 = 15; +const DT_RUNPATH: u64 = 29; +const DT_FLAGS: u64 = 30; +const DT_POSFLAG_1: u64 = 0x6ffffdfd; +const DT_CONFIG: u64 = 0x6ffffefa; +const DT_DEPAUDIT: u64 = 0x6ffffefb; +const DT_AUDIT: u64 = 0x6ffffefc; +const DT_FLAGS_1: u64 = 0x6ffffffb; +const DT_AUXILIARY: u64 = 0x7ffffffd; +const DT_FILTER: u64 = 0x7fffffff; +const FLAGS_MASK: u64 = 0x0000001e; +const FLAGS_1_MASK: u64 = 0x5eff976f; +const RUNTIME_LIB: &str = "/opt/edgezero/runtime-lib"; +const APP_PATH: &str = "/work/bin/app-cli"; +const APP_NAME: &str = "app-cli"; +const LOADER_NAME: &str = "ld-linux-x86-64.so.2"; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ClosureClaim { + StartupOnly, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ObjectRole { + Primary, + Interpreter, + Library, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct VisitedObject { + pub image_path: String, + pub role: ObjectRole, + pub device: u64, + pub inode: u64, + pub needed: Vec, + pub soname: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ElfInspection { + pub machine: String, + pub interpreter: Option, + pub needed: Vec, + pub binary_sha256: String, + pub binary_size: u64, + pub visited: Vec, + pub claim: ClosureClaim, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct LaunchCommand { + pub program: String, + pub args: Vec, +} + +pub fn direct_launch( + interpreter: Option<&str>, + operation_args: &[String], +) -> Result { + let mut args = match interpreter { + None => vec![APP_PATH.into()], + Some(INTERPRETER) => vec![ + INTERPRETER.into(), + "--inhibit-cache".into(), + "--glibc-hwcaps-mask".into(), + String::new(), + "--library-path".into(), + RUNTIME_LIB.into(), + APP_PATH.into(), + ], + Some(_) => return Err("unsupported ELF interpreter".into()), + }; + args.extend_from_slice(operation_args); + Ok(LaunchCommand { + program: "/usr/bin/env".into(), + args, + }) +} + +#[cfg(unix)] +pub fn inspect(primary_path: &Path, image_root: &Path) -> Result { + inspect_primary(primary_path, image_root) +} + +#[cfg(unix)] +pub(crate) fn inspect_staged(primary_path: &Path, image_root: &Path) -> Result { + inspect_primary(primary_path, image_root) +} + +#[cfg(unix)] +fn inspect_primary(primary_path: &Path, image_root: &Path) -> Result { + validate_root(image_root)?; + require_no_preload(image_root)?; + + let (primary, mut primary_file) = open_object(primary_path, APP_PATH, ObjectRole::Primary)?; + let binary_size = primary_file.metadata().map_err(io_error)?.len(); + let binary_sha256 = hash_file(&mut primary_file, binary_size)?; + let mut direct_needed = primary.parsed.needed.clone(); + direct_needed.sort_by(|left, right| left.as_bytes().cmp(right.as_bytes())); + + let Some(interpreter) = primary.parsed.interpreter.as_deref() else { + return Ok(ElfInspection { + machine: "x86_64".into(), + interpreter: None, + needed: direct_needed, + binary_sha256, + binary_size, + visited: vec![primary.visited()], + claim: ClosureClaim::StartupOnly, + }); + }; + require(interpreter == INTERPRETER, "unsupported ELF interpreter")?; + + validate_relative_directory(image_root, &["lib64"])?; + let loader_path = image_root.join("lib64").join(LOADER_NAME); + let (loader, _) = open_object(&loader_path, INTERPRETER, ObjectRole::Interpreter)?; + + validate_relative_directory(image_root, &["opt", "edgezero", "runtime-lib"])?; + let runtime_path = image_root.join("opt/edgezero/runtime-lib"); + let libraries = scan_runtime_libraries(&runtime_path)?; + validate_aliases(&primary, &loader, &libraries)?; + + let mut visited_identities = HashSet::new(); + let mut visited = Vec::new(); + add_visited(&primary, &mut visited_identities, &mut visited); + add_visited(&loader, &mut visited_identities, &mut visited); + visit_dependencies( + &loader.parsed.needed, + &loader, + &libraries, + &mut visited_identities, + &mut visited, + )?; + visit_dependencies( + &primary.parsed.needed, + &loader, + &libraries, + &mut visited_identities, + &mut visited, + )?; + + Ok(ElfInspection { + machine: "x86_64".into(), + interpreter: Some(INTERPRETER.into()), + needed: direct_needed, + binary_sha256, + binary_size, + visited, + claim: ClosureClaim::StartupOnly, + }) +} + +#[cfg(not(unix))] +pub fn inspect(_primary_path: &Path, _image_root: &Path) -> Result { + Err("Protocol-1 ELF inspection requires Unix device and inode metadata".into()) +} + +#[cfg(not(unix))] +pub(crate) fn inspect_staged(_primary_path: &Path, _image_root: &Path) -> Result { + Err("Protocol-1 ELF inspection requires Unix device and inode metadata".into()) +} + +#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq)] +struct Identity { + device: u64, + inode: u64, +} + +#[derive(Clone, Debug)] +struct Object { + image_path: String, + role: ObjectRole, + identity: Identity, + parsed: ParsedElf, +} + +impl Object { + fn visited(&self) -> VisitedObject { + VisitedObject { + image_path: self.image_path.clone(), + role: self.role, + device: self.identity.device, + inode: self.identity.inode, + needed: self.parsed.needed.clone(), + soname: self.parsed.soname.clone(), + } + } +} + +#[derive(Clone, Debug)] +struct ParsedElf { + interpreter: Option, + needed: Vec, + soname: Option, +} + +#[derive(Clone, Copy, Debug)] +struct ProgramHeader { + kind: u32, + flags: u32, + offset: u64, + vaddr: u64, + filesz: u64, + memsz: u64, +} + +#[derive(Clone, Copy)] +enum StringKind { + Needed, + Soname, +} + +#[cfg(unix)] +fn open_object(path: &Path, image_path: &str, role: ObjectRole) -> Result<(Object, File)> { + use std::os::unix::fs::MetadataExt; + + let path_metadata = fs::symlink_metadata(path).map_err(io_error)?; + require( + path_metadata.file_type().is_file(), + "ELF object is not a regular file", + )?; + require(path_metadata.nlink() == 1, "ELF object has multiple links")?; + let mut file = File::open(path).map_err(io_error)?; + let metadata = file.metadata().map_err(io_error)?; + require( + metadata.file_type().is_file(), + "ELF object is not a regular file", + )?; + require(metadata.nlink() == 1, "ELF object has multiple links")?; + require( + path_metadata.dev() == metadata.dev() && path_metadata.ino() == metadata.ino(), + "ELF object changed while opening", + )?; + require( + (1..=BINARY_LIMIT).contains(&metadata.len()), + "ELF object size is outside protocol bounds", + )?; + let filename = path + .file_name() + .and_then(|name| name.to_str()) + .ok_or("ELF filename is not UTF-8")?; + let parsed = parse_elf(&mut file, metadata.len(), role, filename)?; + Ok(( + Object { + image_path: image_path.into(), + role, + identity: Identity { + device: metadata.dev(), + inode: metadata.ino(), + }, + parsed, + }, + file, + )) +} + +fn parse_elf( + reader: &mut R, + file_size: u64, + role: ObjectRole, + filename: &str, +) -> Result { + let header = read_range(reader, 0, ELF_HEADER_SIZE, file_size)?; + require(&header[..4] == b"\x7fELF", "invalid ELF magic")?; + require(header[4] == ELFCLASS64, "ELF is not 64-bit")?; + require(header[5] == ELFDATA2LSB, "ELF is not little-endian")?; + require( + header[6] == EV_CURRENT as u8, + "unsupported ELF ident version", + )?; + require( + matches!(header[7], ELFOSABI_SYSV | ELFOSABI_GNU), + "unsupported ELF OSABI", + )?; + require(header[8] == 0, "unsupported ELF ABI version")?; + require( + header[9..16].iter().all(|byte| *byte == 0), + "nonzero ELF ident padding", + )?; + + let elf_type = u16_at(&header, 16); + let valid_type = match role { + ObjectRole::Primary => matches!(elf_type, ET_EXEC | ET_DYN), + ObjectRole::Interpreter | ObjectRole::Library => elf_type == ET_DYN, + }; + require(valid_type, "unsupported ELF object type")?; + require(u16_at(&header, 18) == EM_X86_64, "unsupported ELF machine")?; + require(u32_at(&header, 20) == EV_CURRENT, "unsupported ELF version")?; + require(u32_at(&header, 48) == 0, "unsupported ELF flags")?; + require( + usize::from(u16_at(&header, 52)) == ELF_HEADER_SIZE, + "invalid ELF header size", + )?; + require( + usize::from(u16_at(&header, 54)) == PROGRAM_HEADER_SIZE, + "invalid program header size", + )?; + let phnum = u16_at(&header, 56); + require(phnum != 0, "ELF has no program headers")?; + require( + phnum != PN_XNUM, + "extended program header numbering is unsupported", + )?; + let phoff = u64_at(&header, 32); + let table_size = u64::from(phnum) + .checked_mul(PROGRAM_HEADER_SIZE as u64) + .ok_or("program header table size overflow")?; + checked_range(phoff, table_size, file_size, "program header table")?; + + let mut programs = Vec::with_capacity(usize::from(phnum)); + for index in 0..u64::from(phnum) { + let offset = phoff + .checked_add( + index + .checked_mul(PROGRAM_HEADER_SIZE as u64) + .ok_or("program header offset overflow")?, + ) + .ok_or("program header offset overflow")?; + let bytes = read_range(reader, offset, PROGRAM_HEADER_SIZE, file_size)?; + let program = ProgramHeader { + kind: u32_at(&bytes, 0), + flags: u32_at(&bytes, 4), + offset: u64_at(&bytes, 8), + vaddr: u64_at(&bytes, 16), + filesz: u64_at(&bytes, 32), + memsz: u64_at(&bytes, 40), + }; + if program.kind == PT_LOAD { + require( + program.filesz <= program.memsz, + "PT_LOAD file size exceeds memory size", + )?; + } + checked_range(program.offset, program.filesz, file_size, "program segment")?; + program + .vaddr + .checked_add(program.filesz) + .ok_or("program file-backed address overflow")?; + program + .vaddr + .checked_add(program.memsz) + .ok_or("program memory address overflow")?; + programs.push(program); + } + + let interps: Vec<_> = programs + .iter() + .filter(|program| program.kind == PT_INTERP) + .collect(); + require(interps.len() <= 1, "multiple PT_INTERP segments")?; + let interpreter = interps + .first() + .map(|program| parse_interpreter(reader, file_size, &programs, program)) + .transpose()?; + + let dynamics: Vec<_> = programs + .iter() + .filter(|program| program.kind == PT_DYNAMIC) + .collect(); + require(dynamics.len() <= 1, "multiple PT_DYNAMIC segments")?; + let dynamic = dynamics + .first() + .map(|program| parse_dynamic(reader, file_size, &programs, program)) + .transpose()?; + let parsed = dynamic.unwrap_or(ParsedElf { + interpreter: None, + needed: Vec::new(), + soname: None, + }); + let is_dynamic = interpreter.is_some() || !parsed.needed.is_empty(); + + match role { + ObjectRole::Primary => { + require(parsed.soname.is_none(), "primary ELF has DT_SONAME")?; + require( + if is_dynamic { + dynamics.len() == 1 && interpreter.as_deref() == Some(INTERPRETER) + } else { + dynamics.is_empty() && interpreter.is_none() + }, + "primary static/dynamic profile is inconsistent", + )?; + } + ObjectRole::Library => { + require(dynamics.len() == 1, "library has no PT_DYNAMIC")?; + require( + interpreter + .as_deref() + .is_none_or(|value| value == INTERPRETER), + "library has unsupported PT_INTERP", + )?; + validate_name(filename, "library filename")?; + require( + !matches!(filename, APP_NAME | LOADER_NAME), + "reserved runtime library filename", + )?; + if let Some(soname) = &parsed.soname { + validate_name(soname, "library SONAME")?; + require(soname == filename, "library SONAME differs from filename")?; + } + } + ObjectRole::Interpreter => { + require(dynamics.len() == 1, "interpreter has no PT_DYNAMIC")?; + require(interpreter.is_none(), "interpreter has PT_INTERP")?; + if let Some(soname) = &parsed.soname { + require(soname == LOADER_NAME, "interpreter has unsupported SONAME")?; + } + } + } + + Ok(ParsedElf { + interpreter, + ..parsed + }) +} + +fn parse_interpreter( + reader: &mut R, + file_size: u64, + programs: &[ProgramHeader], + program: &ProgramHeader, +) -> Result { + require( + program.filesz == program.memsz, + "PT_INTERP file and memory sizes differ", + )?; + require( + program.filesz == (INTERPRETER.len() + 1) as u64, + "unsupported PT_INTERP size", + )?; + let mapped = map_virtual(programs, program.vaddr, program.filesz)?; + require(mapped == program.offset, "contradictory PT_INTERP mapping")?; + let bytes = read_range(reader, program.offset, INTERPRETER.len() + 1, file_size)?; + require(bytes.last() == Some(&0), "PT_INTERP is not NUL terminated")?; + require( + !bytes[..bytes.len() - 1].contains(&0), + "PT_INTERP contains interior NUL", + )?; + let value = + std::str::from_utf8(&bytes[..bytes.len() - 1]).map_err(|_| "PT_INTERP is not UTF-8")?; + require( + !value.chars().any(char::is_control), + "PT_INTERP contains a control character", + )?; + require(value == INTERPRETER, "unsupported ELF interpreter")?; + Ok(value.into()) +} + +fn parse_dynamic( + reader: &mut R, + file_size: u64, + programs: &[ProgramHeader], + dynamic: &ProgramHeader, +) -> Result { + require(dynamic.filesz != 0, "empty PT_DYNAMIC")?; + require( + dynamic.filesz == dynamic.memsz, + "PT_DYNAMIC file and memory sizes differ", + )?; + require( + dynamic.filesz.is_multiple_of(DYNAMIC_ENTRY_SIZE), + "malformed PT_DYNAMIC entry width", + )?; + let mapped = map_virtual(programs, dynamic.vaddr, dynamic.filesz)?; + require(mapped == dynamic.offset, "contradictory PT_DYNAMIC mapping")?; + + let mut seen = HashSet::new(); + let mut string_refs = Vec::new(); + let mut strtab = None; + let mut strsz = None; + let mut terminated_at = None; + let mut needed_count = 0; + let entries = dynamic.filesz / DYNAMIC_ENTRY_SIZE; + let entries_per_chunk = IO_CHUNK_SIZE as u64 / DYNAMIC_ENTRY_SIZE; + let mut index = 0; + 'dynamic: while index < entries { + let chunk_entries = (entries - index).min(entries_per_chunk); + let chunk_size = chunk_entries + .checked_mul(DYNAMIC_ENTRY_SIZE) + .ok_or("dynamic chunk size overflow")?; + let chunk_offset = dynamic + .offset + .checked_add( + index + .checked_mul(DYNAMIC_ENTRY_SIZE) + .ok_or("dynamic entry offset overflow")?, + ) + .ok_or("dynamic entry offset overflow")?; + let chunk = read_range( + reader, + chunk_offset, + usize::try_from(chunk_size).map_err(|_| "dynamic chunk size overflow")?, + file_size, + )?; + for (chunk_index, entry) in chunk.chunks_exact(DYNAMIC_ENTRY_SIZE as usize).enumerate() { + let entry_offset = chunk_offset + .checked_add( + (chunk_index as u64) + .checked_mul(DYNAMIC_ENTRY_SIZE) + .ok_or("dynamic entry offset overflow")?, + ) + .ok_or("dynamic entry offset overflow")?; + let tag = u64_at(entry, 0); + let value = u64_at(entry, 8); + if tag == DT_NULL { + require(value == 0, "nonzero DT_NULL value")?; + terminated_at = Some( + entry_offset + .checked_add(DYNAMIC_ENTRY_SIZE) + .ok_or("dynamic terminator overflow")?, + ); + break 'dynamic; + } + require(!is_forbidden_tag(tag), "forbidden dynamic tag")?; + require(is_allowed_tag(tag), "unknown dynamic tag")?; + if tag != DT_NEEDED { + require(seen.insert(tag), "duplicate singleton dynamic tag")?; + } + match tag { + DT_NEEDED => { + require( + needed_count < MAX_NEEDED_ENTRIES, + "too many DT_NEEDED entries", + )?; + needed_count += 1; + string_refs.push((StringKind::Needed, value)); + } + DT_SONAME => string_refs.push((StringKind::Soname, value)), + DT_STRTAB => strtab = Some(value), + DT_STRSZ => strsz = Some(value), + DT_FLAGS => require(value & !FLAGS_MASK == 0, "unsupported DT_FLAGS bits")?, + DT_FLAGS_1 => require(value & !FLAGS_1_MASK == 0, "unsupported DT_FLAGS_1 bits")?, + _ => {} + } + } + index = index + .checked_add(chunk_entries) + .ok_or("dynamic entry index overflow")?; + } + + let trailing_start = terminated_at.ok_or("PT_DYNAMIC has no DT_NULL")?; + let dynamic_end = dynamic + .offset + .checked_add(dynamic.filesz) + .ok_or("dynamic range overflow")?; + require_zero_range(reader, trailing_start, dynamic_end, file_size)?; + require( + strtab.is_some() == strsz.is_some(), + "incomplete dynamic string table", + )?; + require( + string_refs.is_empty() || strtab.is_some(), + "dynamic string tag has no string table", + )?; + + let mut needed = Vec::new(); + let mut soname = None; + if let (Some(vaddr), Some(size)) = (strtab, strsz) { + require(size != 0, "empty dynamic string table")?; + let table_offset = map_virtual(programs, vaddr, size)?; + for (kind, offset) in string_refs { + let value = read_dynamic_string(reader, file_size, table_offset, size, offset)?; + match kind { + StringKind::Needed => { + validate_name(&value, "DT_NEEDED")?; + needed.push(value); + } + StringKind::Soname => { + validate_name(&value, "DT_SONAME")?; + soname = Some(value); + } + } + } + } + + Ok(ParsedElf { + interpreter: None, + needed, + soname, + }) +} + +fn map_virtual(programs: &[ProgramHeader], vaddr: u64, size: u64) -> Result { + require(size != 0, "empty mapped range")?; + let end = vaddr.checked_add(size).ok_or("virtual range overflow")?; + let mut intersections = 0; + let mut mapping = None; + for load in programs.iter().filter(|program| program.kind == PT_LOAD) { + let memory_end = load + .vaddr + .checked_add(load.memsz) + .ok_or("PT_LOAD range overflow")?; + if vaddr < memory_end && load.vaddr < end { + intersections += 1; + } + + let file_backed_end = load + .vaddr + .checked_add(load.filesz) + .ok_or("PT_LOAD file-backed range overflow")?; + if load.flags & PF_R != 0 + && vaddr >= load.vaddr + && end <= file_backed_end + && end <= memory_end + { + let delta = vaddr + .checked_sub(load.vaddr) + .ok_or("virtual mapping underflow")?; + let offset = load + .offset + .checked_add(delta) + .ok_or("file mapping overflow")?; + let offset_end = offset.checked_add(size).ok_or("file mapping overflow")?; + let load_file_end = load + .offset + .checked_add(load.filesz) + .ok_or("PT_LOAD file range overflow")?; + if offset_end <= load_file_end { + require(mapping.is_none(), "multiple loader-visible mappings")?; + mapping = Some(offset); + } + } + } + require( + intersections == 1, + "mapped range is not uniquely readable and file-backed", + )?; + mapping.ok_or_else(|| "mapped range is not readable and file-backed".into()) +} + +fn read_dynamic_string( + reader: &mut R, + file_size: u64, + table_offset: u64, + table_size: u64, + string_offset: u64, +) -> Result { + require( + string_offset < table_size, + "dynamic string offset is out of range", + )?; + let remaining = table_size + .checked_sub(string_offset) + .ok_or("dynamic string range underflow")?; + let read_size = remaining.min(256); + let offset = table_offset + .checked_add(string_offset) + .ok_or("dynamic string offset overflow")?; + let bytes = read_range( + reader, + offset, + usize::try_from(read_size).map_err(|_| "dynamic string size overflow")?, + file_size, + )?; + let terminator = bytes + .iter() + .position(|byte| *byte == 0) + .ok_or("dynamic string is unterminated or oversized")?; + let value = + std::str::from_utf8(&bytes[..terminator]).map_err(|_| "dynamic string is not UTF-8")?; + require( + !value.chars().any(char::is_control), + "dynamic string contains a control character", + )?; + Ok(value.into()) +} + +fn is_forbidden_tag(tag: u64) -> bool { + matches!( + tag, + DT_RPATH + | DT_RUNPATH + | DT_AUDIT + | DT_DEPAUDIT + | DT_CONFIG + | DT_AUXILIARY + | DT_FILTER + | DT_POSFLAG_1 + ) +} + +fn is_allowed_tag(tag: u64) -> bool { + (tag <= 14) + || (16..=28).contains(&tag) + || tag == 30 + || (32..=37).contains(&tag) + || matches!(tag, 0x6ffffef5..=0x6ffffef7 | 0x6ffffff0 | 0x6ffffff9..=0x6fffffff) + || matches!(tag, 0x70000000 | 0x70000001 | 0x70000003) +} + +#[cfg(unix)] +fn scan_runtime_libraries(runtime_path: &Path) -> Result> { + let mut libraries = BTreeMap::new(); + for entry in fs::read_dir(runtime_path).map_err(io_error)? { + let entry = entry.map_err(io_error)?; + let name = entry + .file_name() + .into_string() + .map_err(|_| "runtime library filename is not UTF-8")?; + validate_name(&name, "runtime library filename")?; + require( + !matches!(name.as_str(), APP_NAME | LOADER_NAME), + "reserved runtime library filename", + )?; + require( + !libraries.contains_key(&name), + "duplicate runtime library basename", + )?; + let image_path = format!("{RUNTIME_LIB}/{name}"); + let (object, _) = open_object(&entry.path(), &image_path, ObjectRole::Library)?; + libraries.insert(name, object); + } + Ok(libraries) +} + +fn validate_aliases( + primary: &Object, + loader: &Object, + libraries: &BTreeMap, +) -> Result<()> { + let mut aliases: HashMap = HashMap::new(); + let mut identities: HashMap = HashMap::new(); + register_object_aliases(primary, APP_NAME, &mut aliases, &mut identities)?; + register_object_aliases(loader, LOADER_NAME, &mut aliases, &mut identities)?; + for (filename, object) in libraries { + register_object_aliases(object, filename, &mut aliases, &mut identities)?; + } + Ok(()) +} + +fn register_object_aliases( + object: &Object, + filename: &str, + aliases: &mut HashMap, + identities: &mut HashMap, +) -> Result<()> { + if let Some(existing) = identities.insert(object.identity, object.image_path.clone()) { + require( + existing == object.image_path, + "distinct ELF paths share device and inode", + )?; + } + for alias in [Some(filename), object.parsed.soname.as_deref()] + .into_iter() + .flatten() + { + if let Some(existing) = aliases.insert(alias.into(), object.identity) { + require( + existing == object.identity, + "cross-object ELF alias collision", + )?; + } + } + Ok(()) +} + +fn visit_dependencies( + dependencies: &[String], + loader: &Object, + libraries: &BTreeMap, + identities: &mut HashSet, + visited: &mut Vec, +) -> Result<()> { + for dependency in dependencies { + let object = if dependency == LOADER_NAME { + loader + } else { + libraries + .get(dependency) + .ok_or_else(|| format!("missing runtime dependency: {dependency}"))? + }; + if identities.insert(object.identity) { + visited.push(object.visited()); + visit_dependencies( + &object.parsed.needed, + loader, + libraries, + identities, + visited, + )?; + } + } + Ok(()) +} + +fn add_visited( + object: &Object, + identities: &mut HashSet, + visited: &mut Vec, +) { + if identities.insert(object.identity) { + visited.push(object.visited()); + } +} + +fn validate_name(value: &str, label: &str) -> Result<()> { + require( + (1..=255).contains(&value.len()) + && !value + .chars() + .any(|character| character.is_control() || matches!(character, '/' | '\\' | '$')), + &format!("invalid {label}"), + ) +} + +fn validate_root(root: &Path) -> Result<()> { + let metadata = fs::symlink_metadata(root).map_err(io_error)?; + require( + metadata.file_type().is_dir(), + "image root is not a real directory", + ) +} + +fn require_no_preload(root: &Path) -> Result<()> { + match fs::symlink_metadata(root.join("etc/ld.so.preload")) { + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(error.to_string()), + Ok(_) => Err("image contains /etc/ld.so.preload".into()), + } +} + +fn validate_relative_directory(root: &Path, components: &[&str]) -> Result<()> { + let mut path = PathBuf::from(root); + for component in components { + path.push(component); + let metadata = fs::symlink_metadata(&path).map_err(io_error)?; + require( + metadata.file_type().is_dir(), + "image directory component is not a real directory", + )?; + } + Ok(()) +} + +fn hash_file(file: &mut File, size: u64) -> Result { + seek(file, 0)?; + let mut remaining = size; + let mut hasher = Sha256::new(); + let mut buffer = [0; IO_CHUNK_SIZE]; + while remaining != 0 { + let limit = usize::try_from(remaining.min(buffer.len() as u64)) + .map_err(|_| "ELF hash size overflow")?; + let read = read_retry(file, &mut buffer[..limit])?; + require(read != 0, "ELF changed while hashing")?; + hasher.update(&buffer[..read]); + remaining = remaining + .checked_sub(read as u64) + .ok_or("ELF hash size underflow")?; + } + let mut extra = [0]; + require( + read_retry(file, &mut extra)? == 0, + "ELF changed while hashing", + )?; + Ok(format!("sha256:{:x}", hasher.finalize())) +} + +fn read_range( + reader: &mut R, + offset: u64, + size: usize, + file_size: u64, +) -> Result> { + checked_range(offset, size as u64, file_size, "ELF read")?; + seek(reader, offset)?; + let mut bytes = vec![0; size]; + let mut filled = 0; + while filled < bytes.len() { + let end = (filled + IO_CHUNK_SIZE).min(bytes.len()); + let read = read_retry(reader, &mut bytes[filled..end])?; + require(read != 0, "unexpected end of ELF object")?; + filled = filled.checked_add(read).ok_or("ELF read size overflow")?; + } + Ok(bytes) +} + +fn require_zero_range( + reader: &mut R, + start: u64, + end: u64, + file_size: u64, +) -> Result<()> { + require(start <= end, "invalid zero range")?; + checked_range(start, end - start, file_size, "dynamic trailing bytes")?; + seek(reader, start)?; + let mut remaining = end - start; + let mut buffer = [0; IO_CHUNK_SIZE]; + while remaining != 0 { + let limit = usize::try_from(remaining.min(buffer.len() as u64)) + .map_err(|_| "dynamic trailing size overflow")?; + let read = read_retry(reader, &mut buffer[..limit])?; + require(read != 0, "unexpected end of PT_DYNAMIC")?; + require( + buffer[..read].iter().all(|byte| *byte == 0), + "nonzero bytes after DT_NULL", + )?; + remaining = remaining + .checked_sub(read as u64) + .ok_or("dynamic trailing size underflow")?; + } + Ok(()) +} + +fn checked_range(offset: u64, size: u64, file_size: u64, label: &str) -> Result<()> { + let end = offset + .checked_add(size) + .ok_or_else(|| format!("{label} overflow"))?; + require( + end <= file_size, + &format!("{label} is outside the ELF object"), + ) +} + +fn seek(reader: &mut R, offset: u64) -> Result<()> { + loop { + match reader.seek(SeekFrom::Start(offset)) { + Ok(actual) => return require(actual == offset, "ELF seek returned wrong offset"), + Err(error) if error.kind() == std::io::ErrorKind::Interrupted => continue, + Err(error) => return Err(error.to_string()), + } + } +} + +fn read_retry(reader: &mut R, bytes: &mut [u8]) -> Result { + loop { + match reader.read(bytes) { + Err(error) if error.kind() == std::io::ErrorKind::Interrupted => continue, + result => return result.map_err(io_error), + } + } +} + +fn u16_at(bytes: &[u8], offset: usize) -> u16 { + u16::from_le_bytes( + bytes[offset..offset + 2] + .try_into() + .expect("fixed ELF field"), + ) +} + +fn u32_at(bytes: &[u8], offset: usize) -> u32 { + u32::from_le_bytes( + bytes[offset..offset + 4] + .try_into() + .expect("fixed ELF field"), + ) +} + +fn u64_at(bytes: &[u8], offset: usize) -> u64 { + u64::from_le_bytes( + bytes[offset..offset + 8] + .try_into() + .expect("fixed ELF field"), + ) +} + +fn io_error(error: std::io::Error) -> String { + error.to_string() +} + +fn require(valid: bool, reason: &str) -> Result<()> { + if valid { Ok(()) } else { Err(reason.into()) } +} + +#[cfg(test)] +mod tests { + use super::{ClosureClaim, ObjectRole, direct_launch, inspect}; + use crate::json_contract::{INTERPRETER, METADATA_LIMIT}; + use std::{ + fs, + io::{self, Cursor, Read, Seek, SeekFrom}, + path::{Path, PathBuf}, + sync::atomic::{AtomicU64, Ordering}, + }; + + const ELF_HEADER_SIZE: usize = 64; + const PROGRAM_HEADER_SIZE: usize = 56; + const PT_LOAD: u32 = 1; + const PT_DYNAMIC: u32 = 2; + const PT_INTERP: u32 = 3; + const PF_R: u32 = 4; + const ET_EXEC: u16 = 2; + const ET_DYN: u16 = 3; + const BASE_VADDR: u64 = 0x400000; + const INTERP_OFFSET: usize = 0x240; + const DYNAMIC_OFFSET: usize = 0x300; + const STRTAB_OFFSET: usize = 0x500; + const FILE_SIZE: usize = 0x800; + const LOADER_NAME: &str = "ld-linux-x86-64.so.2"; + + const DT_NULL: u64 = 0; + const DT_NEEDED: u64 = 1; + const DT_STRTAB: u64 = 5; + const DT_STRSZ: u64 = 10; + const DT_SONAME: u64 = 14; + const DT_RPATH: u64 = 15; + const DT_RUNPATH: u64 = 29; + const DT_FLAGS: u64 = 30; + const DT_POSFLAG_1: u64 = 0x6ffffdfd; + const DT_CONFIG: u64 = 0x6ffffefa; + const DT_DEPAUDIT: u64 = 0x6ffffefb; + const DT_AUDIT: u64 = 0x6ffffefc; + const DT_FLAGS_1: u64 = 0x6ffffffb; + const DT_AUXILIARY: u64 = 0x7ffffffd; + const DT_FILTER: u64 = 0x7fffffff; + + static NEXT_TEMP: AtomicU64 = AtomicU64::new(0); + + struct TempDir(PathBuf); + + impl TempDir { + fn new() -> Self { + let sequence = NEXT_TEMP.fetch_add(1, Ordering::Relaxed); + let path = Path::new("/tmp").join(format!("ezelf-{}-{sequence}", std::process::id())); + fs::create_dir(&path).unwrap(); + Self(path.canonicalize().unwrap()) + } + + fn path(&self) -> &Path { + &self.0 + } + } + + impl Drop for TempDir { + fn drop(&mut self) { + fs::remove_dir_all(&self.0).unwrap(); + } + } + + struct Image { + temp: TempDir, + } + + impl Image { + fn new() -> Self { + Self { + temp: TempDir::new(), + } + } + + fn root(&self) -> &Path { + self.temp.path() + } + + fn write(&self, image_path: &str, bytes: &[u8]) -> PathBuf { + let path = self.root().join(image_path.trim_start_matches('/')); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(&path, bytes).unwrap(); + path + } + + fn primary(&self, bytes: &[u8]) -> PathBuf { + self.write("/work/bin/app-cli", bytes) + } + + fn loader(&self, bytes: &[u8]) -> PathBuf { + self.write(INTERPRETER, bytes) + } + + fn library(&self, name: &str, bytes: &[u8]) -> PathBuf { + self.write(&format!("/opt/edgezero/runtime-lib/{name}"), bytes) + } + + fn runtime_dir(&self) -> PathBuf { + let path = self.root().join("opt/edgezero/runtime-lib"); + fs::create_dir_all(&path).unwrap(); + path + } + + fn inspect(&self, primary: &Path) -> crate::Result { + inspect(primary, self.root()) + } + } + + #[derive(Clone)] + struct Fixture { + elf_type: u16, + osabi: u8, + interp: Option>, + dynamic: bool, + needed: Vec>, + soname: Option>, + extra_tags: Vec<(u64, u64)>, + } + + impl Fixture { + fn static_primary() -> Self { + Self { + elf_type: ET_EXEC, + osabi: 0, + interp: None, + dynamic: false, + needed: Vec::new(), + soname: None, + extra_tags: Vec::new(), + } + } + + fn dynamic_primary(needed: &[&str]) -> Self { + Self { + elf_type: ET_EXEC, + osabi: 0, + interp: Some(nul(INTERPRETER.as_bytes())), + dynamic: true, + needed: needed + .iter() + .map(|value| value.as_bytes().to_vec()) + .collect(), + soname: None, + extra_tags: Vec::new(), + } + } + + fn library(needed: &[&str]) -> Self { + Self { + elf_type: ET_DYN, + osabi: 3, + interp: None, + dynamic: true, + needed: needed + .iter() + .map(|value| value.as_bytes().to_vec()) + .collect(), + soname: None, + extra_tags: Vec::new(), + } + } + + fn loader() -> Self { + Self::library(&[]) + } + + fn build(&self) -> Vec { + let mut strings = vec![0]; + let mut string_tags = Vec::new(); + for needed in &self.needed { + let offset = strings.len() as u64; + strings.extend_from_slice(needed); + strings.push(0); + string_tags.push((DT_NEEDED, offset)); + } + if let Some(soname) = &self.soname { + let offset = strings.len() as u64; + strings.extend_from_slice(soname); + strings.push(0); + string_tags.push((DT_SONAME, offset)); + } + + let mut tags = Vec::new(); + if !string_tags.is_empty() { + tags.push((DT_STRTAB, BASE_VADDR + STRTAB_OFFSET as u64)); + tags.push((DT_STRSZ, strings.len() as u64)); + tags.extend(string_tags); + } + tags.extend_from_slice(&self.extra_tags); + tags.push((DT_NULL, 0)); + + let phnum = 1 + usize::from(self.interp.is_some()) + usize::from(self.dynamic); + let mut bytes = vec![0; FILE_SIZE]; + bytes[..4].copy_from_slice(b"\x7fELF"); + bytes[4] = 2; + bytes[5] = 1; + bytes[6] = 1; + bytes[7] = self.osabi; + bytes[8] = 0; + put_u16(&mut bytes, 16, self.elf_type); + put_u16(&mut bytes, 18, 62); + put_u32(&mut bytes, 20, 1); + put_u64(&mut bytes, 32, ELF_HEADER_SIZE as u64); + put_u32(&mut bytes, 48, 0); + put_u16(&mut bytes, 52, ELF_HEADER_SIZE as u16); + put_u16(&mut bytes, 54, PROGRAM_HEADER_SIZE as u16); + put_u16(&mut bytes, 56, phnum as u16); + + write_ph( + &mut bytes, + 0, + PT_LOAD, + PF_R, + 0, + BASE_VADDR, + FILE_SIZE as u64, + FILE_SIZE as u64, + ); + let mut index = 1; + if let Some(interp) = &self.interp { + bytes[INTERP_OFFSET..INTERP_OFFSET + interp.len()].copy_from_slice(interp); + write_ph( + &mut bytes, + index, + PT_INTERP, + PF_R, + INTERP_OFFSET as u64, + BASE_VADDR + INTERP_OFFSET as u64, + interp.len() as u64, + interp.len() as u64, + ); + index += 1; + } + if self.dynamic { + for (entry, (tag, value)) in tags.iter().enumerate() { + let offset = DYNAMIC_OFFSET + entry * 16; + put_u64(&mut bytes, offset, *tag); + put_u64(&mut bytes, offset + 8, *value); + } + let dynamic_size = (tags.len() * 16) as u64; + write_ph( + &mut bytes, + index, + PT_DYNAMIC, + PF_R, + DYNAMIC_OFFSET as u64, + BASE_VADDR + DYNAMIC_OFFSET as u64, + dynamic_size, + dynamic_size, + ); + bytes[STRTAB_OFFSET..STRTAB_OFFSET + strings.len()].copy_from_slice(&strings); + } + bytes + } + } + + fn nul(bytes: &[u8]) -> Vec { + let mut value = bytes.to_vec(); + value.push(0); + value + } + + fn put_u16(bytes: &mut [u8], offset: usize, value: u16) { + bytes[offset..offset + 2].copy_from_slice(&value.to_le_bytes()); + } + + fn put_u32(bytes: &mut [u8], offset: usize, value: u32) { + bytes[offset..offset + 4].copy_from_slice(&value.to_le_bytes()); + } + + fn put_u64(bytes: &mut [u8], offset: usize, value: u64) { + bytes[offset..offset + 8].copy_from_slice(&value.to_le_bytes()); + } + + fn get_u16(bytes: &[u8], offset: usize) -> u16 { + u16::from_le_bytes(bytes[offset..offset + 2].try_into().unwrap()) + } + + fn get_u32(bytes: &[u8], offset: usize) -> u32 { + u32::from_le_bytes(bytes[offset..offset + 4].try_into().unwrap()) + } + + fn get_u64(bytes: &[u8], offset: usize) -> u64 { + u64::from_le_bytes(bytes[offset..offset + 8].try_into().unwrap()) + } + + #[allow(clippy::too_many_arguments)] + fn write_ph( + bytes: &mut [u8], + index: usize, + kind: u32, + flags: u32, + offset: u64, + vaddr: u64, + filesz: u64, + memsz: u64, + ) { + let ph = ELF_HEADER_SIZE + index * PROGRAM_HEADER_SIZE; + put_u32(bytes, ph, kind); + put_u32(bytes, ph + 4, flags); + put_u64(bytes, ph + 8, offset); + put_u64(bytes, ph + 16, vaddr); + put_u64(bytes, ph + 32, filesz); + put_u64(bytes, ph + 40, memsz); + put_u64(bytes, ph + 48, 8); + } + + fn ph_offset(bytes: &[u8], kind: u32, occurrence: usize) -> usize { + let count = usize::from(get_u16(bytes, 56)); + (0..count) + .filter_map(|index| { + let offset = ELF_HEADER_SIZE + index * PROGRAM_HEADER_SIZE; + (get_u32(bytes, offset) == kind).then_some(offset) + }) + .nth(occurrence) + .unwrap() + } + + fn append_ph(bytes: &mut [u8], source: usize) -> usize { + let count = usize::from(get_u16(bytes, 56)); + let destination = ELF_HEADER_SIZE + count * PROGRAM_HEADER_SIZE; + bytes.copy_within(source..source + PROGRAM_HEADER_SIZE, destination); + put_u16(bytes, 56, (count + 1) as u16); + destination + } + + fn append_partial_load(bytes: &mut [u8], offset: u64, vaddr: u64, size: u64) { + let load = ph_offset(bytes, PT_LOAD, 0); + let partial = append_ph(bytes, load); + put_u64(bytes, partial + 8, offset + size - 1); + put_u64(bytes, partial + 16, vaddr + size - 1); + put_u64(bytes, partial + 32, 2); + put_u64(bytes, partial + 40, 2); + } + + fn dynamic_entry(bytes: &[u8], tag: u64, occurrence: usize) -> usize { + let ph = ph_offset(bytes, PT_DYNAMIC, 0); + let start = get_u64(bytes, ph + 8) as usize; + let size = get_u64(bytes, ph + 32) as usize; + (start..start + size) + .step_by(16) + .filter(|offset| get_u64(bytes, *offset) == tag) + .nth(occurrence) + .unwrap() + } + + fn assert_primary_rejected(bytes: &[u8]) { + let image = Image::new(); + let primary = image.primary(bytes); + assert!(image.inspect(&primary).is_err()); + } + + fn assert_dynamic_primary_rejected(bytes: &[u8]) { + let image = Image::new(); + let primary = image.primary(bytes); + image.loader(&Fixture::loader().build()); + image.runtime_dir(); + assert!(image.inspect(&primary).is_err()); + } + + fn inspect_with_library( + primary_needed: &[&str], + name: &str, + library: &[u8], + ) -> crate::Result { + let image = Image::new(); + let primary = image.primary(&Fixture::dynamic_primary(primary_needed).build()); + image.loader(&Fixture::loader().build()); + image.library(name, library); + image.inspect(&primary) + } + + #[test] + fn valid_static_metadata_and_launch_contract() { + use sha2::{Digest, Sha256}; + + let image = Image::new(); + let bytes = Fixture::static_primary().build(); + let primary = image.primary(&bytes); + let result = image.inspect(&primary).unwrap(); + + assert_eq!(result.machine, "x86_64"); + assert_eq!(result.interpreter, None); + assert!(result.needed.is_empty()); + assert_eq!(result.binary_size, bytes.len() as u64); + assert_eq!( + result.binary_sha256, + format!("sha256:{:x}", Sha256::digest(&bytes)) + ); + assert_eq!(result.claim, ClosureClaim::StartupOnly); + assert_eq!(result.visited.len(), 1); + assert_eq!(result.visited[0].role, ObjectRole::Primary); + + let command = direct_launch(None, &["validate".into(), "--strict".into()]).unwrap(); + assert_eq!(command.program, "/usr/bin/env"); + assert_eq!(command.args, ["/work/bin/app-cli", "validate", "--strict"]); + } + + #[test] + fn valid_dynamic_closure_preserves_sorted_direct_needed_and_cycles() { + let image = Image::new(); + let primary = + image.primary(&Fixture::dynamic_primary(&["libz.so", "liba.so", "libz.so"]).build()); + image.loader(&Fixture::loader().build()); + let mut liba = Fixture::library(&["libz.so"]); + liba.soname = Some(b"liba.so".to_vec()); + image.library("liba.so", &liba.build()); + let mut libz = Fixture::library(&["liba.so"]); + libz.interp = Some(nul(INTERPRETER.as_bytes())); + image.library("libz.so", &libz.build()); + + let result = image.inspect(&primary).unwrap(); + assert_eq!(result.interpreter.as_deref(), Some(INTERPRETER)); + assert_eq!(result.needed, ["liba.so", "libz.so", "libz.so"]); + assert_eq!(result.visited.len(), 4); + assert_eq!( + result + .visited + .iter() + .filter(|object| object.role == ObjectRole::Interpreter) + .count(), + 1 + ); + assert_eq!( + result + .visited + .iter() + .filter(|object| object.role == ObjectRole::Library) + .count(), + 2 + ); + assert!(result.visited.iter().all(|object| object.device != 0)); + assert!(result.visited.iter().all(|object| object.inode != 0)); + + let command = direct_launch(result.interpreter.as_deref(), &["package".into()]).unwrap(); + assert_eq!(command.program, "/usr/bin/env"); + assert_eq!( + command.args, + [ + INTERPRETER, + "--inhibit-cache", + "--glibc-hwcaps-mask", + "", + "--library-path", + "/opt/edgezero/runtime-lib", + "/work/bin/app-cli", + "package", + ] + ); + } + + #[test] + fn interpreter_dependencies_are_part_of_the_recursive_startup_closure() { + let image = Image::new(); + let primary = image.primary(&Fixture::dynamic_primary(&[]).build()); + image.loader(&Fixture::library(&["libloader-dep.so"]).build()); + image.library("libloader-dep.so", &Fixture::library(&[]).build()); + let result = image.inspect(&primary).unwrap(); + + assert!(result.needed.is_empty()); + assert!(result.visited.iter().any(|object| { + object.role == ObjectRole::Library + && object.image_path == "/opt/edgezero/runtime-lib/libloader-dep.so" + })); + } + + #[test] + fn loader_alias_resolves_to_the_validated_interpreter() { + let image = Image::new(); + let primary = image.primary(&Fixture::dynamic_primary(&[LOADER_NAME]).build()); + let mut loader = Fixture::loader(); + loader.soname = Some(LOADER_NAME.as_bytes().to_vec()); + image.loader(&loader.build()); + image.runtime_dir(); + + let result = image.inspect(&primary).unwrap(); + assert_eq!(result.needed, [LOADER_NAME]); + assert_eq!(result.visited.len(), 2); + assert_eq!(result.visited[1].role, ObjectRole::Interpreter); + } + + #[test] + fn header_profile_is_exact() { + for (offset, value) in [(4, 1), (5, 2), (6, 0), (7, 1), (8, 1), (9, 1)] { + let mut bytes = Fixture::static_primary().build(); + bytes[offset] = value; + assert_primary_rejected(&bytes); + } + for (offset, value) in [(16, 1), (18, 3), (52, 63), (54, 55), (56, 0), (56, 0xffff)] { + let mut bytes = Fixture::static_primary().build(); + put_u16(&mut bytes, offset, value); + assert_primary_rejected(&bytes); + } + for (offset, value) in [(20, 0), (48, 1)] { + let mut bytes = Fixture::static_primary().build(); + put_u32(&mut bytes, offset, value); + assert_primary_rejected(&bytes); + } + + for osabi in [0, 3] { + let image = Image::new(); + let mut fixture = Fixture::static_primary(); + fixture.osabi = osabi; + let primary = image.primary(&fixture.build()); + image.inspect(&primary).unwrap(); + } + let mut pie_static = Fixture::static_primary(); + pie_static.elf_type = ET_DYN; + let image = Image::new(); + let primary = image.primary(&pie_static.build()); + image.inspect(&primary).unwrap(); + } + + #[test] + fn magic_and_every_other_osabi_are_rejected() { + let mut bad_magic = Fixture::static_primary().build(); + bad_magic[0] = 0; + assert_primary_rejected(&bad_magic); + + for osabi in 0u8..=u8::MAX { + let mut fixture = Fixture::static_primary(); + fixture.osabi = osabi; + let bytes = fixture.build(); + let result = super::parse_elf( + &mut Cursor::new(bytes.clone()), + bytes.len() as u64, + ObjectRole::Primary, + "app-cli", + ); + assert_eq!(result.is_ok(), matches!(osabi, 0 | 3), "OSABI {osabi}"); + } + } + + #[test] + fn checked_header_segment_and_virtual_ranges_reject_overflow() { + for phoff in [FILE_SIZE as u64 - 8, u64::MAX - 8] { + let mut bytes = Fixture::static_primary().build(); + put_u64(&mut bytes, 32, phoff); + assert_primary_rejected(&bytes); + } + + let mut segment_offset_overflow = Fixture::static_primary().build(); + let load = ph_offset(&segment_offset_overflow, PT_LOAD, 0); + put_u64(&mut segment_offset_overflow, load + 8, u64::MAX - 1); + put_u64(&mut segment_offset_overflow, load + 32, 2); + assert_primary_rejected(&segment_offset_overflow); + + let mut segment_out_of_range = Fixture::static_primary().build(); + let load = ph_offset(&segment_out_of_range, PT_LOAD, 0); + put_u64(&mut segment_out_of_range, load + 8, FILE_SIZE as u64 - 1); + put_u64(&mut segment_out_of_range, load + 32, 2); + assert_primary_rejected(&segment_out_of_range); + + let mut file_vaddr_overflow = Fixture::static_primary().build(); + let load = ph_offset(&file_vaddr_overflow, PT_LOAD, 0); + put_u64(&mut file_vaddr_overflow, load + 16, u64::MAX - 1); + put_u64(&mut file_vaddr_overflow, load + 32, 2); + assert_primary_rejected(&file_vaddr_overflow); + + let mut memory_vaddr_overflow = Fixture::static_primary().build(); + let load = ph_offset(&memory_vaddr_overflow, PT_LOAD, 0); + put_u64(&mut memory_vaddr_overflow, load + 16, u64::MAX - 1); + put_u64(&mut memory_vaddr_overflow, load + 32, 1); + put_u64(&mut memory_vaddr_overflow, load + 40, 2); + assert_primary_rejected(&memory_vaddr_overflow); + } + + #[test] + fn section_header_fields_are_ignored() { + let image = Image::new(); + let mut bytes = Fixture::static_primary().build(); + put_u64(&mut bytes, 40, u64::MAX); + put_u16(&mut bytes, 58, u16::MAX); + put_u16(&mut bytes, 60, u16::MAX); + put_u16(&mut bytes, 62, u16::MAX); + let primary = image.primary(&bytes); + image.inspect(&primary).unwrap(); + } + + #[test] + fn interpreter_profile_is_exact_and_well_formed() { + let cases = [ + nul(b"/lib64/other-loader.so"), + INTERPRETER.as_bytes().to_vec(), + b"/lib64/ld-linux\0-x86-64.so.2\0".to_vec(), + { + let mut value = nul(INTERPRETER.as_bytes()); + value.push(0); + value + }, + ]; + for interp in cases { + let mut fixture = Fixture::dynamic_primary(&[]); + fixture.interp = Some(interp); + assert_dynamic_primary_rejected(&fixture.build()); + } + + let mut no_interp = Fixture::dynamic_primary(&["liba.so"]); + no_interp.interp = None; + assert_dynamic_primary_rejected(&no_interp.build()); + + let image = Image::new(); + let primary = image.primary(&Fixture::dynamic_primary(&[]).build()); + let mut loader = Fixture::loader(); + loader.interp = Some(nul(INTERPRETER.as_bytes())); + image.loader(&loader.build()); + image.runtime_dir(); + assert!(image.inspect(&primary).is_err()); + } + + #[test] + fn duplicate_and_malformed_interp_segments_are_rejected() { + let mut duplicate = Fixture::dynamic_primary(&[]).build(); + let interp = ph_offset(&duplicate, PT_INTERP, 0); + append_ph(&mut duplicate, interp); + assert_dynamic_primary_rejected(&duplicate); + + let mut unequal = Fixture::dynamic_primary(&[]).build(); + let interp = ph_offset(&unequal, PT_INTERP, 0); + let filesz = get_u64(&unequal, interp + 32); + put_u64(&mut unequal, interp + 40, filesz + 1); + assert_dynamic_primary_rejected(&unequal); + + let mut contradictory = Fixture::dynamic_primary(&[]).build(); + let interp = ph_offset(&contradictory, PT_INTERP, 0); + let offset = get_u64(&contradictory, interp + 8); + put_u64(&mut contradictory, interp + 8, offset + 1); + assert_dynamic_primary_rejected(&contradictory); + + let mut nonreadable = Fixture::dynamic_primary(&[]).build(); + let load = ph_offset(&nonreadable, PT_LOAD, 0); + put_u32(&mut nonreadable, load + 4, 0); + assert_dynamic_primary_rejected(&nonreadable); + } + + #[test] + fn static_and_dynamic_segment_rules_are_enforced() { + let mut static_with_dynamic = Fixture::static_primary(); + static_with_dynamic.dynamic = true; + assert_primary_rejected(&static_with_dynamic.build()); + + let mut dynamic_without_table = Fixture::dynamic_primary(&[]); + dynamic_without_table.dynamic = false; + assert_dynamic_primary_rejected(&dynamic_without_table.build()); + + let mut duplicate = Fixture::dynamic_primary(&[]).build(); + let dynamic = ph_offset(&duplicate, PT_DYNAMIC, 0); + append_ph(&mut duplicate, dynamic); + assert_dynamic_primary_rejected(&duplicate); + + let mut unequal = Fixture::dynamic_primary(&[]).build(); + let dynamic = ph_offset(&unequal, PT_DYNAMIC, 0); + let file_size = get_u64(&unequal, dynamic + 32); + put_u64(&mut unequal, dynamic + 40, file_size + 16); + assert_dynamic_primary_rejected(&unequal); + + let mut empty = Fixture::dynamic_primary(&[]).build(); + let dynamic = ph_offset(&empty, PT_DYNAMIC, 0); + put_u64(&mut empty, dynamic + 32, 0); + put_u64(&mut empty, dynamic + 40, 0); + assert_dynamic_primary_rejected(&empty); + + let mut malformed_width = Fixture::dynamic_primary(&[]).build(); + let dynamic = ph_offset(&malformed_width, PT_DYNAMIC, 0); + put_u64(&mut malformed_width, dynamic + 32, 15); + put_u64(&mut malformed_width, dynamic + 40, 15); + assert_dynamic_primary_rejected(&malformed_width); + } + + #[test] + fn libraries_and_interpreter_require_exactly_one_dynamic_segment() { + let mut library_without_dynamic = Fixture::library(&[]); + library_without_dynamic.dynamic = false; + assert!( + inspect_with_library(&["liba.so"], "liba.so", &library_without_dynamic.build()) + .is_err() + ); + + let mut duplicate_library = Fixture::library(&[]).build(); + let dynamic = ph_offset(&duplicate_library, PT_DYNAMIC, 0); + append_ph(&mut duplicate_library, dynamic); + assert!(inspect_with_library(&["liba.so"], "liba.so", &duplicate_library).is_err()); + + let image = Image::new(); + let primary = image.primary(&Fixture::dynamic_primary(&[]).build()); + let mut loader_without_dynamic = Fixture::loader(); + loader_without_dynamic.dynamic = false; + image.loader(&loader_without_dynamic.build()); + image.runtime_dir(); + assert!(image.inspect(&primary).is_err()); + + let image = Image::new(); + let primary = image.primary(&Fixture::dynamic_primary(&[]).build()); + let mut duplicate_loader = Fixture::loader().build(); + let dynamic = ph_offset(&duplicate_loader, PT_DYNAMIC, 0); + append_ph(&mut duplicate_loader, dynamic); + image.loader(&duplicate_loader); + image.runtime_dir(); + assert!(image.inspect(&primary).is_err()); + } + + #[test] + fn every_load_segment_requires_file_size_not_exceeding_memory_size() { + let mut bytes = Fixture::static_primary().build(); + let load = ph_offset(&bytes, PT_LOAD, 0); + let filesz = get_u64(&bytes, load + 32); + put_u64(&mut bytes, load + 40, filesz - 1); + assert_primary_rejected(&bytes); + } + + #[test] + fn dynamic_mapping_must_be_unique_readable_file_backed_and_consistent() { + let mut contradictory = Fixture::dynamic_primary(&[]).build(); + let dynamic = ph_offset(&contradictory, PT_DYNAMIC, 0); + let virtual_address = get_u64(&contradictory, dynamic + 16); + put_u64(&mut contradictory, dynamic + 16, virtual_address + 16); + assert_dynamic_primary_rejected(&contradictory); + + let mut nonreadable = Fixture::dynamic_primary(&[]).build(); + let load = ph_offset(&nonreadable, PT_LOAD, 0); + put_u32(&mut nonreadable, load + 4, 0); + assert_dynamic_primary_rejected(&nonreadable); + + let mut non_file_backed = Fixture::dynamic_primary(&[]).build(); + let load = ph_offset(&non_file_backed, PT_LOAD, 0); + put_u64(&mut non_file_backed, load + 32, DYNAMIC_OFFSET as u64); + assert_dynamic_primary_rejected(&non_file_backed); + + let mut ambiguous = Fixture::dynamic_primary(&[]).build(); + let load = ph_offset(&ambiguous, PT_LOAD, 0); + append_ph(&mut ambiguous, load); + assert_dynamic_primary_rejected(&ambiguous); + + let mut unmapped = Fixture::dynamic_primary(&[]).build(); + let dynamic = ph_offset(&unmapped, PT_DYNAMIC, 0); + put_u64( + &mut unmapped, + dynamic + 16, + BASE_VADDR + FILE_SIZE as u64 + 16, + ); + assert_dynamic_primary_rejected(&unmapped); + } + + #[test] + fn partial_load_overlap_is_ambiguous_for_every_loader_visible_range() { + let mut dynamic = Fixture::dynamic_primary(&[]).build(); + let ph = ph_offset(&dynamic, PT_DYNAMIC, 0); + let offset = get_u64(&dynamic, ph + 8); + let vaddr = get_u64(&dynamic, ph + 16); + let size = get_u64(&dynamic, ph + 32); + append_partial_load(&mut dynamic, offset, vaddr, size); + assert_dynamic_primary_rejected(&dynamic); + + let mut interp = Fixture::dynamic_primary(&[]).build(); + let ph = ph_offset(&interp, PT_INTERP, 0); + let offset = get_u64(&interp, ph + 8); + let vaddr = get_u64(&interp, ph + 16); + let size = get_u64(&interp, ph + 32); + append_partial_load(&mut interp, offset, vaddr, size); + assert_dynamic_primary_rejected(&interp); + + let mut strings = Fixture::dynamic_primary(&["liba.so"]).build(); + let strsz = get_u64(&strings, dynamic_entry(&strings, DT_STRSZ, 0) + 8); + append_partial_load( + &mut strings, + STRTAB_OFFSET as u64, + BASE_VADDR + STRTAB_OFFSET as u64, + strsz, + ); + assert_dynamic_primary_rejected(&strings); + } + + #[test] + fn dynamic_termination_is_exact() { + let mut missing_null = Fixture::dynamic_primary(&[]).build(); + put_u64(&mut missing_null, DYNAMIC_OFFSET, 2); + assert_dynamic_primary_rejected(&missing_null); + + let mut trailing_nonzero = Fixture::dynamic_primary(&[]).build(); + let dynamic = ph_offset(&trailing_nonzero, PT_DYNAMIC, 0); + put_u64(&mut trailing_nonzero, dynamic + 32, 32); + put_u64(&mut trailing_nonzero, dynamic + 40, 32); + trailing_nonzero[DYNAMIC_OFFSET + 16] = 1; + assert_dynamic_primary_rejected(&trailing_nonzero); + + let mut trailing_zero = Fixture::dynamic_primary(&[]).build(); + let dynamic = ph_offset(&trailing_zero, PT_DYNAMIC, 0); + put_u64(&mut trailing_zero, dynamic + 32, 32); + put_u64(&mut trailing_zero, dynamic + 40, 32); + let image = Image::new(); + let primary = image.primary(&trailing_zero); + image.loader(&Fixture::loader().build()); + image.runtime_dir(); + image.inspect(&primary).unwrap(); + + let mut nonzero_null = Fixture::dynamic_primary(&[]).build(); + put_u64(&mut nonzero_null, DYNAMIC_OFFSET + 8, 1); + assert_dynamic_primary_rejected(&nonzero_null); + } + + #[test] + fn string_table_mapping_and_strings_are_bounded_and_unambiguous() { + let base = Fixture::dynamic_primary(&["liba.so"]).build(); + + let mut duplicate_strtab = base.clone(); + let null = dynamic_entry(&duplicate_strtab, DT_NULL, 0); + put_u64(&mut duplicate_strtab, null, DT_STRTAB); + put_u64( + &mut duplicate_strtab, + null + 8, + BASE_VADDR + STRTAB_OFFSET as u64 + 1, + ); + let dynamic = ph_offset(&duplicate_strtab, PT_DYNAMIC, 0); + let size = get_u64(&duplicate_strtab, dynamic + 32) + 16; + put_u64(&mut duplicate_strtab, dynamic + 32, size); + put_u64(&mut duplicate_strtab, dynamic + 40, size); + assert_dynamic_primary_rejected(&duplicate_strtab); + + let mut duplicate_strsz = base.clone(); + let null = dynamic_entry(&duplicate_strsz, DT_NULL, 0); + put_u64(&mut duplicate_strsz, null, DT_STRSZ); + put_u64(&mut duplicate_strsz, null + 8, 1); + let dynamic = ph_offset(&duplicate_strsz, PT_DYNAMIC, 0); + let size = get_u64(&duplicate_strsz, dynamic + 32) + 16; + put_u64(&mut duplicate_strsz, dynamic + 32, size); + put_u64(&mut duplicate_strsz, dynamic + 40, size); + assert_dynamic_primary_rejected(&duplicate_strsz); + + let mut unmapped = base.clone(); + let strtab = dynamic_entry(&unmapped, DT_STRTAB, 0); + put_u64(&mut unmapped, strtab + 8, BASE_VADDR + FILE_SIZE as u64); + assert_dynamic_primary_rejected(&unmapped); + + let mut ambiguous = base.clone(); + let load = ph_offset(&ambiguous, PT_LOAD, 0); + let second = append_ph(&mut ambiguous, load); + put_u64(&mut ambiguous, second + 8, STRTAB_OFFSET as u64); + put_u64( + &mut ambiguous, + second + 16, + BASE_VADDR + STRTAB_OFFSET as u64, + ); + put_u64(&mut ambiguous, second + 32, 64); + put_u64(&mut ambiguous, second + 40, 64); + assert_dynamic_primary_rejected(&ambiguous); + + let mut unterminated = base.clone(); + let string_size = + get_u64(&unterminated, dynamic_entry(&unterminated, DT_STRSZ, 0) + 8) as usize; + unterminated[STRTAB_OFFSET + string_size - 1] = b'x'; + assert_dynamic_primary_rejected(&unterminated); + + for value in [b"bad\x01name".as_slice(), b"bad\xffname".as_slice()] { + let mut fixture = Fixture::dynamic_primary(&[]); + fixture.needed = vec![value.to_vec()]; + assert_dynamic_primary_rejected(&fixture.build()); + } + + let mut strtab_only = Fixture::dynamic_primary(&[]); + strtab_only + .extra_tags + .push((DT_STRTAB, BASE_VADDR + STRTAB_OFFSET as u64)); + assert_dynamic_primary_rejected(&strtab_only.build()); + + let mut strsz_only = Fixture::dynamic_primary(&[]); + strsz_only.extra_tags.push((DT_STRSZ, 1)); + assert_dynamic_primary_rejected(&strsz_only.build()); + + let mut zero_strsz = base.clone(); + let entry = dynamic_entry(&zero_strsz, DT_STRSZ, 0); + put_u64(&mut zero_strsz, entry + 8, 0); + assert_dynamic_primary_rejected(&zero_strsz); + + let mut out_of_range = base; + let size = get_u64(&out_of_range, dynamic_entry(&out_of_range, DT_STRSZ, 0) + 8); + let needed = dynamic_entry(&out_of_range, DT_NEEDED, 0); + put_u64(&mut out_of_range, needed + 8, size); + assert_dynamic_primary_rejected(&out_of_range); + } + + #[test] + fn soname_roles_and_aliases_are_exact() { + let mut primary = Fixture::dynamic_primary(&[]); + primary.soname = Some(b"app-cli".to_vec()); + assert_dynamic_primary_rejected(&primary.build()); + + for soname in [ + Vec::new(), + vec![b'a'; 256], + b"dir/liba.so".to_vec(), + b"dir\\liba.so".to_vec(), + b"$ORIGIN".to_vec(), + b"other.so".to_vec(), + ] { + let mut library = Fixture::library(&[]); + library.soname = Some(soname); + assert!(inspect_with_library(&["liba.so"], "liba.so", &library.build()).is_err()); + } + + let image = Image::new(); + let primary = image.primary(&Fixture::dynamic_primary(&[]).build()); + let mut loader = Fixture::loader(); + loader.soname = Some(b"other-loader.so".to_vec()); + image.loader(&loader.build()); + image.runtime_dir(); + assert!(image.inspect(&primary).is_err()); + + let mut duplicate_soname = Fixture::library(&[]); + duplicate_soname.soname = Some(b"liba.so".to_vec()); + duplicate_soname.extra_tags.push((DT_SONAME, 1)); + assert!(inspect_with_library(&["liba.so"], "liba.so", &duplicate_soname.build()).is_err()); + } + + #[test] + fn cross_object_alias_collision_is_rejected_directly() { + let object = + |image_path: &str, role: ObjectRole, inode: u64, soname: Option<&str>| super::Object { + image_path: image_path.into(), + role, + identity: super::Identity { device: 1, inode }, + parsed: super::ParsedElf { + interpreter: None, + needed: Vec::new(), + soname: soname.map(str::to_owned), + }, + }; + let primary = object("/work/bin/app-cli", ObjectRole::Primary, 1, None); + let loader = object(INTERPRETER, ObjectRole::Interpreter, 2, None); + let colliding = object( + "/opt/edgezero/runtime-lib/libalias.so", + ObjectRole::Library, + 3, + Some("app-cli"), + ); + let libraries = std::collections::BTreeMap::from([("libalias.so".into(), colliding)]); + + assert!(super::validate_aliases(&primary, &loader, &libraries).is_err()); + + let first = object( + "/opt/edgezero/runtime-lib/libfirst.so", + ObjectRole::Library, + 3, + Some("libfirst.so"), + ); + let second = object( + "/opt/edgezero/runtime-lib/libsecond.so", + ObjectRole::Library, + 4, + Some("libfirst.so"), + ); + let libraries = std::collections::BTreeMap::from([ + ("libfirst.so".into(), first), + ("libsecond.so".into(), second), + ]); + assert!(super::validate_aliases(&primary, &loader, &libraries).is_err()); + } + + struct TrackingReader { + inner: Cursor>, + max_request: usize, + total_read: usize, + read_calls: usize, + seek_calls: usize, + } + + impl Read for TrackingReader { + fn read(&mut self, buffer: &mut [u8]) -> io::Result { + self.max_request = self.max_request.max(buffer.len()); + self.read_calls += 1; + let read = self.inner.read(buffer)?; + self.total_read += read; + Ok(read) + } + } + + impl Seek for TrackingReader { + fn seek(&mut self, position: SeekFrom) -> io::Result { + self.seek_calls += 1; + self.inner.seek(position) + } + } + + #[test] + fn parser_uses_bounded_reads_for_an_allowed_large_logical_file() { + let mut reader = TrackingReader { + inner: Cursor::new(Fixture::static_primary().build()), + max_request: 0, + total_read: 0, + read_calls: 0, + seek_calls: 0, + }; + + super::parse_elf( + &mut reader, + crate::json_contract::BINARY_LIMIT, + ObjectRole::Primary, + "app-cli", + ) + .unwrap(); + + assert!(reader.max_request <= ELF_HEADER_SIZE); + assert_eq!(reader.total_read, ELF_HEADER_SIZE + PROGRAM_HEADER_SIZE); + } + + #[test] + fn oversized_needed_table_is_rejected_before_tail_or_string_reads() { + let max_needed_entries = METADATA_LIMIT / 3; + assert_eq!(super::MAX_NEEDED_ENTRIES, max_needed_entries); + let needed_entries = max_needed_entries + 1; + let trailing_entries = 100_000; + let dynamic_entries = 2 + needed_entries + 1 + trailing_entries; + let dynamic_size = dynamic_entries * super::DYNAMIC_ENTRY_SIZE as usize; + let strtab_offset = DYNAMIC_OFFSET + dynamic_size; + let file_size = strtab_offset + 3; + + let mut bytes = Fixture::dynamic_primary(&[]).build(); + bytes.resize(file_size, 0); + let load = ph_offset(&bytes, PT_LOAD, 0); + put_u64(&mut bytes, load + 32, file_size as u64); + put_u64(&mut bytes, load + 40, file_size as u64); + let dynamic = ph_offset(&bytes, PT_DYNAMIC, 0); + put_u64(&mut bytes, dynamic + 32, dynamic_size as u64); + put_u64(&mut bytes, dynamic + 40, dynamic_size as u64); + put_u64(&mut bytes, DYNAMIC_OFFSET, DT_STRTAB); + put_u64( + &mut bytes, + DYNAMIC_OFFSET + 8, + BASE_VADDR + strtab_offset as u64, + ); + put_u64(&mut bytes, DYNAMIC_OFFSET + 16, DT_STRSZ); + put_u64(&mut bytes, DYNAMIC_OFFSET + 24, 3); + for index in 0..needed_entries { + let offset = DYNAMIC_OFFSET + (index + 2) * super::DYNAMIC_ENTRY_SIZE as usize; + put_u64(&mut bytes, offset, DT_NEEDED); + put_u64(&mut bytes, offset + 8, 1); + } + bytes[strtab_offset..strtab_offset + 3].copy_from_slice(b"\0a\0"); + + let mut reader = TrackingReader { + inner: Cursor::new(bytes), + max_request: 0, + total_read: 0, + read_calls: 0, + seek_calls: 0, + }; + let result = super::parse_elf( + &mut reader, + file_size as u64, + ObjectRole::Primary, + "app-cli", + ); + + assert_eq!(result.err().as_deref(), Some("too many DT_NEEDED entries")); + let fixed_reads = 1 + 3 + 1; + let scanned_dynamic_bytes = (2 + needed_entries) * super::DYNAMIC_ENTRY_SIZE as usize; + let dynamic_reads = scanned_dynamic_bytes.div_ceil(super::IO_CHUNK_SIZE); + assert!(reader.max_request <= super::IO_CHUNK_SIZE); + assert!(reader.read_calls <= fixed_reads + dynamic_reads); + assert!(reader.seek_calls <= fixed_reads + dynamic_reads); + assert!( + reader.total_read + <= ELF_HEADER_SIZE + + 3 * PROGRAM_HEADER_SIZE + + INTERPRETER.len() + + 1 + + dynamic_reads * super::IO_CHUNK_SIZE + ); + assert!(reader.total_read < dynamic_size / 2); + } + + #[test] + fn forbidden_loader_acquisition_tags_are_always_rejected() { + for tag in [ + DT_RPATH, + DT_RUNPATH, + DT_AUDIT, + DT_DEPAUDIT, + DT_CONFIG, + DT_AUXILIARY, + DT_FILTER, + DT_POSFLAG_1, + ] { + let mut fixture = Fixture::dynamic_primary(&[]); + fixture.extra_tags.push((tag, 0)); + assert_dynamic_primary_rejected(&fixture.build()); + } + } + + #[test] + fn dynamic_tag_vocabulary_is_closed_at_every_boundary() { + let accepted = [ + 2, 3, 4, 6, 7, 8, 9, 11, 12, 13, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, + 30, 32, 33, 34, 35, 36, 37, 0x6ffffef5, 0x6ffffef6, 0x6ffffef7, 0x6ffffff0, 0x6ffffff9, + 0x6ffffffa, 0x6ffffffb, 0x6ffffffc, 0x6ffffffd, 0x6ffffffe, 0x6fffffff, 0x70000000, + 0x70000001, 0x70000003, + ]; + for tag in accepted { + let mut fixture = Fixture::dynamic_primary(&[]); + fixture.extra_tags.push((tag, 0)); + let image = Image::new(); + let primary = image.primary(&fixture.build()); + image.loader(&Fixture::loader().build()); + image.runtime_dir(); + image + .inspect(&primary) + .unwrap_or_else(|error| panic!("accepted tag {tag:#x}: {error}")); + } + + for tag in [ + 31, + 38, + 0x60000000, + 0x6ffffef4, + 0x6ffffef8, + 0x6fffffef, + 0x6ffffff1, + 0x6ffffff8, + 0x70000002, + 0x70000004, + 0x7ffffffc, + 0x80000000, + u64::MAX, + ] { + let mut fixture = Fixture::dynamic_primary(&[]); + fixture.extra_tags.push((tag, 0)); + assert_dynamic_primary_rejected(&fixture.build()); + } + } + + #[test] + fn flags_masks_are_exact() { + for value in [0, 0x0000001e] { + let mut fixture = Fixture::dynamic_primary(&[]); + fixture.extra_tags.push((DT_FLAGS, value)); + let image = Image::new(); + let primary = image.primary(&fixture.build()); + image.loader(&Fixture::loader().build()); + image.runtime_dir(); + image.inspect(&primary).unwrap(); + } + for value in [1, 0x1f, u64::from(u32::MAX)] { + let mut fixture = Fixture::dynamic_primary(&[]); + fixture.extra_tags.push((DT_FLAGS, value)); + assert_dynamic_primary_rejected(&fixture.build()); + } + + for value in [0, 0x5eff976f] { + let mut fixture = Fixture::dynamic_primary(&[]); + fixture.extra_tags.push((DT_FLAGS_1, value)); + let image = Image::new(); + let primary = image.primary(&fixture.build()); + image.loader(&Fixture::loader().build()); + image.runtime_dir(); + image.inspect(&primary).unwrap(); + } + for value in [ + 0x10, + 0x80, + 0x800, + 0x2000, + 0x4000, + 0x1000000, + 0x20000000, + 1u64 << 32, + ] { + let mut fixture = Fixture::dynamic_primary(&[]); + fixture.extra_tags.push((DT_FLAGS_1, value)); + assert_dynamic_primary_rejected(&fixture.build()); + } + } + + #[test] + fn only_needed_may_repeat() { + let singleton_tags = [ + 2, 3, 4, 6, 7, 8, 9, 11, 12, 13, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, + 30, 32, 33, 34, 35, 36, 37, 0x6ffffef5, 0x6ffffef6, 0x6ffffef7, 0x6ffffff0, 0x6ffffff9, + 0x6ffffffa, 0x6ffffffb, 0x6ffffffc, 0x6ffffffd, 0x6ffffffe, 0x6fffffff, 0x70000000, + 0x70000001, 0x70000003, + ]; + for tag in singleton_tags { + let mut fixture = Fixture::dynamic_primary(&[]); + fixture.extra_tags.extend([(tag, 0), (tag, 0)]); + assert_dynamic_primary_rejected(&fixture.build()); + } + + for tag in [DT_STRTAB, DT_STRSZ] { + let mut fixture = Fixture::dynamic_primary(&["liba.so"]); + fixture.extra_tags.push((tag, 0)); + assert_dynamic_primary_rejected(&fixture.build()); + } + + let image = Image::new(); + let primary = image.primary(&Fixture::dynamic_primary(&["liba.so", "liba.so"]).build()); + image.loader(&Fixture::loader().build()); + image.library("liba.so", &Fixture::library(&[]).build()); + assert_eq!(image.inspect(&primary).unwrap().needed.len(), 2); + } + + #[test] + fn dependency_names_reject_paths_expansion_and_empty_values() { + let names = [ + "", + "dir/liba.so", + "dir\\liba.so", + "$ORIGIN/liba.so", + "${ORIGIN}/liba.so", + "$LIB/liba.so", + "${LIB}/liba.so", + "$PLATFORM/liba.so", + "${PLATFORM}/liba.so", + "lib$dollar.so", + ]; + for name in names { + let fixture = Fixture::dynamic_primary(&[name]); + assert_dynamic_primary_rejected(&fixture.build()); + } + + let oversized = "a".repeat(256); + let fixture = Fixture::dynamic_primary(&[&oversized]); + assert_dynamic_primary_rejected(&fixture.build()); + } + + #[test] + fn missing_direct_transitive_and_mixed_architecture_fail() { + let image = Image::new(); + let primary = image.primary(&Fixture::dynamic_primary(&["missing.so"]).build()); + image.loader(&Fixture::loader().build()); + image.runtime_dir(); + assert!(image.inspect(&primary).is_err()); + + let image = Image::new(); + let primary = image.primary(&Fixture::dynamic_primary(&["liba.so"]).build()); + image.loader(&Fixture::loader().build()); + image.library("liba.so", &Fixture::library(&["missing.so"]).build()); + assert!(image.inspect(&primary).is_err()); + + let mut wrong_arch = Fixture::library(&[]).build(); + put_u16(&mut wrong_arch, 18, 183); + assert!(inspect_with_library(&["liba.so"], "liba.so", &wrong_arch).is_err()); + } + + #[cfg(unix)] + #[test] + fn runtime_directory_rejects_symlinks_hardlinks_subdirectories_and_reserved_names() { + use std::os::unix::fs::symlink; + + let image = Image::new(); + let primary = image.primary(&Fixture::dynamic_primary(&["liba.so"]).build()); + image.loader(&Fixture::loader().build()); + let outside = image.write("/outside.so", &Fixture::library(&[]).build()); + let runtime = image.runtime_dir(); + symlink(&outside, runtime.join("liba.so")).unwrap(); + assert!(image.inspect(&primary).is_err()); + + let image = Image::new(); + let primary = image.primary(&Fixture::dynamic_primary(&["liba.so"]).build()); + image.loader(&Fixture::loader().build()); + let runtime = image.runtime_dir(); + symlink("does-not-exist", runtime.join("liba.so")).unwrap(); + assert!(image.inspect(&primary).is_err()); + + let image = Image::new(); + let primary = image.primary(&Fixture::dynamic_primary(&["liba.so"]).build()); + image.loader(&Fixture::loader().build()); + let library = image.library("liba.so", &Fixture::library(&[]).build()); + fs::hard_link(&library, image.root().join("second-link.so")).unwrap(); + assert!(image.inspect(&primary).is_err()); + + let image = Image::new(); + let primary = image.primary(&Fixture::dynamic_primary(&[]).build()); + image.loader(&Fixture::loader().build()); + image.library("nested/libdup.so", &Fixture::library(&[]).build()); + image.library("other/libdup.so", &Fixture::library(&[]).build()); + assert!(image.inspect(&primary).is_err()); + + for reserved in ["app-cli", LOADER_NAME] { + let image = Image::new(); + let primary = image.primary(&Fixture::dynamic_primary(&[]).build()); + image.loader(&Fixture::loader().build()); + image.library(reserved, &Fixture::library(&[]).build()); + assert!(image.inspect(&primary).is_err()); + } + } + + #[cfg(unix)] + #[test] + fn runtime_directory_rejects_non_regular_entries_and_interpreter_hardlinks() { + let image = Image::new(); + let primary = image.primary(&Fixture::dynamic_primary(&[]).build()); + image.loader(&Fixture::loader().build()); + let runtime = image.runtime_dir(); + fs::create_dir(runtime.join("directory.so")).unwrap(); + assert!(image.inspect(&primary).is_err()); + + let image = Image::new(); + let primary = image.primary(&Fixture::dynamic_primary(&[]).build()); + let loader = image.loader(&Fixture::loader().build()); + fs::hard_link(&loader, image.root().join("loader-link")).unwrap(); + image.runtime_dir(); + assert!(image.inspect(&primary).is_err()); + } + + #[test] + fn resolver_uses_only_runtime_lib_and_ignores_cache_defaults_and_hwcaps() { + let image = Image::new(); + let primary = image.primary(&Fixture::dynamic_primary(&["libchoice.so"]).build()); + image.loader(&Fixture::loader().build()); + image.library("libchoice.so", &Fixture::library(&[]).build()); + image.write("/etc/ld.so.cache", b"synthetic cache-only entry"); + + let mut alternate = Fixture::library(&[]).build(); + put_u16(&mut alternate, 18, 183); + image.write("/lib/libchoice.so", &alternate); + image.write("/usr/lib/libchoice.so", &alternate); + image.write( + "/lib/x86_64-linux-gnu/glibc-hwcaps/x86-64-v3/libchoice.so", + &alternate, + ); + + let result = image.inspect(&primary).unwrap(); + let library = result + .visited + .iter() + .find(|object| object.role == ObjectRole::Library) + .unwrap(); + assert_eq!(library.image_path, "/opt/edgezero/runtime-lib/libchoice.so"); + assert_eq!(result.visited.len(), 3); + + let cache_only = Image::new(); + let primary = cache_only.primary(&Fixture::dynamic_primary(&["libcache.so"]).build()); + cache_only.loader(&Fixture::loader().build()); + cache_only.runtime_dir(); + cache_only.write("/etc/ld.so.cache", b"synthetic cache-only entry"); + cache_only.write("/usr/lib/libcache.so", &Fixture::library(&[]).build()); + assert!(cache_only.inspect(&primary).is_err()); + } + + #[cfg(unix)] + #[test] + fn preload_presence_in_any_form_rejects_static_and_dynamic() { + use std::os::unix::fs::symlink; + + for form in ["file", "directory", "symlink"] { + let image = Image::new(); + let primary = image.primary(&Fixture::dynamic_primary(&[]).build()); + image.loader(&Fixture::loader().build()); + image.runtime_dir(); + let preload = image.root().join("etc/ld.so.preload"); + fs::create_dir_all(preload.parent().unwrap()).unwrap(); + match form { + "file" => fs::write(&preload, b"/tmp/preload.so\n").unwrap(), + "directory" => fs::create_dir(&preload).unwrap(), + "symlink" => symlink("missing-preload", &preload).unwrap(), + _ => unreachable!(), + } + assert!(image.inspect(&primary).is_err(), "preload form: {form}"); + } + + let image = Image::new(); + let primary = image.primary(&Fixture::static_primary().build()); + image.write("/etc/ld.so.preload", b"/tmp/preload.so\n"); + assert!(image.inspect(&primary).is_err()); + } + + #[test] + fn dlopen_objects_are_explicitly_outside_the_startup_claim() { + let image = Image::new(); + let mut primary_bytes = Fixture::dynamic_primary(&[]).build(); + let marker = b"dlopen:libplugin.so\0"; + primary_bytes[0x700..0x700 + marker.len()].copy_from_slice(marker); + let primary = image.primary(&primary_bytes); + image.loader(&Fixture::loader().build()); + image.library("libplugin.so", &Fixture::library(&[]).build()); + + let result = image.inspect(&primary).unwrap(); + assert_eq!(result.claim, ClosureClaim::StartupOnly); + assert!( + primary_bytes + .windows(marker.len()) + .any(|bytes| bytes == marker) + ); + assert!(!result.needed.iter().any(|needed| needed == "libplugin.so")); + assert!( + result + .visited + .iter() + .all(|object| object.image_path != "/opt/edgezero/runtime-lib/libplugin.so") + ); + } +} diff --git a/.github/tools/edgezero-provenance-validator/src/extract.rs b/.github/tools/edgezero-provenance-validator/src/extract.rs new file mode 100644 index 00000000..d819d483 --- /dev/null +++ b/.github/tools/edgezero-provenance-validator/src/extract.rs @@ -0,0 +1,447 @@ +use crate::{ + Result, + archive::{checked_seek, copy_exact, parse}, +}; +use std::{ + fs::{self, File, OpenOptions}, + io::{Read, Seek, SeekFrom, Write}, + path::{Path, PathBuf}, +}; + +#[cfg(unix)] +pub fn extract_binary(archive: &mut R, output_parent: &Path) -> Result { + let parsed = parse(archive)?; + checked_seek(archive, SeekFrom::Start(parsed.binary_offset))?; + atomic_publish( + output_parent, + "app-cli", + 0o755, + |output| copy_exact(archive, output, parsed.binary_size), + |path| verify_file(path, parsed.binary_size, 0o755), + ) +} + +#[cfg(not(unix))] +pub fn extract_binary(_archive: &mut R, _output_parent: &Path) -> Result { + Err("atomic executable extraction requires Unix".into()) +} + +#[cfg(unix)] +pub(crate) fn validate_output_parent(path: &Path) -> Result<()> { + use std::os::unix::fs::PermissionsExt; + + let canonical_parent = path.canonicalize().map_err(|error| error.to_string())?; + require( + canonical_parent.as_os_str() == path.as_os_str(), + "output parent is not canonical", + )?; + let metadata = fs::symlink_metadata(path).map_err(|error| error.to_string())?; + require( + metadata.file_type().is_dir(), + "output parent is not a directory", + )?; + require( + metadata.permissions().mode() & 0o222 != 0, + "output parent is not writable", + )?; + require(directory_is_empty(path)?, "output parent is not empty") +} + +#[cfg(not(unix))] +pub(crate) fn validate_output_parent(_path: &Path) -> Result<()> { + Err("atomic output validation requires Unix".into()) +} + +#[cfg(unix)] +pub(crate) fn atomic_publish( + output_parent: &Path, + basename: &str, + mode: u32, + write: impl FnOnce(&mut File) -> Result<()>, + validate: impl FnOnce(&Path) -> Result<()>, +) -> Result { + use std::os::unix::fs::{OpenOptionsExt, PermissionsExt}; + + validate_output_parent(output_parent)?; + require( + !basename.is_empty() && !basename.contains('/') && !basename.contains('\\'), + "invalid output basename", + )?; + let final_path = output_parent.join(basename); + let temporary_path = output_parent.join(format!(".{basename}.tmp")); + let mut output = OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o600) + .open(&temporary_path) + .map_err(|error| error.to_string())?; + let mut owned_path = match OwnedPath::new(temporary_path.clone(), &output) { + Ok(owned_path) => owned_path, + Err(error) => { + drop(output); + return Err(with_cleanup_error( + error, + cleanup_owned_path(&temporary_path), + )); + } + }; + + let staged = (|| -> Result { + write(&mut output)?; + output.flush().map_err(|error| error.to_string())?; + output + .set_permissions(fs::Permissions::from_mode(mode)) + .map_err(|error| error.to_string())?; + output.sync_all().map_err(|error| error.to_string())?; + let size = output.metadata().map_err(|error| error.to_string())?.len(); + let identity = owned_path.snapshot(&output)?; + owned_path.verify(&identity)?; + verify_file(&temporary_path, size, mode)?; + validate(&temporary_path)?; + owned_path.verify(&identity)?; + Ok(size) + })(); + drop(output); + let size = match staged { + Ok(size) => size, + Err(error) => return Err(with_cleanup_error(error, owned_path.cleanup())), + }; + + if let Err(error) = publish_no_replace(&temporary_path, &final_path) { + return Err(with_cleanup_error(error, owned_path.cleanup())); + } + owned_path.transfer(final_path.clone()); + let published = (|| -> Result<()> { + let identity = owned_path.snapshot_path()?; + owned_path.verify(&identity)?; + verify_file(&final_path, size, mode)?; + owned_path.verify(&identity) + })(); + if let Err(error) = published { + return Err(with_cleanup_error(error, owned_path.cleanup())); + } + owned_path.disarm(); + Ok(final_path) +} + +#[cfg(not(unix))] +pub(crate) fn atomic_publish( + _output_parent: &Path, + _basename: &str, + _mode: u32, + _write: impl FnOnce(&mut File) -> Result<()>, + _validate: impl FnOnce(&Path) -> Result<()>, +) -> Result { + Err("atomic publication requires Unix".into()) +} + +#[cfg(unix)] +fn directory_is_empty(path: &Path) -> Result { + Ok(fs::read_dir(path) + .map_err(|error| error.to_string())? + .next() + .transpose() + .map_err(|error| error.to_string())? + .is_none()) +} + +#[cfg(unix)] +fn verify_file(path: &Path, expected_size: u64, expected_mode: u32) -> Result<()> { + use std::os::unix::fs::{MetadataExt, PermissionsExt}; + + let metadata = fs::symlink_metadata(path).map_err(|error| error.to_string())?; + require( + metadata.file_type().is_file(), + "output is not a regular file", + )?; + require(metadata.len() == expected_size, "output size changed")?; + require( + metadata.permissions().mode() & 0o7777 == expected_mode, + "output mode is incorrect", + )?; + require(metadata.nlink() == 1, "output has multiple links") +} + +pub(crate) fn cleanup_owned_path(path: &Path) -> Result<()> { + match fs::remove_file(path) { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => { + return Err(format!( + "failed to remove owned path {}: {error}", + path.display() + )); + } + } + + match fs::symlink_metadata(path) { + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Ok(_) => Err(format!( + "owned path still exists after cleanup: {}", + path.display() + )), + Err(error) => Err(format!( + "failed to verify owned path cleanup {}: {error}", + path.display() + )), + } +} + +#[cfg(unix)] +fn with_cleanup_error(error: String, cleanup: Result<()>) -> String { + match cleanup { + Ok(()) => error, + Err(cleanup_error) => format!("{error}; cleanup failed: {cleanup_error}"), + } +} + +#[cfg(target_os = "linux")] +fn publish_no_replace(source: &Path, destination: &Path) -> Result<()> { + use std::{ffi::CString, os::unix::ffi::OsStrExt}; + + const AT_FDCWD: i32 = -100; + const RENAME_NOREPLACE: u32 = 1; + + unsafe extern "C" { + fn renameat2( + old_directory: i32, + old_path: *const std::ffi::c_char, + new_directory: i32, + new_path: *const std::ffi::c_char, + flags: u32, + ) -> i32; + } + + let source = + CString::new(source.as_os_str().as_bytes()).map_err(|_| "temporary path contains NUL")?; + let destination = + CString::new(destination.as_os_str().as_bytes()).map_err(|_| "output path contains NUL")?; + // The pinned Bookworm/Linux runtime requires renameat2; unsupported syscall or + // filesystem errors fail closed because Linux intentionally has no fallback. + // Both paths are fixed children of the validated output directory. + let result = unsafe { + renameat2( + AT_FDCWD, + source.as_ptr(), + AT_FDCWD, + destination.as_ptr(), + RENAME_NOREPLACE, + ) + }; + if result == 0 { + Ok(()) + } else { + Err(std::io::Error::last_os_error().to_string()) + } +} + +#[cfg(all(unix, not(target_os = "linux")))] +fn publish_no_replace(source: &Path, destination: &Path) -> Result<()> { + fs::hard_link(source, destination).map_err(|error| error.to_string())?; + if let Err(error) = fs::remove_file(source) { + return Err(with_cleanup_error( + error.to_string(), + cleanup_owned_path(destination), + )); + } + Ok(()) +} + +#[cfg(unix)] +struct OwnedPath { + path: Option, + device: u64, + inode: u64, +} + +#[cfg(unix)] +#[derive(Clone, Debug, PartialEq, Eq)] +struct FileIdentity { + device: u64, + inode: u64, + size: u64, + mode: u32, + links: u64, + modified_seconds: i64, + modified_nanoseconds: i64, + changed_seconds: i64, + changed_nanoseconds: i64, +} + +#[cfg(unix)] +impl FileIdentity { + fn from(metadata: &fs::Metadata) -> Self { + use std::os::unix::fs::MetadataExt; + + Self { + device: metadata.dev(), + inode: metadata.ino(), + size: metadata.len(), + mode: metadata.mode(), + links: metadata.nlink(), + modified_seconds: metadata.mtime(), + modified_nanoseconds: metadata.mtime_nsec(), + changed_seconds: metadata.ctime(), + changed_nanoseconds: metadata.ctime_nsec(), + } + } +} + +#[cfg(unix)] +impl OwnedPath { + fn new(path: PathBuf, file: &File) -> Result { + use std::os::unix::fs::MetadataExt; + + let metadata = file.metadata().map_err(|error| error.to_string())?; + Ok(Self { + path: Some(path), + device: metadata.dev(), + inode: metadata.ino(), + }) + } + + fn cleanup(&mut self) -> Result<()> { + use std::os::unix::fs::MetadataExt; + + let path = self.path.take().ok_or("owned path guard is disarmed")?; + match fs::symlink_metadata(&path) { + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(error) => return Err(error.to_string()), + Ok(metadata) + if !metadata.file_type().is_file() + || metadata.dev() != self.device + || metadata.ino() != self.inode => + { + return Err(format!("owned path identity changed: {}", path.display())); + } + Ok(_) => {} + } + cleanup_owned_path(&path) + } + + fn verify(&self, expected: &FileIdentity) -> Result<()> { + let path = self.path.as_ref().ok_or("owned path guard is disarmed")?; + let metadata = fs::symlink_metadata(path).map_err(|error| error.to_string())?; + require( + metadata.file_type().is_file() && FileIdentity::from(&metadata) == *expected, + &format!("owned path identity changed: {}", path.display()), + ) + } + + fn snapshot(&self, file: &File) -> Result { + let metadata = file.metadata().map_err(|error| error.to_string())?; + let identity = FileIdentity::from(&metadata); + require( + metadata.file_type().is_file() + && identity.device == self.device + && identity.inode == self.inode, + "owned file identity changed while staging", + )?; + Ok(identity) + } + + fn snapshot_path(&self) -> Result { + let path = self.path.as_ref().ok_or("owned path guard is disarmed")?; + let file = File::open(path).map_err(|error| error.to_string())?; + self.snapshot(&file) + } + + fn transfer(&mut self, path: PathBuf) { + self.path = Some(path); + } + + fn disarm(&mut self) { + self.path = None; + } +} + +#[cfg(unix)] +impl Drop for OwnedPath { + fn drop(&mut self) { + if self.path.is_some() { + let _ = self.cleanup(); + } + } +} + +#[cfg(all(test, unix))] +mod tests { + use super::*; + use std::sync::atomic::{AtomicU64, Ordering}; + + static NEXT_TEMP: AtomicU64 = AtomicU64::new(0); + + struct TempDir(PathBuf); + + impl TempDir { + fn new() -> Self { + let sequence = NEXT_TEMP.fetch_add(1, Ordering::Relaxed); + let path = std::env::temp_dir().join(format!( + "edgezero-provenance-extract-{}-{sequence}", + std::process::id() + )); + fs::create_dir(&path).unwrap(); + Self(path.canonicalize().unwrap()) + } + } + + impl Drop for TempDir { + fn drop(&mut self) { + if self.0.exists() { + fs::remove_dir_all(&self.0).unwrap(); + } + } + } + + #[test] + fn review_atomic_publish_rejects_same_inode_mutation_after_validation() { + let parent = TempDir::new(); + let final_path = parent.0.join("artifact"); + let error = atomic_publish( + &parent.0, + "artifact", + 0o644, + |output| { + output + .write_all(b"original") + .map_err(|error| error.to_string()) + }, + |path| { + fs::write(path, b"mutated!").map_err(|error| error.to_string())?; + Ok(()) + }, + ) + .unwrap_err(); + + assert!( + error.contains("identity changed"), + "unexpected error: {error}" + ); + assert!(!final_path.exists()); + assert!(fs::read_dir(&parent.0).unwrap().next().is_none()); + } + + #[test] + fn review_owned_path_drop_preserves_a_replacement() { + let parent = TempDir::new(); + let path = parent.0.join("owned"); + let file = File::options() + .write(true) + .create_new(true) + .open(&path) + .unwrap(); + let owned = OwnedPath::new(path.clone(), &file).unwrap(); + drop(file); + fs::remove_file(&path).unwrap(); + fs::write(&path, b"replacement").unwrap(); + + drop(owned); + + assert_eq!(fs::read(path).unwrap(), b"replacement"); + } +} + +#[cfg(unix)] +fn require(valid: bool, reason: &str) -> Result<()> { + if valid { Ok(()) } else { Err(reason.into()) } +} diff --git a/.github/tools/edgezero-provenance-validator/src/json_contract.rs b/.github/tools/edgezero-provenance-validator/src/json_contract.rs new file mode 100644 index 00000000..8ebcd325 --- /dev/null +++ b/.github/tools/edgezero-provenance-validator/src/json_contract.rs @@ -0,0 +1,951 @@ +use crate::Result; +use serde::{ + Deserialize, Deserializer, Serialize, Serializer, + de::{DeserializeOwned, Visitor}, +}; + +pub const EXPECTED_LIMIT: usize = 16 * 1024; +pub const METADATA_LIMIT: usize = 64 * 1024; +pub const BINARY_LIMIT: u64 = 512 * 1024 * 1024; +pub const INTERPRETER: &str = "/lib64/ld-linux-x86-64.so.2"; +pub const IMAGE_REPOSITORY: &str = "ghcr.io/stackpop/edgezero-build-app-cli"; + +// All object keys are fixed ASCII and declared in lexical order. Only bounded +// integers and strings reach this encoder; it is not a generic JCS serializer. +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(deny_unknown_fields, rename_all = "kebab-case")] +pub struct Caller { + app_cli_bin: String, + app_cli_package: String, + app_repo_id: String, + source_revision: String, + workspace_id: String, +} + +impl Caller { + fn validate(&self) -> Result<()> { + name(&self.app_cli_bin)?; + name(&self.app_cli_package)?; + let id = self + .app_repo_id + .parse::() + .map_err(|_| "invalid repository id")?; + require( + id != 0 && id.to_string() == self.app_repo_id, + "noncanonical repository id", + )?; + require( + nonzero_hex(&self.source_revision, 40), + "invalid source revision", + )?; + digest(&self.workspace_id) + } +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(deny_unknown_fields, rename_all = "kebab-case")] +pub struct Platform { + container_ref: String, + platform_id: String, + provenance_protocol: u8, +} + +impl Platform { + fn validate(&self) -> Result<()> { + digest(&self.platform_id)?; + require( + self.provenance_protocol == 1, + "unsupported provenance protocol", + )?; + require( + self.container_ref == format!("{IMAGE_REPOSITORY}@{}", self.platform_id), + "container-ref differs from platform-id", + ) + } +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(deny_unknown_fields, rename_all = "kebab-case")] +pub struct Expected { + caller: Caller, + platform: Platform, + schema_version: u8, +} + +impl Expected { + pub fn new( + repo: &str, + source: &str, + package: &str, + bin: &str, + workspace: &str, + platform: &str, + ) -> Result { + let expected = Self { + caller: Caller { + app_cli_bin: bin.into(), + app_cli_package: package.into(), + app_repo_id: repo.into(), + source_revision: source.into(), + workspace_id: workspace.into(), + }, + platform: Platform { + container_ref: format!("{IMAGE_REPOSITORY}@{platform}"), + platform_id: platform.into(), + provenance_protocol: 1, + }, + schema_version: 1, + }; + expected.validate()?; + Ok(expected) + } + + pub fn parse(bytes: &[u8]) -> Result { + parse(bytes, EXPECTED_LIMIT, Self::validate) + } + + pub fn canonical_bytes(&self) -> Result> { + self.validate()?; + encode(self, EXPECTED_LIMIT) + } + + fn validate(&self) -> Result<()> { + require(self.schema_version == 1, "unsupported schema version")?; + self.caller.validate()?; + self.platform.validate() + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Interpreter { + Static, + Dynamic, +} + +impl Serialize for Interpreter { + fn serialize(&self, serializer: S) -> std::result::Result { + match self { + Self::Static => serializer.serialize_unit(), + Self::Dynamic => serializer.serialize_str(INTERPRETER), + } + } +} + +impl<'de> Deserialize<'de> for Interpreter { + fn deserialize>(deserializer: D) -> std::result::Result { + struct RequiredInterpreter; + impl Visitor<'_> for RequiredInterpreter { + type Value = Interpreter; + fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("null or the fixed x86-64 interpreter") + } + fn visit_unit(self) -> std::result::Result { + Ok(Interpreter::Static) + } + fn visit_str( + self, + value: &str, + ) -> std::result::Result { + if value == INTERPRETER { + Ok(Interpreter::Dynamic) + } else { + Err(E::custom("unsupported ELF interpreter")) + } + } + } + deserializer.deserialize_any(RequiredInterpreter) + } +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct Abi { + interpreter: Interpreter, + machine: String, + needed: Vec, +} + +impl Abi { + pub fn new(interpreter: Interpreter, mut needed: Vec) -> Result { + needed.sort_by(|left, right| left.as_bytes().cmp(right.as_bytes())); + let abi = Self { + interpreter, + machine: "x86_64".into(), + needed, + }; + abi.validate()?; + Ok(abi) + } + + fn validate(&self) -> Result<()> { + require(self.machine == "x86_64", "unsupported machine")?; + for dependency in &self.needed { + name(dependency)?; + require( + !dependency.contains('$'), + "dependency expansion is unsupported", + )?; + } + require( + self.needed + .windows(2) + .all(|pair| pair[0].as_bytes() <= pair[1].as_bytes()), + "needed entries are not byte-sorted", + )?; + require( + self.interpreter != Interpreter::Static || self.needed.is_empty(), + "static binary has dependencies", + ) + } +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(deny_unknown_fields, rename_all = "kebab-case")] +pub struct Metadata { + abi: Abi, + app_cli_version: String, + binary_sha256: String, + binary_size: u64, + caller: Caller, + platform: Platform, + schema_version: u8, +} + +impl Metadata { + pub fn new( + expected: &Expected, + abi: Abi, + version: &str, + hash: &str, + size: u64, + ) -> Result { + expected.validate()?; + let metadata = Self { + abi, + app_cli_version: version.into(), + binary_sha256: hash.into(), + binary_size: size, + caller: expected.caller.clone(), + platform: expected.platform.clone(), + schema_version: expected.schema_version, + }; + metadata.canonical_bytes()?; + Ok(metadata) + } + + pub fn parse(bytes: &[u8]) -> Result { + parse(bytes, METADATA_LIMIT, Self::validate) + } + + pub fn canonical_bytes(&self) -> Result> { + self.validate()?; + encode(self, METADATA_LIMIT) + } + + pub fn matches(&self, expected: &Expected) -> Result<()> { + self.validate()?; + expected.validate()?; + require( + self.caller == expected.caller + && self.platform == expected.platform + && self.schema_version == expected.schema_version, + "provenance identity mismatch", + ) + } + + pub(crate) fn matches_observation(&self, abi: &Abi, hash: &str, size: u64) -> Result<()> { + self.validate()?; + require( + self.abi == *abi && self.binary_sha256 == hash && self.binary_size == size, + "binary metadata mismatch", + ) + } + + fn validate(&self) -> Result<()> { + require(self.schema_version == 1, "unsupported schema version")?; + self.caller.validate()?; + self.platform.validate()?; + self.abi.validate()?; + name(&self.app_cli_version)?; + digest(&self.binary_sha256)?; + require( + (1..=BINARY_LIMIT).contains(&self.binary_size), + "binary-size is outside protocol bounds", + ) + } +} + +fn require(valid: bool, reason: &str) -> Result<()> { + if valid { Ok(()) } else { Err(reason.into()) } +} + +fn nonzero_hex(value: &str, width: usize) -> bool { + value.len() == width + && value + .bytes() + .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) + && value.bytes().any(|b| b != b'0') +} + +fn digest(value: &str) -> Result<()> { + require( + value + .strip_prefix("sha256:") + .is_some_and(|hash| nonzero_hex(hash, 64)), + "invalid sha256 digest", + ) +} + +fn name(value: &str) -> Result<()> { + require( + (1..=255).contains(&value.len()) + && !value + .chars() + .any(|c| c.is_control() || c == '/' || c == '\\'), + "invalid bounded name", + ) +} + +fn encode(value: &T, limit: usize) -> Result> { + let bytes = serde_json::to_vec(value).map_err(|error| error.to_string())?; + require(bytes.len() <= limit, "JSON exceeds protocol byte limit")?; + Ok(bytes) +} + +fn parse( + bytes: &[u8], + limit: usize, + validate: impl FnOnce(&T) -> Result<()>, +) -> Result { + require( + !bytes.is_empty() && bytes.len() <= limit, + "JSON exceeds protocol byte limit", + )?; + // Each derived struct rejects unknown and duplicate fields while visiting + // keys, before constructing the enclosing object. No Value/map intermediate. + let value: T = serde_json::from_slice(bytes).map_err(|error| error.to_string())?; + validate(&value)?; + require( + encode(&value, limit)? == bytes, + "JSON bytes are not canonical", + )?; + Ok(value) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::{Value, json}; + + const EXPECTED: &[u8] = + include_bytes!("../../../docker/build-app-cli/fixtures/provenance/valid/expected.json"); + const STATIC: &[u8] = + include_bytes!("../../../docker/build-app-cli/fixtures/provenance/valid/static-meta.json"); + const DYNAMIC: &[u8] = + include_bytes!("../../../docker/build-app-cli/fixtures/provenance/valid/dynamic-meta.json"); + + fn changed(bytes: &[u8], path: &str, value: Value) -> Vec { + let mut document: Value = serde_json::from_slice(bytes).unwrap(); + *document.pointer_mut(path).expect("fixture field") = value; + serde_json::to_vec(&document).unwrap() + } + + #[test] + fn golden_bytes_round_trip_and_complete_identity_matches() { + let expected = Expected::parse(EXPECTED).unwrap(); + assert_eq!(expected.canonical_bytes().unwrap(), EXPECTED); + for bytes in [STATIC, DYNAMIC] { + let metadata = Metadata::parse(bytes).unwrap(); + assert_eq!(metadata.canonical_bytes().unwrap(), bytes); + metadata.matches(&expected).unwrap(); + } + } + + #[test] + fn canonical_wire_rejects_alternate_json_spellings() { + for bytes in [EXPECTED, STATIC, DYNAMIC] { + let text = std::str::from_utf8(bytes).unwrap(); + let bad = [ + format!(" {text}"), + format!("{text}\n"), + format!("\u{feff}{text}"), + format!("{text}{{}}"), + text.replace("\"schema-version\":1", "\"schema-version\":1.0"), + text.replace("\"schema-version\":1", "\"schema-version\":1e0"), + text.replace("edgezero-cli", "edgezero\\u002dcli"), + text.replace("\"caller\":", "\"caller\" :"), + text.replace("ghcr.io/", "ghcr.io\\/"), + ]; + for candidate in bad { + let valid = if bytes == EXPECTED { + Expected::parse(candidate.as_bytes()).is_ok() + } else { + Metadata::parse(candidate.as_bytes()).is_ok() + }; + assert!(!valid, "accepted alternate bytes: {candidate}"); + } + } + assert!(Expected::parse(&[0xff]).is_err()); + assert!(Expected::parse(&vec![b' '; 16385]).is_err()); + assert!(Metadata::parse(&vec![b' '; 65537]).is_err()); + } + + #[test] + fn every_object_rejects_unknown_missing_and_duplicate_fields() { + for fixture in [EXPECTED, STATIC, DYNAMIC] { + let original: Value = serde_json::from_slice(fixture).unwrap(); + for path in ["", "/caller", "/platform", "/abi"] { + let Some(object) = original.pointer(path).and_then(Value::as_object) else { + continue; + }; + for key in object.keys() { + let mut missing = original.clone(); + missing + .pointer_mut(path) + .unwrap() + .as_object_mut() + .unwrap() + .remove(key); + let encoded = serde_json::to_vec(&missing).unwrap(); + assert!( + if fixture == EXPECTED { + Expected::parse(&encoded).is_err() + } else { + Metadata::parse(&encoded).is_err() + }, + "missing {path}/{key}" + ); + let prefix = format!("\"{key}\":"); + let duplicate = std::str::from_utf8(fixture).unwrap().replacen( + &prefix, + &format!("{prefix}{},{prefix}", object[key]), + 1, + ); + let error = if fixture == EXPECTED { + Expected::parse(duplicate.as_bytes()).unwrap_err() + } else { + Metadata::parse(duplicate.as_bytes()).unwrap_err() + }; + assert!(error.contains("duplicate field"), "{path}/{key}: {error}"); + } + let mut extra = original.clone(); + extra + .pointer_mut(path) + .unwrap() + .as_object_mut() + .unwrap() + .insert("unknown".into(), json!(1)); + let encoded = serde_json::to_vec(&extra).unwrap(); + assert!(if fixture == EXPECTED { + Expected::parse(&encoded).is_err() + } else { + Metadata::parse(&encoded).is_err() + }); + } + } + } + + #[test] + fn exact_types_versions_decimals_and_hashes() { + for (path, values) in [ + ( + "/schema-version", + vec![json!(0), json!(2), json!("1"), json!(true), Value::Null], + ), + ( + "/platform/provenance-protocol", + vec![json!(0), json!(2), json!("1"), json!(-1)], + ), + ( + "/caller/app-repo-id", + vec![ + json!("0"), + json!("01"), + json!("+1"), + json!("-1"), + json!(" 1"), + json!("18446744073709551616"), + json!(1), + ], + ), + ( + "/caller/source-revision", + vec![ + json!("0".repeat(40)), + json!("A".repeat(40)), + json!("1".repeat(39)), + json!("g".repeat(40)), + Value::Null, + ], + ), + ( + "/caller/workspace-id", + vec![ + json!("sha256:".to_owned() + &"0".repeat(64)), + json!("sha256:".to_owned() + &"A".repeat(64)), + json!("1".repeat(64)), + json!(1), + ], + ), + ( + "/platform/platform-id", + vec![ + json!("sha256:".to_owned() + &"0".repeat(64)), + json!("sha256:123"), + json!("sha512:".to_owned() + &"1".repeat(64)), + ], + ), + ( + "/platform/container-ref", + vec![ + json!("ghcr.io/attacker/image@sha256:".to_owned() + &"3".repeat(64)), + json!("ghcr.io/stackpop/edgezero-build-app-cli:v1"), + json!( + "ghcr.io/stackpop/edgezero-build-app-cli@sha256:".to_owned() + + &"4".repeat(64) + ), + ], + ), + ] { + for value in values { + assert!( + Expected::parse(&changed(EXPECTED, path, value.clone())).is_err(), + "{path}={value}" + ); + assert!( + Metadata::parse(&changed(DYNAMIC, path, value.clone())).is_err(), + "{path}={value}" + ); + } + } + for value in ["1", "18446744073709551615"] { + Expected::parse(&changed(EXPECTED, "/caller/app-repo-id", json!(value))).unwrap(); + } + } + + #[test] + fn names_are_bounded_by_utf8_bytes_and_reject_controls_and_separators() { + for path in [ + "/caller/app-cli-bin", + "/caller/app-cli-package", + "/app-cli-version", + ] { + for value in [ + "".into(), + "x".repeat(256), + "\u{e9}".repeat(128), + "a/b".into(), + "a\\b".into(), + "a\n".into(), + "a\u{85}".into(), + ] { + assert!( + Metadata::parse(&changed(DYNAMIC, path, json!(value))).is_err(), + "{path}" + ); + } + for value in [ + "x".into(), + "x".repeat(255), + "\u{e9}".repeat(127) + "a", + "a\"b".into(), + ] { + let bytes = changed(DYNAMIC, path, json!(value)); + assert_eq!( + Metadata::parse(&bytes).unwrap().canonical_bytes().unwrap(), + bytes + ); + } + } + } + + #[test] + fn binary_observations_and_abi_are_closed() { + for value in [ + json!(0), + json!(536870913u64), + json!(-1), + json!(1.5), + json!("1"), + ] { + assert!(Metadata::parse(&changed(DYNAMIC, "/binary-size", value)).is_err()); + } + for value in [1, 536870912] { + Metadata::parse(&changed(DYNAMIC, "/binary-size", json!(value))).unwrap(); + } + for value in [ + json!("0".repeat(64)), + json!("sha256:".to_owned() + &"0".repeat(64)), + Value::Null, + ] { + assert!(Metadata::parse(&changed(DYNAMIC, "/binary-sha256", value)).is_err()); + } + for value in [json!("aarch64"), json!(62), Value::Null] { + assert!(Metadata::parse(&changed(DYNAMIC, "/abi/machine", value)).is_err()); + } + for value in [json!("/wrong/loader"), json!(""), json!(true)] { + assert!(Metadata::parse(&changed(DYNAMIC, "/abi/interpreter", value)).is_err()); + } + for value in [ + json!(["z.so", "a.so"]), + json!([""]), + json!(["a/b"]), + json!(["a\\b"]), + json!(["$ORIGIN"]), + json!(["a\u{7f}"]), + json!(["a".repeat(256)]), + json!([1]), + Value::Null, + ] { + assert!(Metadata::parse(&changed(DYNAMIC, "/abi/needed", value)).is_err()); + } + for value in [ + json!([]), + json!(["a.so", "a.so", "b.so"]), + json!(["a".repeat(255)]), + ] { + Metadata::parse(&changed(DYNAMIC, "/abi/needed", value)).unwrap(); + } + } + + #[test] + fn each_caller_or_platform_identity_change_is_detected() { + let expected = Expected::parse(EXPECTED).unwrap(); + for (path, value) in [ + ("/caller/app-cli-bin", json!("different")), + ("/caller/app-cli-package", json!("different")), + ("/caller/app-repo-id", json!("2")), + ("/caller/source-revision", json!("2".repeat(40))), + ( + "/caller/workspace-id", + json!("sha256:".to_owned() + &"4".repeat(64)), + ), + ] { + Metadata::parse(&changed(DYNAMIC, path, value)) + .unwrap() + .matches(&expected) + .unwrap_err(); + } + let bytes = changed( + DYNAMIC, + "/platform/platform-id", + json!("sha256:".to_owned() + &"4".repeat(64)), + ); + let bytes = changed( + &bytes, + "/platform/container-ref", + json!("ghcr.io/stackpop/edgezero-build-app-cli@sha256:".to_owned() + &"4".repeat(64)), + ); + Metadata::parse(&bytes) + .unwrap() + .matches(&expected) + .unwrap_err(); + } + + #[test] + fn expected_constructor_fixes_versions_and_derives_container_ref() { + let source = "1".repeat(40); + let workspace = "sha256:".to_owned() + &"2".repeat(64); + let platform = "sha256:".to_owned() + &"3".repeat(64); + let expected = Expected::new( + "123456", + &source, + "edgezero-cli", + "edgezero", + &workspace, + &platform, + ) + .unwrap(); + assert_eq!(expected.canonical_bytes().unwrap(), EXPECTED); + assert!( + Expected::new( + "0", + &source, + "edgezero-cli", + "edgezero", + &workspace, + &platform + ) + .is_err() + ); + assert!( + Expected::new( + "1", + &source, + "edgezero-cli", + "../bad", + &workspace, + &platform + ) + .is_err() + ); + } + + #[test] + fn schema_matches_both_closed_documents_but_is_not_the_wire_parser() { + let schema: Value = serde_json::from_slice(include_bytes!( + "../../../docker/build-app-cli/provenance.schema.json" + )) + .unwrap(); + assert!(jsonschema::draft202012::meta::is_valid(&schema)); + let validator = jsonschema::draft202012::new(&schema).unwrap(); + for fixture in [EXPECTED, STATIC, DYNAMIC] { + let original: Value = serde_json::from_slice(fixture).unwrap(); + assert!(validator.is_valid(&original)); + for path in ["", "/caller", "/platform", "/abi"] { + let Some(object) = original.pointer(path).and_then(Value::as_object) else { + continue; + }; + for key in object.keys() { + let mut missing = original.clone(); + missing + .pointer_mut(path) + .unwrap() + .as_object_mut() + .unwrap() + .remove(key); + assert!(!validator.is_valid(&missing), "missing {path}/{key}"); + let mut wrong = original.clone(); + *wrong.pointer_mut(&format!("{path}/{key}")).unwrap() = json!(true); + assert!(!validator.is_valid(&wrong), "wrong type {path}/{key}"); + } + let mut extra = original.clone(); + extra + .pointer_mut(path) + .unwrap() + .as_object_mut() + .unwrap() + .insert("unknown".into(), json!(1)); + assert!(!validator.is_valid(&extra)); + } + } + let noncanonical = [EXPECTED, b"\n"].concat(); + assert!(validator.is_valid(&serde_json::from_slice::(&noncanonical).unwrap())); + assert!(Expected::parse(&noncanonical).is_err()); + } + + #[test] + fn metadata_byte_limit_is_inclusive_and_needed_order_is_utf8() { + let empty = changed(DYNAMIC, "/abi/needed", json!([])); + let room = METADATA_LIMIT - empty.len(); + let count = (room + 1) / 4; + let remainder = room - (4 * count - 1); + let mut needed = vec!["a".to_owned(); count]; + needed[count - 1] = "z".repeat(1 + remainder); + let exact = changed(DYNAMIC, "/abi/needed", json!(needed)); + assert_eq!(exact.len(), METADATA_LIMIT); + assert_eq!( + Metadata::parse(&exact).unwrap().canonical_bytes().unwrap(), + exact + ); + needed[count - 1].push('z'); + let oversized = changed(DYNAMIC, "/abi/needed", json!(needed)); + assert_eq!(oversized.len(), METADATA_LIMIT + 1); + assert!(Metadata::parse(&oversized).is_err()); + Metadata::parse(&changed( + DYNAMIC, + "/abi/needed", + json!(["\u{e000}", "\u{10000}"]), + )) + .unwrap(); + assert!( + Metadata::parse(&changed( + DYNAMIC, + "/abi/needed", + json!(["\u{10000}", "\u{e000}"]) + )) + .is_err() + ); + assert!(Metadata::parse(&changed(DYNAMIC, "/abi/interpreter", Value::Null)).is_err()); + } + + #[test] + fn escaped_duplicate_keys_are_rejected_during_typed_parsing() { + let expected = std::str::from_utf8(EXPECTED).unwrap().replace( + "\"app-repo-id\":", + "\"app-repo-\\u0069d\":\"123456\",\"app-repo-id\":", + ); + assert!( + Expected::parse(expected.as_bytes()) + .unwrap_err() + .contains("duplicate field") + ); + let metadata = std::str::from_utf8(STATIC).unwrap().replace( + "\"interpreter\":", + "\"\\u0069nterpreter\":null,\"interpreter\":", + ); + assert!( + Metadata::parse(metadata.as_bytes()) + .unwrap_err() + .contains("duplicate field") + ); + } + + #[test] + fn typed_metadata_encoder_preserves_observations_and_sorts_duplicates() { + let expected = Expected::parse(EXPECTED).unwrap(); + let abi = Abi::new( + Interpreter::Dynamic, + vec!["libm.so.6".into(), "libc.so.6".into(), "libc.so.6".into()], + ) + .unwrap(); + let hash = "sha256:".to_owned() + &"4".repeat(64); + assert_eq!( + Metadata::new(&expected, abi.clone(), "0.1.0", &hash, 123) + .unwrap() + .canonical_bytes() + .unwrap(), + DYNAMIC + ); + let static_abi = Abi::new(Interpreter::Static, vec![]).unwrap(); + let static_hash = "sha256:6b25433eed518a44b19e8c821749f4dd07156d0962727ed27c15f8816c3c1c96"; + assert_eq!( + Metadata::new(&expected, static_abi, "0.1.0", static_hash, 2048) + .unwrap() + .canonical_bytes() + .unwrap(), + STATIC + ); + assert!(Metadata::new(&expected, abi.clone(), "", &hash, 123).is_err()); + assert!(Metadata::new(&expected, abi, "0.1.0", &hash, 0).is_err()); + assert!(Abi::new(Interpreter::Static, vec!["libc.so.6".into()]).is_err()); + assert!(Abi::new(Interpreter::Dynamic, vec!["$ORIGIN".into()]).is_err()); + } + + #[test] + fn reordered_keys_and_invalid_surrogates_are_not_canonical_wire() { + let source = std::str::from_utf8(EXPECTED).unwrap(); + let reordered = source.replace( + "\"app-cli-bin\":\"edgezero\",\"app-cli-package\":\"edgezero-cli\"", + "\"app-cli-package\":\"edgezero-cli\",\"app-cli-bin\":\"edgezero\"", + ); + assert!( + Expected::parse(reordered.as_bytes()) + .unwrap_err() + .contains("not canonical") + ); + for spelling in ["\\uD800", "\\uDC00", "\\uD800x"] { + assert!(Expected::parse(source.replace("edgezero-cli", spelling).as_bytes()).is_err()); + } + } + + #[test] + fn schema_enforces_scalar_patterns_constants_and_bounds() { + let schema: Value = serde_json::from_slice(include_bytes!( + "../../../docker/build-app-cli/provenance.schema.json" + )) + .unwrap(); + let validator = jsonschema::draft202012::new(&schema).unwrap(); + for (path, values) in [ + ("/schema-version", vec![json!(0), json!(2)]), + ("/platform/provenance-protocol", vec![json!(0), json!(2)]), + ( + "/caller/app-repo-id", + vec![ + json!("0"), + json!("01"), + json!("-1"), + json!("x"), + json!("1".repeat(21)), + ], + ), + ( + "/caller/source-revision", + vec![ + json!("0".repeat(40)), + json!("A".repeat(40)), + json!("1".repeat(39)), + json!("1".repeat(41)), + ], + ), + ( + "/caller/workspace-id", + vec![ + json!("sha256:".to_owned() + &"0".repeat(64)), + json!("sha256:".to_owned() + &"A".repeat(64)), + json!("sha256:1"), + ], + ), + ( + "/platform/platform-id", + vec![ + json!("sha256:".to_owned() + &"0".repeat(64)), + json!("sha256:1"), + ], + ), + ( + "/platform/container-ref", + vec![ + json!("ghcr.io/attacker/image@sha256:".to_owned() + &"3".repeat(64)), + json!("ghcr.io/stackpop/edgezero-build-app-cli:v1"), + ], + ), + ( + "/caller/app-cli-bin", + vec![ + json!(""), + json!("x".repeat(256)), + json!("a/b"), + json!("a\\b"), + json!("a\n"), + json!("a\u{85}"), + ], + ), + ("/caller/app-cli-package", vec![json!(""), json!("a/b")]), + ("/app-cli-version", vec![json!(""), json!("a/b")]), + ("/binary-size", vec![json!(0), json!(536870913u64)]), + ( + "/binary-sha256", + vec![ + json!("sha256:".to_owned() + &"0".repeat(64)), + json!("sha256:1"), + ], + ), + ("/abi/machine", vec![json!("aarch64")]), + ("/abi/interpreter", vec![json!("/wrong/loader")]), + ( + "/abi/needed", + vec![ + json!([""]), + json!(["x".repeat(256)]), + json!(["$LIB"]), + json!(["a/b"]), + json!(["a\\b"]), + json!(["a\u{7f}"]), + ], + ), + ] { + for value in values { + let candidate: Value = + serde_json::from_slice(&changed(DYNAMIC, path, value.clone())).unwrap(); + assert!( + !validator.is_valid(&candidate), + "schema accepted {path}={value}" + ); + } + } + let multibyte = changed(DYNAMIC, "/app-cli-version", json!("\u{e9}".repeat(128))); + assert!(validator.is_valid(&serde_json::from_slice::(&multibyte).unwrap())); + assert!(Metadata::parse(&multibyte).is_err()); + } + + #[test] + fn invalid_wire_fixtures_remain_invalid() { + let expected = include_bytes!( + "../../../docker/build-app-cli/fixtures/provenance/invalid/expected-duplicate.json" + ); + let metadata = include_bytes!( + "../../../docker/build-app-cli/fixtures/provenance/invalid/meta-missing-interpreter.json" + ); + assert!( + Expected::parse(expected) + .unwrap_err() + .contains("duplicate field") + ); + assert!( + Metadata::parse(metadata) + .unwrap_err() + .contains("missing field `interpreter`") + ); + } +} diff --git a/.github/tools/edgezero-provenance-validator/src/lib.rs b/.github/tools/edgezero-provenance-validator/src/lib.rs new file mode 100644 index 00000000..320830fb --- /dev/null +++ b/.github/tools/edgezero-provenance-validator/src/lib.rs @@ -0,0 +1,15 @@ +pub mod archive; +mod command; +pub mod elf; +pub mod extract; +pub mod json_contract; +mod orchestration; +mod self_test; + +pub use orchestration::{ExecutionLayout, execute}; + +pub type Result = std::result::Result; + +pub(crate) fn require(valid: bool, reason: &str) -> Result<()> { + if valid { Ok(()) } else { Err(reason.into()) } +} diff --git a/.github/tools/edgezero-provenance-validator/src/main.rs b/.github/tools/edgezero-provenance-validator/src/main.rs new file mode 100644 index 00000000..0ca854e3 --- /dev/null +++ b/.github/tools/edgezero-provenance-validator/src/main.rs @@ -0,0 +1,8 @@ +use edgezero_provenance_validator::{ExecutionLayout, execute}; + +fn main() { + if let Err(error) = execute(&ExecutionLayout::container(), std::env::args_os().skip(1)) { + eprintln!("error: {error}"); + std::process::exit(1); + } +} diff --git a/.github/tools/edgezero-provenance-validator/src/orchestration.rs b/.github/tools/edgezero-provenance-validator/src/orchestration.rs new file mode 100644 index 00000000..40e4ed22 --- /dev/null +++ b/.github/tools/edgezero-provenance-validator/src/orchestration.rs @@ -0,0 +1,700 @@ +use crate::{ + Result, + archive::{self, checked_seek, copy_exact}, + command::{self, Command}, + elf, + extract::{atomic_publish, validate_output_parent}, + json_contract::{ + Abi, BINARY_LIMIT, EXPECTED_LIMIT, Expected, Interpreter, METADATA_LIMIT, + Metadata as ProvenanceMetadata, + }, + require, self_test, +}; +use serde_json::Value; +use std::{ + ffi::OsString, + fs::{self, File, Metadata}, + io::{Cursor, Read, SeekFrom, Write}, + path::{Component, Path, PathBuf}, +}; + +const WORK_ROOT: &str = "/work"; +const SCHEMA_PATH: &str = "/usr/local/share/edgezero/provenance.schema.json"; +const FIXTURES_PATH: &str = "/usr/local/share/edgezero/provenance-fixtures"; +const SCHEMA_LIMIT: usize = 64 * 1024; +const ARCHIVE_LIMIT: u64 = BINARY_LIMIT + METADATA_LIMIT as u64 + 4 * archive::BLOCK_SIZE as u64; + +#[derive(Clone, Debug)] +pub struct ExecutionLayout { + image_root: PathBuf, +} + +impl ExecutionLayout { + pub fn container() -> Self { + Self { + image_root: PathBuf::from("/"), + } + } + + pub fn rooted_at(image_root: impl AsRef) -> Self { + Self { + image_root: image_root.as_ref().to_path_buf(), + } + } + + pub(crate) fn image_root(&self) -> Result { + let canonical = self + .image_root + .canonicalize() + .map_err(|error| format!("invalid image root: {error}"))?; + require(canonical == self.image_root, "image root is not canonical")?; + require( + fs::symlink_metadata(&self.image_root) + .map_err(|error| format!("invalid image root: {error}"))? + .file_type() + .is_dir(), + "image root is not a directory", + )?; + Ok(canonical) + } + + pub(crate) fn mapped(&self, literal: &str) -> Result { + let path = Path::new(literal); + require(path.is_absolute(), "container path is not absolute")?; + require( + path.components() + .all(|component| matches!(component, Component::RootDir | Component::Normal(_))), + "container path is not lexically canonical", + )?; + let relative = path + .strip_prefix("/") + .map_err(|_| "container path is not absolute")?; + Ok(self.image_root()?.join(relative)) + } + + fn mapped_root_spelling(&self, spelling: &str) -> Result { + let path = Path::new(spelling); + require(path.is_absolute(), "work root is not absolute")?; + let image_root = self.image_root()?; + let mut mapped = image_root.clone(); + for component in path.components() { + match component { + Component::RootDir | Component::CurDir => {} + Component::ParentDir => { + if mapped != image_root { + mapped.pop(); + } + } + Component::Normal(component) => { + mapped.push(component); + mapped = mapped + .canonicalize() + .map_err(|error| format!("invalid work root: {error}"))?; + require( + mapped.starts_with(&image_root), + "work root spelling escapes image root", + )?; + } + Component::Prefix(_) => return Err("work root is not an absolute Unix path".into()), + } + } + require( + mapped.starts_with(&image_root), + "work root spelling escapes image root", + )?; + Ok(mapped) + } + + fn work_root(&self, supplied: &str) -> Result { + let required = self.mapped(WORK_ROOT)?; + let supplied = self.mapped_root_spelling(supplied)?; + let canonical = supplied + .canonicalize() + .map_err(|error| format!("invalid work root: {error}"))?; + require( + canonical == required, + "work root does not resolve to literal /work", + )?; + require( + fs::metadata(&supplied) + .map_err(|error| format!("invalid work root: {error}"))? + .is_dir(), + "work root is not a directory", + )?; + Ok(canonical) + } + + fn work_input(&self, supplied: &str, work: &Path) -> Result { + require_work_path(supplied, "input path")?; + let path = self.mapped(supplied)?; + require( + path.starts_with(work), + "input path escapes work root lexically", + )?; + SafeFile::open_path(path, Some(work), None) + } + + fn schema(&self, supplied: &str) -> Result { + require_exact(supplied, SCHEMA_PATH, "schema path")?; + let root = self.image_root()?; + let ownership = + fs::symlink_metadata(&root).map_err(|error| format!("invalid image root: {error}"))?; + SafeFile::open_path(self.mapped(supplied)?, None, Some(&ownership)) + } + + fn output_target(&self, supplied: &str, work: &Path) -> Result { + require_work_path(supplied, "output path")?; + let basename = Path::new(supplied) + .file_name() + .and_then(|basename| basename.to_str()) + .ok_or("output basename is not normal UTF-8")?; + require( + !basename.is_empty() && basename != "." && basename != "..", + "output basename is not normal UTF-8", + )?; + let output = self.mapped(supplied)?; + require( + output.starts_with(work), + "output path escapes work root lexically", + )?; + let parent = output.parent().ok_or("output has no parent")?; + let canonical = parent + .canonicalize() + .map_err(|error| format!("invalid output parent: {error}"))?; + require(canonical == parent, "output parent is not canonical")?; + require( + canonical.starts_with(work) && canonical != work, + "output parent escapes work root canonically", + )?; + validate_output_parent(parent)?; + Ok(OutputTarget { + parent: parent.to_path_buf(), + basename: basename.to_owned(), + }) + } + + pub(crate) fn fixture_root(&self, supplied: &str) -> Result { + require_exact(supplied, FIXTURES_PATH, "fixtures path")?; + let root = self.image_root()?; + let mapped = self.mapped(supplied)?; + let canonical = mapped + .canonicalize() + .map_err(|error| format!("invalid fixtures directory: {error}"))?; + require(canonical == mapped, "fixtures directory is not canonical")?; + let root_metadata = fs::symlink_metadata(root).map_err(|error| error.to_string())?; + let metadata = fs::symlink_metadata(&mapped).map_err(|error| error.to_string())?; + require( + metadata.file_type().is_dir(), + "fixtures path is not a directory", + )?; + require_same_owner(&metadata, &root_metadata, "fixtures directory")?; + Ok(mapped) + } + + pub(crate) fn baked_schema(&self) -> Result { + self.schema(SCHEMA_PATH) + } +} + +struct OutputTarget { + parent: PathBuf, + basename: String, +} + +pub fn execute(layout: &ExecutionLayout, arguments: I) -> Result<()> +where + I: IntoIterator, + S: Into, +{ + match command::parse(arguments)? { + Command::WriteExpected { + work_root, + app_repo_id, + source_revision, + app_cli_package, + app_cli_bin, + workspace_id, + platform_id, + provenance_protocol, + output, + } => write_expected( + layout, + &work_root, + &app_repo_id, + &source_revision, + &app_cli_package, + &app_cli_bin, + &workspace_id, + &platform_id, + &provenance_protocol, + &output, + ), + Command::WriteReleaseRequest { + work_root, + gate_sha, + provenance_protocol, + release_tag, + output, + } => write_release_request( + layout, + &work_root, + &gate_sha, + &provenance_protocol, + &release_tag, + &output, + ), + Command::Package { + work_root, + binary, + schema, + expected, + app_cli_version, + archive, + } => package( + layout, + &work_root, + &binary, + &schema, + &expected, + &app_cli_version, + &archive, + ), + Command::Validate { + work_root, + archive, + schema, + expected, + output, + } => validate(layout, &work_root, &archive, &schema, &expected, &output), + Command::SelfTest { fixtures } => self_test::run(layout, &fixtures), + } +} + +#[allow(clippy::too_many_arguments)] +fn write_expected( + layout: &ExecutionLayout, + work_root: &str, + app_repo_id: &str, + source_revision: &str, + app_cli_package: &str, + app_cli_bin: &str, + workspace_id: &str, + platform_id: &str, + provenance_protocol: &str, + output: &str, +) -> Result<()> { + let work = layout.work_root(work_root)?; + require_protocol(provenance_protocol)?; + let document = Expected::new( + app_repo_id, + source_revision, + app_cli_package, + app_cli_bin, + workspace_id, + platform_id, + )?; + let bytes = document.canonical_bytes()?; + let target = layout.output_target(output, &work)?; + publish_bytes(&target.parent, &target.basename, &bytes, |path| { + require( + read_bounded_path(path, EXPECTED_LIMIT)? == bytes, + "staged expected bytes changed", + )?; + Expected::parse(&bytes).map(|_| ()) + }) +} + +fn write_release_request( + layout: &ExecutionLayout, + work_root: &str, + gate_sha: &str, + provenance_protocol: &str, + release_tag: &str, + output: &str, +) -> Result<()> { + let work = layout.work_root(work_root)?; + require_protocol(provenance_protocol)?; + require(nonzero_hex(gate_sha, 40), "invalid gate SHA")?; + validate_release_tag(release_tag)?; + let bytes = format!( + "{{\"gate-sha\":\"{gate_sha}\",\"provenance-protocol\":1,\"release-tag\":\"{release_tag}\"}}" + ) + .into_bytes(); + require_exact(output, "/work/release/release-request.json", "output path")?; + let target = layout.output_target(output, &work)?; + publish_bytes(&target.parent, &target.basename, &bytes, |path| { + require( + read_bounded_path(path, EXPECTED_LIMIT)? == bytes, + "staged release request bytes changed", + ) + }) +} + +fn package( + layout: &ExecutionLayout, + work_root: &str, + binary: &str, + schema: &str, + expected: &str, + app_cli_version: &str, + archive_path: &str, +) -> Result<()> { + let work = layout.work_root(work_root)?; + let binary = layout.work_input(binary, &work)?; + let expected = layout.work_input(expected, &work)?; + let schema = layout.schema(schema)?; + let target = layout.output_target(archive_path, &work)?; + + let schema_bytes = schema.read_bounded(SCHEMA_LIMIT)?; + let expected_bytes = expected.read_bounded(EXPECTED_LIMIT)?; + let identity = Expected::parse(&expected_bytes)?; + validate_schema_instance(&schema_bytes, &expected_bytes)?; + let inspection = elf::inspect(binary.path(), &layout.image_root()?)?; + let abi = abi(&inspection)?; + let metadata = ProvenanceMetadata::new( + &identity, + abi, + app_cli_version, + &inspection.binary_sha256, + inspection.binary_size, + )?; + let metadata_bytes = metadata.canonical_bytes()?; + validate_schema_instance(&schema_bytes, &metadata_bytes)?; + let mut binary_file = binary.open()?; + + atomic_publish( + &target.parent, + &target.basename, + 0o644, + |output| { + archive::encode( + output, + &mut Cursor::new(&metadata_bytes), + metadata_bytes.len() as u64, + &mut binary_file, + inspection.binary_size, + ) + }, + |path| { + let mut staged = File::open(path).map_err(|error| error.to_string())?; + let parsed = archive::parse(&mut staged)?; + require(parsed.metadata == metadata_bytes, "staged metadata changed")?; + let parsed_metadata = ProvenanceMetadata::parse(&parsed.metadata)?; + parsed_metadata.matches(&identity)?; + validate_schema_instance(&schema_bytes, &parsed.metadata)?; + require( + parsed.binary_size == inspection.binary_size, + "staged binary size changed", + )?; + archive::verify_binary_sha256(&mut staged, &parsed, &inspection.binary_sha256)?; + binary.verify()?; + expected.verify()?; + schema.verify() + }, + )?; + Ok(()) +} + +fn validate( + layout: &ExecutionLayout, + work_root: &str, + archive_path: &str, + schema: &str, + expected: &str, + output: &str, +) -> Result<()> { + let work = layout.work_root(work_root)?; + let archive_input = layout.work_input(archive_path, &work)?; + require( + archive_input.size() <= ARCHIVE_LIMIT, + "archive exceeds protocol bounds", + )?; + let expected = layout.work_input(expected, &work)?; + let schema = layout.schema(schema)?; + let target = layout.output_target(output, &work)?; + + let schema_bytes = schema.read_bounded(SCHEMA_LIMIT)?; + let expected_bytes = expected.read_bounded(EXPECTED_LIMIT)?; + let identity = Expected::parse(&expected_bytes)?; + validate_schema_instance(&schema_bytes, &expected_bytes)?; + let mut archive_file = archive_input.open()?; + let parsed = archive::parse(&mut archive_file)?; + let metadata = ProvenanceMetadata::parse(&parsed.metadata)?; + metadata.matches(&identity)?; + validate_schema_instance(&schema_bytes, &parsed.metadata)?; + checked_seek(&mut archive_file, SeekFrom::Start(parsed.binary_offset))?; + + atomic_publish( + &target.parent, + &target.basename, + 0o755, + |output| copy_exact(&mut archive_file, output, parsed.binary_size), + |path| { + let inspection = elf::inspect_staged(path, &layout.image_root()?)?; + let observed_abi = abi(&inspection)?; + metadata.matches_observation( + &observed_abi, + &inspection.binary_sha256, + inspection.binary_size, + )?; + archive_input.verify()?; + expected.verify()?; + schema.verify() + }, + )?; + Ok(()) +} + +fn publish_bytes( + parent: &Path, + basename: &str, + bytes: &[u8], + validate: impl FnOnce(&Path) -> Result<()>, +) -> Result<()> { + atomic_publish( + parent, + basename, + 0o644, + |output| output.write_all(bytes).map_err(|error| error.to_string()), + validate, + )?; + Ok(()) +} + +pub(crate) fn abi(inspection: &elf::ElfInspection) -> Result { + let interpreter = match inspection.interpreter.as_deref() { + None => Interpreter::Static, + Some(crate::json_contract::INTERPRETER) => Interpreter::Dynamic, + Some(_) => return Err("unsupported ELF interpreter".into()), + }; + Abi::new(interpreter, inspection.needed.clone()) +} + +pub(crate) fn validate_schema_instance(schema: &[u8], instance: &[u8]) -> Result<()> { + let schema: Value = serde_json::from_slice(schema) + .map_err(|error| format!("invalid provenance schema: {error}"))?; + require( + jsonschema::draft202012::meta::is_valid(&schema), + "invalid provenance schema", + )?; + let validator = jsonschema::draft202012::new(&schema) + .map_err(|error| format!("invalid provenance schema: {error}"))?; + let instance: Value = serde_json::from_slice(instance) + .map_err(|error| format!("invalid provenance JSON: {error}"))?; + require( + validator.is_valid(&instance), + "provenance JSON does not match schema", + ) +} + +fn require_protocol(value: &str) -> Result<()> { + require(value == "1", "unsupported provenance protocol") +} + +fn validate_release_tag(value: &str) -> Result<()> { + let decimal = value + .strip_prefix("build-container-v") + .ok_or("invalid release tag")?; + require( + decimal + .as_bytes() + .first() + .is_some_and(|digit| (b'1'..=b'9').contains(digit)) + && decimal.as_bytes()[1..].iter().all(u8::is_ascii_digit), + "noncanonical release tag", + ) +} + +fn nonzero_hex(value: &str, width: usize) -> bool { + value.len() == width + && value + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + && value.bytes().any(|byte| byte != b'0') +} + +fn require_exact(actual: &str, expected: &str, label: &str) -> Result<()> { + require( + actual == expected, + &format!("{label} is not the required literal"), + ) +} + +fn require_work_path(value: &str, label: &str) -> Result<()> { + let path = Path::new(value); + require(path.is_absolute(), &format!("{label} is not absolute"))?; + require( + path.components() + .all(|component| matches!(component, Component::RootDir | Component::Normal(_))), + &format!("{label} is not lexically canonical"), + )?; + let root = Path::new(WORK_ROOT); + require( + path.starts_with(root) && path != root, + &format!("{label} escapes work root lexically"), + ) +} + +fn read_bounded_path(path: &Path, limit: usize) -> Result> { + let metadata = fs::symlink_metadata(path).map_err(|error| error.to_string())?; + require(metadata.len() <= limit as u64, "input exceeds byte limit")?; + let file = File::open(path).map_err(|error| error.to_string())?; + let bytes = read_bounded(file, metadata.len() as usize, limit)?; + require( + bytes.len() as u64 == metadata.len(), + "input size changed while reading", + )?; + Ok(bytes) +} + +fn read_bounded(reader: impl Read, capacity: usize, limit: usize) -> Result> { + let mut bytes = Vec::with_capacity(capacity.min(limit)); + reader + .take(limit as u64 + 1) + .read_to_end(&mut bytes) + .map_err(|error| error.to_string())?; + require(bytes.len() <= limit, "input exceeds byte limit")?; + Ok(bytes) +} + +#[derive(Clone, Debug, PartialEq, Eq)] +struct FileIdentity { + device: u64, + inode: u64, + size: u64, + mode: u32, + links: u64, + modified_seconds: i64, + modified_nanoseconds: i64, + changed_seconds: i64, + changed_nanoseconds: i64, +} + +impl FileIdentity { + #[cfg(unix)] + fn from(metadata: &Metadata) -> Self { + use std::os::unix::fs::MetadataExt; + + Self { + device: metadata.dev(), + inode: metadata.ino(), + size: metadata.len(), + mode: metadata.mode(), + links: metadata.nlink(), + modified_seconds: metadata.mtime(), + modified_nanoseconds: metadata.mtime_nsec(), + changed_seconds: metadata.ctime(), + changed_nanoseconds: metadata.ctime_nsec(), + } + } + + #[cfg(not(unix))] + fn from(metadata: &Metadata) -> Self { + Self { + device: 0, + inode: 0, + size: metadata.len(), + mode: 0, + links: 1, + modified_seconds: 0, + modified_nanoseconds: 0, + changed_seconds: 0, + changed_nanoseconds: 0, + } + } +} + +#[derive(Clone, Debug)] +pub(crate) struct SafeFile { + path: PathBuf, + identity: FileIdentity, +} + +impl SafeFile { + pub(crate) fn open_path( + path: PathBuf, + confinement: Option<&Path>, + image_owner: Option<&Metadata>, + ) -> Result { + let canonical = path.canonicalize().map_err(|error| error.to_string())?; + require(canonical == path, "input path is not canonical")?; + if let Some(root) = confinement { + require( + canonical.starts_with(root) && canonical != root, + "input path escapes work root canonically", + )?; + } + let metadata = fs::symlink_metadata(&path).map_err(|error| error.to_string())?; + require( + metadata.file_type().is_file(), + "input is not a regular file", + )?; + let identity = FileIdentity::from(&metadata); + require(identity.links == 1, "input has multiple links")?; + if let Some(owner) = image_owner { + require_same_owner(&metadata, owner, "image file")?; + } + let safe = Self { path, identity }; + drop(safe.open()?); + Ok(safe) + } + + pub(crate) fn path(&self) -> &Path { + &self.path + } + + pub(crate) fn size(&self) -> u64 { + self.identity.size + } + + pub(crate) fn open(&self) -> Result { + let file = File::open(&self.path).map_err(|error| error.to_string())?; + let metadata = file.metadata().map_err(|error| error.to_string())?; + require( + metadata.file_type().is_file(), + "input is not a regular file", + )?; + require( + FileIdentity::from(&metadata) == self.identity, + "input changed while opening", + )?; + Ok(file) + } + + pub(crate) fn read_bounded(&self, limit: usize) -> Result> { + require( + self.identity.size <= limit as u64, + "input exceeds byte limit", + )?; + let bytes = read_bounded(self.open()?, self.identity.size as usize, limit)?; + require( + bytes.len() as u64 == self.identity.size, + "input size changed while reading", + )?; + self.verify()?; + Ok(bytes) + } + + pub(crate) fn verify(&self) -> Result<()> { + let metadata = fs::symlink_metadata(&self.path).map_err(|error| error.to_string())?; + require( + metadata.file_type().is_file() && FileIdentity::from(&metadata) == self.identity, + "input changed during validation", + ) + } +} + +#[cfg(unix)] +fn require_same_owner(actual: &Metadata, expected: &Metadata, label: &str) -> Result<()> { + use std::os::unix::fs::MetadataExt; + + require( + actual.uid() == expected.uid() && actual.gid() == expected.gid(), + &format!("{label} is not image-owned"), + ) +} + +#[cfg(not(unix))] +fn require_same_owner(_actual: &Metadata, _expected: &Metadata, _label: &str) -> Result<()> { + Err("image ownership validation requires Unix".into()) +} diff --git a/.github/tools/edgezero-provenance-validator/src/self_test.rs b/.github/tools/edgezero-provenance-validator/src/self_test.rs new file mode 100644 index 00000000..51f1cdee --- /dev/null +++ b/.github/tools/edgezero-provenance-validator/src/self_test.rs @@ -0,0 +1,509 @@ +use crate::{ + Result, archive, elf, + json_contract::{EXPECTED_LIMIT, Expected, METADATA_LIMIT, Metadata}, + orchestration::{ExecutionLayout, SafeFile, abi, validate_schema_instance}, + require, +}; +use sha2::{Digest, Sha256}; +use std::{ + collections::{BTreeMap, BTreeSet}, + fs::{self, Metadata as FsMetadata}, + io::Read, + path::Path, +}; + +const FIXTURE_LIMIT: u64 = 1024 * 1024; +const AGGREGATE_FIXTURE_LIMIT: u64 = 1024 * 1024; +const MAX_DIRECTORY_DEPTH: usize = 2; +const MAX_FILE_DEPTH: usize = 3; +const DIRECTORIES: &[&str] = &[ + "invalid", + "invalid/elf-malformed", + "valid", + "valid/elf-static", +]; + +#[derive(Clone, Copy)] +enum Kind { + Archive(bool), + Elf(bool), + Expected(bool), + Metadata(bool), +} + +struct Fixture { + path: &'static str, + sha256: &'static str, + kind: Kind, +} + +const FIXTURES: &[Fixture] = &[ + Fixture { + path: "invalid/archive-base256.tar", + sha256: "75493ee2dbbbc8039bd24eb9d251dbbf551f7e8dbbb8804c5a96cdff40cb8ad2", + kind: Kind::Archive(false), + }, + Fixture { + path: "invalid/archive-checksum.tar", + sha256: "68de4b0bf30ca7ffc20d528beeccf494891601b93ab515aad4083225d7546b85", + kind: Kind::Archive(false), + }, + Fixture { + path: "invalid/archive-duplicate.tar", + sha256: "c10edd5f77f7d1111646e4aa150f2d91b747a9cf5c0647a480156e770fd93e98", + kind: Kind::Archive(false), + }, + Fixture { + path: "invalid/archive-embedded-nul.tar", + sha256: "ceb3eca4167017a6e0e670b6f4fa0cf6b2efaca6aee44e26859942690629b049", + kind: Kind::Archive(false), + }, + Fixture { + path: "invalid/archive-end-block.tar", + sha256: "daed89daf33ed29d1cb37eb7ab954dfadcad0137c951a4577ced9c2261254592", + kind: Kind::Archive(false), + }, + Fixture { + path: "invalid/archive-extra-end-block.tar", + sha256: "46f48046763a3eb258d066056419f63d9be9278929608d1422fe296bd2d1c634", + kind: Kind::Archive(false), + }, + Fixture { + path: "invalid/archive-extra.tar", + sha256: "3bce4f6a7369bad91cb786e3d5d84fe2bba5064d4a317f0830ac699b372101f7", + kind: Kind::Archive(false), + }, + Fixture { + path: "invalid/archive-gnu.tar", + sha256: "443d5adbb05f81532e307a62ed24dbe148ff520d7ab5dba9e3da139c91fd95bb", + kind: Kind::Archive(false), + }, + Fixture { + path: "invalid/archive-header.tar", + sha256: "e758f98e82e509d5d879b7b537cef6b0ef4f5c42774da2974cf4028519af963f", + kind: Kind::Archive(false), + }, + Fixture { + path: "invalid/archive-link.tar", + sha256: "4ba5272123152f0d4623f41afee17f329992ca742bb5913439053e12fb208c94", + kind: Kind::Archive(false), + }, + Fixture { + path: "invalid/archive-max-size.tar", + sha256: "1a364c6ce7037a527e6519d25aa1fc4088843029689bfb065b3f57e267c6e93e", + kind: Kind::Archive(false), + }, + Fixture { + path: "invalid/archive-octal-digit.tar", + sha256: "494ca5688ca6a228ff8019b7eaf7f9df5b48a09787de6909a25179116368ecca", + kind: Kind::Archive(false), + }, + Fixture { + path: "invalid/archive-octal-padding.tar", + sha256: "6b3268c71672d4e7e8ebb7a9eee70fe766f08c980371e9846c7b59fda0c4d3fb", + kind: Kind::Archive(false), + }, + Fixture { + path: "invalid/archive-order.tar", + sha256: "60ae90ff78d80daef9b1f2bd80a48dde9ee807121624d2e237eea9bc00e22944", + kind: Kind::Archive(false), + }, + Fixture { + path: "invalid/archive-padding.tar", + sha256: "e778e0fed834ecfa56621aa4e3c9a4ab45040807053e2157bf48b31750f013c6", + kind: Kind::Archive(false), + }, + Fixture { + path: "invalid/archive-pax.tar", + sha256: "205e55d4f39e5cd505515b35b8338ee19625506b1571a4e1fcdc568127596b55", + kind: Kind::Archive(false), + }, + Fixture { + path: "invalid/archive-size-limit.tar", + sha256: "2d7e850dc5bf999314cc538825b40a303249b3469cb1ffb043e06c383733dbf4", + kind: Kind::Archive(false), + }, + Fixture { + path: "invalid/archive-size.tar", + sha256: "f06217b9f69801d5e78e3f88b4f42fa69a69ab8d688899cc384bb4eecbab974e", + kind: Kind::Archive(false), + }, + Fixture { + path: "invalid/archive-sparse.tar", + sha256: "0a4e7d7079fb2b74ad994a9a69013b049222acdec6c408523c74264f8f157bd5", + kind: Kind::Archive(false), + }, + Fixture { + path: "invalid/archive-special.tar", + sha256: "43f1840667cd1e06166ff9a28af39baa5c94a4db60ed6fa821111cd794cf9def", + kind: Kind::Archive(false), + }, + Fixture { + path: "invalid/archive-trailing.tar", + sha256: "c2f1705cbad092ee21caf2dab7adec106a262596308182eb34e161d8bb2a1090", + kind: Kind::Archive(false), + }, + Fixture { + path: "invalid/archive-traversal.tar", + sha256: "11b921b9b519ea14eaa6ca164766096c652924bc6b7fee672375c581dfd62cc4", + kind: Kind::Archive(false), + }, + Fixture { + path: "invalid/elf-malformed/app-cli", + sha256: "b768ce3772e64cd00641976694c5dac01326e322fb5cc7bebf81ac4960298ab3", + kind: Kind::Elf(false), + }, + Fixture { + path: "invalid/expected-duplicate.json", + sha256: "cc629e4bbc2f37d35127cc5fa53414e00968f9679f3f16941fab389e384ca2cb", + kind: Kind::Expected(false), + }, + Fixture { + path: "invalid/meta-missing-interpreter.json", + sha256: "c6299c11b02f864f65bc5feb3ec58a6b37ad8336ae5c8ee77c4a7bf0c3ac4606", + kind: Kind::Metadata(false), + }, + Fixture { + path: "valid/archive.tar", + sha256: "bb041c59d4c24ecc41f3f329040aef224a8afd5477ee515b59c6ae94d5c258f1", + kind: Kind::Archive(true), + }, + Fixture { + path: "valid/dynamic-meta.json", + sha256: "f30b752dc40f104311c42a8275d5c9e46a65336ce788a776b7a8cc06edaf4aee", + kind: Kind::Metadata(true), + }, + Fixture { + path: "valid/elf-static/app-cli", + sha256: "6b25433eed518a44b19e8c821749f4dd07156d0962727ed27c15f8816c3c1c96", + kind: Kind::Elf(true), + }, + Fixture { + path: "valid/expected.json", + sha256: "67faafc666f0f3ac2ff3528aa27befa4f747fa39efbcbe2c9543cec793f2179f", + kind: Kind::Expected(true), + }, + Fixture { + path: "valid/static-meta.json", + sha256: "8ad2590f982dacf2272e8afb1f88b9e910349f43aab38675bac9cd43eb2e47d2", + kind: Kind::Metadata(true), + }, +]; + +pub(crate) fn run(layout: &ExecutionLayout, supplied: &str) -> Result<()> { + let fixture_root = layout.fixture_root(supplied)?; + let image_root = layout.image_root()?; + let image_owner = fs::symlink_metadata(&image_root).map_err(|error| error.to_string())?; + let schema = layout.baked_schema()?; + let schema_bytes = schema.read_bounded(METADATA_LIMIT)?; + let mut files = BTreeMap::new(); + let mut directories = BTreeSet::new(); + let mut aggregate_bytes = 0u64; + collect( + &fixture_root, + &fixture_root, + &image_owner, + &mut files, + &mut directories, + &mut aggregate_bytes, + )?; + + let expected_directories = DIRECTORIES.iter().copied().collect::>(); + require( + directories + .iter() + .map(String::as_str) + .collect::>() + == expected_directories, + "fixture directory manifest mismatch", + )?; + require( + files.len() == FIXTURES.len(), + "fixture file manifest mismatch", + )?; + require( + files + .keys() + .map(String::as_str) + .eq(FIXTURES.iter().map(|fixture| fixture.path)), + "fixture path manifest mismatch", + )?; + + for fixture in FIXTURES { + let file = files + .get(fixture.path) + .ok_or_else(|| format!("missing fixture: {}", fixture.path))?; + require( + sha256(file)? == fixture.sha256, + &format!("fixture digest mismatch: {}", fixture.path), + )?; + let valid = evaluate(fixture.kind, file, &schema_bytes, &image_root).is_ok(); + let expected_valid = match fixture.kind { + Kind::Archive(valid) + | Kind::Elf(valid) + | Kind::Expected(valid) + | Kind::Metadata(valid) => valid, + }; + require( + valid == expected_valid, + &format!("fixture semantic outcome mismatch: {}", fixture.path), + )?; + file.verify()?; + } + schema.verify() +} + +fn collect( + root: &Path, + directory: &Path, + image_owner: &FsMetadata, + files: &mut BTreeMap, + directories: &mut BTreeSet, + aggregate_bytes: &mut u64, +) -> Result<()> { + for entry in fs::read_dir(directory).map_err(|error| error.to_string())? { + let entry = entry.map_err(|error| error.to_string())?; + let path = entry.path(); + let relative = path + .strip_prefix(root) + .map_err(|_| "fixture path escapes fixture root")?; + let relative = relative + .to_str() + .ok_or("fixture path is not UTF-8")? + .replace(std::path::MAIN_SEPARATOR, "/"); + let metadata = fs::symlink_metadata(&path).map_err(|error| error.to_string())?; + require_same_owner(&metadata, image_owner)?; + if metadata.file_type().is_dir() { + require( + relative.split('/').count() <= MAX_DIRECTORY_DEPTH, + "fixture depth limit exceeded", + )?; + require( + DIRECTORIES.contains(&relative.as_str()), + "unexpected fixture directory", + )?; + require( + directories.len() < DIRECTORIES.len(), + "fixture directory count limit exceeded", + )?; + require( + path.canonicalize().map_err(|error| error.to_string())? == path, + "fixture directory is not canonical", + )?; + require(directories.insert(relative), "duplicate fixture directory")?; + collect( + root, + &path, + image_owner, + files, + directories, + aggregate_bytes, + )?; + } else if metadata.file_type().is_file() { + require( + relative.split('/').count() <= MAX_FILE_DEPTH, + "fixture depth limit exceeded", + )?; + require( + files.len() < FIXTURES.len(), + "fixture file count limit exceeded", + )?; + require( + metadata.len() <= FIXTURE_LIMIT, + "fixture exceeds byte limit", + )?; + *aggregate_bytes = aggregate_bytes + .checked_add(metadata.len()) + .ok_or("fixture aggregate size overflow")?; + require( + *aggregate_bytes <= AGGREGATE_FIXTURE_LIMIT, + "fixture aggregate byte limit exceeded", + )?; + let file = SafeFile::open_path(path, Some(root), Some(image_owner))?; + require( + files.insert(relative, file).is_none(), + "duplicate fixture path", + )?; + } else { + return Err("fixture entry is not a regular file or directory".into()); + } + } + Ok(()) +} + +fn evaluate(kind: Kind, file: &SafeFile, schema: &[u8], image_root: &Path) -> Result<()> { + match kind { + Kind::Archive(valid) => { + let mut input = file.open()?; + let parsed = archive::parse(&mut input)?; + let metadata = Metadata::parse(&parsed.metadata)?; + validate_schema_instance(schema, &parsed.metadata)?; + metadata.canonical_bytes()?; + if valid { + evaluate_integrated_archive( + file, &mut input, &parsed, &metadata, schema, image_root, + )?; + } + Ok(()) + } + Kind::Elf(_) => elf::inspect(file.path(), image_root).map(|_| ()), + Kind::Expected(_) => { + let bytes = file.read_bounded(EXPECTED_LIMIT)?; + let expected = Expected::parse(&bytes)?; + validate_schema_instance(schema, &bytes)?; + expected.canonical_bytes().map(|_| ()) + } + Kind::Metadata(_) => { + let bytes = file.read_bounded(METADATA_LIMIT)?; + let metadata = Metadata::parse(&bytes)?; + validate_schema_instance(schema, &bytes)?; + metadata.canonical_bytes().map(|_| ()) + } + } +} + +fn evaluate_integrated_archive( + archive_file: &SafeFile, + input: &mut (impl Read + std::io::Seek), + parsed: &archive::ParsedArchive, + metadata: &Metadata, + schema: &[u8], + image_root: &Path, +) -> Result<()> { + let valid = archive_file + .path() + .parent() + .ok_or("valid archive fixture has no parent")?; + let fixture_root = valid + .parent() + .ok_or("valid archive fixture escapes fixture root")?; + let expected = SafeFile::open_path(valid.join("expected.json"), Some(fixture_root), None)?; + let expected_bytes = expected.read_bounded(EXPECTED_LIMIT)?; + let identity = Expected::parse(&expected_bytes)?; + validate_schema_instance(schema, &expected_bytes)?; + metadata.matches(&identity)?; + + let binary = SafeFile::open_path(valid.join("elf-static/app-cli"), Some(fixture_root), None)?; + let inspection = elf::inspect(binary.path(), image_root)?; + let observed_abi = abi(&inspection)?; + metadata.matches_observation( + &observed_abi, + &inspection.binary_sha256, + inspection.binary_size, + )?; + require( + parsed.binary_size == inspection.binary_size, + "valid archive binary size differs from ELF fixture", + )?; + archive::verify_binary_sha256(input, parsed, &inspection.binary_sha256)?; + binary.verify()?; + expected.verify() +} + +fn sha256(file: &SafeFile) -> Result { + let mut input = file.open()?.take(file.size() + 1); + let mut digest = Sha256::new(); + let mut buffer = [0; 8 * 1024]; + let mut size = 0u64; + loop { + let read = input.read(&mut buffer).map_err(|error| error.to_string())?; + if read == 0 { + break; + } + size = size + .checked_add(read as u64) + .ok_or("fixture size overflow")?; + digest.update(&buffer[..read]); + } + require(size == file.size(), "fixture size changed while hashing")?; + file.verify()?; + Ok(format!("{:x}", digest.finalize())) +} + +#[cfg(unix)] +fn require_same_owner(actual: &FsMetadata, expected: &FsMetadata) -> Result<()> { + use std::os::unix::fs::MetadataExt; + + require( + actual.uid() == expected.uid() && actual.gid() == expected.gid(), + "fixture entry is not image-owned", + ) +} + +#[cfg(not(unix))] +fn require_same_owner(_actual: &FsMetadata, _expected: &FsMetadata) -> Result<()> { + Err("fixture ownership validation requires Unix".into()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::{ + fs, + io::Cursor, + path::PathBuf, + sync::atomic::{AtomicU64, Ordering}, + }; + + const SCHEMA: &[u8] = include_bytes!("../../../docker/build-app-cli/provenance.schema.json"); + const EXPECTED: &[u8] = + include_bytes!("../../../docker/build-app-cli/fixtures/provenance/valid/expected.json"); + const ELF: &[u8] = include_bytes!( + "../../../docker/build-app-cli/fixtures/provenance/valid/elf-static/app-cli" + ); + const ARCHIVE: &[u8] = + include_bytes!("../../../docker/build-app-cli/fixtures/provenance/valid/archive.tar"); + + static NEXT_TEMP: AtomicU64 = AtomicU64::new(0); + + struct TempDir(PathBuf); + + impl TempDir { + fn new() -> Self { + let sequence = NEXT_TEMP.fetch_add(1, Ordering::Relaxed); + let path = std::env::temp_dir().join(format!( + "edgezero-provenance-self-test-{}-{sequence}", + std::process::id() + )); + fs::create_dir(&path).unwrap(); + Self(path.canonicalize().unwrap()) + } + } + + impl Drop for TempDir { + fn drop(&mut self) { + if self.0.exists() { + fs::remove_dir_all(&self.0).unwrap(); + } + } + } + + #[test] + fn integrated_valid_archive_rejects_observation_mismatch_after_layer_validation() { + let image_root = TempDir::new(); + let fixture_root = image_root + .0 + .join("usr/local/share/edgezero/provenance-fixtures"); + let valid = fixture_root.join("valid"); + fs::create_dir_all(valid.join("elf-static")).unwrap(); + fs::write(valid.join("expected.json"), EXPECTED).unwrap(); + fs::write(valid.join("elf-static/app-cli"), ELF).unwrap(); + + let mut bytes = ARCHIVE.to_vec(); + let parsed = archive::parse(&mut Cursor::new(&bytes)).unwrap(); + bytes[usize::try_from(parsed.binary_offset).unwrap()] ^= 1; + let parsed = archive::parse(&mut Cursor::new(&bytes)).unwrap(); + Metadata::parse(&parsed.metadata).unwrap(); + validate_schema_instance(SCHEMA, &parsed.metadata).unwrap(); + + let archive_path = valid.join("archive.tar"); + fs::write(&archive_path, bytes).unwrap(); + let archive = SafeFile::open_path(archive_path, Some(&fixture_root), None).unwrap(); + + let error = evaluate(Kind::Archive(true), &archive, SCHEMA, &image_root.0).unwrap_err(); + assert!( + error.contains("binary metadata") || error.contains("binary digest"), + "unexpected error: {error}" + ); + } +} diff --git a/.github/tools/edgezero-provenance-validator/tests/cli.rs b/.github/tools/edgezero-provenance-validator/tests/cli.rs new file mode 100644 index 00000000..2a76c498 --- /dev/null +++ b/.github/tools/edgezero-provenance-validator/tests/cli.rs @@ -0,0 +1,1138 @@ +use edgezero_provenance_validator::{ExecutionLayout, archive, execute, json_contract::Expected}; +use serde_json::Value; +use std::{ + fs::{self, File}, + io::Cursor, + path::{Path, PathBuf}, + process::Command, + sync::atomic::{AtomicU64, Ordering}, +}; + +const SCHEMA: &[u8] = include_bytes!("../../../docker/build-app-cli/provenance.schema.json"); +const FIXTURES: &str = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../docker/build-app-cli/fixtures/provenance" +); +const SOURCE: &str = "1111111111111111111111111111111111111111"; +const GATE: &str = "2222222222222222222222222222222222222222"; +const WORKSPACE: &str = "sha256:3333333333333333333333333333333333333333333333333333333333333333"; +const PLATFORM: &str = "sha256:4444444444444444444444444444444444444444444444444444444444444444"; + +static NEXT_TEMP: AtomicU64 = AtomicU64::new(0); + +struct TempDir(PathBuf); + +impl TempDir { + fn new() -> Self { + let sequence = NEXT_TEMP.fetch_add(1, Ordering::Relaxed); + let path = std::env::temp_dir().join(format!( + "edgezero-provenance-cli-{}-{sequence}", + std::process::id() + )); + fs::create_dir(&path).unwrap(); + Self(path.canonicalize().unwrap()) + } + + fn path(&self) -> &Path { + &self.0 + } +} + +impl Drop for TempDir { + fn drop(&mut self) { + if self.0.exists() { + fs::remove_dir_all(&self.0).unwrap(); + } + } +} + +struct Harness { + temp: TempDir, + layout: ExecutionLayout, +} + +impl Harness { + fn new() -> Self { + let temp = TempDir::new(); + fs::create_dir(temp.path().join("work")).unwrap(); + let schema = temp + .path() + .join("usr/local/share/edgezero/provenance.schema.json"); + fs::create_dir_all(schema.parent().unwrap()).unwrap(); + fs::write(&schema, SCHEMA).unwrap(); + let layout = ExecutionLayout::rooted_at(temp.path()); + Self { temp, layout } + } + + fn path(&self, container_path: &str) -> PathBuf { + assert!(container_path.starts_with('/')); + self.temp + .path() + .join(container_path.trim_start_matches('/')) + } + + fn mkdir(&self, container_path: &str) { + fs::create_dir_all(self.path(container_path)).unwrap(); + } + + fn write(&self, container_path: &str, bytes: &[u8]) { + let path = self.path(container_path); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(path, bytes).unwrap(); + } + + fn run(&self, arguments: &[String]) -> edgezero_provenance_validator::Result<()> { + execute(&self.layout, arguments.iter().cloned()) + } + + fn install_fixtures(&self) { + copy_tree( + Path::new(FIXTURES), + &self.path("/usr/local/share/edgezero/provenance-fixtures"), + ); + } +} + +fn copy_tree(source: &Path, destination: &Path) { + fs::create_dir(destination).unwrap(); + for entry in fs::read_dir(source).unwrap() { + let entry = entry.unwrap(); + let destination = destination.join(entry.file_name()); + if entry.file_type().unwrap().is_dir() { + copy_tree(&entry.path(), &destination); + } else { + fs::copy(entry.path(), destination).unwrap(); + } + } +} + +fn expected(repo: &str) -> Vec { + Expected::new( + repo, + SOURCE, + "edgezero-cli", + "edgezero", + WORKSPACE, + PLATFORM, + ) + .unwrap() + .canonical_bytes() + .unwrap() +} + +fn write_expected_args() -> Vec { + strings(&[ + "write-expected", + "--work-root", + "/work", + "--app-repo-id", + "123456", + "--source-revision", + SOURCE, + "--app-cli-package", + "edgezero-cli", + "--app-cli-bin", + "edgezero", + "--workspace-id", + WORKSPACE, + "--platform-id", + PLATFORM, + "--provenance-protocol", + "1", + "--output", + "/work/expected/expected.json", + ]) +} + +fn write_release_args() -> Vec { + strings(&[ + "write-release-request", + "--work-root", + "/work", + "--gate-sha", + GATE, + "--provenance-protocol", + "1", + "--release-tag", + "build-container-v7", + "--output", + "/work/release/release-request.json", + ]) +} + +fn package_args() -> Vec { + strings(&[ + "package", + "--work-root", + "/work", + "--binary", + "/work/input/app-cli", + "--schema", + "/usr/local/share/edgezero/provenance.schema.json", + "--expected", + "/work/input/expected.json", + "--app-cli-version", + "0.1.0", + "--archive", + "/work/packaged/artifact.tar", + ]) +} + +fn validate_args() -> Vec { + strings(&[ + "validate", + "--work-root", + "/work", + "--archive", + "/work/input/artifact.tar", + "--schema", + "/usr/local/share/edgezero/provenance.schema.json", + "--expected", + "/work/input/expected.json", + "--output", + "/work/validated/app-cli", + ]) +} + +fn self_test_args() -> Vec { + strings(&[ + "self-test", + "--fixtures", + "/usr/local/share/edgezero/provenance-fixtures", + ]) +} + +fn strings(values: &[&str]) -> Vec { + values.iter().map(|value| (*value).into()).collect() +} + +fn replace_flag(arguments: &mut [String], flag: &str, value: &str) { + let index = arguments.iter().position(|item| item == flag).unwrap(); + arguments[index + 1] = value.into(); +} + +fn prepare_package(harness: &Harness, repo: &str) { + harness.mkdir("/work/input"); + harness.mkdir("/work/packaged"); + harness.write("/work/input/expected.json", &expected(repo)); + harness.write("/work/input/app-cli", &static_elf(0x800)); +} + +fn package_once(repo: &str) -> (Harness, Vec) { + let harness = Harness::new(); + prepare_package(&harness, repo); + harness.run(&package_args()).unwrap(); + let bytes = fs::read(harness.path("/work/packaged/artifact.tar")).unwrap(); + (harness, bytes) +} + +fn prepare_validate(harness: &Harness, repo: &str, archive: &[u8]) { + harness.mkdir("/work/input"); + harness.mkdir("/work/validated"); + harness.write("/work/input/expected.json", &expected(repo)); + harness.write("/work/input/artifact.tar", archive); +} + +fn rewrite_metadata(archive_bytes: &[u8], change: impl FnOnce(&mut Value)) -> Vec { + let mut input = Cursor::new(archive_bytes); + let parsed = archive::parse(&mut input).unwrap(); + let start = usize::try_from(parsed.binary_offset).unwrap(); + let end = start + usize::try_from(parsed.binary_size).unwrap(); + let mut metadata: Value = serde_json::from_slice(&parsed.metadata).unwrap(); + change(&mut metadata); + let metadata = serde_json::to_vec(&metadata).unwrap(); + let mut output = Vec::new(); + archive::encode( + &mut output, + &mut Cursor::new(&metadata), + metadata.len() as u64, + &mut Cursor::new(&archive_bytes[start..end]), + parsed.binary_size, + ) + .unwrap(); + output +} + +#[test] +fn command_grammar_rejects_missing_duplicate_unknown_malformed_mixed_and_extra_arguments() { + let harness = Harness::new(); + harness.mkdir("/work/expected"); + let valid = write_expected_args(); + let mut cases = vec![ + Vec::new(), + strings(&["unknown-command"]), + strings(&["write_expected"]), + ]; + + let mut missing = valid.clone(); + missing.truncate(missing.len() - 2); + cases.push(missing); + + let mut duplicate = valid.clone(); + duplicate.extend(strings(&["--output", "/work/expected/expected.json"])); + cases.push(duplicate); + + let mut unknown = valid.clone(); + unknown.extend(strings(&["--unknown", "value"])); + cases.push(unknown); + + let mut joined = valid.clone(); + joined.push("--output=/work/expected/expected.json".into()); + cases.push(joined); + + let mut positional = valid.clone(); + positional.push("extra".into()); + cases.push(positional); + + let mut mixed = valid; + mixed.extend(strings(&["--archive", "/work/packaged/artifact.tar"])); + cases.push(mixed); + + for arguments in cases { + assert!(harness.run(&arguments).is_err(), "accepted {arguments:?}"); + } + assert!( + fs::read_dir(harness.path("/work/expected")) + .unwrap() + .next() + .is_none() + ); +} + +#[test] +fn host_process_rejects_every_nonliteral_work_root_before_work() { + let executable = env!("CARGO_BIN_EXE_edgezero-provenance-validator"); + let non_work_root = std::env::temp_dir().canonicalize().unwrap(); + for mut arguments in [ + write_expected_args(), + write_release_args(), + package_args(), + validate_args(), + ] { + replace_flag( + &mut arguments, + "--work-root", + non_work_root.to_str().unwrap(), + ); + let output = Command::new(executable).args(&arguments).output().unwrap(); + assert!(!output.status.success(), "accepted {arguments:?}"); + assert!( + String::from_utf8_lossy(&output.stderr) + .contains("work root does not resolve to literal /work"), + "unexpected stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + } +} + +#[test] +fn host_process_self_test_rejects_every_nonliteral_fixture_path() { + let fixtures = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../docker/build-app-cli/fixtures/provenance") + .canonicalize() + .unwrap(); + let output = Command::new(env!("CARGO_BIN_EXE_edgezero-provenance-validator")) + .args(["self-test", "--fixtures"]) + .arg(fixtures) + .output() + .unwrap(); + assert!(!output.status.success()); + assert!( + String::from_utf8_lossy(&output.stderr) + .contains("fixtures path is not the required literal") + ); +} + +#[test] +fn writers_emit_exact_canonical_bytes_and_safe_modes() { + use std::os::unix::fs::{MetadataExt, PermissionsExt}; + + let harness = Harness::new(); + harness.mkdir("/work/expected"); + harness.run(&write_expected_args()).unwrap(); + let expected_path = harness.path("/work/expected/expected.json"); + assert_eq!(fs::read(&expected_path).unwrap(), expected("123456")); + let metadata = fs::symlink_metadata(expected_path).unwrap(); + assert_eq!(metadata.permissions().mode() & 0o7777, 0o644); + assert_eq!(metadata.nlink(), 1); + + harness.mkdir("/work/release"); + let mut release = write_release_args(); + replace_flag(&mut release, "--release-tag", "build-container-v7"); + harness.run(&release).unwrap(); + let release_path = harness.path("/work/release/release-request.json"); + assert_eq!( + fs::read(release_path).unwrap(), + format!( + "{{\"gate-sha\":\"{GATE}\",\"provenance-protocol\":1,\"release-tag\":\"build-container-v7\"}}" + ) + .as_bytes() + ); +} + +#[test] +fn release_request_rejects_noncanonical_values_without_output() { + for (flag, value) in [ + ("--gate-sha", "0000000000000000000000000000000000000000"), + ("--gate-sha", "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"), + ("--provenance-protocol", "01"), + ("--release-tag", "build-container-v0"), + ("--release-tag", "build-container-v01"), + ("--release-tag", "v1"), + ] { + let harness = Harness::new(); + harness.mkdir("/work/release"); + let mut arguments = write_release_args(); + replace_flag(&mut arguments, flag, value); + assert!(harness.run(&arguments).is_err(), "accepted {flag}={value}"); + assert!( + fs::read_dir(harness.path("/work/release")) + .unwrap() + .next() + .is_none() + ); + } +} + +#[test] +fn release_request_accepts_canonical_positive_decimal_beyond_u64() { + let harness = Harness::new(); + harness.mkdir("/work/release"); + let mut arguments = write_release_args(); + replace_flag( + &mut arguments, + "--release-tag", + "build-container-v18446744073709551616", + ); + + harness.run(&arguments).unwrap(); + assert!( + String::from_utf8(fs::read(harness.path("/work/release/release-request.json")).unwrap()) + .unwrap() + .contains("build-container-v18446744073709551616") + ); +} + +#[test] +fn package_is_deterministic_and_validate_round_trips_without_execution() { + use std::os::unix::fs::{MetadataExt, PermissionsExt}; + + let (first_harness, first) = package_once("123456"); + let (_second_harness, second) = package_once("123456"); + assert_eq!(first, second); + + let archive_metadata = + fs::symlink_metadata(first_harness.path("/work/packaged/artifact.tar")).unwrap(); + assert_eq!(archive_metadata.permissions().mode() & 0o7777, 0o644); + assert_eq!(archive_metadata.nlink(), 1); + + let validator = Harness::new(); + prepare_validate(&validator, "123456", &first); + validator.run(&validate_args()).unwrap(); + let output = validator.path("/work/validated/app-cli"); + assert_eq!(fs::read(&output).unwrap(), static_elf(0x800)); + let metadata = fs::symlink_metadata(output).unwrap(); + assert_eq!(metadata.permissions().mode() & 0o7777, 0o755); + assert_eq!(metadata.nlink(), 1); +} + +#[test] +fn validate_rejects_identity_digest_size_and_abi_mismatches_before_publication() { + let (_packager, archive) = package_once("123456"); + let cases = [ + archive.clone(), + rewrite_metadata(&archive, |metadata| { + metadata["binary-sha256"] = Value::String(format!("sha256:{}", "5".repeat(64))); + }), + rewrite_metadata(&archive, |metadata| { + metadata["binary-size"] = Value::from(2049); + }), + rewrite_metadata(&archive, |metadata| { + metadata["abi"]["interpreter"] = Value::String("/lib64/ld-linux-x86-64.so.2".into()); + }), + ]; + + for (index, candidate) in cases.into_iter().enumerate() { + let harness = Harness::new(); + let repo = if index == 0 { "654321" } else { "123456" }; + prepare_validate(&harness, repo, &candidate); + assert!( + harness.run(&validate_args()).is_err(), + "accepted case {index}" + ); + assert!( + fs::read_dir(harness.path("/work/validated")) + .unwrap() + .next() + .is_none() + ); + } +} + +#[test] +fn malformed_schema_expected_archive_and_elf_inputs_leave_fresh_parents_empty() { + let malformed_expected = Harness::new(); + malformed_expected.mkdir("/work/input"); + malformed_expected.mkdir("/work/packaged"); + malformed_expected.write("/work/input/expected.json", b"{}"); + malformed_expected.write("/work/input/app-cli", &static_elf(0x800)); + assert!(malformed_expected.run(&package_args()).is_err()); + assert_empty(&malformed_expected, "/work/packaged"); + + let bad_elf = Harness::new(); + bad_elf.mkdir("/work/input"); + bad_elf.mkdir("/work/packaged"); + bad_elf.write("/work/input/expected.json", &expected("123456")); + bad_elf.write("/work/input/app-cli", b"not ELF"); + assert!(bad_elf.run(&package_args()).is_err()); + assert_empty(&bad_elf, "/work/packaged"); + + let malformed_archive = Harness::new(); + prepare_validate(&malformed_archive, "123456", b"not an archive"); + assert!(malformed_archive.run(&validate_args()).is_err()); + assert_empty(&malformed_archive, "/work/validated"); + + let restrictive_schema = Harness::new(); + prepare_package(&restrictive_schema, "123456"); + restrictive_schema.write( + "/usr/local/share/edgezero/provenance.schema.json", + br#"{"type":"null"}"#, + ); + assert!(restrictive_schema.run(&package_args()).is_err()); + assert_empty(&restrictive_schema, "/work/packaged"); + + let invalid_schema = Harness::new(); + prepare_package(&invalid_schema, "123456"); + invalid_schema.write("/usr/local/share/edgezero/provenance.schema.json", b"{"); + assert!(invalid_schema.run(&package_args()).is_err()); + assert_empty(&invalid_schema, "/work/packaged"); +} + +#[test] +fn review_general_work_paths_accept_confined_alternates() { + let expected_writer = Harness::new(); + expected_writer.mkdir("/work/alternate-expected"); + let mut arguments = write_expected_args(); + replace_flag( + &mut arguments, + "--output", + "/work/alternate-expected/identity.json", + ); + expected_writer.run(&arguments).unwrap(); + assert_eq!( + fs::read(expected_writer.path("/work/alternate-expected/identity.json")).unwrap(), + expected("123456") + ); + + let packager = Harness::new(); + packager.mkdir("/work/source"); + packager.mkdir("/work/identity"); + packager.mkdir("/work/alternate-package"); + packager.write("/work/source/tool", &static_elf(0x800)); + packager.write("/work/identity/build.json", &expected("123456")); + let mut package = package_args(); + replace_flag(&mut package, "--binary", "/work/source/tool"); + replace_flag(&mut package, "--expected", "/work/identity/build.json"); + replace_flag( + &mut package, + "--archive", + "/work/alternate-package/bundle.tar", + ); + packager.run(&package).unwrap(); + let archive = fs::read(packager.path("/work/alternate-package/bundle.tar")).unwrap(); + + let validator = Harness::new(); + validator.mkdir("/work/incoming"); + validator.mkdir("/work/identity"); + validator.mkdir("/work/alternate-install"); + validator.write("/work/incoming/bundle.tar", &archive); + validator.write("/work/identity/build.json", &expected("123456")); + let mut validate = validate_args(); + replace_flag(&mut validate, "--archive", "/work/incoming/bundle.tar"); + replace_flag(&mut validate, "--expected", "/work/identity/build.json"); + replace_flag(&mut validate, "--output", "/work/alternate-install/tool"); + validator.run(&validate).unwrap(); + assert_eq!( + fs::read(validator.path("/work/alternate-install/tool")).unwrap(), + static_elf(0x800) + ); +} + +#[cfg(unix)] +#[test] +fn review_work_root_aliases_must_resolve_to_the_image_work_directory() { + use std::os::unix::fs::symlink; + + let spelling = Harness::new(); + spelling.mkdir("/work/spelling-output"); + let mut spelling_args = write_expected_args(); + replace_flag(&mut spelling_args, "--work-root", "/work/."); + replace_flag( + &mut spelling_args, + "--output", + "/work/spelling-output/identity.json", + ); + spelling.run(&spelling_args).unwrap(); + + let parent_spelling = Harness::new(); + parent_spelling.mkdir("/work/parent-spelling-output"); + let mut parent_spelling_args = write_expected_args(); + replace_flag(&mut parent_spelling_args, "--work-root", "/work/../work"); + replace_flag( + &mut parent_spelling_args, + "--output", + "/work/parent-spelling-output/identity.json", + ); + parent_spelling.run(&parent_spelling_args).unwrap(); + + let alias = Harness::new(); + alias.mkdir("/work/alias-output"); + symlink(alias.path("/work"), alias.path("/work-alias")).unwrap(); + let mut alias_args = write_expected_args(); + replace_flag(&mut alias_args, "--work-root", "/work-alias"); + replace_flag( + &mut alias_args, + "--output", + "/work/alias-output/identity.json", + ); + alias.run(&alias_args).unwrap(); + + let wrong = Harness::new(); + wrong.mkdir("/elsewhere"); + wrong.mkdir("/work/rejected-output"); + symlink(wrong.path("/elsewhere"), wrong.path("/work-alias")).unwrap(); + let mut wrong_args = write_expected_args(); + replace_flag(&mut wrong_args, "--work-root", "/work-alias"); + replace_flag( + &mut wrong_args, + "--output", + "/work/rejected-output/identity.json", + ); + assert!(wrong.run(&wrong_args).is_err()); + assert_empty(&wrong, "/work/rejected-output"); + + let outside = TempDir::new(); + fs::create_dir(outside.path().join("escaped-work")).unwrap(); + let escaped = Harness::new(); + escaped.mkdir("/work/rejected-output"); + symlink( + outside.path().join("escaped-work"), + escaped.path("/outside-alias"), + ) + .unwrap(); + let mut escaped_args = write_expected_args(); + replace_flag(&mut escaped_args, "--work-root", "/outside-alias"); + replace_flag( + &mut escaped_args, + "--output", + "/work/rejected-output/identity.json", + ); + assert!(escaped.run(&escaped_args).is_err()); + assert_empty(&escaped, "/work/rejected-output"); + + let escaped_then_parent = Harness::new(); + escaped_then_parent.mkdir("/work/rejected-output"); + symlink( + outside.path().join("escaped-work"), + escaped_then_parent.path("/outside-alias"), + ) + .unwrap(); + let mut escaped_then_parent_args = write_expected_args(); + replace_flag( + &mut escaped_then_parent_args, + "--work-root", + "/outside-alias/../work", + ); + replace_flag( + &mut escaped_then_parent_args, + "--output", + "/work/rejected-output/identity.json", + ); + assert!(escaped_then_parent.run(&escaped_then_parent_args).is_err()); + assert_empty(&escaped_then_parent, "/work/rejected-output"); +} + +#[test] +fn review_work_paths_reject_lexical_escapes_and_baked_paths_remain_exact() { + let cases = [ + ( + "write-expected", + "--output", + "/work/../outside/expected.json", + ), + ( + "write-release-request", + "--output", + "/work/release/other.json", + ), + ("package", "--binary", "/work/input/../../outside-app"), + ( + "package", + "--expected", + "/work/input/../../outside-expected.json", + ), + ( + "package", + "--archive", + "/work/packaged/../../outside-artifact.tar", + ), + ("package", "--schema", "/work/input/schema.json"), + ( + "validate", + "--archive", + "/work/input/../../outside-artifact.tar", + ), + ( + "validate", + "--expected", + "/work/input/../../outside-expected.json", + ), + ("validate", "--output", "/work/validated/../../outside-app"), + ("validate", "--schema", "/work/input/schema.json"), + ]; + + for (command, flag, value) in cases { + let harness = Harness::new(); + let mut arguments = match command { + "write-expected" => { + harness.mkdir("/work/expected"); + write_expected_args() + } + "write-release-request" => { + harness.mkdir("/work/release"); + write_release_args() + } + "package" => { + prepare_package(&harness, "123456"); + package_args() + } + "validate" => { + harness.mkdir("/work/input"); + harness.mkdir("/work/validated"); + harness.write("/work/input/expected.json", &expected("123456")); + harness.write("/work/input/artifact.tar", b"invalid"); + validate_args() + } + _ => unreachable!(), + }; + replace_flag(&mut arguments, flag, value); + assert!( + harness.run(&arguments).is_err(), + "accepted {command} {flag}={value}" + ); + } +} + +#[cfg(unix)] +#[test] +fn canonical_symlink_and_hardlink_escapes_fail_for_every_path_category() { + use std::os::unix::fs::{MetadataExt, symlink}; + + let binary = Harness::new(); + prepare_package(&binary, "123456"); + let outside_binary = binary.path("/outside-app-cli"); + fs::write(&outside_binary, static_elf(0x800)).unwrap(); + fs::remove_file(binary.path("/work/input/app-cli")).unwrap(); + symlink(&outside_binary, binary.path("/work/input/app-cli")).unwrap(); + assert!(binary.run(&package_args()).is_err()); + + let expected_link = Harness::new(); + prepare_package(&expected_link, "123456"); + let outside_expected = expected_link.path("/outside-expected.json"); + fs::write(&outside_expected, expected("123456")).unwrap(); + fs::remove_file(expected_link.path("/work/input/expected.json")).unwrap(); + symlink( + &outside_expected, + expected_link.path("/work/input/expected.json"), + ) + .unwrap(); + assert!(expected_link.run(&package_args()).is_err()); + + let (_packager, archive_bytes) = package_once("123456"); + let archive_link = Harness::new(); + prepare_validate(&archive_link, "123456", &archive_bytes); + let outside_archive = archive_link.path("/outside-artifact.tar"); + fs::write(&outside_archive, &archive_bytes).unwrap(); + fs::remove_file(archive_link.path("/work/input/artifact.tar")).unwrap(); + symlink( + &outside_archive, + archive_link.path("/work/input/artifact.tar"), + ) + .unwrap(); + assert!(archive_link.run(&validate_args()).is_err()); + + let schema_link = Harness::new(); + prepare_package(&schema_link, "123456"); + let schema = schema_link.path("/usr/local/share/edgezero/provenance.schema.json"); + let alternate = schema_link.path("/alternate-schema.json"); + fs::write(&alternate, SCHEMA).unwrap(); + fs::remove_file(&schema).unwrap(); + symlink(alternate, schema).unwrap(); + assert!(schema_link.run(&package_args()).is_err()); + + let output_link = Harness::new(); + output_link.mkdir("/work/real-packaged"); + symlink( + output_link.path("/work/real-packaged"), + output_link.path("/work/packaged"), + ) + .unwrap(); + output_link.mkdir("/work/input"); + output_link.write("/work/input/expected.json", &expected("123456")); + output_link.write("/work/input/app-cli", &static_elf(0x800)); + assert!(output_link.run(&package_args()).is_err()); + + let expected_output_link = Harness::new(); + expected_output_link.mkdir("/outside-expected-output"); + symlink( + expected_output_link.path("/outside-expected-output"), + expected_output_link.path("/work/expected-link"), + ) + .unwrap(); + let mut expected_output_args = write_expected_args(); + replace_flag( + &mut expected_output_args, + "--output", + "/work/expected-link/identity.json", + ); + assert!(expected_output_link.run(&expected_output_args).is_err()); + + let validate_expected_link = Harness::new(); + prepare_validate(&validate_expected_link, "123456", &archive_bytes); + let outside_validate_expected = validate_expected_link.path("/outside-validate-expected.json"); + fs::write(&outside_validate_expected, expected("123456")).unwrap(); + symlink( + outside_validate_expected, + validate_expected_link.path("/work/input/expected-link.json"), + ) + .unwrap(); + let mut validate_expected_args = validate_args(); + replace_flag( + &mut validate_expected_args, + "--expected", + "/work/input/expected-link.json", + ); + assert!(validate_expected_link.run(&validate_expected_args).is_err()); + + let validate_output_link = Harness::new(); + prepare_validate(&validate_output_link, "123456", &archive_bytes); + validate_output_link.mkdir("/outside-validated-output"); + symlink( + validate_output_link.path("/outside-validated-output"), + validate_output_link.path("/work/validated-link"), + ) + .unwrap(); + let mut validate_output_args = validate_args(); + replace_flag( + &mut validate_output_args, + "--output", + "/work/validated-link/tool", + ); + assert!(validate_output_link.run(&validate_output_args).is_err()); + + let hardlink = Harness::new(); + prepare_package(&hardlink, "123456"); + fs::hard_link( + hardlink.path("/work/input/app-cli"), + hardlink.path("/work/input/app-cli-alias"), + ) + .unwrap(); + assert!( + fs::metadata(hardlink.path("/work/input/app-cli")) + .unwrap() + .nlink() + > 1 + ); + assert!(hardlink.run(&package_args()).is_err()); +} + +#[cfg(unix)] +#[test] +fn output_parents_must_be_empty_real_canonical_writable_directories() { + use std::os::unix::fs::{PermissionsExt, symlink}; + + let nonempty = Harness::new(); + prepare_package(&nonempty, "123456"); + nonempty.write("/work/packaged/sentinel", b"sentinel"); + assert!(nonempty.run(&package_args()).is_err()); + assert_eq!( + fs::read(nonempty.path("/work/packaged/sentinel")).unwrap(), + b"sentinel" + ); + + let symlinked = Harness::new(); + symlinked.mkdir("/work/input"); + symlinked.write("/work/input/expected.json", &expected("123456")); + symlinked.write("/work/input/app-cli", &static_elf(0x800)); + symlinked.mkdir("/work/elsewhere"); + symlink( + symlinked.path("/work/elsewhere"), + symlinked.path("/work/packaged"), + ) + .unwrap(); + assert!(symlinked.run(&package_args()).is_err()); + + let regular = Harness::new(); + regular.mkdir("/work/input"); + regular.write("/work/input/expected.json", &expected("123456")); + regular.write("/work/input/app-cli", &static_elf(0x800)); + regular.write("/work/packaged", b"not a directory"); + assert!(regular.run(&package_args()).is_err()); + + let readonly = Harness::new(); + prepare_package(&readonly, "123456"); + fs::set_permissions( + readonly.path("/work/packaged"), + fs::Permissions::from_mode(0o555), + ) + .unwrap(); + assert!(readonly.run(&package_args()).is_err()); +} + +#[test] +fn final_name_collision_is_no_replace_and_cleans_only_the_owned_temporary_file() { + use std::{ + sync::mpsc, + time::{Duration, Instant}, + }; + + let harness = Harness::new(); + prepare_package(&harness, "123456"); + harness.write("/work/input/app-cli", &static_elf(16 * 1024 * 1024)); + let parent = harness.path("/work/packaged"); + let temporary = parent.join(".artifact.tar.tmp"); + let final_path = parent.join("artifact.tar"); + let (cancel, cancelled) = mpsc::channel(); + let watcher = std::thread::spawn(move || { + let deadline = Instant::now() + Duration::from_secs(60); + loop { + if temporary.exists() { + let mut collision = File::options() + .write(true) + .create_new(true) + .open(final_path) + .map_err(|error| error.to_string())?; + std::io::Write::write_all(&mut collision, b"sentinel") + .map_err(|error| error.to_string())?; + return Ok::<(), String>(()); + } + match cancelled.try_recv() { + Ok(()) | Err(mpsc::TryRecvError::Disconnected) => { + return Err("package completed before temporary output appeared".into()); + } + Err(mpsc::TryRecvError::Empty) => {} + } + if Instant::now() >= deadline { + return Err("timed out waiting for temporary output".into()); + } + std::thread::yield_now(); + } + }); + + let package_result = harness.run(&package_args()); + let _ = cancel.send(()); + let watcher_result = watcher.join().expect("collision watcher panicked"); + + assert!(package_result.is_err()); + watcher_result.unwrap(); + assert_eq!( + fs::read(harness.path("/work/packaged/artifact.tar")).unwrap(), + b"sentinel" + ); + assert!(!harness.path("/work/packaged/.artifact.tar.tmp").exists()); +} + +#[test] +fn host_deletion_recovery_removes_the_whole_parent_before_retry() { + let harness = Harness::new(); + prepare_package(&harness, "123456"); + harness.write( + "/work/packaged/.artifact.tar.tmp", + b"simulated interruption", + ); + assert!(harness.run(&package_args()).is_err()); + + fs::remove_dir_all(harness.path("/work/packaged")).unwrap(); + harness.mkdir("/work/packaged"); + harness.run(&package_args()).unwrap(); + assert_eq!( + fs::read_dir(harness.path("/work/packaged")) + .unwrap() + .count(), + 1 + ); +} + +#[test] +fn self_test_accepts_only_the_complete_unchanged_compiled_fixture_manifest() { + let valid = Harness::new(); + valid.install_fixtures(); + valid.run(&self_test_args()).unwrap(); + + let missing = Harness::new(); + missing.install_fixtures(); + fs::remove_file( + missing.path("/usr/local/share/edgezero/provenance-fixtures/valid/expected.json"), + ) + .unwrap(); + assert!(missing.run(&self_test_args()).is_err()); + + let extra = Harness::new(); + extra.install_fixtures(); + extra.write( + "/usr/local/share/edgezero/provenance-fixtures/extra.json", + b"{}", + ); + assert!(extra.run(&self_test_args()).is_err()); + + let changed = Harness::new(); + changed.install_fixtures(); + changed.write( + "/usr/local/share/edgezero/provenance-fixtures/valid/expected.json", + b"{}", + ); + assert!(changed.run(&self_test_args()).is_err()); +} + +#[cfg(unix)] +#[test] +fn self_test_rejects_symlinked_fixture_entries_and_fixture_root() { + use std::os::unix::fs::symlink; + + let entry = Harness::new(); + entry.install_fixtures(); + let expected_path = + entry.path("/usr/local/share/edgezero/provenance-fixtures/valid/expected.json"); + fs::remove_file(&expected_path).unwrap(); + symlink( + entry.path("/usr/local/share/edgezero/provenance.schema.json"), + expected_path, + ) + .unwrap(); + assert!(entry.run(&self_test_args()).is_err()); + + let root = Harness::new(); + let alternate = root.path("/usr/local/share/edgezero/alternate-fixtures"); + copy_tree(Path::new(FIXTURES), &alternate); + symlink( + alternate, + root.path("/usr/local/share/edgezero/provenance-fixtures"), + ) + .unwrap(); + assert!(root.run(&self_test_args()).is_err()); +} + +#[test] +fn review_self_test_stops_at_file_count_and_aggregate_limits() { + let over_count = Harness::new(); + over_count.install_fixtures(); + over_count.write( + "/usr/local/share/edgezero/provenance-fixtures/valid/extra.json", + b"{}", + ); + let error = over_count.run(&self_test_args()).unwrap_err(); + assert!( + error.contains("file count limit"), + "unexpected error: {error}" + ); + + let aggregate = Harness::new(); + aggregate.install_fixtures(); + File::options() + .write(true) + .open( + aggregate + .path("/usr/local/share/edgezero/provenance-fixtures/valid/elf-static/app-cli"), + ) + .unwrap() + .set_len(1024 * 1024) + .unwrap(); + let error = aggregate.run(&self_test_args()).unwrap_err(); + assert!( + error.contains("aggregate byte limit"), + "unexpected error: {error}" + ); +} + +#[test] +fn review_self_test_rejects_unexpected_and_over_depth_directories_before_recursing() { + let unexpected = Harness::new(); + unexpected.install_fixtures(); + unexpected.mkdir("/usr/local/share/edgezero/provenance-fixtures/unexpected/do-not-scan"); + let error = unexpected.run(&self_test_args()).unwrap_err(); + assert!( + error.contains("unexpected fixture directory"), + "unexpected error: {error}" + ); + + let over_depth = Harness::new(); + over_depth.install_fixtures(); + over_depth + .mkdir("/usr/local/share/edgezero/provenance-fixtures/invalid/elf-malformed/too-deep"); + let error = over_depth.run(&self_test_args()).unwrap_err(); + assert!( + error.contains("fixture depth limit"), + "unexpected error: {error}" + ); +} + +fn assert_empty(harness: &Harness, container_path: &str) { + assert!( + fs::read_dir(harness.path(container_path)) + .unwrap() + .next() + .is_none() + ); +} + +fn static_elf(size: usize) -> Vec { + const ELF_HEADER_SIZE: usize = 64; + const PROGRAM_HEADER_SIZE: usize = 56; + let mut bytes = vec![0; size]; + bytes[..4].copy_from_slice(b"\x7fELF"); + bytes[4] = 2; + bytes[5] = 1; + bytes[6] = 1; + put_u16(&mut bytes, 16, 2); + put_u16(&mut bytes, 18, 62); + put_u32(&mut bytes, 20, 1); + put_u64(&mut bytes, 32, ELF_HEADER_SIZE as u64); + put_u16(&mut bytes, 52, ELF_HEADER_SIZE as u16); + put_u16(&mut bytes, 54, PROGRAM_HEADER_SIZE as u16); + put_u16(&mut bytes, 56, 1); + let ph = ELF_HEADER_SIZE; + put_u32(&mut bytes, ph, 1); + put_u32(&mut bytes, ph + 4, 4); + put_u64(&mut bytes, ph + 16, 0x400000); + put_u64(&mut bytes, ph + 32, size as u64); + put_u64(&mut bytes, ph + 40, size as u64); + put_u64(&mut bytes, ph + 48, 8); + bytes +} + +fn put_u16(bytes: &mut [u8], offset: usize, value: u16) { + bytes[offset..offset + 2].copy_from_slice(&value.to_le_bytes()); +} + +fn put_u32(bytes: &mut [u8], offset: usize, value: u32) { + bytes[offset..offset + 4].copy_from_slice(&value.to_le_bytes()); +} + +fn put_u64(bytes: &mut [u8], offset: usize, value: u64) { + bytes[offset..offset + 8].copy_from_slice(&value.to_le_bytes()); +} + +#[test] +fn input_reads_do_not_require_loading_the_binary_into_memory() { + let harness = Harness::new(); + prepare_package(&harness, "123456"); + let binary = File::options() + .write(true) + .open(harness.path("/work/input/app-cli")) + .unwrap(); + binary.set_len(32 * 1024 * 1024).unwrap(); + drop(binary); + + harness.run(&package_args()).unwrap(); + assert!(harness.path("/work/packaged/artifact.tar").is_file()); +} diff --git a/.github/workflows/build-container-ci.yml b/.github/workflows/build-container-ci.yml new file mode 100644 index 00000000..176c86b4 --- /dev/null +++ b/.github/workflows/build-container-ci.yml @@ -0,0 +1,486 @@ +name: Build container gate +run-name: ${{ github.event_name == 'workflow_dispatch' && format('build-container-release-preflight pr={0} repo={1} sha={2}', inputs.candidate-pr-number, inputs.candidate-head-repository, inputs.candidate-head-sha) || 'Build container gate' }} + +on: + pull_request: + merge_group: + types: [checks_requested] + push: + branches: [main] + workflow_dispatch: + inputs: + candidate-pr-number: + description: Candidate pull request number + required: true + type: number + candidate-head-repository: + description: Candidate head repository + required: true + type: string + candidate-head-sha: + description: Candidate full head SHA + required: true + type: string + +permissions: + contents: read + actions: read + pull-requests: read + +jobs: + build-container-local: + if: ${{ github.event_name != 'workflow_dispatch' }} + runs-on: ubuntu-24.04 + env: + BASH_ENV: "" + ENV: "" + steps: + - name: assert-hosted-runner-context + shell: bash + env: + BASH_ENV: "" + ENV: "" + EDGEZERO_RUNNER_ENVIRONMENT: ${{ runner.environment }} + EDGEZERO_RUNNER_OS: ${{ runner.os }} + EDGEZERO_RUNNER_ARCH: ${{ runner.arch }} + EDGEZERO_REPOSITORY: ${{ github.repository }} + EDGEZERO_WORKFLOW_REF: ${{ github.workflow_ref }} + EDGEZERO_WORKFLOW_SHA: ${{ github.workflow_sha }} + EDGEZERO_GITHUB_SHA: ${{ github.sha }} + EDGEZERO_GATE_SHA: ${{ vars.EDGEZERO_BUILD_CONTAINER_GATE_SHA }} + run: | + set -euo pipefail + [[ "$EDGEZERO_RUNNER_ENVIRONMENT" == github-hosted ]] + [[ "$EDGEZERO_RUNNER_OS" == Linux ]] + [[ "$EDGEZERO_RUNNER_ARCH" == X64 ]] + [[ "$EDGEZERO_REPOSITORY" == stackpop/edgezero ]] + [[ "$EDGEZERO_WORKFLOW_REF" == stackpop/edgezero/.github/workflows/build-container-ci.yml@* ]] + [[ "$EDGEZERO_GATE_SHA" =~ ^[0-9a-f]{40}$ ]] + [[ "$EDGEZERO_GATE_SHA" != 0000000000000000000000000000000000000000 ]] + [[ "$EDGEZERO_WORKFLOW_SHA" =~ ^[0-9a-f]{40}$ ]] + [[ "$EDGEZERO_GITHUB_SHA" =~ ^[0-9a-f]{40}$ ]] + + - name: checkout-active-gate + if: ${{ success() }} + uses: actions/checkout@v7.0.1 + with: + repository: stackpop/edgezero + ref: ${{ vars.EDGEZERO_BUILD_CONTAINER_GATE_SHA }} + path: .edgezero-gate + persist-credentials: false + fetch-depth: 0 + + - name: checkout-subject + if: ${{ success() }} + uses: actions/checkout@v7.0.1 + with: + repository: stackpop/edgezero + ref: ${{ github.sha }} + path: .edgezero-subject + persist-credentials: false + fetch-depth: 0 + + - name: setup-trusted-node + if: ${{ success() }} + uses: actions/setup-node@v6.5.0 + with: + node-version-file: .edgezero-gate/.tool-versions + cache: npm + cache-dependency-path: .edgezero-gate/docs/package-lock.json + + - name: prepare-trusted-documentation-tools + if: ${{ success() }} + shell: bash + run: | + set -euo pipefail + bin="$RUNNER_TEMP/edgezero-build-container-tools/bin" + mkdir -p "$bin" + INSTALL_DIR="$bin" bash "$GITHUB_WORKSPACE/.edgezero-gate/scripts/install-yq.sh" 4.53.3 + [[ "$("$bin/yq" --version)" == 'yq (https://github.com/mikefarah/yq/) version v4.53.3' ]] + INSTALL_DIR="$bin" bash "$GITHUB_WORKSPACE/.edgezero-gate/scripts/install-actionlint.sh" 1.7.12 + [[ "$("$bin/actionlint" -version | sed -n '1p')" == 1.7.12 ]] + printf '%s\n' "$bin" >>"$GITHUB_PATH" + npm --prefix "$GITHUB_WORKSPACE/.edgezero-gate/docs" ci --ignore-scripts + + - name: run-protected-gate-tests + if: ${{ success() }} + shell: bash + env: + CI: "true" + run: | + set -euo pipefail + cd "$GITHUB_WORKSPACE/.edgezero-gate" + bash "$GITHUB_WORKSPACE/.edgezero-gate/.github/actions/deploy-core/tests/run.sh" + + - name: select-candidate-range + id: candidate_range + if: ${{ success() && github.event_name != 'push' }} + shell: bash + env: + EDGEZERO_EVENT_NAME: ${{ github.event_name }} + EDGEZERO_REPOSITORY: ${{ github.repository }} + EDGEZERO_SHA: ${{ github.sha }} + EDGEZERO_WORKFLOW_SHA: ${{ github.workflow_sha }} + EDGEZERO_REF: ${{ github.ref }} + EDGEZERO_REF_PROTECTED: ${{ github.ref_protected }} + EDGEZERO_GATE_SHA: ${{ vars.EDGEZERO_BUILD_CONTAINER_GATE_SHA }} + run: | + set -euo pipefail + bash "$GITHUB_WORKSPACE/.edgezero-gate/.github/docker/build-app-cli/select-build-container-range.sh" \ + --event-name "$EDGEZERO_EVENT_NAME" \ + --github-repository "$EDGEZERO_REPOSITORY" \ + --github-sha "$EDGEZERO_SHA" \ + --github-workflow-sha "$EDGEZERO_WORKFLOW_SHA" \ + --github-ref "$EDGEZERO_REF" \ + --github-ref-protected "$EDGEZERO_REF_PROTECTED" \ + --gate-sha "$EDGEZERO_GATE_SHA" \ + --gate-root "$GITHUB_WORKSPACE/.edgezero-gate" \ + --event-json "$GITHUB_EVENT_PATH" \ + --subject-root "$GITHUB_WORKSPACE/.edgezero-subject" \ + >>"$GITHUB_OUTPUT" + + - name: assert-exact-main-push-context + id: push_range + if: ${{ success() && github.event_name == 'push' }} + shell: bash + env: + EDGEZERO_EVENT_NAME: ${{ github.event_name }} + EDGEZERO_REPOSITORY: ${{ github.repository }} + EDGEZERO_SHA: ${{ github.sha }} + EDGEZERO_WORKFLOW_SHA: ${{ github.workflow_sha }} + EDGEZERO_REF: ${{ github.ref }} + EDGEZERO_REF_PROTECTED: ${{ github.ref_protected }} + EDGEZERO_GATE_SHA: ${{ vars.EDGEZERO_BUILD_CONTAINER_GATE_SHA }} + run: | + set -euo pipefail + bash "$GITHUB_WORKSPACE/.edgezero-gate/.github/docker/build-app-cli/select-build-container-range.sh" \ + --event-name "$EDGEZERO_EVENT_NAME" \ + --github-repository "$EDGEZERO_REPOSITORY" \ + --github-sha "$EDGEZERO_SHA" \ + --github-workflow-sha "$EDGEZERO_WORKFLOW_SHA" \ + --github-ref "$EDGEZERO_REF" \ + --github-ref-protected "$EDGEZERO_REF_PROTECTED" \ + --gate-sha "$EDGEZERO_GATE_SHA" \ + --gate-root "$GITHUB_WORKSPACE/.edgezero-gate" \ + --event-json "$GITHUB_EVENT_PATH" \ + --subject-root "$GITHUB_WORKSPACE/.edgezero-subject" \ + >>"$GITHUB_OUTPUT" + + - name: check-documentation-references + if: ${{ success() }} + shell: bash + env: + GITHUB_TOKEN: ${{ github.token }} + EDGEZERO_BASE: ${{ steps.candidate_range.outputs.base || steps.push_range.outputs.base }} + EDGEZERO_HEAD: ${{ steps.candidate_range.outputs.head || steps.push_range.outputs.head }} + run: | + set -euo pipefail + bash "$GITHUB_WORKSPACE/.edgezero-gate/.github/actions/deploy-core/tests/check-doc-action-pins.sh" \ + --subject-root "$GITHUB_WORKSPACE/.edgezero-subject" \ + --base "$EDGEZERO_BASE" \ + --candidate "$EDGEZERO_HEAD" + + - name: run-build-container-gate + if: ${{ success() }} + shell: bash + env: + GITHUB_TOKEN: ${{ github.token }} + EDGEZERO_KIND: local + EDGEZERO_BASE: ${{ steps.candidate_range.outputs.base || steps.push_range.outputs.base }} + EDGEZERO_HEAD: ${{ steps.candidate_range.outputs.head || steps.push_range.outputs.head }} + EDGEZERO_GATE_SHA: ${{ vars.EDGEZERO_BUILD_CONTAINER_GATE_SHA }} + EDGEZERO_RELEASE_STATE: ${{ vars.EDGEZERO_BUILD_CONTAINER_RELEASE_STATE }} + run: | + set -euo pipefail + bash "$GITHUB_WORKSPACE/.edgezero-gate/.github/docker/build-app-cli/run-build-container-gate.sh" \ + --subject-root "$GITHUB_WORKSPACE/.edgezero-subject" \ + --gate-root "$GITHUB_WORKSPACE/.edgezero-gate" \ + --base "$EDGEZERO_BASE" \ + --head "$EDGEZERO_HEAD" \ + --kind "$EDGEZERO_KIND" \ + --gate-sha "$EDGEZERO_GATE_SHA" \ + --release-state "$EDGEZERO_RELEASE_STATE" \ + --work-root "$RUNNER_TEMP" \ + --completion-file "$RUNNER_TEMP/edgezero-build-container-local-completion" + + - name: assert-terminal-completion + if: ${{ always() }} + shell: bash + run: | + set -euo pipefail + bash "$GITHUB_WORKSPACE/.edgezero-gate/.github/docker/build-app-cli/assert-build-container-completion.sh" \ + --file "$RUNNER_TEMP/edgezero-build-container-local-completion" \ + --kind local + + build-container-pin: + if: ${{ github.event_name != 'workflow_dispatch' }} + runs-on: ubuntu-24.04 + env: + BASH_ENV: "" + ENV: "" + steps: + - name: assert-hosted-runner-context + shell: bash + env: + BASH_ENV: "" + ENV: "" + EDGEZERO_RUNNER_ENVIRONMENT: ${{ runner.environment }} + EDGEZERO_RUNNER_OS: ${{ runner.os }} + EDGEZERO_RUNNER_ARCH: ${{ runner.arch }} + EDGEZERO_REPOSITORY: ${{ github.repository }} + EDGEZERO_WORKFLOW_REF: ${{ github.workflow_ref }} + EDGEZERO_WORKFLOW_SHA: ${{ github.workflow_sha }} + EDGEZERO_GITHUB_SHA: ${{ github.sha }} + EDGEZERO_GATE_SHA: ${{ vars.EDGEZERO_BUILD_CONTAINER_GATE_SHA }} + run: | + set -euo pipefail + [[ "$EDGEZERO_RUNNER_ENVIRONMENT" == github-hosted ]] + [[ "$EDGEZERO_RUNNER_OS" == Linux ]] + [[ "$EDGEZERO_RUNNER_ARCH" == X64 ]] + [[ "$EDGEZERO_REPOSITORY" == stackpop/edgezero ]] + [[ "$EDGEZERO_WORKFLOW_REF" == stackpop/edgezero/.github/workflows/build-container-ci.yml@* ]] + [[ "$EDGEZERO_GATE_SHA" =~ ^[0-9a-f]{40}$ ]] + [[ "$EDGEZERO_GATE_SHA" != 0000000000000000000000000000000000000000 ]] + [[ "$EDGEZERO_WORKFLOW_SHA" =~ ^[0-9a-f]{40}$ ]] + [[ "$EDGEZERO_GITHUB_SHA" =~ ^[0-9a-f]{40}$ ]] + + - name: checkout-active-gate + if: ${{ success() }} + uses: actions/checkout@v7.0.1 + with: + repository: stackpop/edgezero + ref: ${{ vars.EDGEZERO_BUILD_CONTAINER_GATE_SHA }} + path: .edgezero-gate + persist-credentials: false + fetch-depth: 0 + + - name: checkout-subject + if: ${{ success() }} + uses: actions/checkout@v7.0.1 + with: + repository: stackpop/edgezero + ref: ${{ github.sha }} + path: .edgezero-subject + persist-credentials: false + fetch-depth: 0 + + - name: setup-trusted-node + if: ${{ success() }} + uses: actions/setup-node@v6.5.0 + with: + node-version-file: .edgezero-gate/.tool-versions + cache: npm + cache-dependency-path: .edgezero-gate/docs/package-lock.json + + - name: prepare-trusted-documentation-tools + if: ${{ success() }} + shell: bash + run: | + set -euo pipefail + bin="$RUNNER_TEMP/edgezero-build-container-tools/bin" + mkdir -p "$bin" + INSTALL_DIR="$bin" bash "$GITHUB_WORKSPACE/.edgezero-gate/scripts/install-yq.sh" 4.53.3 + [[ "$("$bin/yq" --version)" == 'yq (https://github.com/mikefarah/yq/) version v4.53.3' ]] + INSTALL_DIR="$bin" bash "$GITHUB_WORKSPACE/.edgezero-gate/scripts/install-actionlint.sh" 1.7.12 + [[ "$("$bin/actionlint" -version | sed -n '1p')" == 1.7.12 ]] + printf '%s\n' "$bin" >>"$GITHUB_PATH" + npm --prefix "$GITHUB_WORKSPACE/.edgezero-gate/docs" ci --ignore-scripts + + - name: run-protected-gate-tests + if: ${{ success() }} + shell: bash + env: + CI: "true" + run: | + set -euo pipefail + cd "$GITHUB_WORKSPACE/.edgezero-gate" + bash "$GITHUB_WORKSPACE/.edgezero-gate/.github/actions/deploy-core/tests/run.sh" + + - name: select-candidate-range + id: candidate_range + if: ${{ success() && github.event_name != 'push' }} + shell: bash + env: + EDGEZERO_EVENT_NAME: ${{ github.event_name }} + EDGEZERO_REPOSITORY: ${{ github.repository }} + EDGEZERO_SHA: ${{ github.sha }} + EDGEZERO_WORKFLOW_SHA: ${{ github.workflow_sha }} + EDGEZERO_REF: ${{ github.ref }} + EDGEZERO_REF_PROTECTED: ${{ github.ref_protected }} + EDGEZERO_GATE_SHA: ${{ vars.EDGEZERO_BUILD_CONTAINER_GATE_SHA }} + run: | + set -euo pipefail + bash "$GITHUB_WORKSPACE/.edgezero-gate/.github/docker/build-app-cli/select-build-container-range.sh" \ + --event-name "$EDGEZERO_EVENT_NAME" \ + --github-repository "$EDGEZERO_REPOSITORY" \ + --github-sha "$EDGEZERO_SHA" \ + --github-workflow-sha "$EDGEZERO_WORKFLOW_SHA" \ + --github-ref "$EDGEZERO_REF" \ + --github-ref-protected "$EDGEZERO_REF_PROTECTED" \ + --gate-sha "$EDGEZERO_GATE_SHA" \ + --gate-root "$GITHUB_WORKSPACE/.edgezero-gate" \ + --event-json "$GITHUB_EVENT_PATH" \ + --subject-root "$GITHUB_WORKSPACE/.edgezero-subject" \ + >>"$GITHUB_OUTPUT" + + - name: assert-exact-main-push-context + id: push_range + if: ${{ success() && github.event_name == 'push' }} + shell: bash + env: + EDGEZERO_EVENT_NAME: ${{ github.event_name }} + EDGEZERO_REPOSITORY: ${{ github.repository }} + EDGEZERO_SHA: ${{ github.sha }} + EDGEZERO_WORKFLOW_SHA: ${{ github.workflow_sha }} + EDGEZERO_REF: ${{ github.ref }} + EDGEZERO_REF_PROTECTED: ${{ github.ref_protected }} + EDGEZERO_GATE_SHA: ${{ vars.EDGEZERO_BUILD_CONTAINER_GATE_SHA }} + run: | + set -euo pipefail + bash "$GITHUB_WORKSPACE/.edgezero-gate/.github/docker/build-app-cli/select-build-container-range.sh" \ + --event-name "$EDGEZERO_EVENT_NAME" \ + --github-repository "$EDGEZERO_REPOSITORY" \ + --github-sha "$EDGEZERO_SHA" \ + --github-workflow-sha "$EDGEZERO_WORKFLOW_SHA" \ + --github-ref "$EDGEZERO_REF" \ + --github-ref-protected "$EDGEZERO_REF_PROTECTED" \ + --gate-sha "$EDGEZERO_GATE_SHA" \ + --gate-root "$GITHUB_WORKSPACE/.edgezero-gate" \ + --event-json "$GITHUB_EVENT_PATH" \ + --subject-root "$GITHUB_WORKSPACE/.edgezero-subject" \ + >>"$GITHUB_OUTPUT" + + - name: check-documentation-references + if: ${{ success() }} + shell: bash + env: + GITHUB_TOKEN: ${{ github.token }} + EDGEZERO_BASE: ${{ steps.candidate_range.outputs.base || steps.push_range.outputs.base }} + EDGEZERO_HEAD: ${{ steps.candidate_range.outputs.head || steps.push_range.outputs.head }} + run: | + set -euo pipefail + bash "$GITHUB_WORKSPACE/.edgezero-gate/.github/actions/deploy-core/tests/check-doc-action-pins.sh" \ + --subject-root "$GITHUB_WORKSPACE/.edgezero-subject" \ + --base "$EDGEZERO_BASE" \ + --candidate "$EDGEZERO_HEAD" + + - name: run-build-container-gate + if: ${{ success() }} + shell: bash + env: + GITHUB_TOKEN: ${{ github.token }} + EDGEZERO_KIND: pin + EDGEZERO_BASE: ${{ steps.candidate_range.outputs.base || steps.push_range.outputs.base }} + EDGEZERO_HEAD: ${{ steps.candidate_range.outputs.head || steps.push_range.outputs.head }} + EDGEZERO_GATE_SHA: ${{ vars.EDGEZERO_BUILD_CONTAINER_GATE_SHA }} + EDGEZERO_RELEASE_STATE: ${{ vars.EDGEZERO_BUILD_CONTAINER_RELEASE_STATE }} + run: | + set -euo pipefail + bash "$GITHUB_WORKSPACE/.edgezero-gate/.github/docker/build-app-cli/run-build-container-gate.sh" \ + --subject-root "$GITHUB_WORKSPACE/.edgezero-subject" \ + --gate-root "$GITHUB_WORKSPACE/.edgezero-gate" \ + --base "$EDGEZERO_BASE" \ + --head "$EDGEZERO_HEAD" \ + --kind "$EDGEZERO_KIND" \ + --gate-sha "$EDGEZERO_GATE_SHA" \ + --release-state "$EDGEZERO_RELEASE_STATE" \ + --work-root "$RUNNER_TEMP" \ + --completion-file "$RUNNER_TEMP/edgezero-build-container-pin-completion" + + - name: assert-terminal-completion + if: ${{ always() }} + shell: bash + run: | + set -euo pipefail + bash "$GITHUB_WORKSPACE/.edgezero-gate/.github/docker/build-app-cli/assert-build-container-completion.sh" \ + --file "$RUNNER_TEMP/edgezero-build-container-pin-completion" \ + --kind pin + + build-container-release-preflight: + if: ${{ github.event_name == 'workflow_dispatch' }} + runs-on: ubuntu-24.04 + environment: + name: build-container-release + deployment: false + env: + BASH_ENV: "" + ENV: "" + steps: + - name: assert-hosted-runner-context + shell: bash + env: + BASH_ENV: "" + ENV: "" + EDGEZERO_RUNNER_ENVIRONMENT: ${{ runner.environment }} + EDGEZERO_RUNNER_OS: ${{ runner.os }} + EDGEZERO_RUNNER_ARCH: ${{ runner.arch }} + EDGEZERO_REPOSITORY: ${{ github.repository }} + EDGEZERO_WORKFLOW_REF: ${{ github.workflow_ref }} + EDGEZERO_WORKFLOW_SHA: ${{ github.workflow_sha }} + EDGEZERO_GITHUB_SHA: ${{ github.sha }} + EDGEZERO_GATE_SHA: ${{ vars.EDGEZERO_BUILD_CONTAINER_GATE_SHA }} + run: | + set -euo pipefail + [[ "$EDGEZERO_RUNNER_ENVIRONMENT" == github-hosted ]] + [[ "$EDGEZERO_RUNNER_OS" == Linux ]] + [[ "$EDGEZERO_RUNNER_ARCH" == X64 ]] + [[ "$EDGEZERO_REPOSITORY" == stackpop/edgezero ]] + [[ "$EDGEZERO_WORKFLOW_REF" == stackpop/edgezero/.github/workflows/build-container-ci.yml@refs/heads/main ]] + [[ "$EDGEZERO_GATE_SHA" =~ ^[0-9a-f]{40}$ ]] + [[ "$EDGEZERO_GATE_SHA" != 0000000000000000000000000000000000000000 ]] + [[ "$EDGEZERO_WORKFLOW_SHA" == "$EDGEZERO_GITHUB_SHA" ]] + + - name: checkout-active-gate + if: ${{ success() }} + uses: actions/checkout@v7.0.1 + with: + repository: stackpop/edgezero + ref: ${{ vars.EDGEZERO_BUILD_CONTAINER_GATE_SHA }} + path: .edgezero-gate + persist-credentials: false + fetch-depth: 0 + + - name: assert-exact-g-dispatch-context + if: ${{ success() }} + shell: bash + env: + GITHUB_TOKEN: ${{ github.token }} + EDGEZERO_EVENT_NAME: ${{ github.event_name }} + EDGEZERO_REPOSITORY: ${{ github.repository }} + EDGEZERO_REF: ${{ github.ref }} + EDGEZERO_REF_PROTECTED: ${{ github.ref_protected }} + EDGEZERO_SHA: ${{ github.sha }} + EDGEZERO_WORKFLOW_SHA: ${{ github.workflow_sha }} + EDGEZERO_WORKFLOW_REF: ${{ github.workflow_ref }} + EDGEZERO_GATE_SHA: ${{ vars.EDGEZERO_BUILD_CONTAINER_GATE_SHA }} + EDGEZERO_RELEASE_STATE: ${{ vars.EDGEZERO_BUILD_CONTAINER_RELEASE_STATE }} + EDGEZERO_CANDIDATE_PR_NUMBER: ${{ inputs.candidate-pr-number }} + EDGEZERO_CANDIDATE_HEAD_REPOSITORY: ${{ inputs.candidate-head-repository }} + EDGEZERO_CANDIDATE_HEAD_SHA: ${{ inputs.candidate-head-sha }} + run: | + set -euo pipefail + bash "$GITHUB_WORKSPACE/.edgezero-gate/.github/docker/build-app-cli/assert-build-container-dispatch-context.sh" \ + --gate-root "$GITHUB_WORKSPACE/.edgezero-gate" + + - name: mint-publisher-probe-token + id: publisher_probe + if: ${{ success() }} + uses: actions/create-github-app-token@v3.2.0 + with: + app-id: ${{ vars.EDGEZERO_BUILD_CONTAINER_APP_ID }} + private-key: ${{ secrets.EDGEZERO_BUILD_CONTAINER_APP_PRIVATE_KEY }} + owner: stackpop + repositories: edgezero + permission-contents: write + permission-pull-requests: write + + - name: verify-publisher-probe-token + if: ${{ success() }} + shell: bash + env: + EDGEZERO_APP_TOKEN: ${{ steps.publisher_probe.outputs.token }} + EDGEZERO_INSTALLATION_ID: ${{ steps.publisher_probe.outputs.installation-id }} + EDGEZERO_EXPECTED_INSTALLATION_ID: ${{ vars.EDGEZERO_BUILD_CONTAINER_APP_INSTALLATION_ID }} + run: | + set -euo pipefail + bash "$GITHUB_WORKSPACE/.edgezero-gate/.github/docker/build-app-cli/assert-build-container-app-token.sh" diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 30a452a6..e82450d4 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -59,7 +59,9 @@ jobs: # your codebase is analyzed, see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/codeql-code-scanning-for-compiled-languages steps: - name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@v6.1.0 + with: + persist-credentials: false # Add any setup steps before running the `github/codeql-action/init` action. # This includes steps like installing compilers or runtimes (`actions/setup-node` @@ -69,7 +71,7 @@ jobs: # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@v4 + uses: github/codeql-action/init@v4.37.9 with: languages: ${{ matrix.language }} build-mode: ${{ matrix.build-mode }} @@ -98,6 +100,6 @@ jobs: exit 1 - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v4 + uses: github/codeql-action/analyze@v4.37.9 with: category: "/language:${{matrix.language}}" diff --git a/.github/workflows/deploy-action.yml b/.github/workflows/deploy-action.yml index eff8262d..d438a9e8 100644 --- a/.github/workflows/deploy-action.yml +++ b/.github/workflows/deploy-action.yml @@ -7,10 +7,20 @@ on: # the smokes) — including a brand-new action directory or an unrelated # workflow like test.yml that could introduce a floating ref. - .github/actions/** + - .github/tools/edgezero-provenance-validator/** + - .github/docker/build-app-cli/** + - '**/action.yml' + - '**/action.yaml' - .github/workflows/** - .github/zizmor.yml - scripts/install-actionlint.sh + - scripts/run-actionlint.sh - scripts/install-yq.sh + - docs/package.json + - docs/package-lock.json + - docs/.edgezero-action-release.json + - '**/*.[mM][dD]' + - '**/*.[mM][aA][rR][kK][dD][oO][wW][nN]' # The smoke fixture is a real Cargo app built against this workspace's crate # graph, so a change to the CLI/core/macro crates, the workspace manifest and # lockfile, or the pinned toolchain can change what the smoke compiles. @@ -23,7 +33,7 @@ on: - crates/edgezero-core/** - crates/edgezero-macros/** - docs/guide/deploy-github-actions.md - - docs/specs/** + - docs/superpowers/** push: branches: [main] paths: @@ -31,10 +41,20 @@ on: # the smokes) — including a brand-new action directory or an unrelated # workflow like test.yml that could introduce a floating ref. - .github/actions/** + - .github/tools/edgezero-provenance-validator/** + - .github/docker/build-app-cli/** + - '**/action.yml' + - '**/action.yaml' - .github/workflows/** - .github/zizmor.yml - scripts/install-actionlint.sh + - scripts/run-actionlint.sh - scripts/install-yq.sh + - docs/package.json + - docs/package-lock.json + - docs/.edgezero-action-release.json + - '**/*.[mM][dD]' + - '**/*.[mM][aA][rR][kK][dD][oO][wW][nN]' # The smoke fixture is a real Cargo app built against this workspace's crate # graph, so a change to the CLI/core/macro crates, the workspace manifest and # lockfile, or the pinned toolchain can change what the smoke compiles. @@ -47,13 +67,13 @@ on: - crates/edgezero-core/** - crates/edgezero-macros/** - docs/guide/deploy-github-actions.md - - docs/specs/** + - docs/superpowers/** permissions: contents: read env: - ACTIONLINT_VERSION: 1.7.7 + ACTIONLINT_VERSION: 1.7.12 ZIZMOR_VERSION: 1.16.3 YQ_VERSION: 4.53.3 @@ -61,9 +81,10 @@ jobs: static-checks: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@v7.0.1 with: persist-credentials: false + fetch-depth: 0 - name: Install pinned validation binaries under RUNNER_TEMP (checksum-verified) # Per the action's binary-isolation rule (§5.4/security principle 5), @@ -96,21 +117,14 @@ jobs: # The `-shellcheck` integration runs shellcheck on each `run:` block at a # warning floor (info-level notes in unrelated workflows are not failures). run: | - "$RUNNER_TEMP/tools/bin/actionlint" -shellcheck='shellcheck -S warning' + bash scripts/run-actionlint.sh - name: Third-party actions pinned to a ref run: .github/actions/deploy-core/tests/check-action-pins.sh - name: Zizmor security scan run: | - "$RUNNER_TEMP/tools/bin/zizmor" --offline \ - .github/workflows/deploy-action.yml \ - .github/workflows/fastly-installer-check.yml \ - .github/actions/build-app-cli/action.yml \ - .github/actions/deploy-fastly/action.yml \ - .github/actions/healthcheck-fastly/action.yml \ - .github/actions/rollback-fastly/action.yml \ - .github/actions/config-push-fastly/action.yml + "$RUNNER_TEMP/tools/bin/zizmor" --offline .github/workflows .github/actions - name: ShellCheck action scripts # -e SC1091: the `source "$SCRIPT_DIR/common.sh"` path is dynamic, so @@ -121,15 +135,34 @@ jobs: .github/actions/*/scripts/*.sh \ .github/actions/deploy-core/tests/*.sh \ scripts/install-actionlint.sh \ + scripts/run-actionlint.sh \ scripts/install-yq.sh - - name: Bash contract tests + - name: Install documentation validator dependencies + # The documentation scanner loads its Markdown parser from this checked + # and locked tool tree, never from a separate candidate subject tree. + run: npm --prefix docs ci --ignore-scripts + + - name: Action contract tests run: .github/actions/deploy-core/tests/run.sh + - name: Protocol validator tests and lints + run: | + rust=$(awk '$1 == "rust" { print $2 }' .tool-versions) + rustup toolchain install "$rust" --profile minimal --component rustfmt --component clippy + manifest=.github/tools/edgezero-provenance-validator/Cargo.toml + cargo "+$rust" fmt --manifest-path "$manifest" --all -- --check + cargo "+$rust" clippy --locked --manifest-path "$manifest" --workspace --all-targets --all-features -- -D warnings + cargo "+$rust" test --locked --manifest-path "$manifest" --workspace --all-targets + + - name: Documentation references and release state + env: + GITHUB_TOKEN: ${{ github.token }} + run: bash .github/actions/deploy-core/tests/check-doc-action-pins.sh + - name: Validate docs run: | cd docs - npm ci npm run format npm run lint npm run build @@ -145,7 +178,7 @@ jobs: FASTLY_ENDPOINT: https://inherited.invalid FASTLY_HOME: /nonexistent/inherited steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@v7.0.1 with: persist-credentials: false @@ -216,7 +249,7 @@ jobs: handoff-build: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@v7.0.1 with: persist-credentials: false - name: Create fixture app (app-owned CLI) @@ -237,7 +270,7 @@ jobs: FASTLY_ENDPOINT: https://inherited.invalid FASTLY_HOME: /nonexistent/inherited steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@v7.0.1 with: persist-credentials: false # The deploy job needs the app SOURCE (working-directory); the CLI binary comes @@ -270,7 +303,7 @@ jobs: cache-smoke: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@v7.0.1 with: persist-credentials: false - name: Create fixture app (app-owned CLI) @@ -337,7 +370,7 @@ jobs: env: FAKE_LOSE_VERSION: "1" steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@v7.0.1 with: persist-credentials: false - name: Create fixture app (app-owned CLI) @@ -369,7 +402,7 @@ jobs: EDGEZERO__TEST__PREVIOUS_VERSION: ${{ steps.deploy.outputs['previous-version'] }} run: .github/actions/deploy-core/tests/assert-lost-version.sh - name: Download the CLI artifact for recovery - uses: actions/download-artifact@v8 + uses: actions/download-artifact@v8.0.1 with: name: edgezero-cli-recovery path: recover-cli @@ -408,7 +441,7 @@ jobs: FASTLY_ENDPOINT: https://inherited.invalid FASTLY_HOME: /nonexistent/inherited steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@v7.0.1 with: persist-credentials: false @@ -489,7 +522,7 @@ jobs: FASTLY_HOME: /nonexistent/inherited FASTLY_API_TOKEN: inherited-must-not-reach-production-probes steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@v7.0.1 with: persist-credentials: false diff --git a/.github/workflows/deploy-docs.yml b/.github/workflows/deploy-docs.yml index c34d0ccb..094820bb 100644 --- a/.github/workflows/deploy-docs.yml +++ b/.github/workflows/deploy-docs.yml @@ -8,11 +8,8 @@ on: - ".github/workflows/deploy-docs.yml" workflow_dispatch: # Allow manual triggers -# Sets permissions for GitHub Pages deployment permissions: contents: read - pages: write - id-token: write # Prevent concurrent deployments concurrency: @@ -24,9 +21,10 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@v6.1.0 with: fetch-depth: 0 # For lastUpdated feature + persist-credentials: false - name: Retrieve Node.js version id: node-version @@ -38,14 +36,14 @@ jobs: fi - name: Setup Node.js - uses: actions/setup-node@v6 + uses: actions/setup-node@v6.5.0 with: node-version: ${{ steps.node-version.outputs.node-version }} cache: "npm" cache-dependency-path: docs/package-lock.json - name: Setup Pages - uses: actions/configure-pages@v6 + uses: actions/configure-pages@v6.0.0 - name: Install dependencies working-directory: docs @@ -56,11 +54,14 @@ jobs: run: npm run build - name: Upload artifact - uses: actions/upload-pages-artifact@v5 + uses: actions/upload-pages-artifact@v5.0.0 with: path: docs/.vitepress/dist deploy: + permissions: + pages: write + id-token: write environment: name: github-pages url: ${{ steps.deployment.outputs.page_url }} @@ -69,4 +70,4 @@ jobs: steps: - name: Deploy to GitHub Pages id: deployment - uses: actions/deploy-pages@v5 + uses: actions/deploy-pages@v5.0.1 diff --git a/.github/workflows/fastly-installer-check.yml b/.github/workflows/fastly-installer-check.yml index 06c3468b..d9f362b9 100644 --- a/.github/workflows/fastly-installer-check.yml +++ b/.github/workflows/fastly-installer-check.yml @@ -34,7 +34,7 @@ jobs: real-install: runs-on: ubuntu-24.04 steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@v7.0.1 with: persist-credentials: false diff --git a/.github/workflows/format.yml b/.github/workflows/format.yml index ee9d851f..e1e2e73e 100644 --- a/.github/workflows/format.yml +++ b/.github/workflows/format.yml @@ -18,10 +18,12 @@ jobs: name: cargo fmt runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v6.1.0 + with: + persist-credentials: false - name: Cache cargo dependencies - uses: actions/cache@v5 + uses: actions/cache@v5.1.0 with: path: | ~/.cargo/registry/index/ @@ -38,7 +40,7 @@ jobs: shell: bash - name: Set up rust toolchain - uses: actions-rust-lang/setup-rust-toolchain@v1 + uses: actions-rust-lang/setup-rust-toolchain@v1.17.0 with: components: "clippy, rustfmt" toolchain: ${{ steps.rust-version.outputs.rust-version }} @@ -98,10 +100,12 @@ jobs: target: wasm32-wasip2 features: spin steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v6.1.0 + with: + persist-credentials: false - name: Cache Cargo dependencies - uses: actions/cache@v5 + uses: actions/cache@v5.1.0 with: path: | ~/.cargo/registry/index/ @@ -118,7 +122,7 @@ jobs: shell: bash - name: Set up Rust tool chain - uses: actions-rust-lang/setup-rust-toolchain@v1 + uses: actions-rust-lang/setup-rust-toolchain@v1.17.0 with: toolchain: ${{ steps.rust-version.outputs.rust-version }} components: clippy @@ -139,7 +143,9 @@ jobs: working-directory: docs steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v6.1.0 + with: + persist-credentials: false - name: Retrieve Node.js version id: node-version @@ -148,7 +154,7 @@ jobs: shell: bash - name: Use Node.js - uses: actions/setup-node@v6 + uses: actions/setup-node@v6.5.0 with: node-version: ${{ steps.node-version.outputs.node-version }} cache: "npm" diff --git a/.github/workflows/publish-build-container.yml b/.github/workflows/publish-build-container.yml new file mode 100644 index 00000000..c15f4f88 --- /dev/null +++ b/.github/workflows/publish-build-container.yml @@ -0,0 +1,418 @@ +name: Publish build container + +on: + push: + tags: + - build-container-v* + +permissions: {} + +concurrency: + group: edgezero-build-container-publication + cancel-in-progress: false + queue: max + +jobs: + build-and-verify: + runs-on: ubuntu-24.04 + permissions: + actions: read + contents: read + packages: write + outputs: + source-revision: ${{ steps.publish.outputs.source-revision }} + image-digest: ${{ steps.publish.outputs.image-digest }} + provenance-protocol: ${{ steps.publish.outputs.provenance-protocol }} + release-tag: ${{ steps.publish.outputs.release-tag }} + approval-challenge: ${{ steps.challenge.outputs.approval-challenge }} + build-attempt: ${{ steps.publish.outputs.build-attempt }} + steps: + - name: assert-hosted-runner-context + shell: bash + env: + BASH_ENV: "" + ENV: "" + EDGEZERO_RUNNER_ENVIRONMENT: ${{ runner.environment }} + EDGEZERO_RUNNER_OS: ${{ runner.os }} + EDGEZERO_RUNNER_ARCH: ${{ runner.arch }} + EDGEZERO_REPOSITORY: ${{ github.repository }} + run: | + set -euo pipefail + [[ "$EDGEZERO_RUNNER_ENVIRONMENT" == github-hosted ]] + [[ "$EDGEZERO_RUNNER_OS" == Linux ]] + [[ "$EDGEZERO_RUNNER_ARCH" == X64 ]] + [[ "$EDGEZERO_REPOSITORY" == stackpop/edgezero ]] + + - name: checkout-active-gate + if: ${{ success() }} + uses: actions/checkout@v7.0.1 + with: + repository: stackpop/edgezero + ref: ${{ vars.EDGEZERO_BUILD_CONTAINER_GATE_SHA }} + path: .edgezero-gate + persist-credentials: false + fetch-depth: 0 + + - name: checkout-release-source + if: ${{ success() }} + uses: actions/checkout@v7.0.1 + with: + repository: stackpop/edgezero + ref: ${{ github.sha }} + path: .edgezero-source + persist-credentials: false + fetch-depth: 0 + + - name: assert-exact-publisher-context + if: ${{ success() }} + shell: bash + env: + EDGEZERO_EVENT_NAME: ${{ github.event_name }} + EDGEZERO_REF: ${{ github.ref }} + EDGEZERO_REF_NAME: ${{ github.ref_name }} + EDGEZERO_REF_PROTECTED: ${{ github.ref_protected }} + EDGEZERO_SHA: ${{ github.sha }} + EDGEZERO_WORKFLOW_SHA: ${{ github.workflow_sha }} + EDGEZERO_GATE_SHA: ${{ vars.EDGEZERO_BUILD_CONTAINER_GATE_SHA }} + EDGEZERO_RELEASE_STATE: ${{ vars.EDGEZERO_BUILD_CONTAINER_RELEASE_STATE }} + EDGEZERO_RUN_ID: ${{ github.run_id }} + EDGEZERO_RUN_ATTEMPT: ${{ github.run_attempt }} + run: | + set -euo pipefail + [[ "$EDGEZERO_EVENT_NAME" == push ]] + [[ "$EDGEZERO_REF_NAME" =~ ^build-container-v[1-9][0-9]*$ ]] + [[ "$EDGEZERO_REF" == "refs/tags/$EDGEZERO_REF_NAME" ]] + [[ "$EDGEZERO_REF_PROTECTED" == true ]] + [[ "$EDGEZERO_SHA" =~ ^[0-9a-f]{40}$ ]] + [[ "$EDGEZERO_SHA" == "$EDGEZERO_WORKFLOW_SHA" ]] + [[ "$EDGEZERO_GATE_SHA" =~ ^[0-9a-f]{40}$ ]] + [[ "$EDGEZERO_RELEASE_STATE" == enabled ]] + [[ "$EDGEZERO_RUN_ID" =~ ^[1-9][0-9]*$ ]] + [[ "$EDGEZERO_RUN_ATTEMPT" =~ ^[1-9][0-9]*$ ]] + + - name: verify-rotation-prerequisite + if: ${{ success() }} + shell: bash + env: + GITHUB_TOKEN: ${{ github.token }} + EDGEZERO_GATE_SHA: ${{ vars.EDGEZERO_BUILD_CONTAINER_GATE_SHA }} + EDGEZERO_SOURCE_REVISION: ${{ github.sha }} + EDGEZERO_PUBLISHER_PREREQUISITE: ${{ vars.EDGEZERO_BUILD_CONTAINER_PUBLISHER_PREREQUISITE }} + run: | + set -euo pipefail + prerequisite="$RUNNER_TEMP/edgezero-publisher-prerequisite.json" + printf '%s' "$EDGEZERO_PUBLISHER_PREREQUISITE" >"$prerequisite" + bash "$GITHUB_WORKSPACE/.edgezero-gate/.github/docker/build-app-cli/verify-gate-rotation-lock.sh" \ + publisher \ + --gate-root "$GITHUB_WORKSPACE/.edgezero-gate" \ + --gate-sha "$EDGEZERO_GATE_SHA" \ + --source-revision "$EDGEZERO_SOURCE_REVISION" \ + --publisher-prerequisite-json "$prerequisite" + + - name: validate-release-request + if: ${{ success() }} + shell: bash + env: + EDGEZERO_GATE_SHA: ${{ vars.EDGEZERO_BUILD_CONTAINER_GATE_SHA }} + EDGEZERO_SOURCE_REVISION: ${{ github.sha }} + EDGEZERO_RELEASE_TAG: ${{ github.ref_name }} + EDGEZERO_RELEASE_STATE: ${{ vars.EDGEZERO_BUILD_CONTAINER_RELEASE_STATE }} + run: | + set -euo pipefail + classification="$RUNNER_TEMP/edgezero-release-classification" + bash "$GITHUB_WORKSPACE/.edgezero-gate/.github/docker/build-app-cli/classify-build-container-change.sh" \ + --subject-root "$GITHUB_WORKSPACE/.edgezero-source" \ + --gate-root "$GITHUB_WORKSPACE/.edgezero-gate" \ + --base "$EDGEZERO_GATE_SHA" \ + --head "$EDGEZERO_SOURCE_REVISION" \ + --kind local \ + --gate-sha "$EDGEZERO_GATE_SHA" \ + --release-state "$EDGEZERO_RELEASE_STATE" >"$classification" + printf 'mode=ordinary\nrelevant=true\n' | cmp -s - "$classification" + request_tag=$(git --no-replace-objects -C "$GITHUB_WORKSPACE/.edgezero-source" \ + show "$EDGEZERO_SOURCE_REVISION:.github/docker/build-app-cli/release-request.json" | \ + jq -er 'select(type == "object") | ."release-tag" | select(type == "string")') + [[ "$request_tag" == "$EDGEZERO_RELEASE_TAG" ]] + + - name: stage-trusted-build-context + if: ${{ success() }} + shell: bash + env: + EDGEZERO_GATE_SHA: ${{ vars.EDGEZERO_BUILD_CONTAINER_GATE_SHA }} + EDGEZERO_SOURCE_REVISION: ${{ github.sha }} + run: | + set -euo pipefail + bash "$GITHUB_WORKSPACE/.edgezero-gate/.github/docker/build-app-cli/stage-build-context.sh" \ + --gate-root "$GITHUB_WORKSPACE/.edgezero-gate" \ + --source-root "$GITHUB_WORKSPACE/.edgezero-source" \ + --gate-sha "$EDGEZERO_GATE_SHA" \ + --source-sha "$EDGEZERO_SOURCE_REVISION" \ + --output "$RUNNER_TEMP/edgezero-build-context" + + - name: build-publish-and-verify + id: publish + if: ${{ success() }} + shell: bash + env: + BASH_ENV: "" + ENV: "" + EDGEZERO_REGISTRY_TOKEN: ${{ github.token }} + EDGEZERO_REGISTRY_ACTOR: ${{ github.actor }} + EDGEZERO_SOURCE_REVISION: ${{ github.sha }} + EDGEZERO_RELEASE_TAG: ${{ github.ref_name }} + EDGEZERO_RUN_ATTEMPT: ${{ github.run_attempt }} + run: | + { set +x; set +a; } 2>/dev/null + set -euo pipefail + export DOCKER_CONFIG="$RUNNER_TEMP/edgezero-docker-config" + mkdir -m 0700 "$DOCKER_CONFIG" + cleanup_registry() { + docker logout ghcr.io >/dev/null 2>&1 || true + rm -rf -- "$DOCKER_CONFIG" + } + trap cleanup_registry EXIT + printf '%s' "$EDGEZERO_REGISTRY_TOKEN" | docker login ghcr.io \ + --username "$EDGEZERO_REGISTRY_ACTOR" --password-stdin + unset EDGEZERO_REGISTRY_TOKEN + repository=ghcr.io/stackpop/edgezero-build-app-cli + metadata="$RUNNER_TEMP/edgezero-build-metadata.json" + docker buildx build \ + --platform linux/amd64 \ + --provenance=false \ + --sbom=false \ + --build-arg "IMAGE_SOURCE_REVISION=$EDGEZERO_SOURCE_REVISION" \ + --tag "$repository:$EDGEZERO_RELEASE_TAG" \ + --metadata-file "$metadata" \ + --file "$RUNNER_TEMP/edgezero-build-context/.github/docker/build-app-cli/Dockerfile" \ + --push \ + "$RUNNER_TEMP/edgezero-build-context" + digest=$(jq -er '."containerimage.digest" | select(test("^sha256:[0-9a-f]{64}$"))' "$metadata") + docker logout ghcr.io + rm -rf -- "$DOCKER_CONFIG" + trap - EXIT + bash "$GITHUB_WORKSPACE/.edgezero-gate/.github/docker/build-app-cli/verify-published-image.sh" \ + --ref "$repository@$digest" \ + --source-sha "$EDGEZERO_SOURCE_REVISION" \ + --protocol 1 \ + --build-metadata "$metadata" + { + printf 'source-revision=%s\n' "$EDGEZERO_SOURCE_REVISION" + printf 'image-digest=%s\n' "$digest" + printf 'provenance-protocol=1\n' + printf 'release-tag=%s\n' "$EDGEZERO_RELEASE_TAG" + printf 'build-attempt=%s\n' "$EDGEZERO_RUN_ATTEMPT" + } >>"$GITHUB_OUTPUT" + + - name: generate-approval-challenge + id: challenge + if: ${{ success() }} + shell: bash + env: + EDGEZERO_SOURCE_REVISION: ${{ steps.publish.outputs.source-revision }} + EDGEZERO_IMAGE_DIGEST: ${{ steps.publish.outputs.image-digest }} + EDGEZERO_RELEASE_TAG: ${{ steps.publish.outputs.release-tag }} + EDGEZERO_RUN_ID: ${{ github.run_id }} + EDGEZERO_RUN_ATTEMPT: ${{ github.run_attempt }} + run: | + set -euo pipefail + challenge=$(od -An -N32 -tx1 /dev/urandom | tr -d ' \n') + [[ "$challenge" =~ ^[0-9a-f]{64}$ ]] + printf 'approval-challenge=%s\n' "$challenge" >>"$GITHUB_OUTPUT" + { + printf 'Source revision: `%s`\n' "$EDGEZERO_SOURCE_REVISION" + printf 'Image digest: `%s`\n' "$EDGEZERO_IMAGE_DIGEST" + printf 'Release tag: `%s`\n' "$EDGEZERO_RELEASE_TAG" + printf 'Run: `%s` attempt `%s`\n' "$EDGEZERO_RUN_ID" "$EDGEZERO_RUN_ATTEMPT" + printf 'Approval challenge: `%s`\n' "$challenge" + } >>"$GITHUB_STEP_SUMMARY" + + update-pin: + needs: build-and-verify + runs-on: ubuntu-24.04 + environment: + name: build-container-release + deployment: false + permissions: + actions: read + contents: read + steps: + - name: assert-hosted-runner-context + shell: bash + env: + BASH_ENV: "" + ENV: "" + EDGEZERO_RUNNER_ENVIRONMENT: ${{ runner.environment }} + EDGEZERO_RUNNER_OS: ${{ runner.os }} + EDGEZERO_RUNNER_ARCH: ${{ runner.arch }} + EDGEZERO_REPOSITORY: ${{ github.repository }} + run: | + set -euo pipefail + [[ "$EDGEZERO_RUNNER_ENVIRONMENT" == github-hosted ]] + [[ "$EDGEZERO_RUNNER_OS" == Linux ]] + [[ "$EDGEZERO_RUNNER_ARCH" == X64 ]] + [[ "$EDGEZERO_REPOSITORY" == stackpop/edgezero ]] + + - name: checkout-active-gate + if: ${{ success() }} + uses: actions/checkout@v7.0.1 + with: + repository: stackpop/edgezero + ref: ${{ vars.EDGEZERO_BUILD_CONTAINER_GATE_SHA }} + path: .edgezero-gate + persist-credentials: false + fetch-depth: 0 + + - name: checkout-release-source + if: ${{ success() }} + uses: actions/checkout@v7.0.1 + with: + repository: stackpop/edgezero + ref: ${{ needs.build-and-verify.outputs.source-revision }} + path: .edgezero-source + persist-credentials: false + fetch-depth: 0 + + - name: assert-exact-publisher-context + if: ${{ success() }} + shell: bash + env: + EDGEZERO_EVENT_NAME: ${{ github.event_name }} + EDGEZERO_REF: ${{ github.ref }} + EDGEZERO_REF_NAME: ${{ github.ref_name }} + EDGEZERO_REF_PROTECTED: ${{ github.ref_protected }} + EDGEZERO_SHA: ${{ github.sha }} + EDGEZERO_WORKFLOW_SHA: ${{ github.workflow_sha }} + EDGEZERO_SOURCE_REVISION: ${{ needs.build-and-verify.outputs.source-revision }} + EDGEZERO_RELEASE_TAG: ${{ needs.build-and-verify.outputs.release-tag }} + EDGEZERO_BUILD_ATTEMPT: ${{ needs.build-and-verify.outputs.build-attempt }} + EDGEZERO_RUN_ID: ${{ github.run_id }} + EDGEZERO_RUN_ATTEMPT: ${{ github.run_attempt }} + EDGEZERO_GATE_SHA: ${{ vars.EDGEZERO_BUILD_CONTAINER_GATE_SHA }} + EDGEZERO_RELEASE_STATE: ${{ vars.EDGEZERO_BUILD_CONTAINER_RELEASE_STATE }} + run: | + set -euo pipefail + [[ "$EDGEZERO_EVENT_NAME" == push ]] + [[ "$EDGEZERO_REF" == "refs/tags/$EDGEZERO_RELEASE_TAG" ]] + [[ "$EDGEZERO_REF_NAME" == "$EDGEZERO_RELEASE_TAG" ]] + [[ "$EDGEZERO_REF_PROTECTED" == true ]] + [[ "$EDGEZERO_SHA" == "$EDGEZERO_WORKFLOW_SHA" ]] + [[ "$EDGEZERO_SHA" == "$EDGEZERO_SOURCE_REVISION" ]] + [[ "$EDGEZERO_BUILD_ATTEMPT" == "$EDGEZERO_RUN_ATTEMPT" ]] + [[ "$EDGEZERO_RUN_ID" =~ ^[1-9][0-9]*$ ]] + [[ "$EDGEZERO_RUN_ATTEMPT" =~ ^[1-9][0-9]*$ ]] + [[ "$EDGEZERO_GATE_SHA" =~ ^[0-9a-f]{40}$ ]] + [[ "$EDGEZERO_RELEASE_STATE" == enabled ]] + + - name: verify-rotation-prerequisite + id: prerequisite + if: ${{ success() }} + shell: bash + env: + GITHUB_TOKEN: ${{ github.token }} + EDGEZERO_GATE_SHA: ${{ vars.EDGEZERO_BUILD_CONTAINER_GATE_SHA }} + EDGEZERO_SOURCE_REVISION: ${{ needs.build-and-verify.outputs.source-revision }} + EDGEZERO_PUBLISHER_PREREQUISITE: ${{ vars.EDGEZERO_BUILD_CONTAINER_PUBLISHER_PREREQUISITE }} + run: | + set -euo pipefail + prerequisite="$RUNNER_TEMP/edgezero-publisher-prerequisite.json" + printf '%s' "$EDGEZERO_PUBLISHER_PREREQUISITE" >"$prerequisite" + bash "$GITHUB_WORKSPACE/.edgezero-gate/.github/docker/build-app-cli/verify-gate-rotation-lock.sh" \ + publisher \ + --gate-root "$GITHUB_WORKSPACE/.edgezero-gate" \ + --gate-sha "$EDGEZERO_GATE_SHA" \ + --source-revision "$EDGEZERO_SOURCE_REVISION" \ + --publisher-prerequisite-json "$prerequisite" + jq -er ' + select(."schema-version" == 2) + | select(."source-revision" == $source) + | [ ."source-pr", ."evidence-url" ] | @tsv + ' --arg source "$EDGEZERO_SOURCE_REVISION" "$prerequisite" | + while IFS=$'\t' read -r source_pr evidence_url; do + printf 'source-pr=%s\n' "$source_pr" + printf 'evidence-url=%s\n' "$evidence_url" + done >>"$GITHUB_OUTPUT" + + - name: verify-release-approval + id: approval + if: ${{ success() }} + shell: bash + env: + GITHUB_TOKEN: ${{ github.token }} + EDGEZERO_GATE_SHA: ${{ vars.EDGEZERO_BUILD_CONTAINER_GATE_SHA }} + EDGEZERO_SOURCE_REVISION: ${{ needs.build-and-verify.outputs.source-revision }} + EDGEZERO_RELEASE_TAG: ${{ needs.build-and-verify.outputs.release-tag }} + EDGEZERO_IMAGE_DIGEST: ${{ needs.build-and-verify.outputs.image-digest }} + EDGEZERO_APPROVAL_CHALLENGE: ${{ needs.build-and-verify.outputs.approval-challenge }} + EDGEZERO_BUILD_ATTEMPT: ${{ needs.build-and-verify.outputs.build-attempt }} + EDGEZERO_RUN_ID: ${{ github.run_id }} + EDGEZERO_RUN_ATTEMPT: ${{ github.run_attempt }} + run: | + set -euo pipefail + output_dir="$RUNNER_TEMP/edgezero-approval" + mkdir -m 0700 "$output_dir" + approval="$output_dir/approval.json" + bash "$GITHUB_WORKSPACE/.edgezero-gate/.github/docker/build-app-cli/release-approval-gate.sh" \ + --gate-root "$GITHUB_WORKSPACE/.edgezero-gate" \ + --gate-sha "$EDGEZERO_GATE_SHA" \ + --run-id "$EDGEZERO_RUN_ID" \ + --run-attempt "$EDGEZERO_RUN_ATTEMPT" \ + --build-attempt "$EDGEZERO_BUILD_ATTEMPT" \ + --source-revision "$EDGEZERO_SOURCE_REVISION" \ + --release-tag "$EDGEZERO_RELEASE_TAG" \ + --image-digest "$EDGEZERO_IMAGE_DIGEST" \ + --approval-challenge "$EDGEZERO_APPROVAL_CHALLENGE" \ + --approval-out "$approval" + printf 'approval-json=%s\n' "$approval" >>"$GITHUB_OUTPUT" + + - name: mint-publisher-token + id: publisher-token + if: ${{ success() }} + uses: actions/create-github-app-token@v3.2.0 + with: + app-id: ${{ vars.EDGEZERO_BUILD_CONTAINER_APP_ID }} + private-key: ${{ secrets.EDGEZERO_BUILD_CONTAINER_APP_PRIVATE_KEY }} + owner: stackpop + repositories: edgezero + permission-contents: write + permission-pull-requests: write + + - name: assert-publisher-token + if: ${{ success() }} + shell: bash + env: + EDGEZERO_APP_TOKEN: ${{ steps.publisher-token.outputs.token }} + EDGEZERO_INSTALLATION_ID: ${{ steps.publisher-token.outputs.installation-id }} + EDGEZERO_EXPECTED_INSTALLATION_ID: ${{ vars.EDGEZERO_BUILD_CONTAINER_APP_INSTALLATION_ID }} + run: | + set -euo pipefail + bash "$GITHUB_WORKSPACE/.edgezero-gate/.github/docker/build-app-cli/assert-build-container-app-token.sh" + + - name: update-pin-branch + if: ${{ success() }} + shell: bash + env: + EDGEZERO_BUILD_CONTAINER_APP_TOKEN: ${{ steps.publisher-token.outputs.token }} + EDGEZERO_GATE_SHA: ${{ vars.EDGEZERO_BUILD_CONTAINER_GATE_SHA }} + EDGEZERO_SOURCE_REVISION: ${{ needs.build-and-verify.outputs.source-revision }} + EDGEZERO_RELEASE_TAG: ${{ needs.build-and-verify.outputs.release-tag }} + EDGEZERO_IMAGE_DIGEST: ${{ needs.build-and-verify.outputs.image-digest }} + EDGEZERO_SOURCE_PR: ${{ steps.prerequisite.outputs.source-pr }} + EDGEZERO_EVIDENCE_URL: ${{ steps.prerequisite.outputs.evidence-url }} + EDGEZERO_APPROVAL_JSON: ${{ steps.approval.outputs.approval-json }} + EDGEZERO_BOT_ID: ${{ vars.EDGEZERO_BUILD_CONTAINER_PUBLISHER_BOT_ID }} + EDGEZERO_BOT_LOGIN: ${{ vars.EDGEZERO_BUILD_CONTAINER_PUBLISHER_BOT_LOGIN }} + run: | + set -euo pipefail + bash "$GITHUB_WORKSPACE/.edgezero-gate/.github/docker/build-app-cli/update-image-pin-pr.sh" \ + --gate-root "$GITHUB_WORKSPACE/.edgezero-gate" \ + --gate-sha "$EDGEZERO_GATE_SHA" \ + --repository-root "$GITHUB_WORKSPACE/.edgezero-source" \ + --source-revision "$EDGEZERO_SOURCE_REVISION" \ + --release-tag "$EDGEZERO_RELEASE_TAG" \ + --image-digest "$EDGEZERO_IMAGE_DIGEST" \ + --provenance-protocol 1 \ + --approval-json "$EDGEZERO_APPROVAL_JSON" \ + --source-pr "$EDGEZERO_SOURCE_PR" \ + --evidence-url "$EDGEZERO_EVIDENCE_URL" \ + --expected-bot-id "$EDGEZERO_BOT_ID" \ + --expected-bot-login "$EDGEZERO_BOT_LOGIN" diff --git a/.github/workflows/rotate-build-container-gate.yml b/.github/workflows/rotate-build-container-gate.yml new file mode 100644 index 00000000..ec736f27 --- /dev/null +++ b/.github/workflows/rotate-build-container-gate.yml @@ -0,0 +1,162 @@ +name: Rotate build container gate + +on: + workflow_dispatch: + +permissions: {} + +concurrency: + group: edgezero-build-container-publication + cancel-in-progress: false + queue: max + +jobs: + acquire: + runs-on: ubuntu-24.04 + permissions: + actions: read + contents: read + outputs: + old-gate-sha: ${{ steps.capture.outputs.old-gate-sha }} + dispatch-sha: ${{ steps.capture.outputs.dispatch-sha }} + run-actor-login: ${{ steps.capture.outputs.run-actor-login }} + steps: + - name: assert-hosted-runner-context + shell: bash + env: + BASH_ENV: "" + ENV: "" + EDGEZERO_RUNNER_ENVIRONMENT: ${{ runner.environment }} + EDGEZERO_RUNNER_OS: ${{ runner.os }} + EDGEZERO_RUNNER_ARCH: ${{ runner.arch }} + EDGEZERO_REPOSITORY: ${{ github.repository }} + run: | + set -euo pipefail + [[ "$EDGEZERO_RUNNER_ENVIRONMENT" == github-hosted ]] + [[ "$EDGEZERO_RUNNER_OS" == Linux ]] + [[ "$EDGEZERO_RUNNER_ARCH" == X64 ]] + [[ "$EDGEZERO_REPOSITORY" == stackpop/edgezero ]] + + - name: checkout-captured-old-gate + if: ${{ success() }} + uses: actions/checkout@v7.0.1 + with: + repository: stackpop/edgezero + ref: ${{ vars.EDGEZERO_BUILD_CONTAINER_GATE_SHA }} + path: .edgezero-gate + persist-credentials: false + fetch-depth: 0 + + - name: capture-protected-dispatch + id: capture + if: ${{ success() }} + shell: bash + env: + EDGEZERO_EVENT_NAME: ${{ github.event_name }} + EDGEZERO_REF: ${{ github.ref }} + EDGEZERO_REF_PROTECTED: ${{ github.ref_protected }} + EDGEZERO_SHA: ${{ github.sha }} + EDGEZERO_WORKFLOW_SHA: ${{ github.workflow_sha }} + EDGEZERO_GATE_SHA: ${{ vars.EDGEZERO_BUILD_CONTAINER_GATE_SHA }} + EDGEZERO_RELEASE_STATE: ${{ vars.EDGEZERO_BUILD_CONTAINER_RELEASE_STATE }} + EDGEZERO_RUN_ACTOR: ${{ github.actor }} + run: | + set -euo pipefail + [[ "$EDGEZERO_EVENT_NAME" == workflow_dispatch ]] + [[ "$EDGEZERO_REF" == refs/heads/main ]] + [[ "$EDGEZERO_REF_PROTECTED" == true ]] + [[ "$EDGEZERO_SHA" == "$EDGEZERO_WORKFLOW_SHA" ]] + [[ "$EDGEZERO_SHA" =~ ^[0-9a-f]{40}$ ]] + [[ "$EDGEZERO_GATE_SHA" =~ ^[0-9a-f]{40}$ ]] + [[ "$EDGEZERO_RELEASE_STATE" == enabled ]] + gate_root="$GITHUB_WORKSPACE/.edgezero-gate" + [[ "$(git --no-replace-objects -C "$gate_root" rev-parse --verify HEAD)" == "$EDGEZERO_GATE_SHA" ]] + [[ "$(git --no-replace-objects -C "$gate_root" rev-parse --is-shallow-repository)" == false ]] + [[ -z "$(git --no-replace-objects -C "$gate_root" status --porcelain=v1 --untracked-files=all)" ]] + git --no-replace-objects -C "$gate_root" merge-base --is-ancestor \ + "$EDGEZERO_GATE_SHA" "$EDGEZERO_SHA" + + manifest="$gate_root/.github/docker/build-app-cli/gate-paths.txt" + [[ -f "$manifest" && ! -L "$manifest" ]] + [[ "$(tail -c 1 "$manifest" | od -An -tx1 | tr -d '[:space:]')" == 0a ]] + previous= + count=0 + while IFS= read -r path; do + [[ "$path" =~ ^[A-Za-z0-9._/+-]+$ ]] + [[ -n "$path" && "$path" != /* && "$path" != -* && "$path" != */ && + "$path" != . && "$path" != .. && "$path" != ../* && + "$path" != */../* && "$path" != */.. && "$path" != */./* && + "$path" != */. && "$path" != *//* && "$path" != *\\* ]] + [[ -z "$previous" || "$previous" < "$path" ]] + previous=$path + gate_entry=$(git --no-replace-objects -C "$gate_root" ls-tree "$EDGEZERO_GATE_SHA" -- "$path") + dispatch_entry=$(git --no-replace-objects -C "$gate_root" ls-tree "$EDGEZERO_SHA" -- "$path") + [[ -n "$gate_entry" && "$gate_entry" != *$'\n'* && "$gate_entry" == *$'\t'* ]] + [[ "${gate_entry#*$'\t'}" == "$path" ]] + gate_metadata=${gate_entry%%$'\t'*} + read -r gate_mode gate_type gate_oid <<<"$gate_metadata" + [[ "$gate_mode" =~ ^100(644|755)$ && "$gate_type" == blob && + "$gate_oid" =~ ^[0-9a-f]{40,64}$ ]] + [[ "$dispatch_entry" == "$gate_entry" ]] + count=$((count + 1)) + done <"$manifest" + ((count > 0)) + printf 'old-gate-sha=%s\n' "$EDGEZERO_GATE_SHA" >>"$GITHUB_OUTPUT" + printf 'dispatch-sha=%s\n' "$EDGEZERO_SHA" >>"$GITHUB_OUTPUT" + printf 'run-actor-login=%s\n' "$EDGEZERO_RUN_ACTOR" >>"$GITHUB_OUTPUT" + + wait: + needs: acquire + runs-on: ubuntu-24.04 + environment: + name: build-container-gate-rotation-lock + deployment: false + permissions: + actions: read + contents: read + steps: + - name: assert-hosted-runner-context + shell: bash + env: + BASH_ENV: "" + ENV: "" + EDGEZERO_RUNNER_ENVIRONMENT: ${{ runner.environment }} + EDGEZERO_RUNNER_OS: ${{ runner.os }} + EDGEZERO_RUNNER_ARCH: ${{ runner.arch }} + EDGEZERO_REPOSITORY: ${{ github.repository }} + run: | + set -euo pipefail + [[ "$EDGEZERO_RUNNER_ENVIRONMENT" == github-hosted ]] + [[ "$EDGEZERO_RUNNER_OS" == Linux ]] + [[ "$EDGEZERO_RUNNER_ARCH" == X64 ]] + [[ "$EDGEZERO_REPOSITORY" == stackpop/edgezero ]] + + - name: checkout-captured-old-gate + if: ${{ success() }} + uses: actions/checkout@v7.0.1 + with: + repository: stackpop/edgezero + ref: ${{ needs.acquire.outputs.old-gate-sha }} + path: .edgezero-gate + persist-credentials: false + fetch-depth: 0 + + - name: assert-exact-rotation-context + shell: bash + env: + GITHUB_TOKEN: ${{ github.token }} + EDGEZERO_OLD_GATE_SHA: ${{ needs.acquire.outputs.old-gate-sha }} + EDGEZERO_DISPATCH_SHA: ${{ needs.acquire.outputs.dispatch-sha }} + EDGEZERO_RUN_ID: ${{ github.run_id }} + EDGEZERO_RUN_ATTEMPT: ${{ github.run_attempt }} + EDGEZERO_RUN_ACTOR_LOGIN: ${{ needs.acquire.outputs.run-actor-login }} + run: | + set -euo pipefail + bash "$GITHUB_WORKSPACE/.edgezero-gate/.github/docker/build-app-cli/verify-gate-rotation-lock.sh" \ + waiting \ + --gate-root "$GITHUB_WORKSPACE/.edgezero-gate" \ + --old-gate-sha "$EDGEZERO_OLD_GATE_SHA" \ + --dispatch-sha "$EDGEZERO_DISPATCH_SHA" \ + --run-id "$EDGEZERO_RUN_ID" \ + --run-attempt "$EDGEZERO_RUN_ATTEMPT" \ + --run-actor-login "$EDGEZERO_RUN_ACTOR_LOGIN" diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index e629d17a..6768be89 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -18,10 +18,12 @@ jobs: name: cargo test runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v6.1.0 + with: + persist-credentials: false - name: Cache Cargo dependencies - uses: actions/cache@v5 + uses: actions/cache@v5.1.0 with: path: | ~/.cargo/bin/ @@ -39,7 +41,7 @@ jobs: shell: bash - name: Set up Rust tool chain - uses: actions-rust-lang/setup-rust-toolchain@v1 + uses: actions-rust-lang/setup-rust-toolchain@v1.17.0 with: toolchain: ${{ steps.rust-version.outputs.rust-version }} @@ -121,10 +123,12 @@ jobs: runner_env: CARGO_TARGET_WASM32_WASIP2_RUNNER runner_value: wasmtime run steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v6.1.0 + with: + persist-credentials: false - name: Cache Cargo dependencies - uses: actions/cache@v5 + uses: actions/cache@v5.1.0 with: path: | ~/.cargo/bin/ @@ -143,7 +147,7 @@ jobs: shell: bash - name: Set up Rust tool chain - uses: actions-rust-lang/setup-rust-toolchain@v1 + uses: actions-rust-lang/setup-rust-toolchain@v1.17.0 with: toolchain: ${{ steps.rust-version.outputs.rust-version }} @@ -173,7 +177,9 @@ jobs: # by default. Force-overwriting is safe — `--locked` pins the version. - name: Install wasm-bindgen test runner if: matrix.adapter == 'cloudflare' - run: cargo install wasm-bindgen-cli --version "${{ steps.wasm-bindgen-version.outputs.version }}" --locked --force + env: + WASM_BINDGEN_VERSION: ${{ steps.wasm-bindgen-version.outputs.version }} + run: cargo install wasm-bindgen-cli --version "$WASM_BINDGEN_VERSION" --locked --force - name: Resolve Viceroy version if: matrix.adapter == 'fastly' @@ -185,7 +191,9 @@ jobs: if: matrix.adapter == 'fastly' # Version comes from .tool-versions (single source of truth shared with # local dev). - run: cargo install viceroy --version "${{ steps.viceroy-version.outputs.version }}" --locked --force + env: + VICEROY_VERSION: ${{ steps.viceroy-version.outputs.version }} + run: cargo install viceroy --version "$VICEROY_VERSION" --locked --force - name: Resolve Wasmtime version if: matrix.adapter == 'spin' @@ -195,6 +203,8 @@ jobs: - name: Setup Wasmtime if: matrix.adapter == 'spin' + env: + WASMTIME_VERSION: ${{ steps.wasmtime-version.outputs.version }} # Direct GitHub-release tarball install. The official # `https://wasmtime.dev/install.sh` script broke as of # 2026-05-19 (interpolation failure: tried to download @@ -212,7 +222,7 @@ jobs: install_dir="$HOME/.wasmtime/bin" echo "$install_dir" >> "$GITHUB_PATH" export PATH="$install_dir:$PATH" - version="${{ steps.wasmtime-version.outputs.version }}" + version="$WASMTIME_VERSION" # Compare exact versions so a pin like `44.0.1` does not silently # accept `wasmtime 44.0.10` from a stale cache or runner-provided # binary. diff --git a/.github/zizmor.yml b/.github/zizmor.yml index b344d1d9..38ecb00c 100644 --- a/.github/zizmor.yml +++ b/.github/zizmor.yml @@ -6,13 +6,10 @@ # tag like `actions-rust-lang/setup-rust-toolchain@v1.17.0`. Relax the blanket policy # to `ref-pin` so a version tag satisfies the audit for every action. # -# This does NOT permit floating refs: zizmor's `ref-pin` alone would accept a -# branch, but `check-action-pins.sh` rejects any ref that is not a full commit SHA -# or a release version tag (so `@main`, `@develop`, `@latest`, etc. all fail). The -# two gates together mean every `uses:` resolves to a full SHA or a released version -# tag — a concrete, reviewable ref. Note this is a pinning policy, not immutability: -# a version tag (a major tag such as `@v4` especially) can still be repointed by the -# action's publisher. Pin to a full commit SHA where that matters. +# The structural check-action-pins.sh gate is stricter: public references require +# canonical exact stable vMAJOR.MINOR.PATCH tags. It rejects SHAs, branches, +# major/minor tags, prereleases, and build metadata. Docker actions require exact +# sha256 digests. Third-party tag movement remains an accepted release risk. rules: unpinned-uses: config: diff --git a/docs/guide/deploy-github-actions.md b/docs/guide/deploy-github-actions.md index 44c7de74..030e34aa 100644 --- a/docs/guide/deploy-github-actions.md +++ b/docs/guide/deploy-github-actions.md @@ -1,5 +1,10 @@ # Deploying from GitHub Actions +> Prepublication: these examples use `` and are not runnable +> until the caching release and adoption migration are complete. Public action and +> workflow references require one exact stable `vMAJOR.MINOR.PATCH` version, never a +> SHA, branch, major/minor tag, or prerelease. Third-party tag movement is accepted. + EdgeZero ships a set of reusable GitHub composite actions that deploy a checked-out EdgeZero application to Fastly Compute. They are **layered** so that adding another provider later does not rewrite the deploy engine, and the @@ -7,7 +12,7 @@ adding another provider later does not rewrite the deploy engine, and the deploy logic in YAML; they compile your CLI, scope credentials, and invoke it. The design reference lives in -[`docs/specs/edgezero-deploy-github-action.md`](https://github.com/stackpop/edgezero/blob/main/docs/specs/edgezero-deploy-github-action.md); +[`docs/superpowers/specs/edgezero-deploy-github-action.md`](https://github.com/stackpop/edgezero/blob/main/docs/superpowers/specs/edgezero-deploy-github-action.md); this page is the practical how-to. ## The three layers @@ -23,12 +28,11 @@ this page is the practical how-to. Under the hood a private `deploy-core` engine (a set of shared scripts) holds all provider-neutral behavior; the wrappers above are thin. -**Runner support:** Linux x86-64 only (`ubuntu-latest` is tested). On **self-hosted** -runners you must run **Actions Runner 2.327.1 or newer**: these actions use -Node 24 dependencies (`actions/download-artifact@v8`, `actions/cache@v6`, -`actions/upload-artifact@v7`, `actions/checkout@v7`), and the Node 24 runtime they -require ships only in runner 2.327.1+. Hosted runners already meet this; older -self-hosted runners fail to launch the actions. +**Runner support for the caching release:** standard GitHub-hosted `ubuntu-24.04` +Linux x86-64. Every step-based job using a public action declares that literal +label. A job-level reusable-workflow caller omits both `steps` and `runs-on`; +the called workflow selects its runner. Self-hosted, larger, and custom-image +runners are unsupported. ## What you provide @@ -67,29 +71,29 @@ self-hosted runners fail to launch the actions. ```yaml jobs: deploy: - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 permissions: contents: read steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7.0.1 with: persist-credentials: false - id: cli - uses: stackpop/edgezero/.github/actions/build-app-cli@ + uses: stackpop/edgezero/.github/actions/build-app-cli@ with: app-cli-package: my-app-cli # the CLI crate in your workspace - id: deploy # recovery/rollback below reads steps.deploy.outputs.* - uses: stackpop/edgezero/.github/actions/deploy-fastly@ + uses: stackpop/edgezero/.github/actions/deploy-fastly@ with: app-cli-artifact: ${{ steps.cli.outputs.app-cli-artifact }} fastly-api-token: ${{ secrets.FASTLY_API_TOKEN }} fastly-service-id: ${{ vars.FASTLY_SERVICE_ID }} ``` -Use a trusted `@` — a released tag, or a full commit SHA when you need a -reproducible production deploy. +The release migration replaces `` with one published, +immutable, exact stable patch version across every EdgeZero reference. ## Separate deployer and application repositories @@ -106,48 +110,51 @@ The snippet below is a job fragment with placeholders you must fill in: - The application checkout's `token:` reads `steps.app-token.outputs.token`, which assumes an earlier `id: app-token` step that mints the token (shown below). Swap it for however you mint yours. -- Replace every `@` in a `uses:` with a real ref — a released tag, or a full - commit SHA for a reproducible production deploy. +- The release migration replaces every `` with the same + exact stable EdgeZero release version. ```yaml -steps: - - name: Checkout deployer - uses: actions/checkout@v4 - with: - path: deployer - persist-credentials: false - - # Mint the app-scoped token consumed by the application checkout below. This - # example uses a GitHub App; a fine-grained PAT in a secret works too. - - id: app-token - uses: actions/create-github-app-token@ - with: - app-id: ${{ vars.APP_ID }} - private-key: ${{ secrets.APP_PRIVATE_KEY }} - repositories: my-edgezero-app - - - name: Checkout application - uses: actions/checkout@v4 - with: - repository: stackpop/my-edgezero-app - ref: ${{ inputs.ref }} # requires an `on:` input named `ref`, or use a literal - path: app - persist-credentials: false - token: ${{ steps.app-token.outputs.token }} # app-scoped token - - - id: cli - uses: stackpop/edgezero/.github/actions/build-app-cli@ - with: - app-cli-package: my-app-cli - working-directory: app - - - id: deploy # recovery/rollback below reads steps.deploy.outputs.* - uses: stackpop/edgezero/.github/actions/deploy-fastly@ - with: - app-cli-artifact: ${{ steps.cli.outputs.app-cli-artifact }} - working-directory: app - fastly-api-token: ${{ secrets.FASTLY_API_TOKEN }} - fastly-service-id: ${{ vars.FASTLY_SERVICE_ID }} +jobs: + deploy: + runs-on: ubuntu-24.04 + steps: + - name: Checkout deployer + uses: actions/checkout@v7.0.1 + with: + path: deployer + persist-credentials: false + + # Mint the app-scoped token consumed by the application checkout below. This + # example uses a GitHub App; a fine-grained PAT in a secret works too. + - id: app-token + uses: actions/create-github-app-token@v3.2.0 + with: + app-id: ${{ vars.APP_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} + repositories: my-edgezero-app + + - name: Checkout application + uses: actions/checkout@v7.0.1 + with: + repository: stackpop/my-edgezero-app + ref: ${{ inputs.ref }} # requires an `on:` input named `ref`, or use a literal + path: app + persist-credentials: false + token: ${{ steps.app-token.outputs.token }} # app-scoped token + + - id: cli + uses: stackpop/edgezero/.github/actions/build-app-cli@ + with: + app-cli-package: my-app-cli + working-directory: app + + - id: deploy # recovery/rollback below reads steps.deploy.outputs.* + uses: stackpop/edgezero/.github/actions/deploy-fastly@ + with: + app-cli-artifact: ${{ steps.cli.outputs.app-cli-artifact }} + working-directory: app + fastly-api-token: ${{ secrets.FASTLY_API_TOKEN }} + fastly-service-id: ${{ vars.FASTLY_SERVICE_ID }} ``` ## Monorepo application @@ -158,25 +165,29 @@ workspace may be the subdirectory itself), so a monorepo caches the right `target/`. ```yaml -- id: cli - uses: stackpop/edgezero/.github/actions/build-app-cli@ - with: - app-cli-package: api-cli - working-directory: apps/api - -- id: deploy # recovery/rollback below reads steps.deploy.outputs.* - uses: stackpop/edgezero/.github/actions/deploy-fastly@ - with: - app-cli-artifact: ${{ steps.cli.outputs.app-cli-artifact }} - working-directory: apps/api - manifest: edgezero.toml - # `cache` only takes effect with `build-mode: always` — that credential-free - # build is what seeds the cache (the token-bearing deploy is never cached). With - # the Fastly default `build-mode: never`, `cache: true` is a no-op. - build-mode: always - cache: true - fastly-api-token: ${{ secrets.FASTLY_API_TOKEN }} - fastly-service-id: ${{ vars.FASTLY_SERVICE_ID }} +jobs: + deploy: + runs-on: ubuntu-24.04 + steps: + - id: cli + uses: stackpop/edgezero/.github/actions/build-app-cli@ + with: + app-cli-package: api-cli + working-directory: apps/api + + - id: deploy # recovery/rollback below reads steps.deploy.outputs.* + uses: stackpop/edgezero/.github/actions/deploy-fastly@ + with: + app-cli-artifact: ${{ steps.cli.outputs.app-cli-artifact }} + working-directory: apps/api + manifest: edgezero.toml + # `cache` only takes effect with `build-mode: always` — that credential-free + # build is what seeds the cache (the token-bearing deploy is never cached). With + # the Fastly default `build-mode: never`, `cache: true` is a no-op. + build-mode: always + cache: true + fastly-api-token: ${{ secrets.FASTLY_API_TOKEN }} + fastly-service-id: ${{ vars.FASTLY_SERVICE_ID }} ``` ## Keeping the credential out of the build phase @@ -204,30 +215,30 @@ boundaries). ```yaml jobs: build: - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 permissions: contents: read steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7.0.1 with: persist-credentials: false - id: cli - uses: stackpop/edgezero/.github/actions/build-app-cli@ + uses: stackpop/edgezero/.github/actions/build-app-cli@ with: app-cli-package: my-app-cli # No provider secret is available anywhere in this job. deploy: needs: build - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 permissions: contents: read steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7.0.1 with: persist-credentials: false - id: deploy # recovery/rollback below reads steps.deploy.outputs.* - uses: stackpop/edgezero/.github/actions/deploy-fastly@ + uses: stackpop/edgezero/.github/actions/deploy-fastly@ with: app-cli-artifact: edgezero-cli # the build job's artifact name fastly-api-token: ${{ secrets.FASTLY_API_TOKEN }} @@ -308,11 +319,15 @@ The default `provider-env-clear` list repeats the shipped aliases so the dynamic layer is self-contained. Add your own provider's aliases if you have one: ```yaml -- uses: stackpop/edgezero/.github/actions/build-app-cli@ - with: - app-cli-package: my-app-cli - # Defaults cover Fastly/Cloudflare/Spin; add your own provider's aliases. - provider-env-clear: '["FASTLY_API_TOKEN","ACME_DEPLOY_TOKEN"]' +jobs: + deploy: + runs-on: ubuntu-24.04 + steps: + - uses: stackpop/edgezero/.github/actions/build-app-cli@ + with: + app-cli-package: my-app-cli + # Defaults cover Fastly/Cloudflare/Spin; add your own provider's aliases. + provider-env-clear: '["FASTLY_API_TOKEN","ACME_DEPLOY_TOKEN"]' ``` The value must be a JSON array of non-empty variable names; anything else fails @@ -392,67 +407,71 @@ the build ran in this job or a not cross job boundaries. ```yaml -- name: Fetch the app CLI for recovery - # Runs on ANY non-success — NOT gated on mutation-attempted, whose absence is - # not proof of no mutation (a lost signal must not skip reconciliation). The - # active-version check below is idempotent: it only rolls back if the live - # version actually differs from the pre-deploy one. - if: ${{ failure() || cancelled() }} - uses: actions/download-artifact@ - with: - name: edgezero-cli # the same app-cli-artifact name the deploy used - path: ${{ runner.temp }}/recover-cli -- name: Read the currently-active version - id: recover - # Runs on ANY non-success — NOT gated on mutation-attempted, whose absence is - # not proof of no mutation (a lost signal must not skip reconciliation). The - # active-version check below is idempotent: it only rolls back if the live - # version actually differs from the pre-deploy one. - if: ${{ failure() || cancelled() }} - env: - FASTLY_API_TOKEN: ${{ secrets.FASTLY_API_TOKEN }} # active-version calls the API - # Pass the service id through env, never interpolate `${{ vars.* }}` into the - # script — a value containing a quote could otherwise escape the argument. - SERVICE_ID: ${{ vars.FASTLY_SERVICE_ID }} - # Explicit `shell: bash` runs with `-eo pipefail`, so a failing `active-version` - # (in the `$(…)` below) aborts rather than silently yielding an empty version and - # skipping rollback. - shell: bash - run: | - case "$SERVICE_ID" in '' | *[!A-Za-z0-9_-]*) - echo "::error::FASTLY_SERVICE_ID is empty or malformed"; exit 1;; esac - dir="${{ runner.temp }}/recover-cli" - tar -C "$dir" -xf "$dir"/*.tar - bin="$dir/$(jq -r '."app-cli-bin"' "$dir/app-cli-meta.json")" - out=$("$bin" active-version --adapter fastly --service-id "$SERVICE_ID") - # Require EXACTLY ONE `version=` line (a malformed or repeated line is an - # error), and accept an EMPTY value: active-version emits `version=` when the - # service has no active version yet (a first-ever deploy) — that is "nothing to - # roll back", not a failure. The value is the digits, or empty. - # - # Use HERE-STRINGS, never `printf … | grep -q`: grep -q exits on the first - # match and SIGPIPEs the writer, which under pipefail returns 141 and would fail - # this step (skipping rollback) when active-version prints extra lines. - n=$(grep -cE '^version=' <<<"$out" || true) - [ "$n" = "1" ] || { echo "::error::expected exactly one 'version=' line, got $n"; exit 1; } - grep -qE '^version=[0-9]*$' <<<"$out" || - { echo "::error::active-version emitted a malformed version line"; exit 1; } - v=$(sed -n 's/^version=//p' <<<"$out") - echo "version=$v" >>"$GITHUB_OUTPUT" # empty -> the rollback step's guard skips it -- name: Roll back only if the deploy activated a NEW version over a known previous one - if: >- - (failure() || cancelled()) && - steps.deploy.outputs['previous-version'] != '' && - steps.recover.outputs.version != '' && - steps.recover.outputs.version != steps.deploy.outputs['previous-version'] - uses: stackpop/edgezero/.github/actions/rollback-fastly@ - with: - app-cli-artifact: edgezero-cli - deploy-to: production - fastly-version: ${{ steps.recover.outputs.version }} # current (bad) version - rollback-to: ${{ steps.deploy.outputs['previous-version'] }} - fastly-api-token: ${{ secrets.FASTLY_API_TOKEN }} - fastly-service-id: ${{ vars.FASTLY_SERVICE_ID }} +jobs: + deploy: + runs-on: ubuntu-24.04 + steps: + - name: Fetch the app CLI for recovery + # Runs on ANY non-success — NOT gated on mutation-attempted, whose absence is + # not proof of no mutation (a lost signal must not skip reconciliation). The + # active-version check below is idempotent: it only rolls back if the live + # version actually differs from the pre-deploy one. + if: ${{ failure() || cancelled() }} + uses: actions/download-artifact@v8.0.1 + with: + name: edgezero-cli # the same app-cli-artifact name the deploy used + path: ${{ runner.temp }}/recover-cli + - name: Read the currently-active version + id: recover + # Runs on ANY non-success — NOT gated on mutation-attempted, whose absence is + # not proof of no mutation (a lost signal must not skip reconciliation). The + # active-version check below is idempotent: it only rolls back if the live + # version actually differs from the pre-deploy one. + if: ${{ failure() || cancelled() }} + env: + FASTLY_API_TOKEN: ${{ secrets.FASTLY_API_TOKEN }} # active-version calls the API + # Pass the service id through env, never interpolate `${{ vars.* }}` into the + # script — a value containing a quote could otherwise escape the argument. + SERVICE_ID: ${{ vars.FASTLY_SERVICE_ID }} + # Explicit `shell: bash` runs with `-eo pipefail`, so a failing `active-version` + # (in the `$(…)` below) aborts rather than silently yielding an empty version and + # skipping rollback. + shell: bash + run: | + case "$SERVICE_ID" in '' | *[!A-Za-z0-9_-]*) + echo "::error::FASTLY_SERVICE_ID is empty or malformed"; exit 1;; esac + dir="${{ runner.temp }}/recover-cli" + tar -C "$dir" -xf "$dir"/*.tar + bin="$dir/$(jq -r '."app-cli-bin"' "$dir/app-cli-meta.json")" + out=$("$bin" active-version --adapter fastly --service-id "$SERVICE_ID") + # Require EXACTLY ONE `version=` line (a malformed or repeated line is an + # error), and accept an EMPTY value: active-version emits `version=` when the + # service has no active version yet (a first-ever deploy) — that is "nothing to + # roll back", not a failure. The value is the digits, or empty. + # + # Use HERE-STRINGS, never `printf … | grep -q`: grep -q exits on the first + # match and SIGPIPEs the writer, which under pipefail returns 141 and would fail + # this step (skipping rollback) when active-version prints extra lines. + n=$(grep -cE '^version=' <<<"$out" || true) + [ "$n" = "1" ] || { echo "::error::expected exactly one 'version=' line, got $n"; exit 1; } + grep -qE '^version=[0-9]*$' <<<"$out" || + { echo "::error::active-version emitted a malformed version line"; exit 1; } + v=$(sed -n 's/^version=//p' <<<"$out") + echo "version=$v" >>"$GITHUB_OUTPUT" # empty -> the rollback step's guard skips it + - name: Roll back only if the deploy activated a NEW version over a known previous one + if: >- + (failure() || cancelled()) && + steps.deploy.outputs['previous-version'] != '' && + steps.recover.outputs.version != '' && + steps.recover.outputs.version != steps.deploy.outputs['previous-version'] + uses: stackpop/edgezero/.github/actions/rollback-fastly@ + with: + app-cli-artifact: edgezero-cli + deploy-to: production + fastly-version: ${{ steps.recover.outputs.version }} # current (bad) version + rollback-to: ${{ steps.deploy.outputs['previous-version'] }} + fastly-api-token: ${{ secrets.FASTLY_API_TOKEN }} + fastly-service-id: ${{ vars.FASTLY_SERVICE_ID }} ``` **First-ever deploy** is the one case this cannot automate: if `previous-version` @@ -553,17 +572,21 @@ the config store rather than assume it is unchanged. A production config push, using the same build artifact: ```yaml -- id: cli - uses: stackpop/edgezero/.github/actions/build-app-cli@ - with: - app-cli-package: my-app-cli - -- uses: stackpop/edgezero/.github/actions/config-push-fastly@ - with: - app-cli-artifact: ${{ steps.cli.outputs.app-cli-artifact }} - fastly-api-token: ${{ secrets.FASTLY_API_TOKEN }} - # deploy-to: staging # writes the _staging twin instead - # app-config-inline: ${{ vars.APP_CONFIG_TOML }} # or push inline content +jobs: + deploy: + runs-on: ubuntu-24.04 + steps: + - id: cli + uses: stackpop/edgezero/.github/actions/build-app-cli@ + with: + app-cli-package: my-app-cli + + - uses: stackpop/edgezero/.github/actions/config-push-fastly@ + with: + app-cli-artifact: ${{ steps.cli.outputs.app-cli-artifact }} + fastly-api-token: ${{ secrets.FASTLY_API_TOKEN }} + # deploy-to: staging # writes the _staging twin instead + # app-config-inline: ${{ vars.APP_CONFIG_TOML }} # or push inline content ``` **Staging config is the same store, a different key.** Fastly config stores are @@ -627,37 +650,41 @@ your app CLI; the actions are thin wrappers. You wire the trio — the actions carry no orchestration policy of their own. ```yaml -- id: cli - uses: stackpop/edgezero/.github/actions/build-app-cli@ - with: { app-cli-package: my-app-cli } - -- id: stage - uses: stackpop/edgezero/.github/actions/deploy-fastly@ - with: - app-cli-artifact: ${{ steps.cli.outputs.app-cli-artifact }} - deploy-to: staging - fastly-api-token: ${{ secrets.FASTLY_API_TOKEN }} - fastly-service-id: ${{ vars.FASTLY_SERVICE_ID }} - -- id: check - uses: stackpop/edgezero/.github/actions/healthcheck-fastly@ - with: - app-cli-artifact: ${{ steps.cli.outputs.app-cli-artifact }} - deploy-to: staging - domain: staging.example.com - fastly-version: ${{ steps.stage.outputs.fastly-version }} - fastly-api-token: ${{ secrets.FASTLY_API_TOKEN }} - fastly-service-id: ${{ vars.FASTLY_SERVICE_ID }} - -- if: >- - (failure() || cancelled()) && steps.stage.outputs.fastly-version != '' - uses: stackpop/edgezero/.github/actions/rollback-fastly@ - with: - app-cli-artifact: ${{ steps.cli.outputs.app-cli-artifact }} - deploy-to: staging - fastly-version: ${{ steps.stage.outputs.fastly-version }} - fastly-api-token: ${{ secrets.FASTLY_API_TOKEN }} - fastly-service-id: ${{ vars.FASTLY_SERVICE_ID }} +jobs: + deploy: + runs-on: ubuntu-24.04 + steps: + - id: cli + uses: stackpop/edgezero/.github/actions/build-app-cli@ + with: { app-cli-package: my-app-cli } + + - id: stage + uses: stackpop/edgezero/.github/actions/deploy-fastly@ + with: + app-cli-artifact: ${{ steps.cli.outputs.app-cli-artifact }} + deploy-to: staging + fastly-api-token: ${{ secrets.FASTLY_API_TOKEN }} + fastly-service-id: ${{ vars.FASTLY_SERVICE_ID }} + + - id: check + uses: stackpop/edgezero/.github/actions/healthcheck-fastly@ + with: + app-cli-artifact: ${{ steps.cli.outputs.app-cli-artifact }} + deploy-to: staging + domain: staging.example.com + fastly-version: ${{ steps.stage.outputs.fastly-version }} + fastly-api-token: ${{ secrets.FASTLY_API_TOKEN }} + fastly-service-id: ${{ vars.FASTLY_SERVICE_ID }} + + - if: >- + (failure() || cancelled()) && steps.stage.outputs.fastly-version != '' + uses: stackpop/edgezero/.github/actions/rollback-fastly@ + with: + app-cli-artifact: ${{ steps.cli.outputs.app-cli-artifact }} + deploy-to: staging + fastly-version: ${{ steps.stage.outputs.fastly-version }} + fastly-api-token: ${{ secrets.FASTLY_API_TOKEN }} + fastly-service-id: ${{ vars.FASTLY_SERVICE_ID }} ``` - `deploy-fastly` with `deploy-to: staging` clones the active version, uploads the built @@ -674,27 +701,31 @@ in Fastly's version metadata — so a production rollback **cannot infer** what re-activate. Capture the target at deploy time and thread it through: ```yaml -- id: deploy - uses: stackpop/edgezero/.github/actions/deploy-fastly@ - with: - # A literal artifact name works whether the build ran in this job or a - # separate one — steps.cli.* does not cross job boundaries. - app-cli-artifact: edgezero-cli - fastly-api-token: ${{ secrets.FASTLY_API_TOKEN }} - fastly-service-id: ${{ vars.FASTLY_SERVICE_ID }} - -# ... run your production health checks here ... - -- if: >- - (failure() || cancelled()) && steps.deploy.outputs.fastly-version != '' && - steps.deploy.outputs.previous-version != '' - uses: stackpop/edgezero/.github/actions/rollback-fastly@ - with: - app-cli-artifact: edgezero-cli - fastly-api-token: ${{ secrets.FASTLY_API_TOKEN }} - fastly-service-id: ${{ vars.FASTLY_SERVICE_ID }} - fastly-version: ${{ steps.deploy.outputs.fastly-version }} - rollback-to: ${{ steps.deploy.outputs.previous-version }} +jobs: + deploy: + runs-on: ubuntu-24.04 + steps: + - id: deploy + uses: stackpop/edgezero/.github/actions/deploy-fastly@ + with: + # A literal artifact name works whether the build ran in this job or a + # separate one — steps.cli.* does not cross job boundaries. + app-cli-artifact: edgezero-cli + fastly-api-token: ${{ secrets.FASTLY_API_TOKEN }} + fastly-service-id: ${{ vars.FASTLY_SERVICE_ID }} + + # ... run your production health checks here ... + + - if: >- + (failure() || cancelled()) && steps.deploy.outputs.fastly-version != '' && + steps.deploy.outputs.previous-version != '' + uses: stackpop/edgezero/.github/actions/rollback-fastly@ + with: + app-cli-artifact: edgezero-cli + fastly-api-token: ${{ secrets.FASTLY_API_TOKEN }} + fastly-service-id: ${{ vars.FASTLY_SERVICE_ID }} + fastly-version: ${{ steps.deploy.outputs.fastly-version }} + rollback-to: ${{ steps.deploy.outputs.previous-version }} ``` The rollback needs **both** outputs, so it guards on both. `previous-version` is @@ -754,12 +785,11 @@ mode-`600` temp file (it can contain credential material under debug flags) and remove it with a best-effort `EXIT` trap. A `SIGKILL`, runner shutdown, or hard `timeout-minutes` bypasses that trap, so on a **persistent self-hosted** runner a hard kill can leave that file — and any inline config — behind for another job to -read. The supported model is an ephemeral runner (GitHub-hosted, or self-hosted -one-job-per-VM); on a persistent runner, post-kill temp hygiene is on you. +read. The caching release supports only standard GitHub-hosted `ubuntu-24.04`; +self-hosted, custom, and larger runners are not supported. Add `timeout-minutes`, a protected GitHub Environment with required reviewers, -and pin third-party actions to readable released tags (or full SHAs for -production). +and pin third-party actions to canonical exact stable patch-version tags. ## Non-goals diff --git a/docs/package-lock.json b/docs/package-lock.json index f83d7f48..ea5f7992 100644 --- a/docs/package-lock.json +++ b/docs/package-lock.json @@ -12,6 +12,7 @@ "@eslint/js": "^10", "@types/node": "^24.10", "eslint": "^10", + "markdown-it": "15.0.1", "prettier": "^3.9.6", "typescript-eslint": "^8.69", "vitepress": "^1.5.0" @@ -178,6 +179,7 @@ "integrity": "sha512-gA8oJOV1LnQQkDf91iebNnFInHuW0gRPEgLSOQ7EfipCEjYTHm5swm1DlH9H5RaRw4RrHuzHBegnlzc0MAstcg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@algolia/client-common": "5.52.1", "@algolia/requester-browser-xhr": "5.52.1", @@ -1494,6 +1496,7 @@ "integrity": "sha512-GUUEShf+PBCGW2KaXwcIt3Yk+e3pkKwWKb9GSyM9WQVE+ep2jzmHdGsHzu4wgcZy5fN9FBdVzjpBQsYlpfpgLA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -1557,6 +1560,7 @@ "integrity": "sha512-l4b0DhWioGg6Gt2ebGlvfkFMOjRsauxtsnDRwUSRX1qHq3HdTfQHV8wW9zEXeciai6HfeaKOedQn2Zoofx3WBw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.69.0", "@typescript-eslint/types": "8.69.0", @@ -2020,6 +2024,7 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2060,6 +2065,7 @@ "integrity": "sha512-fHA8+kXTbjagw3jkLiaS7KKrH8qe2DyOsiUhGlN4cdT77PEsfqXZl7ewDk1hsg+pJnPlnE50XtLxjR91iJOpmg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@algolia/abtesting": "1.18.1", "@algolia/client-abtesting": "5.52.1", @@ -2080,6 +2086,23 @@ "node": ">= 14.0.0" } }, + "node_modules/argparse": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-3.0.1.tgz", + "integrity": "sha512-nM4mHF/KM1v59ZNKX7zfusQz5wUAxR511YG8Vo6TyiV4aqhu++rbJW4v04xsWhpSsHFj66flT8P7znVpyO20xQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "PSF-2.0" + }, "node_modules/balanced-match": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", @@ -2322,6 +2345,7 @@ "integrity": "sha512-9VaAkDURekixUQJy0oJYl2DcN6oKMfxay7XzaGYAWQwsb6qfKf+x76R2k1L8kb1boc+FyCAaTA9GmiKaaiaF+A==", "dev": true, "license": "MIT", + "peer": true, "workspaces": [ "packages/*" ], @@ -2574,6 +2598,7 @@ "integrity": "sha512-/yNdlIkpWbM0ptxno3ONTuf+2g318kh2ez3KSeZN5dZ8YC6AAmgeWz+GasYYiBJPFaYcSAPeu4GfhUaChzIJXA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "tabbable": "^6.4.0" } @@ -2770,6 +2795,26 @@ "node": ">= 0.8.0" } }, + "node_modules/linkify-it": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-6.1.0.tgz", + "integrity": "sha512-wJ/TwpSDTLepCrQoYWYIExIKg5Zchex2Nn5yk2mFnB+6PtdkHtyLx742md9csRjjOnGkKIS/RrbY7l8D6gT9Vw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/markdown-it" + } + ], + "license": "MIT", + "dependencies": { + "uc.micro": "^3.0.0" + } + }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -2803,6 +2848,47 @@ "dev": true, "license": "MIT" }, + "node_modules/markdown-it": { + "version": "15.0.1", + "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-15.0.1.tgz", + "integrity": "sha512-9/7gE95FNPkfUWrjJIoHZza2iLmuJlPD0UNMxPi7bxUrbCR525YZY0r+zyfes0dZI5ZZ/uNIXUJca0pJvtw41g==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/markdown-it" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^3.0.0", + "entities": "^8.0.0", + "linkify-it": "^6.0.0", + "mdurl": "^2.1.0", + "punycode.js": "^2.3.1", + "uc.micro": "^3.0.0" + }, + "bin": { + "markdown-it": "bin/markdown-it.mjs" + } + }, + "node_modules/markdown-it/node_modules/entities": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/mdast-util-to-hast": { "version": "13.2.1", "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", @@ -2825,6 +2911,13 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/mdurl": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.1.0.tgz", + "integrity": "sha512-1+HBaOx0zi/dQWht8rNv9MYf9qqpqL/kxI0hXImU6Y547zM6Sni8BQibt7ifgMcYtQg41ao3Ivd6cnSM86inpg==", + "dev": true, + "license": "MIT" + }, "node_modules/micromark-util-character": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", @@ -3084,6 +3177,7 @@ "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -3178,6 +3272,16 @@ "node": ">=6" } }, + "node_modules/punycode.js": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz", + "integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/regex": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/regex/-/regex-6.1.0.tgz", @@ -3484,6 +3588,13 @@ "typescript": ">=4.8.4 <6.1.0" } }, + "node_modules/uc.micro": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-3.0.0.tgz", + "integrity": "sha512-U3PppEkleoTnIfi8BozMx3yju3qc/L6SwqWo2Sw+54PX+PX0q9I+r1Um5HCmqD7n9VDX5/v3vQH/AjA6deDdtw==", + "dev": true, + "license": "MIT" + }, "node_modules/undici-types": { "version": "7.16.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", @@ -3610,6 +3721,7 @@ "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", @@ -3712,6 +3824,7 @@ "integrity": "sha512-WdLBG9gm02OgJIG9axd5Hpx0TFLdzVgfG2evFFu8Rur5O/IoGc5cMjnjh3tPL6GnRGsYvUhBSKVPYVcxRKpMCA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vue/compiler-dom": "3.5.34", "@vue/compiler-sfc": "3.5.34", diff --git a/docs/package.json b/docs/package.json index db8b5039..174cf7c3 100644 --- a/docs/package.json +++ b/docs/package.json @@ -16,6 +16,7 @@ "devDependencies": { "@types/node": "^24.10", "eslint": "^10", + "markdown-it": "15.0.1", "@eslint/js": "^10", "typescript-eslint": "^8.69", "prettier": "^3.9.6", diff --git a/docs/specs/edgezero-deploy-build-caching.md b/docs/specs/edgezero-deploy-build-caching.md deleted file mode 100644 index 6dee7a89..00000000 --- a/docs/specs/edgezero-deploy-build-caching.md +++ /dev/null @@ -1,244 +0,0 @@ -# EdgeZero Deploy Actions — Build Caching Spec - -**Status:** Design (proposed) — v6.14 (sccache pivot) - -**Related:** `docs/specs/edgezero-deploy-github-action.md`, -`docs/specs/edgezero-deploy-action-implementation-plan.md`, -`docs/specs/edgezero-deploy-adoption-guide.md`, `docs/guide/deploy-github-actions.md` - -## 1. Problem - -`build-app-cli` compiles the application's CLI (native) with **no caching**, so every deploy -recompiles the whole dependency graph (~10 min for `stackpop/trusted-server-deployer`, which -checks out a **separate** application repo and builds its CLI). Caching must work for that -**cross-repository deployer** topology **and for real EdgeZero apps, whose crates are unpublished -git dependencies** (so a crates.io-only rule is unusable). - -## 2. Trust model and v1 shape - -- The build compiles **trusted code** (the deploy target); `build.rs` is trusted. -- Caching runs **only for authorized deployer events/refs (fail before compiling otherwise)**; - the runtime credential and the narrow app-checkout PAT are explicitly trusted. -- **The deployer owns and writes its repo-scoped cache**; every writer that can write the - deployer's **current-/default-branch** cache is trusted; the deployer's protected workflow - allowlists `app-repository`/`app-ref`. -- **The reusable workflow is the only SUPPORTED producer** (build + deploy in one **pinned - container**, §3.6). Provenance is a **consistency check, not producer authentication** (an - other-job archive can self-assert; attestation is §7). The direct composite is an internal `$/` - step only. -- **GitHub-hosted `linux/amd64` runners only** (no reliable ephemeral self-hosted predicate). - -## 3. Design - -### 3.1 Cache mechanism: fresh target + action-owned sccache (no `target/` pruner) - -Rather than caching and pruning `target/` (whose unit graph and intermediate layout Cargo treats -as **internal and unstable**), v1 uses **`sccache`** — the compiler cache Cargo itself recommends -for shared dependency acceleration: - -- **`CARGO_TARGET_DIR` is FRESH every run** (an action-owned path under `RUNNER_TEMP`, never - cached, never inside the checkout) — so there is no stale-`target/`, no source-in-target, no - workspace-crate-output, and no unit-graph classification problem. -- **`RUSTC_WRAPPER` is set (action-owned) to a pinned `sccache`** baked into the container. - `sccache` stores compiled rustc outputs in `SCCACHE_DIR` (an action-owned path), **keyed by the - content of the preprocessed source + compiler + flags**. Correctness is content-addressed: - restoring an older `SCCACHE_DIR` is always safe (a cached object is used only when its inputs - match), so there is no immutable-cache staleness and **no custom pruning**. `sccache` bounds its - own size (`SCCACHE_CACHE_SIZE`, LRU) — the cached directory is self-managing. -- **Cache contents = `SCCACHE_DIR` only** (compiled objects + sccache's index). **No `.crate` - sources, no `registry/src`, no `git/*`, no `CARGO_HOME/bin`, no config, no credentials** are - cached — so a cold build's `registry/src` extraction is irrelevant to the audit, and **no - dependency source is ever cached** (only compiled objects). Re-downloading crates each run is the - small remaining cost; caching `.crate` archives is §7. -- **Any dependency source is supported** (crates.io, the public **EdgeZero git repo** the generator - emits, other git deps) — sccache caches their compilation regardless of source. The old - crates.io-only restriction is **removed**; `cache: false` and `cache: true` resolve dependencies - identically (caching never changes resolution). - -### 3.2 Own restore + save, coarse rolling key - -`actions/cache/restore` + `save` over **`SCCACHE_DIR` only**: - -- **Key** = `edgezero-sccache-v1---`, restore-keys prefix - `edgezero-sccache-v1---`. `` is `github.run_id` (unique per - run), so each run **saves a fresh generation** (never colliding with an immutable prior entry) - and **restores the newest matching prefix**. `platform-id` = the container digest (which encodes - toolchain + ABI); `suffix-hash` = the validated `cache-key-suffix`. No lockfile/manifest hashing - is needed — sccache content-addresses internally. -- **Restore → audit → build → best-effort save.** After restore, **audit** that the restored path - is exactly `SCCACHE_DIR` and contains only sccache's blob/index layout (fail closed / **discard - and build cold once** on a corrupt or unexpected restore). After the build, `actions/cache/save` - under the run's `` key is **best-effort** (its failures are warnings). Bump the - `-v1-` namespace whenever the mechanism changes. - -### 3.3 Action-owned Cargo/sccache environment - -The build runs under a **constructed minimal environment** (`env -i` + an explicit allowlist), -not scrub-then-reject, so there is nothing to miss: only the action-owned variables and an -allowlist of benign ones exist. Action-owned (fixed, exact): `CARGO_HOME`, `CARGO_TARGET_DIR` -(fresh), `HOME`, `TMPDIR`, `SCCACHE_DIR`, `SCCACHE_CACHE_SIZE`, `RUSTC_WRAPPER=sccache`, -`RUSTUP_TOOLCHAIN`, `CARGO_ENCODED_RUSTFLAGS=""`, `CARGO_INCREMENTAL=0`. A **caller-supplied** -`RUSTC`/`RUSTC_WRAPPER`/`RUSTC_WORKSPACE_WRAPPER`/`RUSTDOC`/`RUSTFLAGS`/native-tool var simply is -**not present** in the constructed env (never inherited). The effective **Cargo config** over the -full chain (cwd → `/`, incl. the working directory, plus `CARGO_HOME`) must contain only benign -allowlisted keys (registry index URLs, `net.retry`, `http.timeout`/`check-revoke`); anything else -fails closed. Default-features-only; `Cargo.lock` must be a tracked, regular file. External path -deps outside the workspace root are rejected. Fixed internal container paths (`CARGO_HOME`, -`CARGO_TARGET_DIR`, `HOME`, writable `/tmp`). - -### 3.4 Identity - -`git-root` (path, confinement); `app-repo` (`owner/repo`); **`app-repo-id`** (canonical decimal -**string**, always required, **verified via the GitHub REST API to belong to `app-repository`**). -`workspace-root` canonicalized, confined beneath `git-root`, `working-directory` beneath it, -asserted `== cargo metadata.workspace_root`. `workspace-id` = hash(`app-repo-id`, workspace-root -rel `git-root`). `platform-id` = the container digest, **read inside every action from `image.json` -at the same EdgeZero SHA — never caller-supplied**; `container-ref` = `@`. - -### 3.5 Writer fidelity vs. source authorization - -Cache runs only on `push`/`workflow_dispatch`/`schedule` on a **protected deployer ref** with -`HEAD == resolved app SHA` (action fidelity); the deployer's protected workflow allowlists the app -identity, and every writer of the deployer's **current-/default-branch** cache scope is trusted -(deployer authorization). Normative in the guide. - -### 3.6 Container, runner, launcher - -- **Image:** EdgeZero-published, **public** (anonymous pull) + retained, single-manifest - `linux/amd64`, pinned by **manifest digest**, from a versioned in-repo Dockerfile baking the - pinned Rust toolchain, `wasm32-wasip1`, the pinned **`sccache`**, the pinned **Fastly CLI** - (`versions.json`), and `git jq tar curl cc`. Run **`--read-only`, non-root**, explicit writable - mounts only. `platform-id` = its digest. -- **Runner: GitHub-hosted `linux/amd64` only** (fail closed on self-hosted). Host-level job, local - Docker daemon. -- **One launcher `run-app-cli-in-container`** with **enumerated mounts** (never `RUNNER_TEMP` - wholesale): - - **Writable working COPY of the checkout.** The CLI runs arbitrary manifest commands via - `sh -c` in the manifest root and may create `dist/`, `node_modules/`, generated manifests, - etc. — so the working directory is a **disposable writable copy (or overlay)** of the app - checkout, not read-only source. The **read-only original** is used for the before/after source - checks (§3.7). (v1 alternative: prohibit manifest-command overrides; the writable overlay is - preferred.) - - **Other writable (specific):** `CARGO_TARGET_DIR`, `CARGO_HOME`, `SCCACHE_DIR`, a Fastly/ - provider `HOME`, a package/output dir. **Read-only:** the validated CLI binary, and — for - config-push — the **specific inline-config temp file** (by exact path). UID/GID mapping so the - non-root container user owns the mounts. - - **env:** only the required provider token + `EDGEZERO_*`; no GitHub file-command channels - inside the container. - - **signals/outputs:** host↔container readiness handshake; **`mutation-attempted` published - host-side to `$GITHUB_OUTPUT` before launching the mutating CLI**; named container + host-side - signal forwarding (`docker stop -t ` → `docker rm`) - - post-cancel reconciliation. - -### 3.7 Source freezing, provenance, disclosure, actions - -- **Source freezing:** on the **read-only original** checkout, assert the initial `HEAD` SHA - unchanged + tree clean (tracked + untracked + recursive submodules) **before and after** all - app-controlled commands (commands run in the writable copy); reject escaping symlinks. Consumers - additionally **verify their mounted checkout's repository id, `HEAD`, and workspace against the - artifact before and after commands**. -- **`ExpectedIdentity`:** `app-repo-id` (decimal string), `source-revision` (full SHA, explicit), - `app-cli-package`, `app-cli-bin`, `workspace-id` — **caller-supplied and checkout-verified**; - `platform-id`/`container-ref` are **derived inside every action from same-SHA `image.json`, not - accepted from the caller**. -- **Schema/canonicalization (normative, with golden vectors):** `app-cli-meta.json` is - **canonical JSON** — UTF-8, keys **lexicographically sorted at every level**, no duplicate keys - (a duplicate-key-rejecting parser is required; JSON Schema cannot do this), minimal number/string - forms — validated by a committed **JSON Schema 2020-12** file **plus** the procedural - canonical/dup-key pass. Numeric caps: meta ≤ **64 KiB**. Fields = `ExpectedIdentity` + - `app-cli-version` (informational) + `binary-sha256` + `binary-size` + `abi` - (`{ machine, interp, needed: [sorted str] }`). -- **Archive contract (normative):** a **`ustar`/`pax` tar** with **exactly two** regular members, - `app-cli-meta.json` then the `app-cli-bin` binary — **any extra/duplicate/renamed member, - symlink, hardlink, device, or path-traversal header is rejected**; total logical size ≤ **512 - MiB**, binary ≤ `binary-size`, meta ≤ 64 KiB; the extracted binary's sha256/size re-verified. -- **`validate-app-cli-provenance`** (fresh pinned container, minimal env): enforce the archive - contract; canonical-JSON + JSON-Schema validate; re-verify binary digest/size; **ABI loadability - proof** — recompute `PT_INTERP`, `DT_NEEDED`, and search paths from the binary, **resolve every - required library inside the immutable image**, then run a **credential-free, network-disabled - `--help` smoke**; compare every caller `ExpectedIdentity` field. Output `app-cli-path`. -- **`active-version-fastly`** — inputs: `artifact-tar`, `ExpectedIdentity`, `fastly-service-id`, - `fastly-api-token`; validates, runs `active-version` via the launcher; output `version` (empty on - a first-ever **production** deploy = success). **Recovery is PRODUCTION-only.** -- **`compute-app-cli-identity`** — inputs: `app-repository`/`app-repo-id`, `source-revision`, - `workspace-root`, `app-cli-package`/`app-cli-bin`; reads `platform-id`/`container-ref` from - same-SHA `image.json`; outputs the full `ExpectedIdentity`. -- **Disclosure (enforceable):** because the action cannot compare reader sets, require - **`disclosure-acknowledged: true` for every cross-repository build** (`app-repo-id` ≠ the deployer - repo id), **exempting only equal repository ids**. The sccache cache holds **compiled objects** - (not dependency source), so the exposure it acknowledges is compiled artifacts; `deploy-fastly.cache` - carries the same acknowledgement. - -### 3.8 Reusable-workflow contract - -Inputs: `app-repository`, `app-ref`, **`app-repo-id`** (string, always required), `working-directory` -(`.`), `workspace-root` (required), `app-cli-package` (required), `app-cli-bin`, `app-cli-artifact` -(**unique per matrix leg**), `cache` (default `false`), `cache-key-suffix`, `disclosure-acknowledged` -(required-true for cross-repo), `timeout-minutes` (30). **No `rust-toolchain`/feature inputs.** Secret -`app-checkout-token`. Job `permissions: { contents: read }` (caller grants ≥ that); -`persist-credentials: false`. **Runner floor 2.336.0** (self-repo `$/`). - -**Matrix:** v1's shared workflow outputs are **single-build** (GitHub returns only the last matrix -leg's outputs). A **matrix caller uses unique per-leg `app-cli-artifact` names and computes each -leg's `ExpectedIdentity` via `compute-app-cli-identity`** — it does not consume the shared outputs. - -## 4. Testing - -sccache (fresh `CARGO_TARGET_DIR` each run; `RUSTC_WRAPPER=sccache` action-owned; cold-to-warm shows -a sccache hit-rate rise and reduced compile with **network disabled** on the warm run; a corrupt -restored `SCCACHE_DIR` triggers one cold rebuild; the audited cache path is exactly `SCCACHE_DIR`; -**a git dependency (the EdgeZero repo) builds and caches**). Container/runner/launcher (self-hosted -fails closed; read-only rootfs; manifest command creating `dist/` succeeds in the writable copy while -the original stays clean; enumerated mounts only; host-side `mutation-attempted` before mutation; -cancellation `docker stop -t`+reconcile). Env/config (constructed minimal env — a caller -`RUSTC_WRAPPER` is absent, not merely rejected; non-allowlisted config anywhere fails). Identity -(`app-repo-id` API-verified against `app-repository`; `platform-id` from `image.json`, not caller; -consumer re-verifies checkout id/HEAD/workspace before+after). Provenance (canonical-JSON + dup-key; -archive exactly-two-members/format/size; **ABI loadability** — resolve `DT_NEEDED` in the image + a -network-disabled `--help`; a real wrong-runtime rejected; provenance documented consistency-only). -Disclosure required for every cross-repo build (equal-id exempt). Recovery production-only. - -## 5. Rollout, docs, migration - -**Atomic same-SHA rollout** (container image w/ sccache, reusable workflow, all three actions, -consumers, recovery); direct-composite producer retired → adopters migrate to the **two-job** -topology; runner floor **2.336.0**. Scope the parent's exact-key/target-only caching language to -`deploy-fastly.cache`; document that `build-app-cli.cache` is an **sccache disk cache** (compiled -objects, no source); apply the cross-repo disclosure rule to both caches; add the container-runner, -sccache, provenance, single-producer, and 2.336.0 updates; correct the "consumers own -checkout/runner/timeout; actions never call `checkout`" claims. Pin gate/`zizmor`/actionlint: -container digest pin, `$/` carve-outs. Public-surface golden: the `ExpectedIdentity` table, the -committed JSON Schema + **golden meta/archive vectors**, all three actions. - -## 6. Default and effect - -**Off by default** (caching). Container execution + provenance unconditional. With `cache: true` on -an authorized deployer build, sccache reuses compiled dependency objects across runs (the bulk of -the ~10 min); changed local crates recompile. - -## 7. Out of scope / future - -Caching checksum-verified `.crate` archives (download savings); workflow-bound artifact -**attestation**; native-tool (`cc`) sccache wrapping; trusted **self-hosted** runner mode; -cross-image/directional ABI; alternate toolchains (a second container); non-default features; -`cli-profile`; non-Fastly adapters. - -## 8. History - -… v6.11 (container-only) → v6.12 (own restore+save, full-runtime container) → v6.13 (crates.io-only, -hosted-only, four-root prune) → **v6.14 (sccache pivot)**: replace the unbuildable `target/` unit-graph -pruner and the unusable crates.io-only rule with a **fresh `CARGO_TARGET_DIR` + an action-owned pinned -`sccache` disk cache** (content-addressed, no pruning, any source incl. git deps, no source cached); -coarse rolling `run_id` generation key; **constructed minimal build env**; **writable working copy** -for manifest commands (read-only original for the freeze checks); `app-repo-id` **API-verified**, -`platform-id` **from `image.json` not the caller**, consumer **re-verifies checkout before+after**; -**disclosure required for every cross-repo build** (equal-id exempt); **ABI loadability** via resolved -`DT_NEEDED` + a network-disabled `--help`; normative **canonical-JSON + tar** contracts with golden -vectors; **matrix caller computes per-leg identity**; container plan gains a **verify-by-digest-then-PR** -publish (§ container sub-plan). - -## 9. Deferred to the implementation plan (mechanics only) - -Exact `prepare`/`compile`/launcher/helper signatures; the Dockerfile (checksum-verified Fastly CLI + -pinned sccache) + digest-pin + **verify-by-digest-then-PR** GHCR publish; the committed JSON Schema + -golden vectors; and the writer-fidelity / API-repo-id-binding / canonical-JSON predicate expressions. diff --git a/docs/superpowers/plans/2026-08-20-build-cache-actions.md b/docs/superpowers/plans/2026-08-20-build-cache-actions.md new file mode 100644 index 00000000..2bcf6747 --- /dev/null +++ b/docs/superpowers/plans/2026-08-20-build-cache-actions.md @@ -0,0 +1,217 @@ +# Build Cache Actions Implementation Plan (plan 2 of 5) + +> **Execution:** Start only after `2026-08-20-build-cache-container.md` records passing +> `{G,S,D,B}`. Use test-driven development and the gate-rotation procedure for every gate-owned test +> or helper change. + +**Goal:** Implement the shared app-source/identity/environment preflight and the optional sccache +restore/compile/save primitive that later plans consume, without making cache availability part of +build correctness or exposing credentials to compilation. + +**Spec:** `docs/superpowers/specs/2026-08-20-edgezero-deploy-build-caching-design.md` v6.39 Sections +2 through 5 and 9. + +## 1. Fixed decisions + +- Pin `actions/cache/restore@v6.1.0` and `actions/cache/save@v6.1.0` exactly. +- Pin host Git LFS to exact `3.7.1` and verify + `git-lfs-linux-amd64-v3.7.1.tar.gz` against SHA-256 + `1c0b6ee5200ca708c5cebebb18fdeb0e1c98f1af5c1a9cba205a4c0ab5a5ec08` and exact size + 5,524,590 bytes before installation. +- Cache only `${RUNNER_TEMP}/edgezero-sccache-v1`, mounted as `/work/sccache`; never cache target, + Cargo home, sources, registry data, credentials, or artifacts. +- Cache family is exactly `edgezero-sccache-v1--`, generation is exact + `job.check_run_id`, and the only restore prefix is `-`. +- `cache: false` selects `uncached-compile` and performs no restore, cache audit, sccache mount/start, + stats, stop, or save. `cache: true` accepts the documented stale-object and disclosure risks but + does not authorize save by itself. +- After the required metadata preflight, Cargo has exactly one compile/build invocation. The action + has no compile retry. Pinned sccache's documented post-`CompileStarted` local fallback is accepted + internal client behavior. + +## 2. Gate update before implementation + +- [ ] Add gate-owned failing fixtures and structural tests for the exact cache action versions, + authority/export boundaries, shared identity/environment/toolchain policy, stable host/path + literals, key grammar, separate lookup/save predicates, no credential mount/environment, exact + ordering, warning-only restore/save behavior, and one Cargo compile/build invocation after the + closed container-only metadata preflight. Reject any host `cargo metadata` invocation. +- [ ] Add shell fixtures for sccache 0.10.0 layout/stats, including corrupt records, unexpected paths, + write errors, stop failure, sparse files, hardlinks, special files, owner mismatch, path limits, + entry-count limit, and checked-byte overflow. +- [ ] Add closed canonical `.github/actions/deploy-core/host-tools.json`, the trusted Git LFS + installer, object-first authority materializer, exporter implementation, and focused tests to + the gate-owned path manifest. These exact runtime helper bytes are part of the gate update; no + public action metadata or workflow calls them yet. The manifest has exactly the design's literal + JCS bytes; trusted code constructs the fixed official release URL. + Permit at most three HTTPS redirects from exact `github.com` to a final exact + `release-assets.githubusercontent.com` host without credentials. Require final HTTP 200, + identity encoding, one exact `Content-Length: 5524590`, a streaming cap of 5,524,590 bytes, and + exact received size. Reject unknown/missing fields, unsupported platforms, a different asset/ + version/size, downgrade/host drift, absent/duplicate/malformed/mismatched length, chunked or + oversized/partial/trailing transfer, checksum or archive-layout mismatch, and replacement or + wrong-version execution of the installed binary. +- [ ] Land those tests as a gate-update PR under old `G`, activate the resulting gate revision, and + complete the full rotation/recovery checklist. Record it as this plan's active gate. Do not mix + public action wiring/metadata into the gate PR; the reviewed materializer/exporter helper itself + is gate code and lands here. Do not publish a new image because image-context bytes are unchanged. +- [ ] Exercise this handoff with protected main already beyond `B`: dispatch at protected head `Q`, + prove active `G` ancestry and manifested-byte equality, then activate the new gate through the + plan-1 lock protocol. Do not require the entire main commit to equal the old gate commit. + +## 3. Shared build preflight, identity, and keys + +- [ ] Write failing tests for the length-framed `workspace-id` and suffix hash. Commit vectors for root + `.`, nested workspace, repository IDs of different decimal lengths, empty/255-byte suffixes, + overlong/control suffixes, malformed UTF-8 paths, and byte-distinct NFC/NFD names. +- [ ] Implement identity calculation in one shared host orchestrator, which remains the sole owner in + later plans. Validate the authority checkout, canonical `git-root`, workspace and working-directory + containment, and tracked regular `Cargo.lock`; then invoke the canonical launcher's closed + `metadata-preflight` profile against read-only Copy A in the producer, Copy B in a source-bearing + deploy action, or the action-local identity-only Copy I owned by plan 3/4 when neither build copy + exists. It runs exact + `cargo metadata --locked --format-version 1` with bridge network, 1 GiB/no extra swap, 128 pids, + ten-minute timeout, fresh Cargo home/tmpfs, exact Rustup toolchain, and no target/sccache/app-env/ + token/Git credential/configuration, authority, or other host mount. Duplicate-reject and parse one bounded + stdout JSON document, require exact workspace/package/bin/path-dependency agreement, and construct + identity only afterward. Tests replace host `cargo` with a fatal sentinel and cover malformed/ + oversized/trailing metadata, stderr diagnostics, public dependency fetch, and container failure. +- [ ] Consume, without modifying, the active gate's shared authority materializer and trusted + exporter. Wire validated repository/ref/token inputs to those immutable helper bytes. They + install the exact verified Git LFS binary; fetch exact commits with system/global config, + includes, templates, hooks, credential helpers, worktree creation, filter execution, and + submodule initialization disabled; recursively validate committed attributes/config/gitlinks; + then create worktrees with filters disabled and materialize through the absolute LFS binary. + Require their existing tests for no forbidden command/origin contact, pointer residue, + configuration races, credential cleanup, and `.git`-free non-hardlinked Copy A/B to pass. Any + required helper change stops action work and returns to a separate gate rotation. +- [ ] Move the duplicate-rejecting bounded `app-env` decoder, empty Cargo-config/credentials policy, + path-dependency confinement, and exact `rust-toolchain` comparison into shared helpers here. + Deny exact `RUSTC`/`RUSTDOC`, every `RUSTC_`/`RUSTDOC_` name, and native-tool/flag prefix and + suffix forms including `CC_`, `_CC`, `HOST_CC`, `TARGET_CC`, and corresponding + `ARFLAGS`/`CFLAGS`/`CXXFLAGS`/`CPPFLAGS`/`LDFLAGS` variants. Also deny `CXXSTDLIB`, + `CXXSTDLIB_STATIC`, `CRATE_CC_NO_DEFAULTS`, all `CRATE_CC_*`, and every other exact design + control. Commit the full valid/invalid name/value/filter/config/toolchain fixtures and prove both + cached and uncached profiles reject compiler/wrapper/native-build replacement. Plans 3 and 4 + call these helpers and must not reimplement them. +- [ ] Emit shell-safe typed outputs and reject duplicate, missing, multiline, or malformed output. + Tests independently recompute SHA-256 bytes; they do not call the implementation as oracle. +- [ ] Implement the sole shared runner-eligibility helper with exactly three values bound directly + from `runner.environment`, `runner.os`, and `runner.arch`. Require exact `github-hosted`, `Linux`, + and `X64`; missing, empty, differently cased, self-hosted, caller-input/env-derived, or + architecture-only proofs fail. The helper accepts and validates no workflow, action, + application, cache-generation, or provider identity. Plans 3 and 4 must call it as every public + action's first executable step, with no conditional, continuation, or failure-masking wrapper; + each shell-backed caller declares step-local empty `BASH_ENV` and `ENV` in metadata so no caller + startup file executes before the helper body. Structural fixtures reject missing, inherited, or + nonempty bindings; + helper failure must success-gate every later non-cleanup internal step. Plan 3 separately owns + the checkout-independent reusable-producer bootstrap that validates workflow identity, app ref, + and `job.check_run_id` before checkout or cache access. +- [ ] Implement the sole canonical container-launch helper, including `metadata-preflight` before any + compile operation. It owns the closed operation enum, + profile-to-environment mapping, sorted env-file serializer, placeholder split-string builder, + exact `/usr/bin/env -S` argv, `docker create` array construction, env-file deletion before + start, attach, timeout, named-container removal, and cleanup verification. Its tests require + exact placeholder/env-file name equality and prove no argv element is constructed by inserting + an environment value. Plans 3 and 4 may add reviewed enum variants and profile data, but must + call this serializer and lifecycle unchanged rather than reimplementing them. + +## 4. Restore and pre-use audit + +- [ ] Compute lookup eligibility before creating the host root or invoking `actions/cache/restore`. + First authenticate `app-repository`, verify its actual repository id and authority checkout, + and reject a mismatched caller `app-repo-id`. Same-repository builds are eligible only when that + verified id equals the event repository id; cross-repository `cache:true` requires boolean + `disclosure-acknowledged:true`. Missing, false, stringified, or malformed acknowledgement fails + the action before restore rather than degrading to restore-only. Commit a complete lookup truth + table, including a forged same-repository id, independent of save authorization. +- [ ] Create the stable host directory fresh and prove its canonical path equals the fixed runner-temp + child. Require it absent before create, mode 0700, uid/gid 1001, non-mount status, and stable + device/inode. Reject symlinked runner temp, any preexisting path, root replacement, nested mount, + wrong owner, or cleanup failure. +- [ ] Restore with exact primary key and sole family prefix. Restore failure, absence, download error, + or audit failure emits a warning, removes the complete host directory without following links, + recreates it empty, and continues cold. +- [ ] Implement the audit over descriptor-relative traversal. Accept only expected sccache 0.10.0 + regular files/directories beneath root with container uid/gid and `nlink==1` for files. Reject + links, sockets, FIFOs, devices, nested mounts, path escape, unknown layout, and arithmetic error. +- [ ] Enforce at most 2,147,483,648 summed regular-file `st_size` bytes, no sparse file + (`st_blocks*512 < st_size`), at most 100,000 descendants, path length at most 4,096 bytes, and + component length at most 255 bytes. Do not inspect or predict the cache action's tar/zstd wire + representation. + +## 5. Compile lifecycle + +- [ ] Implement a closed `uncached-compile` branch with Copy A, fresh target/Cargo home, and the shared + validated environment, but no sccache mount, process, socket, wrapper, or `SCCACHE_*` variable. + Invoke Cargo compile/build exactly once after metadata and test the complete mount/environment/ + process snapshot. +- [ ] Launch the pinned image by digest with read-only root, uid/gid 1001, dropped capabilities, + `no-new-privileges`, 6 GiB memory/no extra swap, 512 pids, bridge network, numeric integer + `timeout-minutes` in 1..120, and only the cached-compile mounts from the design. + Checkout tokens, GitHub file-command paths, provider inputs, and provider tokens must be absent. +- [ ] Construct the exact closed environment, including absolute `RUSTC_WRAPPER`, `SCCACHE_DIR`, 2G + managed size, `SCCACHE_IGNORE_SERVER_IO_ERROR=1`, zero incremental mode, empty encoded rustflags, + and validated `app-env`. Use the sole canonical launcher to serialize sorted `NAME=value` + env-file lines and launch fixed `/usr/bin/env`, literal `-S`, and the exact sorted + `-i NAME=${NAME}` placeholder string before the absolute Cargo command. Prove inherited + `RUST_VERSION`, Docker `HOSTNAME`, and seeded poison are absent at target-command entry and no + argv element is value-derived. Enforce the design's empty Cargo-config policy before launch. +- [ ] Start sccache, zero stats, invoke Cargo compile/build exactly once after metadata with locked inputs, capture exact + `sccache --show-stats --stats-format=json`, and stop the server. Validate the complete closed + v0.10.0 `ServerInfo`/`ServerStats` schema before reading `stats.cache_write_errors`: the six + top-level fields and all 22 exact `stats` fields listed by the design, exact language/count-map + and duration shapes, `cache_location` equal to `Local disk: "/work/sccache"`, bounded nonnull + `cache_size`, `max_cache_size:2147483648`, false preprocessor mode, and version `0.10.0`. Reject + duplicate/unknown/missing fields, wrong types, invalid duration nanoseconds, negative/overflow + counters, wrong version/cache path, and trailing data. Surface Cargo failure once. A wrapper startup/connection or + nonaccepted sccache failure is a build failure, not an action retry. +- [ ] On successful compile, require stop success, parse exact 0.10.0 stats, and rerun the complete + stopped-directory audit. Stop failure, malformed stats, nonzero `cache_write_errors`, or final + audit failure skips save with a warning but preserves successful build output. + +## 6. Save authorization + +- [ ] Commit a complete save truth table distinct from lookup eligibility. Save is true only for exact `push` or `workflow_dispatch`, boolean + `github.ref_protected==true`, boolean `github.event.repository.fork==false`, equal event/current + repository IDs, passed workflow identity, successful compile/stop/audit, zero write errors, and + satisfied cross-repository disclosure. Missing, stringified, malformed, or caller-supplied + substitutes are false. +- [ ] Explicitly cover `pull_request`, `pull_request_target`, `merge_group`, fork events, deleted or + unprotected refs, repository mismatch, same-repository disclosure exemption, and cross-repository + acknowledgement. A save-denied row remains restore-only only when lookup eligibility passed. +- [ ] Save with the immutable generation key. Save failure is warning-only. Verify no cache deletion, + reservation protocol, fallback key, mutable exact-key overwrite, or post-token save exists. +- [ ] After the sole save attempt or every earlier terminal path, remove only the recorded root by + descriptor-relative no-follow traversal and verify it is absent. Cleanup failure is fatal even + when restore/save failure was warning-only; test consecutive invocations in one job cannot + inherit a dirty fixed directory. + +## 7. Integration and completion + +- [ ] Integrate the cache/compile primitive only into a non-public test harness under the protected + contract suite. The build-only reusable workflow does not exist before plan 3, and the current + direct-composite producer must not expose an intermediate cache/provenance contract. Its sole + change in this plan is to call the shared runner-eligibility helper as its first executable step; + tests require rejection of missing/malformed/self-hosted context and of a conditional, + continued, failure-masked, or nonempty/inherited `BASH_ENV`/`ENV` guard before existing producer work. Do not otherwise change + provider or public producer behavior in this plan. +- [ ] Run cold, warm, corrupt-restore, concurrent-generation, stop-failure, write-error, audit-failure, + save-denied, save-warning, and sccache response-loss fixtures. Assert the design's exact cold + Rust miss/write counters, a new-job warm restore with at least one post-zero Rust cache hit and + equal binary digest, and complete default-off absence; elapsed time is never evidence. Model + seven-day eviction as an earlier warm generation becoming absent and require an ordinary cold + success with no retry or deletion claim. Place arbitrary app-written bytes in an otherwise valid + regular sccache entry, prove the shape/size audit accepts opaque content only after the applicable + disclosure acknowledgement, and prove cross-repository lookup rejects it without acknowledgement. +- [ ] Run shellcheck, the protected contract/container harness, `scripts/run-actionlint.sh`, zizmor, + and all Rust checks. Defer non-public workflow harness integration to plan 3 after the workflow + exists and actual literal-`C` public cold/warm qualification to plan 5. Confirm all non-local + actions remain exact-version pinned. +- [ ] Merge through the one-entry queue. Record the resulting commit and active gate revision for plan + 3; do not designate final `P` yet. + +**Gate:** the isolated primitive compiles correctly with an empty or unavailable cache; no restored +byte or application process can observe a credential; and only an action-derived authorized event can +attempt a save. It is not a supported producer until plan 3 integrates it. diff --git a/docs/superpowers/plans/2026-08-20-build-cache-consumer-adoption.md b/docs/superpowers/plans/2026-08-20-build-cache-consumer-adoption.md new file mode 100644 index 00000000..39aff217 --- /dev/null +++ b/docs/superpowers/plans/2026-08-20-build-cache-consumer-adoption.md @@ -0,0 +1,337 @@ +# Build Cache Consumer and Adoption Implementation Plan (plan 5 of 5) + +> **Execution:** Start after plan 4 is merged and its gate revision is active. Select candidate exact +> version `C` before opening the final executable candidate. Select and publish stable `V` only after +> that merged commit passes detached local and immutable-candidate hosted verification; release the +> runnable documentation afterward as protected revision `R`. + +**Goal:** Ship the two-job producer/consumer workflow, prove real app repositories can adopt it with +full-SHA app identity and explicit inputs, publish exact stable action version `V`, then activate +synchronized runnable documentation at revision `R` without ever merging an unpublished version ref. + +**Spec:** `docs/superpowers/specs/2026-08-20-edgezero-deploy-build-caching-design.md` v6.39 Sections +3.3, 5.1, 5.2, 5.4, 7, 9, and 10, plus the parent deploy lifecycle contract. + +## 1. Release structure + +- `C` is an unused canonical exact patch version with no prerelease suffix. After the final candidate + reaches protected main as commit `H`, `C` is published as an immutable GitHub Release at `H` with + `prerelease:true` so hosted cross-repository workflows can test the exact merged bytes while every + `uses:` ref remains a full version. +- `H` becomes final action revision `P` only after the complete clean-detached local suite at `H` and + complete hosted suite through literal `C` pass. Only then is distinct unused canonical stable + version `V` selected and published as an immutable release at exact `P`. +- `R` is a later documentation-only protected-main commit. After the literal-`V` hosted smoke passes, + it adds canonical `docs/.edgezero-action-release.json` bound to `{V,P}` and replaces the bootstrap + placeholder in tracked Markdown with literal `V`. It changes no action/workflow implementation or + gate-owned path, and it is not a new action revision. +- Repository immutable releases must report enabled, and exact no-bypass `refs/tags/v*` rules prevent + action-version tag update/deletion. Failed candidate versions remain immutable; a correction uses a + new commit and newly selected unused `C`. A failed published stable version is never + retargeted; it is superseded by a new patch. Deletion of a GitHub Release object or mutation of its + title, notes, prerelease, or latest metadata by a sufficiently privileged actor remains an accepted + availability/discovery risk, so final evidence preserves release attestations and audits release + existence/state; tag deletion and retargeting remain blocked by the no-bypass ruleset. +- The gate built in plan 1 owns the entire bootstrap-to-released documentation transition. Before + `R`, only the four named prepublication documents may use the exact placeholder and are not claimed + runnable. At and after `R`, all tracked Markdown is placeholder-free and every EdgeZero `uses:` ref + names literal stable `V`. + +## 2. Gate update and contract fixtures + +- [ ] Add parsed-workflow tests for exact two-job separation, canonical exact patch-version + reusable-workflow/action refs, unique artifact names, caller and action identity forwarding, + public identity-action use, action-local source materialization with no authority-path handoff, + no aggregate matrix identity, minimal permissions, secret confinement, and absence of + direct-composite producer use. Classify jobs by parsed AST shape. Require literal + `runs-on: ubuntu-24.04` in the reusable producer and every step-based consumer job containing a + `steps[*].uses` public EdgeZero action reference. Require a job-level EdgeZero reusable-workflow + caller with `jobs..uses` to have neither `steps` nor `runs-on`. Reject mixed shapes, absent or + dynamic step-based labels, `ubuntu-latest`, other standard, larger, custom-image, and self-hosted + labels. Require the producer bootstrap, inline checkout verification, and shared runner helper + in exact order without conditional, continuation, failure masking, pre-verification local + execution, inherited/nonempty `BASH_ENV` or `ENV`, or non-cleanup always-run paths. Every + shell-backed first guard has step-local empty values. Require the legacy path at candidate `H` to be only + the exact non-producing retirement stub, and reject any workflow or documentation that invokes + it as a producer. +- [ ] Add public/private app repository fixtures, root/nested workspace fixtures, public Git and + sibling path dependencies, submodules, no-filter and pinned-LFS cases, app-env migration, + generated outputs, production/staging provider paths, and cache enabled/disabled cases. +- [ ] Include a private-app adoption fixture whose only stored checkout credentials are an App client + id and private key, plus token-mode and mixed/partial/absent-mode rejection fixtures. Cover a + two-phase hosted run whose protected consumer remains unapproved without a runner for more than + 24 validated hours, then downloads the original artifact under adequate default retention; cover + token minting only after that approval, token expiry before a + later source-bearing step, and missing/expired artifacts under inadequate retention. Never use + a masked job output or an artifact to transfer checkout credentials between jobs. +- [ ] Add negative fixtures for major/minor/prerelease/SHA/branch refs in published workflows, mixed + EdgeZero versions, producer provider inputs, missing authority/Copy B identity check, authority + path/descriptor/handle passed between actions, checkout token passed to a source-free action, + artifact-name reuse, caller-supplied platform/expected identity, ambient env reliance, custom + Git filters, and legacy `--stage`. +- [ ] Land and activate this gate update before changing reusable workflow/action interfaces. The gate + validates version grammar and equality, not a not-yet-created value for `V`. Use protected + dispatch head `Q` with active-`G` ancestry/manifest equality; this is a post-`B` rotation, not a + new bootstrap requiring main to equal `G`. + +## 3. Final reusable producer workflow + +- [ ] Freeze `.github/workflows/build-app-cli.yml` as a build-only `workflow_call` interface with the + exact design inputs, required `rust-toolchain`, exclusive token/App authentication with optional + `app-checkout-client-id` and the two optional checkout secrets, hosted-only workflow + version/resolved-SHA identity checks, literal `runs-on: ubuntu-24.04`, the first-step inline + producer bootstrap, bounded timeout, cache default `false`, and no provider credential or + mutation surface. +- [ ] Preserve plan 3's host-only App minting inside the called workflow, after verified action-source + bootstrap and before app fetching. Scope it to the single app repository with contents-read + permission, forbid token/private-key outputs and runtime-container exposure, and require + post-step revocation. Token mode does not mint or revoke a caller-owned token. +- [ ] Materialize EdgeZero source separately at exact `job.workflow_sha`. Checkout the app into a + recursive non-sparse authority root at full lowercase `app-ref`, verify authenticated repository + id and CallerExpectedIdentity, remove credentials, enforce the no-filter-or-pinned-LFS contract, + and invoke the shared exporter to create non-hardlinked `.git`-free Copy A. Prove no local action + or helper resolves from app data. +- [ ] Compile/package through the digest-pinned image and upload one deterministic named artifact. + Reverify the authority after use and remove Copy A plus every operation root on all exits. +- [ ] Emit only `artifact-name`, trusted `action-version`, resolved `action-revision`, and the five + CallerExpectedIdentity fields. Do not expose host paths, image ref/digest, protocol, cache path, + token, provider state, or an aggregate matrix value. +- [ ] Preserve separate cache lookup and save decisions: cross-repository cache use without disclosure + acknowledgement fails before restore; save additionally requires the protected-event predicate. + `cache:false` uses the no-sccache `uncached-compile` profile and Cargo has exactly one compile/ + build invocation after metadata preflight. + +## 4. Consumer job topology + +- [ ] Provide tested workflows in which job 1 calls the reusable producer and job 2 invokes public + `compute-app-cli-identity` at the same action version with the exact + repository/ref/id/workspace/cwd/package/bin/toolchain inputs and `app-checkout-token`. Compare + all five typed outputs with the producer and only then invoke provider actions with the named + artifact. Every step-based consumer job containing a public EdgeZero action reference declares + literal `runs-on: ubuntu-24.04`; the caller job with `jobs..uses` that directly invokes the + reusable workflow has neither `steps` nor `runs-on`, and the called workflow owns its literal + label. The identity action destroys its action-private authority before returning and exposes no + host path or handle. +- [ ] Pin every EdgeZero reusable workflow and action within a published consumer workflow to one + identical literal stable `V`. Generated candidate-release tests substitute one identical literal + `C`. Reject mixed versions, major/minor tags, prereleases in published examples, SHAs, and + branches even when each ref resolves. +- [ ] Pin every third-party action to a separately reviewed canonical stable patch version. Record its + release URL and resolved commit in release evidence and prove no same-named branch exists at + review time. Later version-tag movement/deletion or same-name branch ambiguity introduced by a + trusted third-party publisher is accepted risk; branches, major/minor tags, prereleases, commit + SHAs, and mutable Docker tags remain prohibited in repository text. +- [ ] Pass producer `action-version` and `action-revision` to the comparison step. Every EdgeZero + composite requires its runner-provided action repository/ref to equal + `stackpop/edgezero@` before downloading anything. EdgeZero immutable release and + tag protections bind `V` or `C` to the producer revision. +- [ ] Give each matrix leg a deterministic unique artifact name and keep its identity comparison in + that leg. Reject aggregation, `merge-multiple`, wildcard downloads, and outputs inferred from a + matrix-wide reusable-workflow call. +- [ ] Keep checkout tokens host-side and provider tokens only in consumer steps that require them. + Pass `app-checkout-token` only to the identity action and the exact source-bearing actions + `deploy-fastly` and `config-push-fastly`; each independently materializes its authority and + removes its checkout credential channel before app code or provider-token creation/injection. + Source-free actions receive neither source-materialization inputs nor the token. Define explicit + job permissions and prove artifacts, caches, summaries, logs, public EdgeZero outputs, and + job/workflow outputs contain neither token. The token action's masked same-job output is the + sole output-channel exception. +- [ ] In App-authenticated consumer jobs, mint a fresh repository-scoped contents-read token after + environment approval using exact `actions/create-github-app-token@v3.2.0`. Supply its masked + same-job output to identity/source-bearing actions, mint again before a later invocation when + the previous token could have expired, and retain mandatory revocation. Never pass a private + key into an EdgeZero composite or a token through `needs`. +- [ ] Exercise `validate-app-cli-provenance`, `active-version-fastly`, `deploy-fastly`, + `healthcheck-fastly`, `rollback-fastly`, and `config-push-fastly` as independent consumers. Each + action downloads its own artifact, derives PlatformIdentity locally, writes a fresh expected file + from verified caller plus local platform fields, validates/smokes, and cleans its private state. + `deploy-fastly` and `config-push-fastly` additionally rematerialize and verify independent + action-local authorities; they never reuse the identity action's destroyed authority. + +## 5. App-repository migration behavior + +- [ ] Document both producer authentication modes, their exact fields, and exclusive validation. + App-only adopters pass the client id and private-key secret to the reusable producer, then mint + separate tokens in approved consumer jobs. Explain the single-app-repository App-token scope; + private external submodules require a pre-provisioned token covering the source graph, with no + implicit token widening. Same-repository callers may explicitly pass `GITHUB_TOKEN` in token mode. +- [ ] Preserve repository-default artifact retention and require it to cover the documented maximum + approval/retry/recovery window. Do not impose one day, transport secrets in artifacts, silently + rebuild after expiry, or imply that caching controls artifact availability or readership. + Artifacts inherit the caller repository's readership even with caching off; private-app/public- + deployer adoption must explicitly acknowledge that exposure in its reviewed workflow policy. +- [ ] Replace ambient workflow `env` examples with the explicit duplicate-safe `app-env` JSON object. + Document the exact deny rules and state that otherwise allowed values are caller-classified as + non-secret and may affect cross-repository compilation cache contents. +- [ ] Add `generated-output-paths` only for absent repository-relative roots genuinely written by the + credential-free app build. Explain that selected Fastly project `bin` and `pkg` roots are + implicit, callers must not list them, and preexisting/overlapping/tracked-containing roots fail. +- [ ] Require full app commit SHA, canonical repository id, explicit workspace root/package/bin and + `rust-toolchain`, and a tracked lockfile. Cover nested workspaces and private authority checkouts + without suggesting app branch or tag refs. +- [ ] Document the exact consumer authority interface: `compute-app-cli-identity` returns only five + typed identity fields; source-bearing actions receive the same explicit source inputs plus the + checkout token and rematerialize independently; source-free actions receive neither. Do not + document or expose an authority path, checkout step output, or reusable handle. +- [ ] State that the caller repository's effective Actions policy must permit version-tag action and + reusable-workflow refs; an organization/enterprise full-SHA mandate is incompatible with this + release policy and must fail adoption preflight rather than trigger an undocumented SHA fallback. +- [ ] State that v1 supports only step-based consumer jobs containing public EdgeZero action + references with literal `runs-on: ubuntu-24.04`, plus job-level reusable-workflow callers that + omit `steps` and `runs-on` and delegate to the producer's identical internal label. Other + standard Ubuntu labels, `ubuntu-latest`, larger runners, custom GitHub-hosted images, and + self-hosted runners are outside the compatibility contract; the action's runner-context + predicate is still required because the label is not security evidence and is not observable + from a composite action. +- [ ] Explain that protocol 1 rejects custom Git filters, supports only no filter or the action's + pinned Git LFS materialization path, rejects repository/enclosing Cargo config and credentials, + requires the image toolchain, permits only public dependency fetching, and preserves the + accepted undeclared proc-macro/build-script cache risk. +- [ ] Keep caching opt-in and distinguish `build-app-cli.cache` from `deploy-fastly.cache`. Both require + disclosure acknowledgement before cross-repository restore and use the same protected-event save + predicate. The latter applies only to credential-free app-build, saves before token introduction, + and does not prevent Fastly's token-bearing deploy compile. +- [ ] Preserve production/staging, first-deploy, healthcheck, rollback, cancellation, config-push, and + mutation-attempt semantics from the parent guide. Every staged command uses `--staging`; no + compatibility alias or legacy `--stage` instruction remains. + +## 6. Candidate-independent integration preparation + +- [ ] Create disposable public and private app repositories or equivalent GitHub-owned fixtures with + immutable source SHAs. Validate their repository ids, authority/export state, required LFS cases, + explicit app inputs, and provider test credentials before selecting `V`; do not replace hosted + evidence with local `act` or Docker-only tests. +- [ ] Build a release harness that writes one exact EdgeZero version into every producer/provider ref, + verifies all refs match, triggers literal `ubuntu-24.04` hosted linux/amd64 runs, polls exact + run/job attempts, and records artifact/image/action/app identities without logging credentials + or app-env values. +- [ ] Locally prove cold/warm/default-off cache behavior, source relocation, nested workspace identity, + artifact transfer, identity-action/source-bearing authority separation, Copy A/Copy B + independence, expected-file freshness, exact caller/platform/action validation, sorted + placeholder-only `env -S` launch argv, and exact post-`env -i` target environments using + generated fixtures. Every public action fixture also covers missing/malformed context bindings + and self-hosted Linux/X64 rejection before artifact, source, Docker, or token work. +- [ ] Prepare production/staging provider fixtures for successful deploy, unhealthy rollback, first + deploy, active-version, healthcheck, config push, and cancellation reconciliation. Assert no + mutation on identity, source-freeze, output-root, loader, or token-order failure. + +## 7. Build final candidate `H` + +- [ ] Query current releases and remote refs, select unused canonical patch version `C`, and validate + its grammar. Require no current Git ref or release with that `tag_name`; release display `name` + is not identity and supplies no historical proof. Record the queries and results, and require + later creation to succeed without tag reuse. Do not create the tag yet, and do not select `V`. +- [ ] Reconcile the parent spec, original implementation plan, adoption guide, and public guide + against shipped metadata: two-job topology, authority materialization, action/caller identity, + exact app inputs, `app-env`, generated outputs, cache defaults, provider lifecycle, and literal + `runs-on: ubuntu-24.04` on every step-based consumer job containing a public EdgeZero action + reference, with no `runs-on` or `steps` on a job-level reusable-workflow caller. Keep the exact + `` bootstrap placeholder in these four prepublication documents; do + not introduce a guessed or unpublished stable version. +- [ ] Run plan 1's permanent documentation scanner in bootstrap mode over every tracked Markdown + file. Prove `docs/.edgezero-action-release.json` is absent, the placeholder appears only in the + four named surfaces, no other action-ref placeholder exists, and every third-party ref is an + exact stable patch version. Require the exact step-based/reusable-caller AST and runner-label + rules for every fenced consumer workflow. Do not modify the gate-owned scanner in this plan. +- [ ] Run every protocol, cache, image, launcher, source-freeze, provider, workflow, fixture, docs/pin, + actionlint, zizmor, shellcheck, Rust, and local integration suite at one clean candidate descended + from `B`. Confirm `image.json` remains reviewed `{D,S,protocol}`. +- [ ] Run independent contract and release-adversary reviews against design v6.39, including exact-tag + policy, third-party tag movement risk, EdgeZero immutable releases, action-version mixing, + substitution, identity replay, malformed artifacts, host/container races, source mutation, + generated-output escape, credential flow, cache disclosure, rollback, and cancellation. +- [ ] Merge through the one-entry queue and require exact protected-main push checks. Record the + resulting lowercase full commit SHA as `H`; do not designate it `P` or publish stable `V` yet. + +## 8. Qualify `H` and publish `V` + +- [ ] From a clean detached checkout at exact `H`, rerun the complete local suite from Section 7, + including the bootstrap-state docs/pin scanner, exact staged container build, Docker-backed + provider tests, and repository CI commands. Require + `git status --short --untracked-files=all --ignore-submodules=none` to be empty before and after + qualification, and verify the running Node version equals the exact `nodejs` value in + `.tool-versions` before `npm --prefix docs ci --ignore-scripts`. Preserve logs and digests bound + to `H`. +- [ ] Select the release operator before `C` exists. Use a short-lived fine-grained PAT selected only + for `stackpop/edgezero`, expiring within 24 hours, with exactly repository `Contents:write` and + `Workflows:write`, implicit metadata read, organization `Members:read`, and no other grant. Keep + it outside Actions/argv/environment, reject classic or installation tokens, and preserve its + settings as review evidence. The local helper permits only no-redirect versioned `GET /user`, + exact organization-id/team-id membership GET, release POST, release-id PATCH, and release-id GET + routes from the design. Require active team membership and no unallowlisted method/path/query/ + body field; destroy the token after release work. +- [ ] For each action release, create a draft with exact `tag_name`, full target commit, no assets, + and required `prerelease` boolean; publish only by a PATCH that changes `draft` to false. Record + requests/responses, authenticated login, release `author.login`, team membership, release id/ + URL, remote peeled ref, exact state, and generated attestation. The approved release POST must + return 201 plus one complete JSON value, and the release-id PATCH must return 200 plus one + complete JSON value. A different actor, broader token, wrong status/body, or direct unrecorded + tag creation fails release. +- [ ] Reconfirm the immutable-releases endpoint returns HTTP 200 with `enabled:true` and boolean + `enforced_by_owner`, and both action-version tag rulesets are exact. Publish candidate `C` as an + immutable release targeting exact `H` with API field `prerelease:true`; require its exact + patch-version `tag_name`, `draft:false`, `immutable:true`, and the remote peeled tag to resolve + to `H`, and record the generated release attestation. Never move or delete a failed candidate + tag. +- [ ] Run the complete hosted cross-repository suite with every EdgeZero workflow/action ref equal to + literal `C`: cold/warm/default-off caches; public/private and LFS source; two matrix identities; + artifact/identity/expected handoff; all provider lifecycles; all negative pre-mutation cases; and + cancellation reconciliation. Include the recorded beyond-24-hour retained-artifact qualification + run. Verify exact run attempts and `action-revision==H`. +- [ ] If either exact-`H` suite fails, fix on a new commit, choose a new unused `C`, and repeat Sections + 7-8; no stable `V` has been selected. After both pass, designate `H` as final action revision `P`. +- [ ] Query current releases and remote refs, then select a distinct unused canonical stable patch + version `V`. Require no current ref or release with that `tag_name`. Publish `V` through the same + exact fine-grained-PAT actor/draft/publish procedure targeting `P`; require API and anonymous + peeled-ref resolution to equal `P`, `draft:false`, `prerelease:false`, and `immutable:true`. + Preserve the attestation/operator evidence and verify no major/minor alias was created or moved. +- [ ] Run a final hosted producer/consumer identity smoke with every EdgeZero ref literal `V` and + require `action-revision==P`. A post-publication failure does not permit changing `V`; publish a + corrected new patch through the normal protected process. Do not create documentation revision + `R` until this smoke passes. + +## 9. Publish documentation revision `R` + +- [ ] Create canonical `docs/.edgezero-action-release.json` with exact JCS bytes + `{"action-revision":"

","action-version":"","schema-version":1}` and no trailing newline. + Replace every `` ref in tracked Markdown with literal `V`. Change only + tracked Markdown plus that record; do not touch action/workflow code, metadata, gate-owned paths, + or implementation fixtures. +- [ ] Run the plan-1 dual-state scanner on the documentation PR and its final merge-group candidate + using the closed event-to-range table. For PRs select authenticated synthetic first parent `F`, + not the possibly older payload base `A`; for merge groups use the event's exact base SHA. The + event-bound base may be newer than `P`; later main movement does not change it, and the + synthetic candidate is not named `R`. Require the + one-way bootstrap-to-released transition, exact record schema/JCS, no placeholder in any fenced + YAML, one identical EdgeZero `V` per workflow, exact step-based/reusable-caller job shapes and + runner labels, and exact third-party patch versions. The hosted transition verifier must prove + public release `V` is `draft:false`, `prerelease:false`, `immutable:true` and its API target and + anonymously peeled ref both equal record `P`. +- [ ] Parse every fenced YAML example, validate it against action/workflow metadata, and prove examples + are runnable after only repository/application-value substitution. Run the complete docs build, + pin scanner, actionlint, and required repository checks; merge through the one-entry queue and + record the resulting protected-main commit as `R`. Require the protected-main push scanner to + compare `event.before` with `event.after==R`, then rerun released-state checks. Do not move or + recreate `V`. + +## 10. Final review + +- [ ] Compare every documented input, output, default, secret, permission, mount, environment, + artifact, cache, source-freeze, provider, and failure behavior at documentation revision `R` to + actual action metadata/tests at `P`. +- [ ] Search for forbidden external major/minor/prerelease/branch/SHA `uses:` refs, EdgeZero version + mismatches, direct `build-app-cli` composite producer guidance, caller-provided platform or + expected fields, unsupported runner labels, ambient app env, writable source mounts outside + declared roots, and legacy `--stage`. Every hit must be a clearly marked rejected example or + fail documentation release. +- [ ] Verify public anonymous image pull by digest and an end-to-end fresh app adoption from the + published guide using literal `V`. Record image-release gate `G`, the ordered gate-rotation + lineage with each `{old-G,Q_d,new-G,Q_f}` and final active gate, `{S,D,B,P,C,V,R,protocol}`, action-version resolved commits, + release attestations, hosted run ids/attempts, and documentation-check results. Do not collapse + the post-`B` gate revisions into the image-release `G` label. + +**Gate:** exact merged revision `P` passed the complete local and candidate-version hosted suites; +immutable stable release `V` resolves to `P`; protected documentation revision `R` activated the +one-way released scanner state and all concrete consumer examples use `V`; no consumer relies on a +floating ref, same-job producer shortcut, ambient application environment, or provider mutation +before independent validation. diff --git a/docs/superpowers/plans/2026-08-20-build-cache-container.md b/docs/superpowers/plans/2026-08-20-build-cache-container.md index eb9a0df6..1bab561f 100644 --- a/docs/superpowers/plans/2026-08-20-build-cache-container.md +++ b/docs/superpowers/plans/2026-08-20-build-cache-container.md @@ -1,393 +1,1790 @@ -# Build-Cache Container Implementation Plan (sub-plan 1 of 4) - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Publish a pinned, single-manifest `linux/amd64` build container that bakes the exact Rust toolchain + build tools, so `platform-id` for the cached-build feature is an immutable digest. - -**Architecture:** A versioned in-repo Dockerfile builds an image FROM a digest-pinned base with the workspace's pinned Rust toolchain and the tools `build-app-cli` needs (`git`, `jq`, `tar`, `curl`, `ca-certificates`, a C toolchain for `build.rs`). A publish workflow builds it single-arch, pushes it to GHCR, and records its **manifest digest** in a committed `image.json`. A fail-closed `check-image-pin.sh` (wired into the existing pin gate's test harness) proves the recorded reference is pinned by a 64-hex `sha256` digest, never a mutable tag. - -**Tech Stack:** Docker (BuildKit), GitHub Actions (`docker/build-push-action`), GHCR, Bash, `jq`. - -**Spec:** `docs/specs/edgezero-deploy-build-caching.md` (v6.14, sccache pivot) — §2 (single-producer, hosted-only v1), §3.1 (sccache cache mechanism), §3.6 (image contract: baked Rust + `wasm32-wasip1` + **sccache** + Fastly CLI, read-only/non-root), §5 (digest pin, atomic same-SHA rollout). - -## Global Constraints - -- **Rust toolchain baked = `1.95.0`** (verbatim from `.tool-versions`); a build that resolves a different toolchain must fail closed downstream, so this image is the single source of truth. -- **Full build+deploy runtime baked** (spec §3.6): `1.95.0` + `wasm32-wasip1` + a pinned **`sccache`** (the cache mechanism, spec §3.1) + the pinned **Fastly CLI `15.1.0`** (`.tool-versions`) + `git jq tar curl cc` — the container is the deploy runtime, not only the CLI-compile runtime. -- **Runtime posture:** consumed **read-only root filesystem, non-root user**, explicit writable mounts only (spec §3.7). -- **Single-manifest `linux/amd64` only** — no multi-arch index (an index digest can select another architecture). -- **No Python in CI tooling** — Bash + `jq` only. -- **Pin policy:** every referenced image/action is pinned; the base image is pinned by `sha256` digest, and the published image is recorded by `sha256` digest. -- **No AI bylines** in commits or PR bodies. -- **Bash 3.2-compatible** scripts (macOS dev parity); scripts are `shellcheck -S warning` clean. - -## File Structure - -- `.github/docker/build-app-cli/Dockerfile` — the image definition (one responsibility: the build environment). -- `.github/docker/build-app-cli/image.json` — the published image's canonical reference + digest (the pin record). -- `.github/docker/build-app-cli/check-image-pin.sh` — fail-closed validator of `image.json`. -- `.github/actions/deploy-core/tests/check-image-pin.test.sh` — unit tests for the validator (colocated with the existing action test harness). -- `.github/workflows/publish-build-container.yml` — build + push + digest capture (runs on a `build-container-v*` tag). -- `.github/actions/deploy-core/tests/run.sh` — modified to invoke the new validator suite. - ---- - -### Task 1: Fail-closed `image.json` validator (pure TDD) +# Build-Cache Container Implementation Plan (plan 1 of 5) + +> **Execution:** Use `superpowers:subagent-driven-development` or +> `superpowers:executing-plans`. Follow the tasks in order and stop at every release checkpoint. + +**Goal:** Publish and pin a public, leaf `linux/amd64` runtime image containing the exact EdgeZero +build/deploy toolchain and the trusted provenance validator required by build caching. + +**Architecture:** A separately landed, immutable gate baseline `G` owns the validator, fixtures, +classifier, image verifier, publisher checker, exact Dockerfile, complete image-context manifest, and +organization-required workflow. Source revision `S` is an isolated canonical release request whose +repository image-context bytes remain identical to `G`. The publisher stages a fresh context solely from the +verified `G` checkout, captures and verifies immutable digest `D`, proves anonymous access, and opens +an idempotent, forward-only App-authored PR adding the image and release-evidence records. That pin +forms baseline `B`. Four remaining feature plans land on top. Their final passing action revision `P` +contains the unchanged `{D, S, protocol}` record and prepublication adoption documents with the gated +placeholder. After `P` qualifies, immutable stable release `V` is published at `P`; documentation +revision `R` replaces the placeholders, and consumers pin `V`. + +**Spec:** `docs/superpowers/specs/2026-08-20-edgezero-deploy-build-caching-design.md` v6.39. + +**Tooling:** Rust, Docker BuildKit/buildx, GHCR, GitHub Actions, Bash 3.2, `jq`, `gh`, `actionlint`, +`shellcheck`, and `zizmor`. + +## 1. Non-negotiable contracts + +- Rust is the exact version in `.tool-versions` (`1.95.0` at plan time). +- Fastly CLI is the exact version/checksum in `.github/actions/deploy-fastly/versions.json` + (`15.1.0` at plan time). +- sccache is exactly `0.10.0`, fetched as the upstream + `sccache-v0.10.0-x86_64-unknown-linux-musl.tar.gz` client artifact and verified against upstream + checksum `1fbb35e135660d04a2d5e42b59c7874d39b3deb17de56330b25b713ec59f849b`. +- The base is the official `rust:1.95.0-slim-bookworm` `linux/amd64` leaf manifest, resolved on + 2026-08-31 as `sha256:6f9e63259f12e1e599296f5ecfed2bae46de4af0ee0525dd8b89c046e236d5c5` + and re-resolved immediately before the Dockerfile commit. No placeholder digest or checksum is + committed. +- The final image is a leaf `linux/amd64` image manifest, not an OCI index. +- The final image contains an installed `wasm32-wasip1` target, not merely a rustc target-list entry. +- The project-owned validator, schema, and capability fixtures are baked and tested before push. +- Runtime is non-root uid/gid 1001 and works with a read-only root filesystem plus explicit tmpfs. +- Every target command is launched through baked GNU `/usr/bin/env` with the design's exact two-argument + `-S` placeholder protocol. Image and Docker-created variables are absent from target-command entry, + and no Docker or runtime argv element is constructed from an environment value. +- Every step-based repository-owned workflow job introduced by this plan declares literal + `runs-on: ubuntu-24.04`. Its first executable step is a fixed inline bootstrap requiring + context-derived `runner.environment:github-hosted`, `runner.os:Linux`, and `runner.arch:X64` + before checkout, Docker, credential, or mutation work. The label selects the supported host image; + runner labels and host command output are not substitutes for the context predicate. The bootstrap + has no `if` or `continue-on-error`, cannot mask command failure, and gates every later protected + operation. An `if: always()` cleanup needs no guard-success condition; any recovery path must + be a fixed protocol-required recovery/reconciliation step and conjunctively require guard success + and its protocol-specific transition marker. Every other non-cleanup always-run path fails. +- Every shell-backed first bootstrap/runner guard declares step-local `BASH_ENV: ""` and `ENV: ""` + in workflow or composite metadata. Clearing either variable in the script body is too late because + Bash may source the caller-selected file before executing that body. +- Every committed non-local external action and reusable workflow ref is a canonical exact stable + `v..` release tag. Major/minor tags, prereleases, branches, commit SHAs, and + floating refs fail. Docker image refs use immutable `sha256` digests. Local `./...` actions remain + local refs. Third-party tag movement/deletion and future same-name branch ambiguity are explicitly + accepted risks; EdgeZero `V` is an immutable release protected by no-bypass tag rules. +- In this plan, an action/workflow "pin" means that exact stable version tag. Full Git commit SHAs + identify app source, resolved workflow execution, protected gate/release commits, and the + organization required-workflow descriptor only; they are never written as non-local `uses:` refs. +- Bash scripts are Bash 3.2-compatible and `shellcheck -S warning` clean. CI helper scripts do not use + Python. No AI bylines appear in commits or PRs. +- Publication never records a digest before the image passes authenticated verification and a clean, + anonymous pull by digest. +- Candidate source never supplies the workflow, classifier, verifier, completion marker, or publisher + policy used to approve itself. Gate code runs from full SHA `G` with no secret or mutation token. +- `T` is the exact event-selected subject head: authenticated synthetic merge `M` for + `pull_request`, payload head equal to `github.sha` for `merge_group`, and protected head `Q` for + `push`. It is not rollout commit `H` unless that particular final-candidate event actually selects + `H`. +- The exact actionlint version is `1.7.12`; the installer carries reviewed SHA-256 values for its + linux/darwin amd64/arm64 archives. Earlier actionlint releases are not release evidence because they + reject the required `environment.deployment: false` syntax. Because 1.7.12 predates GitHub's + `concurrency.queue`, the gate uses the design's exact yq-backed compatibility wrapper; no other + actionlint diagnostic is ignored. +- The yq installer accepts only version `4.53.3` and URL + `https://github.com/mikefarah/yq/releases/download/v4.53.3/yq__`. Its exact + platform/SHA-256 pairs are linux/amd64 + `fa52a4e758c63d38299163fbdd1edfb4c4963247918bf9c1c5d31d84789eded4`, linux/arm64 + `578648e463a11c1b6db6010cbf41eafed6bee79466fcffa1bb446672cf7945ea`, darwin/amd64 + `b4ba1ecce3c47f00803f4f964de38394326c7a32eb6540616e04fb2935a0f08d`, and darwin/arm64 + `877de31753a4dd2401aa048937aa9a7fc4d5f6ce858cf31508c5802954297213`. Every other version, + platform, URL, asset spelling, or digest fails before installation. + +## 2. Dependency order + +Although this is plan 1 of five, image publication cannot run first. Execute these gates: + +The already committed Section 5.1 JSON tranche is retained as reviewed preparatory work. Do not begin +Section 5.2 or claim Task 1 complete until the Task 0 selector, placeholder, hosted-verifier +implementation, and bootstrap hosted-CI checks are green. The live transition proof is not a Task 0 +prerequisite: the design keeps `docs/.edgezero-action-release.json` absent through `P`, creates stable +release `V` only after `P` qualifies, and exercises the real API/ref proof in the documentation-only +revision `R` under consumer plan Section 9. This exception records branch history; it does not relax +the landing order below. + +1. Complete the repository-wide exact-version pin-gate migration and actionlint upgrade (Task 0). +2. Complete the protocol-owner validator, schema, fixtures, pin validator, image verifier, exact image + source/context, fail-closed classifier, publisher contract checker, and protected workflow (Tasks + 1-2). Merge these as the separately reviewed protected gate baseline `G`. +3. Configure the organization required-workflow descriptor at exact SHA `G`, mandatory merge queue, + protected environment, split tag rulesets, audit credentials, and dedicated GitHub App. Prove the rules + and credential smoke before any candidate becomes `S` (Task 2). +4. Add only the canonical `release-request.json` for `G`. Run this isolated candidate through `G`, + merge only through the queue, and require the API-visible exact post-merge push assertion. Record + that default-branch commit as `S` (Task 3). +5. Run the already-landed publisher at `S`, verify digest `D`, and merge its ancestry-checked pin PR to + create baseline `B` (Tasks 3-4). +6. Execute the four remaining cached-build, provenance-integration, launcher, and consumer plans on + `B`; their final passing commit becomes action revision `P`. + +Do not publish a provisional image without the validator. Do not use a placeholder `image.json` to +break the dependency cycle. + +Within Task 2, implement and fixture-test both `check-build-container-publisher.sh` and +`verify-build-container-publication.sh` before completing the gate-update/rollback and pin branches of +`run-build-container-gate.sh`. The structural checker uses inert workflow fixtures before the real +publisher workflows exist; after those workflows are created, its golden test must accept their exact +committed forms before `G` is complete. + +## 3. Planned file surface + +Create: + +- `.github/tools/edgezero-provenance-validator/{Cargo.toml,Cargo.lock}` +- `.github/tools/edgezero-provenance-validator/src/{lib,main,json_contract,archive,command,elf,extract,orchestration,self_test}.rs` +- `.github/tools/edgezero-provenance-validator/tests/cli.rs` +- `.github/docker/build-app-cli/provenance.schema.json` +- `.github/docker/build-app-cli/fixtures/provenance/**` +- `.github/docker/build-app-cli/fixtures/wasm-smoke.rs` +- `.github/docker/build-app-cli/fixtures/gnu-smoke.rs` +- `.github/docker/build-app-cli/Dockerfile` +- `.dockerignore` +- `.github/docker/build-app-cli/gate-paths.txt` +- `.github/docker/build-app-cli/image-context-paths.txt` +- `.github/docker/build-app-cli/verify-toolchain.sh` +- `.github/docker/build-app-cli/verify-published-image.sh` +- `.github/docker/build-app-cli/stage-build-context.sh` +- `.github/docker/build-app-cli/assert-build-container-context.sh` +- `.github/docker/build-app-cli/select-build-container-range.sh` +- `.github/docker/build-app-cli/assert-build-container-completion.sh` +- `.github/docker/build-app-cli/assert-build-container-dispatch-context.sh` +- `.github/docker/build-app-cli/assert-build-container-app-token.sh` +- `.github/docker/build-app-cli/verify-release-prerequisites.sh` +- `.github/docker/build-app-cli/write-publisher-prerequisite.sh` +- `.github/docker/build-app-cli/release-approval-gate.sh` +- `.github/docker/build-app-cli/write-image-release-record.sh` +- `.github/docker/build-app-cli/update-image-pin-pr.sh` +- `.github/docker/build-app-cli/classify-build-container-change.sh` +- `.github/docker/build-app-cli/run-build-container-gate.sh` +- `.github/docker/build-app-cli/check-build-container-publisher.sh` +- `.github/docker/build-app-cli/verify-build-container-publication.sh` +- `.github/docker/build-app-cli/verify-gate-rotation-lock.sh` +- `.github/actions/deploy-core/tests/verify-toolchain.test.sh` +- `.github/actions/deploy-core/tests/verify-published-image.test.sh` +- `.github/actions/deploy-core/tests/stage-build-context.test.sh` +- `.github/actions/deploy-core/tests/assert-build-container-context.test.sh` +- `.github/actions/deploy-core/tests/select-build-container-range.test.sh` +- `.github/actions/deploy-core/tests/assert-build-container-completion.test.sh` +- `.github/actions/deploy-core/tests/assert-build-container-dispatch-context.test.sh` +- `.github/actions/deploy-core/tests/assert-build-container-app-token.test.sh` +- `.github/actions/deploy-core/tests/verify-release-prerequisites.test.sh` +- `.github/actions/deploy-core/tests/write-publisher-prerequisite.test.sh` +- `.github/actions/deploy-core/tests/release-approval-gate.test.sh` +- `.github/actions/deploy-core/tests/write-image-release-record.test.sh` +- `.github/actions/deploy-core/tests/update-image-pin-pr.test.sh` +- `.github/actions/deploy-core/tests/classify-build-container-change.test.sh` +- `.github/actions/deploy-core/tests/run-build-container-gate.test.sh` +- `.github/actions/deploy-core/tests/check-build-container-publisher.test.sh` +- `.github/actions/deploy-core/tests/verify-build-container-publication.test.sh` +- `.github/actions/deploy-core/tests/verify-gate-rotation-lock.test.sh` +- `.github/actions/deploy-core/tests/build-container-workflows.test.sh` +- `.github/actions/deploy-core/tests/check-doc-action-pins.sh` +- `.github/actions/deploy-core/tests/check-doc-action-pins.mjs` +- `.github/actions/deploy-core/tests/check-doc-action-pins.test.mjs` +- `.github/actions/deploy-core/tests/install-actionlint.test.sh` +- `.github/actions/deploy-core/tests/install-yq.test.sh` +- `.github/actions/deploy-core/tests/run-actionlint.test.sh` +- `.github/workflows/build-container-ci.yml` +- `.github/workflows/publish-build-container.yml` +- `.github/workflows/rotate-build-container-gate.yml` +- `.github/CODEOWNERS` +- `scripts/install-actionlint.sh` +- `scripts/install-yq.sh` +- `scripts/run-actionlint.sh` + +Created by the isolated source/release PR, not gate `G`: + +- `.github/docker/build-app-cli/release-request.json` + +Created by the pin PR, not source revision `S`: + +- `.github/docker/build-app-cli/image.json` +- `.github/docker/build-app-cli/image-release-evidence.json` + +Modify: + +- `.github/docker/build-app-cli/check-image-pin.sh` +- `.github/actions/deploy-core/tests/check-image-pin.test.sh` +- `.github/actions/deploy-core/tests/check-action-pins.sh` +- `.github/actions/deploy-core/tests/run.sh` +- `.github/zizmor.yml` +- `.github/workflows/deploy-action.yml` +- `scripts/install-actionlint.sh` +- `docs/{package.json,package-lock.json}` (the exact Markdown parser used by the documentation gate) +- every existing `.github` workflow/composite containing a non-local external `uses:` ref +- the four deploy/adoption documents containing consumer `uses:` examples + +## 4. Task 0: Enforce exact-version external references repository-wide + +The current pin gate permits major/minor tags, prereleases, and commit SHAs. That is broader than +v6.39 and must be narrowed before adding the write-privileged publisher. **Files:** -- Create: `.github/docker/build-app-cli/check-image-pin.sh` -- Test: `.github/actions/deploy-core/tests/check-image-pin.test.sh` -**Interfaces:** -- Consumes: nothing (leaf). -- Produces: `check-image-pin.sh ` — exit `0` iff the JSON has string `repository`, string `tag`, and a `digest` matching `^sha256:[0-9a-f]{64}$`; prints `::error::` and exits `1` otherwise. Reused by the pin gate and the publish workflow. - -- [ ] **Step 1: Write the failing test** +- Modify `.github/actions/deploy-core/tests/check-action-pins.sh` and its tests in `run.sh`. +- Create `.github/actions/deploy-core/tests/check-doc-action-pins.sh`. +- Modify `.github/zizmor.yml`. +- Modify `scripts/install-actionlint.sh` and the workflow environment that selects its version. +- Create `scripts/run-actionlint.sh` and its focused compatibility test. +- Modify external refs in `.github/workflows/{codeql,deploy-action,deploy-docs,fastly-installer-check,format,test}.yml`. +- Modify external refs in `.github/actions/{build-app-cli,config-push-fastly,deploy-fastly,healthcheck-fastly,rollback-fastly}/action.yml`. +- Modify examples in `docs/superpowers/specs/edgezero-deploy-github-action.md`, + `docs/superpowers/plans/edgezero-deploy-action-implementation-plan.md`, + `docs/superpowers/specs/edgezero-deploy-adoption-guide.md`, and `docs/guide/deploy-github-actions.md`. + +- [x] Write failing pin-gate tests proving `@v1`, `@v1.2`, branches, prereleases, build metadata, + full/abbreviated SHAs, malformed/leading-zero versions, and empty refs fail; canonical stable + `@v1.2.3` passes; local actions and digest-pinned Docker actions remain valid. Generate invalid + YAML fixtures under the test's temporary directory; do not commit them into a surface scanned + by the production gate. +- [x] Resolve each existing external ref to a reviewed upstream exact stable patch release. Record + the release URL and resolved commit in review evidence, prove the release tag exists and no + same-named branch exists at review time, but write the version tag in YAML. +- [x] Change the structural YAML scanner to require canonical exact stable patch versions for every non-local external + action and reusable workflow. Its default scan is exactly workflow `*.yml`/`*.yaml` files directly + under `.github/workflows`, plus every repository-wide `action.yml`/`action.yaml`, pruning `.git`, + `target`, and `node_modules`. Shell source and arbitrary YAML test data are not inputs. Do not add a + low-privilege exception. +- [x] Reject empty and null `uses` scalars and count only parsed non-local external refs for the + non-vacuity assertion. Encode each structurally extracted scalar so a multiline value cannot split + into multiple shell records. +- [x] Require Docker action refs to match an immutable lowercase + `docker://@sha256:<64-lowercase-hex>` form; tags, uppercase hex, short digests, and other + algorithms fail unless a separately reviewed digest algorithm is added to the policy. +- [x] Update the four prepublication adoption documents to use literal + `` where the future consumer will substitute stable release `V`; + examples for third-party actions use real reviewed exact patch versions. Replace + `ubuntu-latest` and every other runner label on a step-based consumer job containing a + `steps[*].uses` EdgeZero action reference with literal `runs-on: ubuntu-24.04`. A caller job with + `jobs..uses` must omit both `steps` and `runs-on`; its called workflow selects the runner. +- [x] Add `check-doc-action-pins.sh` to parse fenced YAML in every tracked Markdown file and implement + both documentation states from the design. In bootstrap state, absent + `docs/.edgezero-action-release.json` permits the exact EdgeZero placeholder only in the four + named prepublication documents. In transition state, a candidate adds the exact JCS `{V,P}` + record, changes only tracked Markdown plus that record, removes every placeholder, and uses one + literal `V` in all EdgeZero refs. In released state, the base record cannot disappear; it is + either byte-identical or replaced atomically with all documentation refs by a strictly greater + stable version under the same hosted release/ref proof. Concrete third-party refs always use + exact stable patch versions; major/minor/prerelease/branch/SHA refs fail. In all three + documentation states, classify each parsed job by AST shape: a step-based job containing a + `steps[*].uses` EdgeZero action reference requires literal `runs-on: ubuntu-24.04`; a job-level + EdgeZero reusable-workflow call with `jobs..uses` must have no `steps` and no `runs-on`. + Reject mixed shapes, absent or dynamic labels on step-based jobs, `ubuntu-latest`, other + standard, larger, custom, and self-hosted labels. Add positive/negative state-transition, + base/candidate, partial-update, downgrade/deletion, hidden-placeholder, mixed-version, job- + shape, and runner-label cases to `run.sh`. In transition and released states, reject the literal + placeholder anywhere in every tracked Markdown document, including prose and non-YAML fences; + YAML parsing remains the authority for reference and job-shape checks. +- [x] Reconcile the implemented v6.29 selector with v6.31 before freezing `G`. Add the reproduced + hosted case where payload base `37f1a137...` precedes synthetic first parent `593fc928...`, and + head `8f68476d...` is the second parent. Authenticate the context-selected synthetic commit; + require exactly `[F,J]` and `A` ancestor-or-equal to `F`; compare `F..M`, not `A..M` or latest + main. Reject wrong/missing/reordered/extra parents, unavailable/shallow objects, replace/graft + history, wrong repository/ref, and reversed/incomparable ancestry. Test a release record added + upstream at `F`, intervening upstream executable changes, candidate-only mixed changes, and + later main movement. Run every subject Git command with `--no-replace-objects` and the design's + exact minimal child environment; inject and reject repository/worktree/common-dir, namespace, + index, object/alternate, shallow-file, inline-config, replacement-base, XDG, and arbitrary + ambient state. Keep these tasks unchecked until code, fixtures, and hosted CI agree. +- [x] Add the hosted transition verifier to gate `G`. With only read permissions and fixed + no-redirect versioned requests, it proves record `V` is a published `draft:false`, + `prerelease:false`, `immutable:true` release whose API target and anonymous peeled remote ref + both equal record `P`. Bootstrap and unchanged released-state checks remain offline. Candidate + code cannot replace the verifier or release record parser. + Implementation uses Node's typed JSON and Git subprocess APIs plus exact `markdown-it@15.0.1` + for Markdown fence parsing and pinned yq for YAML. The Bash entrypoint and colocated module, + tests, and docs dependency manifests belong to `G`; install dependencies with + `npm --prefix /docs ci --ignore-scripts`. Never resolve parser modules or run npm + from the candidate subject checkout. Its release GET uses API version `2026-03-10`, exact gate + user agent and media headers, and verifies the selected-version/content-type response headers; + the earlier `2022-11-28` request is not compliant transition evidence. Pass authorization only + through curl config stdin and give curl only runner `PATH` plus `LC_ALL=C`; fixtures reject + inherited token/proxy/CA/home/XDG/curl-home/arbitrary values. Give anonymous release-ref Git only + its existing closed temporary-home/config environment plus replacement-object hardening. Task 0 + qualifies the implementation with adversarial fixtures and a hosted bootstrap run; consumer plan + Section 9 supplies the real immutable `V` transition proof when revision `R` can legally add the + release record. +- [x] Retain global zizmor `ref-pin` as defense in depth and document that the structural scanner is + stricter. Rewrite `.github/zizmor.yml`'s existing comment so it no longer claims full commit + SHAs pass repository policy: `ref-pin` accepts symbolic refs, while the structural gate permits + only exact stable patch tags. Update contradictory prose in all four named documents, not only + their fenced YAML examples. +- [x] Upgrade actionlint to exactly `1.7.12`. Pin these reviewed release archives in + `scripts/install-actionlint.sh`: linux/amd64 + `8aca8db96f1b94770f1b0d72b6dddcb1ebb8123cb3712530b08cc387b349a3d8`, linux/arm64 + `325e971b6ba9bfa504672e29be93c24981eeb1c07576d730e9f7c8805afff0c6`, darwin/amd64 + `5b44c3bc2255115c9b69e30efc0fecdf498fdb63c5d58e17084fd5f16324c644`, and darwin/arm64 + `aba9ced2dee8d27fecca3dc7feb1a7f9a52caefa1eb46f3271ea66b6e0e6953f`. Test exact version, + supported tuples, unknown tuple rejection, and checksum mismatch. Add an actionlint regression + fixture containing `environment: {name: build-container-release, deployment: false}`. +- [x] Before the publisher exists, add failing tests for pinned actionlint's two known syntax gaps. + `run-actionlint.sh` requires mikefarah yq 4.53.3 and structurally permits workflow-level + `concurrency.queue: max` only in the publisher and gate-rotation workflows, each with exact group + `edgezero-build-container-publication` and literal `cancel-in-progress:false`. It permits exactly + the four `job.workflow_*` properties only in approved expressions/checkout ref locations of + `.github/workflows/build-app-cli.yml`. Reject duplicate/aliased/misplaced/wrong values, + misspellings, extra job properties, other workflows, and dynamic expressions. +- [x] After structural validation, make line-count-preserving temporary copies that blank only the two + approved queue lines and substitute same-type constants for only the approved job-context + expressions. Run unfiltered actionlint 1.7.12 on those files and remap paths/lines; do not use + `-ignore` or filter diagnostics. Raw canonical fixtures must emit exactly the reviewed unsupported + queue/job-context diagnostic set, sanitized fixtures must pass, and every unrelated actionlint + error must remain fatal. +- [x] Add `install-yq.test.sh` to the protected suite and gate manifest. Test exact version `4.53.3`, + all four literal URL/platform/digest pairs above, successful exact-version execution, unknown + version/OS/architecture and URL/asset-spelling rejection, checksum mismatch, and failure before + installation for every unpinned tuple. +- [x] Scan that exact default surface, including reusable-workflow job-level `uses`, and require at + least one parsed external ref so a broken parser cannot pass vacuously. +- [x] Run the pin suite, actionlint, and zizmor. ```bash -#!/usr/bin/env bash -# .github/actions/deploy-core/tests/check-image-pin.test.sh -set -euo pipefail -DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) -CHECK="$DIR/../../../docker/build-app-cli/check-image-pin.sh" -WORK=$(mktemp -d) -trap 'rm -rf "$WORK"' EXIT -pass=0 fail=0 -ok(){ printf ' ok %s\n' "$1"; pass=$((pass+1)); } -no(){ printf ' FAIL %s\n' "$1"; fail=$((fail+1)); } -run(){ bash "$CHECK" "$1" >/dev/null 2>&1; } - -printf '{"repository":"ghcr.io/stackpop/edgezero-build-app-cli","tag":"v1","digest":"sha256:%064d"}\n' 0 >"$WORK/ok.json" -run "$WORK/ok.json" && ok "a digest-pinned reference passes" || no "a digest-pinned reference passes" - -printf '{"repository":"ghcr.io/x","tag":"v1","digest":"v1"}\n' >"$WORK/tag.json" -run "$WORK/tag.json" && no "a non-digest (tag) reference is rejected" || ok "a non-digest (tag) reference is rejected" - -printf '{"repository":"ghcr.io/x","tag":"v1"}\n' >"$WORK/nodigest.json" -run "$WORK/nodigest.json" && no "a missing digest is rejected" || ok "a missing digest is rejected" - -printf 'not json\n' >"$WORK/bad.json" -run "$WORK/bad.json" && no "malformed JSON fails closed" || ok "malformed JSON fails closed" - -printf 'Passed: %d Failed: %d\n' "$pass" "$fail" -[ "$fail" -eq 0 ] +bash .github/actions/deploy-core/tests/run.sh +.github/actions/deploy-core/tests/check-action-pins.sh +.github/actions/deploy-core/tests/check-doc-action-pins.sh +scripts/run-actionlint.sh +zizmor --offline .github/workflows .github/actions ``` -- [ ] **Step 2: Run it to verify it fails** +**Gate:** both structural scanners pass their exact surfaces and report non-zero parsed-reference +counts; no broad `rg` gate scans intentional invalid test strings. -Run: `bash .github/actions/deploy-core/tests/check-image-pin.test.sh` -Expected: FAIL (the `check-image-pin.sh` file does not exist yet). +## 5. Task 1: Implement the protocol-owner validator for gate baseline `G` -- [ ] **Step 3: Write the minimal implementation** +This task owns protocol-1 encoding and validation. No shell, `jq`, system `tar`, or general-purpose +archive crate may become a second wire implementation. It is a hard dependency of Task 2 and lands in +protected gate baseline `G` before source revision `S` is proposed. -```bash -#!/usr/bin/env bash -# .github/docker/build-app-cli/check-image-pin.sh -# Fail-closed: the build container reference must be pinned by a sha256 digest, -# never a mutable tag (spec §3.7/§5). Requires mikefarah yq/jq-free: uses jq. -set -euo pipefail - -file="${1:?usage: check-image-pin.sh }" -if ! command -v jq >/dev/null 2>&1; then - echo "::error::check-image-pin.sh requires jq" >&2 - exit 2 -fi -if ! json=$(jq -e . "$file" 2>/dev/null); then - echo "::error::$file is not valid JSON — refusing to pass an unreadable image pin" >&2 - exit 1 -fi -repo=$(jq -r '.repository // empty' <<<"$json") -tag=$(jq -r '.tag // empty' <<<"$json") -digest=$(jq -r '.digest // empty' <<<"$json") -if [[ -z "$repo" || -z "$tag" ]]; then - echo "::error::$file must set string 'repository' and 'tag'" >&2 - exit 1 -fi -if [[ ! "$digest" =~ ^sha256:[0-9a-f]{64}$ ]]; then - echo "::error::$file 'digest' must be a sha256 manifest digest (sha256:<64-hex>), not a tag: '$digest'" >&2 - exit 1 -fi -echo "build container reference is pinned: $repo@$digest" -``` - -- [ ] **Step 4: Run the test to verify it passes** - -Run: `chmod +x .github/docker/build-app-cli/check-image-pin.sh && bash .github/actions/deploy-core/tests/check-image-pin.test.sh` -Expected: `Passed: 4 Failed: 0`. - -- [ ] **Step 5: Shellcheck** +**Files:** -Run: `shellcheck -S warning .github/docker/build-app-cli/check-image-pin.sh` -Expected: no output (clean). +- Create standalone workspace `.github/tools/edgezero-provenance-validator` with its own + `Cargo.toml`, `[workspace]`, `Cargo.lock`, and + `src/{lib,main,json_contract,archive,elf,extract}.rs`. +- Put module unit tests beside their implementation under `src/`; create only the true process-level + integration test `.github/tools/edgezero-provenance-validator/tests/cli.rs`. +- Create `.github/docker/build-app-cli/provenance.schema.json`. +- Create `.github/docker/build-app-cli/fixtures/provenance/{valid,invalid}/**`. +- Do not modify or include the root workspace manifests; the validator has no external local path + dependency. + +### 5.1 JSON/schema tranche + +- [x] Add one Draft 2020-12 schema and exact valid/invalid fixtures for both `expected.json` and + `app-cli-meta.json` from design Section 6.2. Write colocated failing tests for RFC 8785 bytes, + recursive duplicate-key rejection before object construction, every exact field/type/bound, + unknown and missing fields, noncanonical decimal/hash/name values, schema/protocol mismatch, + `container-ref` derivation, `workspace-id` rendering, and complete caller/platform identity + mismatch. Workspace/suffix hash computation vectors belong to the cache-actions follow-on plan, + not this protocol crate. +- [x] Test a closed typed canonical encoder. Protocol 1 contains only bounded strings, positive + integers, null, fixed objects, and the `needed` array; no generic floating-point value is accepted. +- [x] Run `cargo test --manifest-path .github/tools/edgezero-provenance-validator/Cargo.toml json_contract::tests`; expected: non-zero for + unimplemented behavior. +- [x] Implement only `json_contract.rs`; rerun the focused and full crate tests; expected: pass. + Commit the green JSON/schema tranche. + +### 5.2 Archive/extraction tranche + +- [x] Add a byte-for-byte golden archive from design Section 6.3 plus malformed base-256/octal, + checksum, embedded-NUL, PAX/GNU, sparse, duplicate, extra, traversal, link, special-file, header, + order, size, padding, end-block, overflow, and trailing-data fixtures. +- [x] Write failing encoder, parser, and extraction tests. Assert two repeated encodes are identical, + all payload padding is zero, exactly two end blocks precede EOF, and failure leaves the fresh output + parent empty. +- [x] Run `cargo test --manifest-path .github/tools/edgezero-provenance-validator/Cargo.toml archive::tests`; expected: non-zero for + unimplemented protocol behavior. +- [x] Implement `archive.rs` and `extract.rs` directly over bounded `Read + Seek`/`Write`; do not + invoke system `tar`, add a tar crate, or load the allowed 512 MiB binary wholesale. Create outputs + atomically and require the final regular file to have mode 0755 and link count one. Rerun focused + and full crate tests; expected: pass. Commit the green archive/extraction tranche. + +### 5.3 ELF/loadability tranche + +- [x] Add controlled static/dynamic valid, wrong class/endian/type/architecture/interpreter, + wrong `EI_VERSION`/`e_version`/`EI_OSABI`/`EI_ABIVERSION`/`EI_PAD`/`e_flags`/`e_ehsize`/ + `e_phentsize`, zero `e_phnum`, `PN_XNUM`, + malformed/duplicate `PT_DYNAMIC`, unequal file/memory sizes, contradictory file-offset versus + virtual-address dynamic tables, nonunique/nonreadable/non-file-backed mappings, + missing/nonzero-after `DT_NULL`, conflicting string-table tags, + unmapped/overlapping string ranges, malformed string/interpreter termination, RPATH/RUNPATH, + AUDIT/DEPAUDIT/CONFIG/AUXILIARY/FILTER/POSFLAG rejection, System-V/GNU OSABI acceptance and every + other OSABI rejection, libc-style dependency `PT_INTERP` acceptance only for the exact fixed + interpreter, primary SONAME rejection, optional library SONAME equal to its filename, reserved + primary/interpreter filenames, cross-object alias collisions, empty/oversized/slash/backslash/ + dollar-containing/duplicate SONAME rejection, NODEFLIB/LOADFLTR and unknown-flag rejection, every + in-range and just-outside case for the closed numeric tag allowlist, exact + `DT_FLAGS=0x0000001e` and `DT_FLAGS_1=0x5eff976f` mask boundaries, unknown standard/GNU/OS/processor + tag rejection, duplicate rejection for every singleton tag, slash/backslash/dollar-containing + dependency including every `$ORIGIN`, `$LIB`, and `$PLATFORM` spelling, missing + direct/transitive flat-closure library, duplicate basename, dangling or escaping candidate, + mixed architecture, duplicate-needed, interpreter dependency, and cycle fixtures for the + controlled loader profile in design Section 6.4. +- [x] Write failing tests for machine, interpreter/null, byte-sorted duplicate-preserving direct + `DT_NEEDED`, digest, size, exact `/opt/edgezero/runtime-lib` lookup, symlink/hardlink/subdirectory + rejection, duplicate basename, interpreter parsing, the one `ld-linux-x86-64.so.2` alias resolving + to the already-validated interpreter rather than a flat copy, and recursive dependency resolution against + a synthetic image root. Add preload presence, cache-only/default-directory/hardware-capability + substitution, direct-loader argv, and explicit `dlopen` non-claim fixtures. +- [x] Run `cargo test --manifest-path .github/tools/edgezero-provenance-validator/Cargo.toml elf::tests`; expected: non-zero for + unimplemented inspection/loadability behavior. +- [x] Implement `elf.rs` with bounded ranged reads and checked offsets. Do not invoke `ldd`, the + loader, or the artifact. Rerun focused and full crate tests; expected: pass. Commit the green ELF + tranche. + +### 5.4 CLI/capability tranche + +- [x] Write failing library integration tests using a private synthetic-root harness for deterministic + expected-write/package/validate round trips, identity mismatch, atomic cleanup, no-replace + collision, and host-deletion recovery. This + harness calls library entry points and is not a CLI option or production bypass. Write host process + tests proving all output-producing commands reject every `--work-root` that does not canonicalize + to literal `/work`, plus process tests for self-test fixture integrity. Run + `cargo test --manifest-path .github/tools/edgezero-provenance-validator/Cargo.toml --test cli`; + expected: non-zero until wired. Implement: + +```text +edgezero-provenance-validator write-expected \ + --work-root /work \ + --app-repo-id \ + --source-revision <40-lowercase-hex> \ + --app-cli-package \ + --app-cli-bin \ + --workspace-id sha256:<64-lowercase-hex> \ + --platform-id sha256:<64-lowercase-hex> \ + --provenance-protocol 1 \ + --output /work/expected/expected.json + +edgezero-provenance-validator write-release-request \ + --work-root /work \ + --gate-sha <40-lowercase-hex> \ + --provenance-protocol 1 \ + --release-tag build-container-v \ + --output /work/release/release-request.json + +edgezero-provenance-validator package \ + --work-root /work \ + --binary /work/input/app-cli \ + --schema /usr/local/share/edgezero/provenance.schema.json \ + --expected /work/input/expected.json \ + --app-cli-version \ + --archive /work/packaged/artifact.tar + +edgezero-provenance-validator validate \ + --work-root /work \ + --archive /work/input/artifact.tar \ + --schema /usr/local/share/edgezero/provenance.schema.json \ + --expected /work/input/expected.json \ + --output /work/validated/app-cli + +edgezero-provenance-validator self-test \ + --fixtures /usr/local/share/edgezero/provenance-fixtures +``` -- [ ] **Step 6: Commit** +- [x] Make production `write-expected`, `write-release-request`, `package`, and `validate` require + canonical `--work-root /work`. + `write-expected` accepts only the typed bounded scalars above, fixes schema version `1`, derives + `container-ref`, and is the sole expected-identity encoder. Every command creates exactly one + output through a create-new temporary sibling plus Linux no-replace rename and fails if the parent + is not fresh, empty, canonical, writable, and confined. Test lexical and canonical confinement for + every binary, expected, archive, schema, fixture, and output path; require schema and fixture paths + to equal their baked image-owned literals. Handled failures remove the + sibling; synthetic-root library tests model host deletion of the whole parent after + SIGKILL/timeout. The validator never executes the app binary. Positive CLI round trips run only in + Task 3's container, where literal `/work` exists. +- [x] Make `write-release-request` the sole release-request producer. Accept only typed gate SHA, + protocol `1`, canonical release tag, and the literal fresh output path; test exact three-key JCS + bytes, duplicate/missing/unknown flags, no-replace publication, and output cleanup. +- [x] Implement `self-test` as a compiled manifest of exact relative paths, fixture SHA-256 values, + and valid/invalid outcomes. A missing, extra, or changed fixture fails. +- [x] Use synchronous Rust; do not add Tokio or change dependencies of core/adapter crates. +- [x] Run process, focused, and full crate tests; expected: pass. Commit the green CLI/capability + tranche. +- [x] Run the focused crate tests, then the repository-required Rust and documentation checks. ```bash -git add .github/docker/build-app-cli/check-image-pin.sh .github/actions/deploy-core/tests/check-image-pin.test.sh -git commit -m "build-cache container: fail-closed image.json digest-pin validator" +cargo test --manifest-path .github/tools/edgezero-provenance-validator/Cargo.toml +cargo fmt --manifest-path .github/tools/edgezero-provenance-validator/Cargo.toml --all -- --check +cargo clippy --locked --manifest-path .github/tools/edgezero-provenance-validator/Cargo.toml \ + --workspace --all-targets --all-features -- -D warnings +cargo fmt --all -- --check +cargo clippy --locked --workspace --all-targets --all-features -- -D warnings +cargo test --locked --workspace --all-targets +cargo check --locked --workspace --all-targets --features "fastly cloudflare spin" +cargo check --locked -p edgezero-adapter-spin --target wasm32-wasip2 --features spin +npm --prefix docs ci +npm --prefix docs run format +npm --prefix docs run lint +npm --prefix docs run build +./scripts/check_no_placeholder_pins.sh +./scripts/check_no_legacy_typed_reads.sh +cargo run -q --bin check_no_nested_app_config --features nested-app-config-check -- \ + examples/app-demo crates/edgezero-cli/src/templates +cargo test -p edgezero-cli --features nested-app-config-check --bin check_no_nested_app_config +cargo test -p edgezero-adapter-fastly --all-targets --features cli +cargo test -p edgezero-cli --test generated_project_builds -- --ignored +cargo clippy -p edgezero-adapter-fastly --features cli --all-targets -- -D warnings +cargo clippy -p edgezero-adapter-fastly --no-default-features --lib -- -D warnings +cargo fmt --manifest-path examples/app-demo/Cargo.toml --all -- --check +cargo clippy --manifest-path examples/app-demo/Cargo.toml \ + --workspace --all-targets --all-features -- -D warnings +cargo test --manifest-path examples/app-demo/Cargo.toml --locked --workspace --all-targets ``` ---- +**Gate:** deterministic expected-write/package/validate golden tests and every capability fixture hash pass from a +clean checkout. The candidate PR must also pass every current format/test matrix job, including the +four wasm clippy legs and three wasm test runners; the local command list does not replace those +runner-backed gates. Task 2 copies the exact reviewed validator source, schema, and fixtures into the +gate-owned image source; the Dockerfile rebuilds the binary from that closed source rather than +copying this host build. -### Task 2: The pinned Dockerfile +## 6. Task 2: Establish protected gate baseline `G` -**Files:** -- Create: `.github/docker/build-app-cli/Dockerfile` -- Create: `.github/docker/build-app-cli/image.json` (placeholder digest until Task 3 publishes) - -**Interfaces:** -- Consumes: the Global Constraints (Rust `1.95.0`, single-arch amd64). -- Produces: an image whose `rustc --version` is `1.95.0` and which has `git jq tar curl cc` on `PATH`; consumed by Task 3's publish and by sub-plans 2–4 as `platform-id`. - -- [ ] **Step 1: Write the Dockerfile** - -```dockerfile -# .github/docker/build-app-cli/Dockerfile -# Single-manifest linux/amd64 FULL build+deploy runtime (spec §3.7): the pinned -# Rust toolchain, wasm32-wasip1, the pinned Fastly CLI, and build tools. This -# image IS the toolchain/ABI identity; it runs read-only/non-root at runtime. -# Base pinned by digest; replace the digest below with a current -# rust:1.95.0-bookworm linux/amd64 manifest digest (see README in this dir). -FROM rust:1.95.0-bookworm@sha256:0000000000000000000000000000000000000000000000000000000000000000 - -# Pinned downloads (spec §3.6): fastly 15.1.0 (versions.json) and a pinned sccache. -# Each ARG carries the exact release URL + sha256 (fill the sccache values from the -# chosen sccache release; the fastly values are versions.json's). -ARG FASTLY_URL="https://github.com/fastly/cli/releases/download/v15.1.0/fastly_v15.1.0_linux-amd64.tar.gz" -ARG FASTLY_SHA256="3ba3d8a739b7a88d0a612825a9755d735efb87a9b02ea67e53a11b96d178d500" -ARG SCCACHE_VERSION="0.10.0" -ARG SCCACHE_URL="https://github.com/mozilla/sccache/releases/download/v0.10.0/sccache-v0.10.0-x86_64-unknown-linux-musl.tar.gz" -ARG SCCACHE_SHA256="REPLACE_WITH_RELEASE_SHA256" - -RUN set -eux; \ - apt-get update; \ - apt-get install -y --no-install-recommends \ - git jq tar curl ca-certificates build-essential; \ - rm -rf /var/lib/apt/lists/*; \ - rustup target add wasm32-wasip1; \ - curl -fsSL -o /tmp/fastly.tar.gz "$FASTLY_URL"; \ - echo "${FASTLY_SHA256} /tmp/fastly.tar.gz" | sha256sum -c -; \ - tar -xzf /tmp/fastly.tar.gz -C /usr/local/bin fastly; \ - curl -fsSL -o /tmp/sccache.tar.gz "$SCCACHE_URL"; \ - echo "${SCCACHE_SHA256} /tmp/sccache.tar.gz" | sha256sum -c -; \ - tar -xzf /tmp/sccache.tar.gz --strip-components=1 -C /usr/local/bin "sccache-v${SCCACHE_VERSION}-x86_64-unknown-linux-musl/sccache"; \ - chmod +x /usr/local/bin/sccache; \ - rm /tmp/fastly.tar.gz /tmp/sccache.tar.gz; \ - fastly version; sccache --version - -# No ambient rustflags/wrapper env (spec §3.8 also scrubs at runtime); non-root. -ENV CARGO_TERM_COLOR=never RUSTFLAGS="" CARGO_ENCODED_RUSTFLAGS="" -RUN useradd -m -u 1001 build -USER build -WORKDIR /home/build -``` +This task creates the trust root that evaluates the later release request, owns every repository +image-context input and fixed external-source verification rule, and owns every script that can see a +release credential. Candidate code is always subject data. No image is published in this task. -> The Fastly CLI download is checksum-verified against `versions.json`'s pinned -> `sha256` (above). The publish workflow (Task 3) builds on a hosted runner and -> **makes the GHCR package public** (GHCR packages are private on first publish); the -> image is consumed **read-only/non-root** with explicit writable mounts (spec §3.7). +**Files:** -- [ ] **Step 2: Write the placeholder pin record** +- Create `.github/docker/build-app-cli/{gate-paths,image-context-paths}.txt`, the Dockerfile, and root + `.dockerignore`. +- Create `.github/CODEOWNERS`. +- Modify `.github/docker/build-app-cli/check-image-pin.sh`. +- Create `.github/docker/build-app-cli/{verify-toolchain,verify-published-image}.sh`. +- Create `.github/docker/build-app-cli/{stage-build-context,assert-build-container-context}.sh`. +- Create `.github/docker/build-app-cli/{classify-build-container-change,run-build-container-gate}.sh`. +- Create `.github/docker/build-app-cli/{verify-release-prerequisites,release-approval-gate}.sh`. +- Create `.github/docker/build-app-cli/{update-image-pin-pr,check-build-container-publisher,verify-build-container-publication}.sh`. +- Create `.github/docker/build-app-cli/{write-publisher-prerequisite,verify-gate-rotation-lock}.sh`. +- Create the matching focused tests under `.github/actions/deploy-core/tests/`. +- Create `.github/workflows/{build-container-ci,publish-build-container,rotate-build-container-gate}.yml`. +- Modify `.github/actions/deploy-core/tests/run.sh` and + `.github/workflows/deploy-action.yml`. + +### 6.1 Pin-record validator + +- [x] Extend `check-image-pin.test.sh` first. Cover the valid five-field record; malformed, + duplicate, extra, and missing fields; wrong JSON types; foreign or empty repository; mutable, + zero, uppercase, or malformed digest/source; non-integer or non-`1` protocol; malformed tag; + and any use of the tag as a runtime pull reference. +- [x] Add independent canonical bytes and malformed cases for the exact ten-field + `image-release-evidence.json`: duplicate/extra/missing/reordered keys, non-JCS bytes, wrong + strings/integer, run id/attempt precision, invalid UTC grammar/calendar, login/challenge/digest/tag/S + grammar, and every cross-file mismatch. Pair add/change/delete must be atomic. +- [x] Implement `check-image-pin.sh ` with Bash and `jq`. Detect duplicate top-level + keys from streaming parse events before ordinary object construction. Accept exactly: ```json { "repository": "ghcr.io/stackpop/edgezero-build-app-cli", "tag": "build-container-v1", - "digest": "sha256:0000000000000000000000000000000000000000000000000000000000000000" + "digest": "sha256:<64-lowercase-hex>", + "image-source-revision": "<40-lowercase-hex>", + "provenance-protocol": 1 } ``` -(The placeholder digest is intentional; Task 3's publish workflow overwrites it with the real one, and `check-image-pin.sh` still passes on shape. The pin-gate wiring in Task 4 additionally forbids the all-zero placeholder in a release.) + The tag matches `^build-container-v[1-9][0-9]*$` and is informational. Expose only the + digest-qualified runtime ref, source SHA, and protocol through typed subcommands or shell-safe + output fields. -- [ ] **Step 3: Verify the image builds and bakes the toolchain (local integration check)** +- [x] Run the focused test and `shellcheck -S warning`. Do not create placeholder + `image.json` or evidence record. +- [x] Implement and test the gate-owned typed release-record writer and paired validator with exactly + this CLI: -Run (requires Docker + a real base digest substituted into the `FROM`): -```bash -docker build --platform linux/amd64 -t edgezero-build-app-cli:local .github/docker/build-app-cli -docker run --rm --platform linux/amd64 edgezero-build-app-cli:local rustc --version -docker run --rm --platform linux/amd64 edgezero-build-app-cli:local rustc --print target-list | grep -x wasm32-wasip1 -docker run --rm --platform linux/amd64 edgezero-build-app-cli:local fastly version -docker run --rm --platform linux/amd64 edgezero-build-app-cli:local sccache --version -docker run --rm --platform linux/amd64 edgezero-build-app-cli:local sh -c 'command -v git jq tar curl cc' -# read-only/non-root smoke (spec §3.7): a read-only rootfs run still works with a tmpfs. -docker run --rm --read-only --tmpfs /tmp --user 1001 --platform linux/amd64 edgezero-build-app-cli:local rustc --version +```text +write-image-release-record.sh --image-path --evidence-path --repository ghcr.io/stackpop/edgezero-build-app-cli --release-tag --image-digest --source-revision --provenance-protocol 1 --approval-challenge <64-lowercase-hex> --approver-login --reviewed-at --run-attempt --run-id --screenshot-sha256 ``` -Expected: `rustc 1.95.0 (...)`, `wasm32-wasip1` present, `fastly` reports 15.1.0, all five tools resolve, and the read-only/non-root run succeeds. -- [ ] **Step 4: Commit** + The writer accepts only those typed current publisher/approval scalars and never raw JSON. Its + two absent output paths are distinct direct children of one canonical mode-0700 private directory + outside every Git repository. It invokes the paired validator, atomically publishes both + mode-0644 files without replacement, removes a partial pair on failure, and emits no stdout on + success. `image.json` has exactly these compact bytes: -```bash -git add .github/docker/build-app-cli/Dockerfile .github/docker/build-app-cli/image.json -git commit -m "build-cache container: pinned single-arch Dockerfile + image pin record" +```text +{"digest":"","image-source-revision":"","provenance-protocol":1,"repository":"ghcr.io/stackpop/edgezero-build-app-cli","tag":""} ``` ---- + The evidence output has exactly these ten-field JCS bytes: -### Task 3: Publish workflow (build, push, record digest) - -**Files:** -- Create: `.github/workflows/publish-build-container.yml` - -**Interfaces:** -- Consumes: `.github/docker/build-app-cli/Dockerfile`, `check-image-pin.sh`. -- Produces: a GHCR image `ghcr.io/stackpop/edgezero-build-app-cli` whose **manifest digest** is written back to `image.json` on the release tag; consumed by sub-plans 2–4. - -- [ ] **Step 1: Write the workflow** - -```yaml -# .github/workflows/publish-build-container.yml -name: Publish build container -on: - push: - tags: ["build-container-v*"] -permissions: - contents: write # push the pin branch - packages: write # push the image to GHCR - pull-requests: write # open the image.json PR -jobs: - publish: - runs-on: ubuntu-24.04 - steps: - - uses: actions/checkout@v7 - # Trusted publish job (no app code runs here); keep the token so the - # pin-record PR branch can be pushed. - with: - persist-credentials: true - - name: Log in to GHCR - run: echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u "${{ github.actor }}" --password-stdin - - name: Build and push (single-arch amd64) - id: push - run: | - set -euo pipefail - REPO="ghcr.io/stackpop/edgezero-build-app-cli" - TAG="${GITHUB_REF_NAME}" - docker buildx build --platform linux/amd64 \ - --provenance=false --sbom=false \ - --tag "$REPO:$TAG" --push .github/docker/build-app-cli - DIGEST=$(docker buildx imagetools inspect "$REPO:$TAG" --format '{{json .Manifest.Digest}}' | tr -d '"') - echo "digest=$DIGEST" >> "$GITHUB_OUTPUT" - - name: Verify the pushed image BY DIGEST before recording it - env: - REPO: ghcr.io/stackpop/edgezero-build-app-cli - DIGEST: ${{ steps.push.outputs.digest }} - run: | - set -euo pipefail - REF="$REPO@$DIGEST" - # Single-manifest linux/amd64 (reject a multi-arch index). - n=$(docker buildx imagetools inspect "$REF" --format '{{json .}}' \ - | jq '[.. | .manifests? // empty | .[] | select(.platform.os != "unknown")] | length') - [ "${n:-1}" -le 1 ] || { echo "::error::not single-manifest ($n)"; exit 1; } - # Anonymous pull (the package must be public) + the runtime smoke contract. - docker logout ghcr.io || true - docker run --rm --platform linux/amd64 "$REF" rustc --version | grep -F '1.95.0' - docker run --rm --platform linux/amd64 "$REF" sh -c 'rustc --print target-list | grep -qx wasm32-wasip1' - docker run --rm --platform linux/amd64 "$REF" fastly version - docker run --rm --platform linux/amd64 "$REF" sccache --version - docker run --rm --read-only --tmpfs /tmp --user 1001 --platform linux/amd64 "$REF" rustc --version - - name: Open a reviewable image.json PR (not an in-place commit) - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - DIGEST: ${{ steps.push.outputs.digest }} - run: | - set -euo pipefail - f=.github/docker/build-app-cli/image.json - jq --arg t "${GITHUB_REF_NAME}" --arg d "${DIGEST}" '.tag=$t | .digest=$d' "$f" > "$f.tmp" && mv "$f.tmp" "$f" - bash .github/docker/build-app-cli/check-image-pin.sh "$f" - br="build-container-pin-${GITHUB_REF_NAME}" - git switch -c "$br" - git add "$f" - git -c user.name=edgezero-ci -c user.email=ci@stackpop \ - commit -m "build container: pin ${GITHUB_REF_NAME} = ${DIGEST}" - git push -u origin "$br" - gh pr create --fill --base main --head "$br" \ - --title "Pin build container ${GITHUB_REF_NAME}" \ - --body "Digest verified by the publish workflow (single-manifest, anonymous pull, runtime smoke)." +```text +{"approval-challenge":"<64-lowercase-hex>","approver-login":"","image-digest":"","release-tag":"","reviewed-at":"","run-attempt":"","run-id":"","schema-version":1,"screenshot-sha256":"sha256:<64-lowercase-hex>","source-revision":""} ``` -The publish thus **pushes → inspects by digest → verifies single-manifest + anonymous pull + the runtime smoke → then opens a reviewable `image.json` PR** — the pin the rest of the feature keys on is never recorded until it has been proven against the actual pushed digest. - -- [ ] **Step 2: Actionlint the workflow** - -Run: `actionlint .github/workflows/publish-build-container.yml` -Expected: no output. - -- [ ] **Step 3: Commit** - -```bash -git add .github/workflows/publish-build-container.yml -git commit -m "build-cache container: GHCR publish workflow recording the manifest digest" + Tests cover exact CLI rejection, + pre-existing/split/non-private/in-repository outputs, exact current bytes, mode, atomic + no-replace pair publication, partial-pair cleanup, silent success, and cross-record validation. + The updater alone copies the verified bytes into its private worktree; the writer never writes + inside a repository. Runtime actions continue to parse only `image.json`. + +### 6.2 Image and runtime verification + +- [x] Immediately before the gate-image commit, re-resolve the official + `rust:1.95.0-slim-bookworm` linux/amd64 leaf and require the reviewed digest in Section 1. + Independently download the exact sccache asset and checksum companion and require the reviewed + checksum. Stop for review on movement or disagreement; never commit a placeholder. +- [x] Commit the exact multi-stage Dockerfile, root `.dockerignore`, and sorted + `image-context-paths.txt` as gate-owned inputs. The context manifest excludes the root workspace + manifests and contains only the complete standalone validator directory, schema/fixtures, + `.tool-versions`, Fastly versions, Dockerfile, and `.dockerignore`; every entry is also in + `gate-paths.txt`. Run `cargo metadata --locked --manifest-path +.github/tools/edgezero-provenance-validator/Cargo.toml` inside the staged context and reject any + workspace member or path dependency outside that validator directory. Any new effective input + requires gate rotation. +- [x] Write `stage-build-context.test.sh` before its helper. Cover missing/extra/duplicate/unsorted + manifest entries, symlink/hardlink/FIFO/device inputs, path escape, dirty gate checkout, + candidate Dockerfile substitution, changed `S` copy of a manifested byte, unmanifested source, + remote `ADD`, bind-mounted build context, broad `COPY`, and post-install replacement. The helper + creates a fresh directory outside both checkouts and copies only regular manifested files from + exact clean `G`, preserving paths and executable modes; the publisher passes that directory as + the sole Docker context. +- [x] Build the validator in the Dockerfile with one + `cargo build --locked --release --manifest-path +.github/tools/edgezero-provenance-validator/Cargo.toml` invocation. Test both the exact staged- + context metadata command and exact Docker build. Use the reviewed Rust base digest and + checksum-verified Fastly/sccache assets; install `wasm32-wasip1`; copy only + the exact final binaries/assets and the complete startup-library closure. No later Dockerfile + instruction may replace an installed tool, validator, schema, fixture, interpreter, or closure + member. Static contract tests bind the instruction sequence and destinations. +- [x] Populate flat `/opt/edgezero/runtime-lib` with the complete reviewed x86-64 startup closure, + excluding the interpreter. Require exact dynamic interpreter `/lib64/ld-linux-x86-64.so.2`, + validate every library's role and filename/SONAME aliases, reserve `app-cli` and + `ld-linux-x86-64.so.2` against flat-directory copies, and remove + `/etc/ld.so.preload`. Dynamic app-binary launches use the container runtime's argv API with exact + interpreter options `--inhibit-cache --glibc-hwcaps-mask '' --library-path +/opt/edgezero/runtime-lib`; static binaries run directly. Test that no shell, cache, default + directory, hardware-capability directory, or preload can substitute a startup object. +- [x] Before freezing `G`, compile a real GNU-target Rust CLI using the selected image toolchain. + Run package, validate, and controlled-loader smoke through their exact isolated profiles against + the image's actual Bookworm libc/interpreter closure. Record the measured libc/loader digests + and accepted headers; do not substitute synthetic ELF fixtures or a distro-package inspection + for this image acceptance test. In a separate isolated loader-comparison harness, prove primary + and dependency SONAME-collision fixtures are rejected and the interpreter alias resolves to the + same object the parser predicts. No application execution occurs in the parser profile. +- [x] Write failing command-fixture tests for `verify-toolchain.sh`. Cover exact, prerelease, + extra-text, missing, and malformed Rust/Fastly/sccache version output; absent + `wasm32-wasip1`; failed minimal compile; invalid wasm magic; validator self-test failure; + wrong uid/gid; read-only-root failure; and missing, replaced, or semantically incompatible GNU + `/usr/bin/env -S`. +- [x] Snapshot the `self-test` container's complete create/run contract: digest-pinned linux/amd64 + image, uid/gid 1001, read-only root, all capabilities dropped, no-new-privileges, no network, + 2 GiB memory/swap, 64 pids, 10-minute wall limit, no host bind mounts, only `/work/home` and + `/work/tmp` tmpfs, fixed `/usr/bin/env` entrypoint, exact `-S` plus sorted placeholder split + string, exact three-variable target environment, and exact baked-fixture argv. Seed inherited + image/Docker poison variables and prove they are absent from the target. Reject every extra + target environment name, mount, flag, entrypoint, command prefix, non-placeholder assignment, + value-derived argv construction, or path. Snapshot the exact container `Path` and `Args`; do not + use accidental byte inequality between values and argv as the assertion. +- [x] Before publication, drive the verifier through real `docker create --env-file`, delete and + verify absence of that file before `docker start --attach`, and assert exact post-`env -i` + bytes for empty values plus spaces, quotes, backslashes, dollar signs, `#`, `=`, literal + `${...}` text, and non-ASCII values. Prove expansion is neither recursive nor resplit, the + placeholder list and env-file names agree exactly, and image/Docker poison is absent. Reject + newline/NUL serialization, duplicate/bare/comment/blank/extra env-file entries, missing + placeholders, and any argv element constructed by inserting a value. +- [x] Implement exact semantic-version parsing, installed-target inspection, and compilation of the + committed `wasm-smoke.rs` library into tmpfs. Substring version matching is forbidden. +- [x] Write failing fixture tests for `verify-published-image.sh`. Cover accepted leaf Docker and + OCI manifests; rejected one-entry/multi-entry indexes; missing config/layers; malformed BuildKit + metadata; wrong OS/architecture; wrong source/revision/protocol labels; mutable-tag lookup; private + registry response; and every toolchain/validator failure. +- [x] Implement the verifier over a supplied `repository@digest`, source SHA, and protocol. Use + `docker buildx imagetools inspect "$REF" --raw` for media type and + `--format '{{json .Image}}'` for image OS/architecture. Never inspect a tag to discover + identity. +- [x] Exercise the baked validator's `write-expected` profile with only a fresh writable + `/work/expected`, tmpfs home/temp, no repository/binary/target/Cargo/cache/token mount, and + `--network=none`. Then run deterministic package twice and validate every golden/malformed + archive with each operation's exact mount profile. Assert no `/work/package` convention + exists. +- [x] Run both focused tests and shellcheck before implementing, then rerun them green. + +### 6.3 Protected classifier and required workflow + +- [x] Commit `gate-paths.txt` as the canonical, sorted list of every gate-owned path: + the standalone validator manifest/lockfile, validator crate, schema and fixtures, pin scanner, + all classifier/verifier/policy/approval/updater/publisher-checker helpers, + context manifest/Dockerfile/`.dockerignore`, their focused tests and `run.sh` wiring, + `scripts/{install-actionlint,install-yq,run-actionlint}.sh`, the installer tests including + `install-yq.test.sh`, `.github/CODEOWNERS`, + `.github/zizmor.yml`, and + all three container workflows (`build-container-ci.yml`, `publish-build-container.yml`, and + `rotate-build-container-gate.yml`). Include exact `docs/package.json` and + `docs/package-lock.json`. The manifest contains itself. `image-context-paths.txt` is a strict + subset, is also canonical/sorted, and closes over every Docker build input and local Cargo + dependency. +- [x] Freeze the literal gate manifest only after every gate-owned helper and focused test exists. + At that point, copy the reviewed final path list into this plan as a fenced exact sorted block and + require it to be byte-equal to `.github/docker/build-app-cli/gate-paths.txt`. Do not invent that + final block before the file surface is complete. + The frozen manifest is: + +```text +.dockerignore +.github/CODEOWNERS +.github/actions/deploy-core/tests/assert-build-container-app-token.test.sh +.github/actions/deploy-core/tests/assert-build-container-completion.test.sh +.github/actions/deploy-core/tests/assert-build-container-context.test.sh +.github/actions/deploy-core/tests/assert-build-container-dispatch-context.test.sh +.github/actions/deploy-core/tests/build-container-workflows.test.sh +.github/actions/deploy-core/tests/check-action-pins.sh +.github/actions/deploy-core/tests/check-build-container-publisher.test.sh +.github/actions/deploy-core/tests/check-doc-action-pins.mjs +.github/actions/deploy-core/tests/check-doc-action-pins.sh +.github/actions/deploy-core/tests/check-doc-action-pins.test.mjs +.github/actions/deploy-core/tests/check-image-pin.test.sh +.github/actions/deploy-core/tests/classify-build-container-change.test.sh +.github/actions/deploy-core/tests/install-actionlint.test.sh +.github/actions/deploy-core/tests/install-yq.test.sh +.github/actions/deploy-core/tests/release-approval-gate.test.sh +.github/actions/deploy-core/tests/run-actionlint.test.sh +.github/actions/deploy-core/tests/run-build-container-gate.test.sh +.github/actions/deploy-core/tests/run.sh +.github/actions/deploy-core/tests/select-build-container-range.test.sh +.github/actions/deploy-core/tests/stage-build-context.test.sh +.github/actions/deploy-core/tests/update-image-pin-pr.test.sh +.github/actions/deploy-core/tests/verify-build-container-publication.test.sh +.github/actions/deploy-core/tests/verify-gate-rotation-lock.test.sh +.github/actions/deploy-core/tests/verify-published-image.test.sh +.github/actions/deploy-core/tests/verify-release-prerequisites.test.sh +.github/actions/deploy-core/tests/verify-toolchain.test.sh +.github/actions/deploy-core/tests/write-image-release-record.test.sh +.github/actions/deploy-core/tests/write-publisher-prerequisite.test.sh +.github/actions/deploy-fastly/versions.json +.github/docker/build-app-cli/Dockerfile +.github/docker/build-app-cli/assert-build-container-app-token.sh +.github/docker/build-app-cli/assert-build-container-completion.sh +.github/docker/build-app-cli/assert-build-container-context.sh +.github/docker/build-app-cli/assert-build-container-dispatch-context.sh +.github/docker/build-app-cli/check-build-container-publisher.sh +.github/docker/build-app-cli/check-image-pin.sh +.github/docker/build-app-cli/classify-build-container-change.sh +.github/docker/build-app-cli/fixtures/gnu-smoke.rs +.github/docker/build-app-cli/fixtures/provenance/invalid/archive-base256.tar +.github/docker/build-app-cli/fixtures/provenance/invalid/archive-checksum.tar +.github/docker/build-app-cli/fixtures/provenance/invalid/archive-duplicate.tar +.github/docker/build-app-cli/fixtures/provenance/invalid/archive-embedded-nul.tar +.github/docker/build-app-cli/fixtures/provenance/invalid/archive-end-block.tar +.github/docker/build-app-cli/fixtures/provenance/invalid/archive-extra-end-block.tar +.github/docker/build-app-cli/fixtures/provenance/invalid/archive-extra.tar +.github/docker/build-app-cli/fixtures/provenance/invalid/archive-gnu.tar +.github/docker/build-app-cli/fixtures/provenance/invalid/archive-header.tar +.github/docker/build-app-cli/fixtures/provenance/invalid/archive-link.tar +.github/docker/build-app-cli/fixtures/provenance/invalid/archive-max-size.tar +.github/docker/build-app-cli/fixtures/provenance/invalid/archive-octal-digit.tar +.github/docker/build-app-cli/fixtures/provenance/invalid/archive-octal-padding.tar +.github/docker/build-app-cli/fixtures/provenance/invalid/archive-order.tar +.github/docker/build-app-cli/fixtures/provenance/invalid/archive-padding.tar +.github/docker/build-app-cli/fixtures/provenance/invalid/archive-pax.tar +.github/docker/build-app-cli/fixtures/provenance/invalid/archive-size-limit.tar +.github/docker/build-app-cli/fixtures/provenance/invalid/archive-size.tar +.github/docker/build-app-cli/fixtures/provenance/invalid/archive-sparse.tar +.github/docker/build-app-cli/fixtures/provenance/invalid/archive-special.tar +.github/docker/build-app-cli/fixtures/provenance/invalid/archive-trailing.tar +.github/docker/build-app-cli/fixtures/provenance/invalid/archive-traversal.tar +.github/docker/build-app-cli/fixtures/provenance/invalid/elf-malformed/app-cli +.github/docker/build-app-cli/fixtures/provenance/invalid/expected-duplicate.json +.github/docker/build-app-cli/fixtures/provenance/invalid/meta-missing-interpreter.json +.github/docker/build-app-cli/fixtures/provenance/valid/archive.tar +.github/docker/build-app-cli/fixtures/provenance/valid/dynamic-meta.json +.github/docker/build-app-cli/fixtures/provenance/valid/elf-static/app-cli +.github/docker/build-app-cli/fixtures/provenance/valid/expected.json +.github/docker/build-app-cli/fixtures/provenance/valid/static-meta.json +.github/docker/build-app-cli/fixtures/wasm-smoke.rs +.github/docker/build-app-cli/gate-paths.txt +.github/docker/build-app-cli/image-context-paths.txt +.github/docker/build-app-cli/provenance.schema.json +.github/docker/build-app-cli/release-approval-gate.sh +.github/docker/build-app-cli/run-build-container-gate.sh +.github/docker/build-app-cli/select-build-container-range.sh +.github/docker/build-app-cli/stage-build-context.sh +.github/docker/build-app-cli/update-image-pin-pr.sh +.github/docker/build-app-cli/verify-build-container-publication.sh +.github/docker/build-app-cli/verify-gate-rotation-lock.sh +.github/docker/build-app-cli/verify-published-image.sh +.github/docker/build-app-cli/verify-release-prerequisites.sh +.github/docker/build-app-cli/verify-toolchain.sh +.github/docker/build-app-cli/write-image-release-record.sh +.github/docker/build-app-cli/write-publisher-prerequisite.sh +.github/tools/edgezero-provenance-validator/Cargo.lock +.github/tools/edgezero-provenance-validator/Cargo.toml +.github/tools/edgezero-provenance-validator/src/archive.rs +.github/tools/edgezero-provenance-validator/src/command.rs +.github/tools/edgezero-provenance-validator/src/elf.rs +.github/tools/edgezero-provenance-validator/src/extract.rs +.github/tools/edgezero-provenance-validator/src/json_contract.rs +.github/tools/edgezero-provenance-validator/src/lib.rs +.github/tools/edgezero-provenance-validator/src/main.rs +.github/tools/edgezero-provenance-validator/src/orchestration.rs +.github/tools/edgezero-provenance-validator/src/self_test.rs +.github/tools/edgezero-provenance-validator/tests/cli.rs +.github/workflows/build-container-ci.yml +.github/workflows/publish-build-container.yml +.github/workflows/rotate-build-container-gate.yml +.github/zizmor.yml +.tool-versions +docs/package-lock.json +docs/package.json +scripts/install-actionlint.sh +scripts/install-yq.sh +scripts/run-actionlint.sh ``` -- [ ] **Step 4: Publish (operator step, out of band)** - -Tag `build-container-v1` and push it. The workflow pushes the image, **verifies it by digest** (single-manifest, anonymous pull, runtime smoke), and **opens a PR** updating `image.json` to the real `sha256` digest. Review and merge that PR — the digest is the pin the rest of the feature keys on, and it is only recorded after passing verification against the actual pushed image. - -**One-time GHCR visibility + retention (operator):** GHCR packages are **private on first publish** and there is no clean REST endpoint to flip a container package public, so set the package `edgezero-build-app-cli` to **public** in its GHCR package settings (or set the org's default package visibility) so consumers can **anonymously** pull by digest (spec §3.7), and enable a retention policy that never prunes a digest referenced by a committed `image.json`. Verify anonymous access: -```bash -docker logout ghcr.io -docker pull "ghcr.io/stackpop/edgezero-build-app-cli@$(jq -r .digest .github/docker/build-app-cli/image.json)" +- [x] Generate `.github/CODEOWNERS` exactly from that canonical manifest: one line per manifested path + in manifest order, spelled `/ @stackpop/edgezero-build-container-gate-reviewers`, with no + wildcard, alternate owner, unowned manifest entry, or extra non-comment rule. `CODEOWNERS` and + `gate-paths.txt` contain and therefore protect themselves. +- [x] Write failing classifier tests for the design's closed pull-request, merge-group, and protected- + push event-to-range table, including exact payload/context SHA and ref agreement; + add/change/rename/delete; all-zero first-push base; shallow/missing commits; malformed or duplicate + output; ordinary, isolated-release-request, and gate-update modes; mixed gate/non-gate changes; + old/candidate manifest union handling; exact failed-`G'` gate-rollback restoration; every + interrupted pointer/policy state; and every context-manifest entry. Pin classification is + exact paired add/change/delete detection for `image.json` and `image-release-evidence.json`; a release request is relevant only when it is + the sole changed path and has exact canonical shape and active `G`. +- [x] Implement the classifier over explicit base/head SHAs from a full subject checkout. Its only + stdout is exactly two fixed-order lines: `mode=ordinary|gate-update|gate-rollback` and + `relevant=true|false`; both gate modes require true. Any invalid range, missing object, gitlink, + path ambiguity, mixed + gate/non-gate change, or unmanifested repository image-context input fails. Gate-update mode requires the + base's manifested tree to equal old `G`, validates candidate manifest and CODEOWNERS as inert data, and permits + changed paths only in the union of old and candidate manifests. Gate-rollback requires disabled + release, both pointers at old `G`, a base valid as failed `G'`, a head tree byte-equal to old + `G`, the two-manifest union, and no non-gate/release/pin change. +- [x] Derive those classifier SHAs only through the trusted workflow's closed event selector. For + `pull_request`, require exact base repository/ref and authenticate `M=github.sha`; read exactly + ordered parents `[F,J]` with second parent equal to event head, require event base `A` + ancestor-or-equal to `F`, and select `F..M`. Both scanner and classifier use that pair for state, + changed paths, and base-manifest reads, with `{A,F,J,M}` diagnostics; no moving API/main, + merge-base, PR-head-only, or three-dot substitute is allowed. For + `merge_group:checks_requested`, require payload base/head SHAs, exact base/head refs, + `head_sha==github.sha`, the `gh-readonly-queue/main/` head prefix, and base ancestry. For + protected-main `push`, require nonzero `before`, `after==github.sha==github.workflow_sha`, exact + main ref, and base ancestry. Reject every other event or inconsistent/missing field before the + classifier runs. `workflow_dispatch` is a separate credential-smoke path and invokes neither + the classifier nor documentation scanner. +- [x] Write `run-build-container-gate.test.sh` before its driver. Test separate gate/subject + roots, full-SHA checkout assertions, candidate helper substitution, symlink escape, dirty checkout, + missing/duplicate completion markers, ordinary relevant, gate-update, gate-rollback, and + explicit-not-applicable branches, pin deletion, mixed-change rejection, and propagation of every verifier + failure. +- [x] Implement the driver so all authority comes from its own canonical gate root. The subject root is + read/build input only. It never sources, executes, or resolves a helper from the subject. A + relevant pin invokes `verify-build-container-publication.sh`; the network-free structural + `check-build-container-publisher.sh` is reserved for inert gate-update/rollback workflow checks. +- [x] Before classification in every stable job, have the trusted driver enumerate the complete tree + at event-selected subject head `T`; select every direct `.github/workflows/*.{yml,yaml}` and every + `action.{yml,yaml}` basename anywhere; require paths to use only `[A-Za-z0-9._/+-]`; reject + ambiguous, symlink, gitlink, or missing + entries; extract exact regular blobs outside both repositories; and invoke only `G`'s + `.github/actions/deploy-core/tests/check-action-pins.sh` with the complete explicit list. Test + omitted/nested workflows, action metadata outside `.github`, deletion, malformed YAML, floating, + prerelease, SHA, and Docker-tag refs, candidate scanner substitution, and not-applicable changes. + Require the scanner's exact success line to report a positive external-reference count; consume + it only to reject a vacuous/malformed scan and never pass it to another step. Any + enumeration/extraction/parser/policy failure stops both stable jobs. +- [x] Create workflow contract tests before YAML. They require: - organization-rule events `pull_request` and `merge_group` with + `merge_group` limited to `checks_requested`, local + protected-main `push`, and manual `workflow_dispatch`; - no workflow-level path filter and stable jobs `build-container-local` and + `build-container-pin` on every PR/merge-group candidate; - workflow permissions exactly `contents: read`, `actions: read`, and `pull-requests: read`, with + no secret/environment/mutation token in either required job; - repository variable `EDGEZERO_BUILD_CONTAINER_GATE_SHA` validated as a full SHA; - separate gate and subject checkouts with persisted credentials disabled; - all classifier, driver, completion, and verifier commands resolved under the gate checkout; - exact protected workflow repository/path parsing from `github.workflow_ref` and exact + `github.workflow_sha` assertions for required runs; - explicit not-applicable execution and an unconditional terminal completion assertion; and - fixed steps `assert-exact-g-dispatch-context` and `assert-exact-main-push-context`; generic + protected-main push assertions for event, ref, `event.after`, current head `Q`, workflow SHA, + active gate SHA, and both latest-attempt job conclusions; literal `runs-on: ubuntu-24.04` and an + unconditional context-derived `github-hosted`/`Linux`/`X64` bootstrap as the first executable + step of every job before either checkout; no bootstrap `if`, `continue-on-error`, or masked + failure; success-gated later non-cleanup steps; and separate release-request identity when + `Q=S`. Negative fixtures cover absent/dynamic/wrong labels, missing/late/skipped/continued + guards, failure masking, arbitrary non-cleanup always-run steps, and required recovery/ + reconciliation steps without exact guard-success and transition-marker conditions. +- [x] Implement `build-container-ci.yml`. For organization-required runs, + `github.workflow_ref` must identify `stackpop/edgezero` and the exact path, and + `github.workflow_sha` must equal repository variable `EDGEZERO_BUILD_CONTAINER_GATE_SHA`, whose + value is `G`. Push runs use local workflow SHA equal to current protected head `Q` while still + executing gate code from that variable. Every job uses the literal runner label and fixed + first-step bootstrap above. Each stable push job invokes the trusted context helper in exactly + one named `assert-exact-main-push-context` step. Keep the existing path-filtered + `deploy-action.yml` separate. +- [x] Wire all focused suites into `run.sh`. The protected gate runs its own tests; it never + executes a candidate test script. + +### 6.4 Release-policy verifier and publisher + +- [x] Apply the design's common trusted-helper interface first: unique explicit nonsecret flags, + environment-only named credentials, canonical regular-file structured inputs, absent create-new + output paths, atomic no-replace publication, silent success, sanitized stderr, and exact exit + statuses 0/1/2 for success/contract/usage-tooling failure. +- [x] Implement only these four prerequisite-auditor command shapes from design v6.39; reject every + cross-mode, missing, duplicate, or extra flag before reading a credential: + +```text +verify-release-prerequisites.sh configuration --gate-root --gate-sha --smoke-run-id --smoke-run-attempt --expected-app-id --expected-installation-id --expected-team-id --expected-bot-id --expected-bot-login --policy-token-review-json --policy-token-review-png --administrator-bypass-png --administrator-bypass-reviewer --administrator-bypass-reviewed-at --evidence-out +verify-release-prerequisites.sh release --gate-root --gate-sha --candidate-pr --evidence-url --source-revision --merge-group-sha --merge-group-run-id --merge-group-run-attempt --smoke-run-id --smoke-run-attempt --push-run-id --push-run-attempt --expected-app-id --expected-installation-id --expected-team-id --expected-bot-id --expected-bot-login --package-auditor-login --package-state --policy-token-review-json --policy-token-review-png --administrator-bypass-png --administrator-bypass-reviewer --administrator-bypass-reviewed-at --evidence-out --publisher-prerequisite-out +verify-release-prerequisites.sh rotation-review --gate-root --old-gate-sha --new-gate-sha --dispatch-sha --final-head-sha --lock-run-id --lock-run-attempt --operator-login --result --policy-token-review-json --policy-token-review-png --evidence-out --approval-comment-out +verify-release-prerequisites.sh rotation-complete --gate-root --gate-sha --lock-run-id --lock-run-attempt --policy-token-review-json --policy-token-review-png --evidence-out --publisher-prerequisite-out ``` -Expected: the pull succeeds without credentials. - ---- - -### Task 4: Wire the digest pin into the pin gate - -**Files:** -- Modify: `.github/actions/deploy-core/tests/run.sh` (add the validator suite) -- Modify: `.github/actions/deploy-core/tests/check-image-pin.test.sh` (add a reject-placeholder case) - -**Interfaces:** -- Consumes: `check-image-pin.sh`, `image.json`. -- Produces: a CI gate that fails if the build container is not digest-pinned (or is the all-zero placeholder), alongside the existing action-pin gate. - -- [ ] **Step 1: Add the failing placeholder-rejection test** -Append to `check-image-pin.test.sh` (before the summary), a case asserting the real repo `image.json` is not the all-zero placeholder: - -```bash -REAL="$DIR/../../../docker/build-app-cli/image.json" -zero="sha256:$(printf '%064d' 0)" -if [ "$(jq -r '.digest' "$REAL")" = "$zero" ]; then - no "committed image.json is still the all-zero placeholder" -else - ok "committed image.json carries a real digest" -fi + Read only `EDGEZERO_RELEASE_POLICY_AUDIT_TOKEN`, plus + `EDGEZERO_RELEASE_PACKAGE_AUDIT_TOKEN` in `release` and owner-only mode-0600 key path + `EDGEZERO_RELEASE_APP_PRIVATE_KEY_FILE` in `configuration`/`release`. Implement and test the exact + canonical review schemas and mode-specific outputs from the design before API interpretation. + +- [x] Require both the policy-token review PNG and administrator-bypass PNG to be canonical absolute- + path regular non-symlink files of 8 through 10,485,760 bytes, beginning with exact signature + bytes `89 50 4e 47 0d 0a 1a 0a`. Recompute each SHA-256; require the policy image digest to equal + the canonical review's `screenshot-sha256` and record the administrator-bypass image digest in + canonical audit evidence. The two PNG inputs remain distinct and non-interchangeable. +- [x] Produce `evidence-out` only in the trusted auditor as a nonempty 1..1,048,576-byte UTF-8 JSON + object serialized by that gate revision as RFC 8785 JCS with no BOM, surrounding whitespace, or + trailing LF. Bind the SHA-256 of those exact raw bytes in the separate small prerequisite record. + The downstream writer treats the attachment as opaque bytes: it checks only size and raw digest, + never parses audit members or derives machine state from its prose/detail fields. + +- [x] Write fake-API tests before `verify-release-prerequisites.sh`. Implement the exact + credential-specific method/path/query allowlists from design Section 8. Reject redirects, + unvalidated placeholders, wrong credential use, any persistent mutation route, + hidden/missing `bypass_actors`, and pagination failure before network or state interpretation. + Every request must use exact `Accept: application/vnd.github+json`, + `X-GitHub-Api-Version: 2026-03-10`, and `User-Agent: edgezero-build-container-gate/1`; reject a + missing/different header. Every curl request must also use exact + `--disable --silent --show-error --connect-timeout 10 --max-time 30 --max-redirs 0`; timeout is + terminal. Require response `X-GitHub-Api-Version-Selected: 2026-03-10`; a response body has + parsed media type exactly `application/json`, with no charset or charset `utf-8`, and contains + exactly one complete JSON value. GET requires 200. Approved token, release, and pull-request + POSTs require 201 plus JSON. Approved release and pull-request PATCHes require 200 plus JSON. + Token DELETE and the publisher-prerequisite variable PATCH require 204 and an empty body, with + no JSON media-type requirement. Require a clean detached checkout at exact `G` before reading + any credential. +- [x] Freeze list-query bytes to exactly these ordered forms; scalar-object GETs, selected run, + approval and pull-request GETs, exact-ref GETs, and release-id GETs have no query: + +```text +/orgs/stackpop/rulesets?per_page=100&page= +/repos/stackpop/edgezero/rulesets?per_page=100&page= +/repos/stackpop/edgezero/environments/build-container-release/deployment-branch-policies?per_page=100&page= +/repos/stackpop/edgezero/actions/runs//jobs?per_page=100&page= +/repos/stackpop/edgezero/actions/runs//attempts//jobs?per_page=100&page= +/repos/stackpop/edgezero/actions/workflows/rotate-build-container-gate.yml/runs?event=workflow_dispatch&per_page=100&page= +/repos/stackpop/edgezero/commits//check-runs?check_name=&filter=latest&app_id=15368&per_page=100&page= +/orgs/stackpop/packages?package_type=container&per_page=100&page= +/installation/repositories?per_page=100&page= +/repos/stackpop/edgezero/pulls?state=all&base=main&sort=created&direction=asc&per_page=100&page= ``` -- [ ] **Step 2: Run it to verify it fails** - -Run: `bash .github/actions/deploy-core/tests/check-image-pin.test.sh` -Expected: FAIL on "committed image.json carries a real digest" until Task 3's publish lands a real digest. + The deployment-protection-rules route is a scalar no-query GET whose single response must contain + `total_count:0` and an empty `custom_deployment_protection_rules` array. `` is + exactly `build-container-release-preflight`, + `build-container-local`, or `build-container-pin`, always with `filter=latest` and + `app_id=15368` in that byte order. Start at canonical page 1, synthesize only successive pages, + and stop at 100 pages or 10,000 unique items. Never follow a response URL. Reject duplicate keys + or items, repeated payloads, encoding aliases, fragments, unlisted query bytes, inconsistent + `total_count`, premature/missing/duplicate/inconsistent RFC 8288 `Link` relations, `next` after a + short or count-complete page, missing `next` before an incomplete declared count, and 100 items + on page 100 as truncation. Endpoint-specific one-page contracts, including the publisher's exact- + attempt two-job query, remain exactly page 1 and reject every continuation. + +- [x] Require local `EDGEZERO_RELEASE_POLICY_AUDIT_TOKEN` to be a short-lived fine-grained PAT + owned by the verified active `stackpop` organization-owner login and selected only for + `stackpop/edgezero` with repository Actions/read, Checks/read, Contents/read, + Environments/read, Pull requests/read, Variables/read, Metadata/read, Administration/write and organization + Members/read, Administration/write. A second operator records token id, resource owner, repository + selection, expiry, exact displayed grants, screenshot digest, and every absent write surface in + the design's literal canonical policy-token-review JSON shape. The helper does not claim GitHub + can report the complete PAT grant set. +- [x] Require separate `EDGEZERO_RELEASE_PACKAGE_AUDIT_TOKEN` to authenticate an active + `stackpop` owner and report exactly normalized scopes + `{read:org,read:packages}`. Reject byte-equal audit tokens before the first request. +- [x] Verify the environment's nonempty reviewer rule, `prevent_self_review=true`, final sole + tag deployment policy `build-container-v*`, empty custom deployment-protection-rules endpoint, + and supplied manual administrator-bypass + evidence. Verify repository variable `EDGEZERO_BUILD_CONTAINER_GATE_SHA=G`; the exact + organization required-workflow descriptor bound directly to gate commit `G`, with no ref or + bypass actor, exact repository-id/main-ref conditions, and `do_not_enforce_on_create=false`; + and repository ruleset + `edgezero-build-container-main` with + target `branch`, active enforcement, no bypass, exact `main` include/no excludes, exact + pull-request review fields, and merge queue `{timeout:60, ALLGREEN, build:1, merge:1, SQUASH, +min:1, wait:0}` from the design. Every missing, extra, defaulted, or changed semantic field + fails. Require the immutable-releases endpoint to return HTTP 200, parse `enabled` as exact + boolean `true`, require `enforced_by_owner` to be present as a boolean, and record its value; + tolerate additional response fields. Require exact + image tag rulesets `edgezero-build-container-tag-creation` and + `edgezero-build-container-tag-immutability` for `refs/tags/build-container-v*`, plus action tag + rulesets `edgezero-action-version-tag-creation` and + `edgezero-action-version-tag-immutability` for `refs/tags/v*`; all have exact repository source, + tag target, active enforcement, include, and no excludes. Each creation ruleset has only the + creation rule and sole reviewed-team `always` bypass. Each immutability ruleset has no bypass + and only deletion plus update with `update_allows_fetch_and_merge:false`. + Require Repository ruleset `edgezero-build-container-pin-branches` with exact repository source, + branch target, active enforcement, `refs/heads/edgezero-build-container-pin/*` include/no + excludes, only the dedicated App Integration `always` bypass, and exact creation/update/deletion + rule array. +- [x] Verify organization and repository Actions permissions both report + `sha_pinning_required:false`; add their exact GET routes to the policy-token allowlist. Run a + hosted exact-patch-version action fixture so any stricter enterprise override fails before + publication. +- [x] Verify the candidate PR identity, final queue `merge_group` required-workflow run from + `G`, and exact post-merge run/job API records for `S`. The run must have event `push`, exact + workflow path, and `head_sha=S`; each stable job must have `head_sha=S`, succeed, and contain + exactly one successful `assert-exact-main-push-context` step. The trusted step checks ref, + `event.after`, current main head `Q`, `github.sha`, `github.workflow_sha`, and active gate internally because those + values are not exposed by the run REST response. Missing/duplicate/wrong-attempt assertion steps + fail. Matching check names from candidate workflow code are not evidence. +- [x] Verify protected-environment App/installation/team variables and private-key secret metadata. + Use the local App key to authenticate the exact dedicated App and selected-repository installation + with only contents/write, pull-requests/write, and implicit metadata/read. First mint + `InstallationMetadataAudit` with exact body `{"permissions":{"metadata":"read"}}`, omitting + both repository selectors. Require only metadata-read returned permission and complete, + duplicate-free repository enumeration with `total_count:1` and only the verified EdgeZero + id/full name. Its wrapper allows only repository enumeration and revocation. Revoke it + successfully, then mint distinct `EdgeZeroPublisherProbe` with exact body + `{"repository_ids":[E],"permissions":{"contents":"write","pull_requests":"write"}}`. + The probe's wrapper allows only the specified enumeration, EdgeZero GET, and revocation. + Neither token can cross wrappers or be stored in Actions. Reject broadened permissions, + narrowed audit bodies, omitted permission bodies, extra repositories, duplicate/truncated/count- + mismatched pages, and revocation failure. Include a two-repository installation whose bounded + probe sees only EdgeZero: the installation audit must still reject it. Only these two local + token creations/revocations are non-GET operations; persistent mutations remain forbidden. +- [x] Resolve `EDGEZERO_BUILD_CONTAINER_PUBLISHER_BOT_LOGIN` through the exact public user endpoint + and require the response login, numeric id, and `type:"Bot"` to equal the independently reviewed + repository variables. Reject a user/login collision before trusting pin-PR authorship. +- [x] Prove package absence only through the fully paginated verified-owner listing before first push. + After first push, require public visibility and linkage to `stackpop/edgezero`. A 404 or + authorization failure is never absence. +- [x] Emit canonical auditor evidence with every identity, rule id/URL, workflow/run/attempt/job URL, + permission, package state, manual-evidence digest, and timestamp, but no credential. A separately + authenticated operator first creates one inert placeholder comment on the candidate PR and + supplies exact URL + `https://github.com/stackpop/edgezero/pull/#issuecomment-` to release + audit. The auditor binds it but never mutates a comment. After audit, the operator edits that same + stable comment to attach the exact output and byte-identical review PNG; final independent review + precedes writer invocation. In post-merge mode also emit this exact closed, ordered small JCS machine record shape as a + separate derived output: + +```text +{"evidence-sha256":"sha256:<64-lowercase-hex>","evidence-url":null,"gate-sha":"<40-lowercase-hex>","previous-value-sha256":null,"rotation-history":{"state":"bootstrap-no-rotation"},"schema-version":2,"source-pr":null,"source-revision":null} +``` -- [ ] **Step 3: Invoke the suite from the contract runner** + The initial inert record shown above is created manually by a separate reviewed repository- + variable POST procedure; the PATCH-only writer never observes or creates an absent variable. + Every record submitted to the writer has `previous-value-sha256` equal to the SHA-256 of the + exact prior variable UTF-8 bytes. Inert records have null `source-revision`, `source-pr`, and + `evidence-url`. Release-bound records have exact `S`, candidate PR as a canonical positive-u64 + string, and the exact matching evidence-comment URL; the tuple is all-null or all-non-null. + Same-source package recovery may refresh evidence digest and URL but preserves source PR. `rotation-history` is + either the bootstrap object above or exactly + `{"created-at":"","evidence-sha256":"sha256:<64-lowercase-hex>","history-sha256":"sha256:<64-lowercase-hex>","run-attempt":"","run-id":"","run-number":"","state":"verified"}`. + For verified history, `rotation-history.evidence-sha256` is the same value as the first rotation- + approval line's `evidence-sha256` and hashes only the exact compact JSON object bytes on the + second approval line, excluding its protocol prefix and every newline. + The record contains no `required-workflow-sha`. Its trusted local-auditor evidence requires + `gate-sha` to equal both the live active-gate variable and live required-workflow descriptor SHA; + the writer independently checks only the live active-gate variable, while the publisher checks + the authenticated verified-rotation receipt's descriptor SHA. Bootstrap descriptor equality + is an explicitly trusted manual administrative assertion, not a machine-authenticated or live + Actions read. Retain exact rotation history so + an old same-gate `S` record is stale after rollback. The opaque evidence attachment never contains + this record or its digest. Test evidence-post failure and reject an `S`-bound value before exact- + `S` push evidence exists. + +- [x] Write `write-publisher-prerequisite.test.sh` before its helper. Cover exact byte-equal idempotence + after local record validation as a mutation-free no-op that occurs before predecessor-hash + evaluation and before PATCH. Every non-idempotent mutation must match the exact prior-value digest. + Cover gate change only to an inert, verified-rotation record, with a greater selected run number + when prior history is verified; same-gate inert to `S`; same-`S` evidence refresh; and forward `S` + only when the old source is a Git ancestor. Every ordinary same-gate source transition preserves + exact rotation history. Allow same-gate source clear or inert refresh only for post-rollback + recovery. Bootstrap history may make its first such transition only to an inert verified record, + without comparing absent predecessor rotation fields. For verified prior history, selected run + number must increase, run id must differ, and both receipt and full-history digests must change. + Require every non-null source to descend from + the current gate. Reject an absent prerequisite variable, every other source clear or inert + refresh, source regression, incomparable source, cross-gate source carry, stale same-gate rotation + identity after rollback, malformed JCS, unknown/reordered/duplicate fields, mismatched evidence + digests, unexpected prior value, wrong actor/grants, redirect/header/status failure, attempted + predecessor/PATCH evaluation on no-op, and failed post-write readback. The helper runs only from clean detached active `G`, reads + `EDGEZERO_PUBLISHER_PREREQUISITE_WRITE_TOKEN` only from its environment, authenticates the expected + active organization member, and accepts only a short-lived fine-grained PAT selected for + `stackpop/edgezero` with repository Variables/write, implicit Metadata/read, organization + Members/read, and no other displayed grant. Require the design's literal canonical writer-token- + review JSON shape and an independent final reviewer who recomputes the outer evidence digest and, + only for verified nonempty rotation history, the nested receipt digest. + Treat review `token-id` as screenshot-bound fine-grained-token inventory identity, never as + `/user.id`. Require an operator-serialized single-writer interval from the first prerequisite GET + through final readback; document that GitHub's unconditional variable PATCH is not atomic CAS and + that a concurrent privileged writer is an accepted operational violation. Reject promisor/partial + clones and object alternates and disable lazy Git fetches before credential use. +- [x] Give the writer only this exact interface: + +```text +write-publisher-prerequisite.sh --gate-root --gate-sha --evidence-json --publisher-prerequisite-json --writer-token-review-json --writer-token-review-png +``` -Add to `.github/actions/deploy-core/tests/run.sh` (near the other suite invocations): + It reads only `EDGEZERO_PUBLISHER_PREREQUISITE_WRITE_TOKEN`, emits no file or stdout, and treats + `evidence-json` as opaque nonempty bytes of at most 1,048,576 bytes, recomputing only its raw + SHA-256. Require `writer-token-review-png` to be a canonical absolute-path regular non-symlink file + of 8 through 10,485,760 bytes with exact PNG signature `89 50 4e 47 0d 0a 1a 0a` and digest equal + to the canonical writer review's `screenshot-sha256`. Its credential wrapper permits only the + design's user, membership, active-gate-variable, and prerequisite-variable GETs; exact PATCH of + the prerequisite variable with body + `{"name":"EDGEZERO_BUILD_CONTAINER_PUBLISHER_PREREQUISITE","value":""}`; + and post-write GET. Every GET, including the prerequisite-variable GET, requires 200 plus exactly + one complete JSON value; missing/404 prerequisite state fails. PATCH requires 204 with an empty + body; the readback value must equal the exact requested record. The writer is PATCH-only and cannot + POST/create or DELETE a variable, write another name, parse the opaque audit attachment, or mutate + any other endpoint. + +- [x] Add `build-container-release-preflight` to the gate-owned CI workflow only for + a `workflow_dispatch` body with `ref:"main"` at protected snapshot `Q`, with a required + candidate PR-number, head-repository, and full head-SHA input. Its exact candidate-bound + `run-name` is API-visible. It uses + `environment: {name: build-container-release, deployment: false}`, declares literal + `runs-on: ubuntu-24.04`, runs the fixed hosted/Linux/X64 bootstrap as its first executable step, + checks out only exact `G`, runs no candidate code, contains exactly one fixed + `assert-exact-g-dispatch-context` step that fetches the PR with the read-only token and compares + all three inputs, exact workflow/ref identity, boolean ref protection, + `github.sha==github.workflow_sha==Q`, active `G` pointers, `G` ancestry, and gate/context + presence/mode/byte equality at `Q`, and + uses the pinned App-token action only to prove the stored key can mint the exact repository- + scoped token. The run API must later show event `workflow_dispatch`, exact path, `head_sha=Q`, + and that successful named assertion step. Record `{G,Q}` separately; do not require whole- + commit equality or recompute `Q` from moving main. The operator checks the dispatch contract + before dispatch and the independent reviewer checks the actual run snapshot before approval. +- [x] Fixture-test the bounded credential smoke: temporarily add literal deployment branch policy + `main`, dispatch with body `ref:"main"` under that `{G,Q}` contract, approve and complete the smoke, remove + only `main`, and restore the sole tag policy. Any wildcard, caller branch, candidate + commit, changed active gate, manifested-byte mismatch, or invalid dispatch identity invalidates + the smoke. Test bootstrap `Q=G` and later `Q>G` with non-gate changes; captured snapshots do not + change when main advances. Capture + the pre-`S` administrator-bypass PNG only after final tag-only policy is restored. +- [x] Before dispatch, snapshot variables as `{name,value,created_at,updated_at}` and secret metadata + as `{name,created_at,updated_at}` using the local policy auditor, never the key value. Require + every `updated_at` strictly earlier than the smoke run's `created_at` and each record's creation + no later than its update. Recompare all four records after smoke/policy restoration and at final + audit. Bind candidate, `{G,Q}`, run/attempt/job ids, cutoff, and snapshots in evidence. Test equal + cutoff, malformed times, key replacement after job start but before completion, post-smoke + replacement, delete/recreate, changed variable value, and stale-attempt substitution. Changes + require a fresh dispatch. Record job `completed_at`, never treat run `updated_at` as completion. +- [x] Write `release-approval-gate.test.sh`. Cover canonical valid comment; missing, duplicate, + rejected, and bypassed reviews; wrong environment/reviewer/run id/run attempt/source/tag/PNG digest; + wrong/missing/duplicate challenge and image digest; attempted future-attempt predeclaration; + malformed/extra/reordered JSON; invalid calendar, fractional, offset, stale, or future time; + API/non-200/malformed response; reused earlier-attempt + evidence; and proof that token creation and every mutation have not run on failure. +- [x] Add partial-rerun fixtures: inherited `build-attempt:"1"` with current/API/comment attempt 2 + fails before minting; missing/zero/leading-zero/overflow attempts also fail. A rebuilt attempt 2 + with a new challenge and approval passes. Use real API JSON integer fields: positive u64 `id` + and positive u32 `run_attempt`, canonically rendered without lossy conversion for comparison + with string context/output/comment values. Reject API strings, fractions, zero, and overflow. + Structural tests require the build step to capture + its own context and the approval helper to compare it unconditionally before credential minting; + an updater-derived attempt, late check, continuation, or failure masking is rejected. +- [x] Give the approval helper only this exact interface: + `release-approval-gate.sh --gate-root --gate-sha --run-id --run-attempt + --build-attempt --source-revision --release-tag --image-digest +--approval-challenge <64-lowercase-hex> --approval-out `. It reads only `GITHUB_TOKEN`, + emits no stdout, and atomically writes the exact ten-field approval record for the updater. +- [x] Implement the gate with only `actions:read` and `contents:read` available. It + performs only the exact two no-redirect GETs for the current run and its non-paginated approval + history, requires complete valid 200 responses, and requires exactly: + +```text +edgezero-release-evidence-v1 {"challenge":"<64-lowercase-hex>","image-digest":"","png-sha256":"sha256:<64-lowercase-hex>","release-tag":"","reviewed-at":"","run-attempt":"","run-id":"","source-revision":""} +``` -```bash -bash "$(dirname -- "${BASH_SOURCE[0]}")/check-image-pin.test.sh" + Run it from the verified `G` checkout; it must pass before App-token creation. First require + context-derived build output `build-attempt` to equal current `github.run_attempt` and the + canonical decimal rendering of API integer `run_attempt`; output/context/comment attempts + are canonical positive-u32 strings, not the API field's JSON type. Require exactly one + protocol-prefixed record claiming the current run id/attempt, exact current challenge and `D`, and + approved state; any second or mismatched current-attempt record fails. The API reviewer login is + the evidence approver. Earlier attempts never satisfy the current `github.run_attempt`. + This is the sole 15-minute publisher-authorization check. The typed writer, pin validator, and + later publication verifier validate timestamp grammar/calendar and exact authenticated comment + equality but never compare archived evidence with a later wall clock. + +- [x] Implement and fixture-test `update-image-pin-pr.sh` in the gate. Its only interface is: + +```text +update-image-pin-pr.sh --gate-root --gate-sha --repository-root --source-revision --release-tag --image-digest --provenance-protocol 1 --approval-json --source-pr --evidence-url --expected-bot-id --expected-bot-login ``` -- [ ] **Step 4: Run the full suite** + It reads only `EDGEZERO_BUILD_CONTAINER_APP_TOKEN`, accepts no caller-produced image or evidence + JSON, validates the gate-produced approval, invokes `G`'s typed pair writer, and emits no file or + stdout. The evidence URL is exactly + `https://github.com/stackpop/edgezero/pull/#issuecomment-`. Let `I` be the + current protected-base source pin. Permit first pin, `I==S`, or `I` ancestor of `S`; reject older + or incomparable `S`. Fully enumerate matching pin PRs and require the exact authenticated App + author id/login plus head repository `stackpop/edgezero`; an actor/repository collision fails. + Close older proposals when superseding them, make an older run a mutation-free superseded success, + and fail malformed, incomparable, multiple-same-source, or ambiguous state. + +- [x] Freeze the updater's App-token REST allowlist to + `GET /users/`, + `GET /repos/stackpop/edgezero`, the exact fully paginated all-state/main-base pull-list query, + selected `GET /repos/stackpop/edgezero/pulls/`, + `POST /repos/stackpop/edgezero/pulls`, and selected + `PATCH /repos/stackpop/edgezero/pulls/`. GET/PATCH require 200 plus exactly one JSON + value; POST requires 201 plus exactly one JSON value. The branch is + `edgezero-build-container-pin/`, title is + `chore(actions): pin build container for `, and body is + exactly this one line with no trailing LF: + +```text +edgezero-build-container-pin-v1 {"evidence-url":"","image-digest":"","release-tag":"","source-pr":"","source-revision":""} +``` -Run: `bash .github/actions/deploy-core/tests/run.sh` -Expected: the image-pin cases run and (after Task 3) pass. + Create uses exactly + `{"base":"main","body":"","draft":false,"head":"edgezero-build-container-pin/","title":"chore(actions): pin build container for "}`. + Close uses exactly `{"state":"closed"}`. Reopen or metadata reconciliation uses exactly + `{"base":"main","body":"","state":"open","title":"chore(actions): pin build container for "}`. + No other request body, PR mutation, issue/comment endpoint, or GraphQL call is permitted. + Require exact `[bot]` login grammar and a matching public user object with the expected + numeric id and `type:"Bot"`; installation tokens never call `/user`. Retain the protected + workflow's exact installation-ID output check before updater use and require every final PR author + to equal this bot identity. + +- [x] Limit updater Git access to `https://github.com/stackpop/edgezero.git`; carry the token only through + a mode-0700 private askpass helper, never argv or URL. Record remote `main` and pin-branch OIDs and + classify all PR/branch/ancestry state before mutation. Create a mode-0700 private temporary clone + from the supplied clean full repository, always fetch named `main` without tags or submodules, + and fetch the named pin branch only when its recorded OID is nonempty. Require every fetched OID + to equal the recorded value. For creation, repeat the exact remote-ref lookup and require absence + immediately before the empty-lease push. Create a private detached worktree at recorded main. + Invoke the typed pair writer into a separate private output directory; install only the two mode-0644 files, + stage only `.github/docker/build-app-cli/{image.json,image-release-evidence.json}`, and create at + most one unsigned, hook-free commit with exact PR-title message and fixed authenticated-bot + identity. Push only + `HEAD:refs/heads/edgezero-build-container-pin/` using + `--force-with-lease=refs/heads/edgezero-build-container-pin/:` (empty OID only for + creation), `--porcelain --no-verify`, no tags/submodules, and terminal prompting disabled. Require + immediate remote-ref readback before PR mutation, then GET every mutated or selected PR and prove + exact final author, repository, base, head, title, body, state, merge, and commit identities. + Created, updated, already-current, already-merged, and mutation-free superseded outcomes must be + proved from final remote/API state; already-current state performs no mutation. + Permit an existing pin commit parent that is an ancestor of recorded current main and rebuild on + that current main; reject incomparable parents. Require both existing record entries to be + mode-0644 blobs. In same-source/different-digest replacement, verify the closed old PR at the new + post-push branch OID before creating and verifying its replacement. +- [x] Test absent branch, exact remote OID, force-with-lease race, one open PR, closed-unmerged exact PR, + already-merged exact record, same-`S`/new-`D`, forward source, older source, + incomparable source, newer existing PR, stale PR after newer merge, missing head repository, + wrong author/repository collision, conditional omission of an absent target fetch, target + creation between absence recheck and empty-lease push, pagination/API/reopen failure, and + idempotent rerun. No test may depend on the real network. +- [x] Write `check-build-container-publisher.test.sh` and then its network-free structural checker. + Its exact CLI is `check-build-container-publisher.sh --gate-root +--subject-root --gate-sha --candidate-sha `. It runs only from + clean detached `G`, reads candidate workflows and the complete workflow-name set as Git blobs at + `T`, emits no stdout, and never executes candidate content. It rejects + changes to the gate-owned publisher topology, permissions, concurrency group, action versions, + gate checkout, helper paths, output set, token ordering, package deletion/admin scope, build secret + exposure, missing/late release-state and rotation checks, predictable/hard-coded/pre-verification + challenge generation, any job without literal `runs-on: ubuntu-24.04`, missing or late + hosted-Linux/X64 bootstrap assertions, conditional or continued guards, masked failures, + unguarded non-cleanup always-run paths, or pin mutation outside the trusted updater. Apply the same exact + concurrency/group/queue checks to the gate-rotation workflow and reject any third workflow using + that group or either approved workflow using a different one. Parse both workflows and require + literal runner label and context-derived hosted/Linux/X64 first-executable-step bootstrap in + every publisher and rotation job before checkout or any later step consumes protected + environment data or credentials, creates an installation token, invokes Docker, calls a mutation + API, or mutates the repository. Require those shell-backed first guards to bind step-local empty + `BASH_ENV` and `ENV`; fixtures with a missing, inherited, nonempty, different, dynamic, caller-derived, or + late label or assertion, guard `if`, `continue-on-error`, masked failure, arbitrary non-cleanup + always-run step, or required recovery/reconciliation step lacking exact guard-success and + transition-marker conditions fail. Do not claim that a step runs before GitHub resolves a + job-level protected environment. +- [x] Write `verify-build-container-publication.test.sh` before its runtime verifier. Its exact CLI is + `verify-build-container-publication.sh --gate-root --subject-root + --gate-sha --candidate-sha --image-json + --evidence-json `. Require separate clean, + full repositories at exact top levels, exact `G`/`T` checkouts, exact regular extracted blobs, + no promisor/partial-clone configuration or object alternates, distinct object stores, and lazy + Git fetching disabled, + canonical records, and no stdout. Read only `GITHUB_TOKEN`; permit no mutation. Poll the bound + run immediately at most 30 times, sleeping exactly 10 seconds only between incomplete successful + identity-bound responses. Every request uses curl connect timeout 10 seconds and total timeout + 30 seconds; timeout is terminal. Never retry an HTTP/header/media/JSON/identity failure. Require + raw run `path` exactly `.github/workflows/publish-build-container.yml@`. After + completed/success, fetch the exact-attempt jobs endpoint once with only + `?per_page=100&page=1`; require `total_count:2`, exactly two jobs, and exactly the successful + `build-and-verify` and `update-pin` jobs, each with exactly one successful + `assert-exact-publisher-context` step. Fetch non-paginated approvals once; parse every + current-run/current-attempt protocol record, require exactly one approved + `build-container-release` record, compare every field and reviewer login with the evidence, and + ignore earlier-attempt reviews as inert. Repeat the exact run-detail GET after approvals and + require the same immutable identity plus current completed/successful attempt; define that final + read as the publication linearization point. Fixture candidate workflow names, PR bodies, and check names + are not evidence. Cover pending exhaustion, no final sleep, endpoint substitution, redirects, + response metadata, bare and UTF-8 JSON media types, malformed/duplicate jobs and steps, + earlier/current attempt mixtures, a rerun beginning between approval and final detail reads, + inherited xtrace/exported token aliases, partial/promisor repositories, object alternates, + missing tools, and complete closed curl argv for every endpoint, + rejected/bypassed/malformed/mismatched reviews, token leakage, and pre-network record failure. +- [ ] Create gate-owned `publish-build-container.yml`. It triggers only protected + `build-container-v*` tags and uses exact concurrency group + `edgezero-build-container-publication` with `cancel-in-progress: false` and `queue: max`. + Every job declares literal `runs-on: ubuntu-24.04` and begins with the inline runner bootstrap. + Document and test one running plus at most 100 pending runs; a run rejected at capacity publishes + no pin and must be rerun. Run it through the exact structurally validated actionlint + compatibility wrapper. +- [x] Write `verify-gate-rotation-lock.test.sh` before its helper. Implement only + `verify-gate-rotation-lock.sh waiting --gate-root --old-gate-sha +--dispatch-sha --run-id --run-attempt --run-actor-login ` and + `verify-gate-rotation-lock.sh publisher --gate-root --gate-sha --source-revision + --publisher-prerequisite-json `. Both modes read only `GITHUB_TOKEN`, emit + no file or stdout, and use only their design-specific read-only route allowlists. Cover flag + crossover, caller-supplied derived receipt fields, incomplete history, and mutation attempts. +- [ ] Create gate-owned `rotate-build-container-gate.yml`, dispatched only from protected main at + snapshot `Q_d` while both pointers identify old `G`. Prove the design's protected dispatch + identity, `G` ancestry, and complete gate/context presence/mode/byte equality; non-gate main + changes are allowed. Both jobs retain captured `{G,Q_d}` and execute only captured old-`G` + helpers, even after activation changes the gate variable. Use the same workflow-level concurrency + contract. Every job declares literal + `runs-on: ubuntu-24.04` and begins with the inline runner bootstrap. Its unprivileged acquire job runs + after older publishers; its second job waits on secret-free + `build-container-gate-rotation-lock` with `deployment:false`, parses the exact current-run + rotation approval/evidence, and releases only after activated or rolled-back final state at + `Q_f`, with `Q_d` ancestry and exact final manifested bytes/pointers. Implement the spec's + exact two-line rotation approval, canonical receipt schema, and digest bytes. The local-only + policy auditor repeats private variable/ruleset/environment reads after final restoration; + its receipt binds current lock run/attempt, dispatch/final heads, observed gate/descriptor + SHAs, enabled state, audit time, and full policy-evidence digest. The independent reviewer + verifies the attached full audit and supplies the receipt through the authenticated approval + comment. The waiting helper trusts that review for private-policy observations and uses only + `actions:read`/`contents:read` GETs for current run, approvals, and exact main ref; it never + calls private-policy endpoints or receives an audit PAT. Verify API reviewer/actor separation, + current numeric API attempt, both receipt/head bindings, final Git tree, digest, ordered + timestamps, and 15-minute freshness. Add valid activation/rollback fixtures and reject changed + receipts, stale audits, duplicate/rejected reviews, head/attempt mismatch, variable API calls, + and ambient `vars` substituted for the fresh local audit. A superseded protocol review + requires a fresh lock dispatch. Name the single unconditional success-gating verification step + exactly `assert-exact-rotation-context`; test continuation, skipped guards, and masked failures. + The waiting-run policy receipt is not final publisher prerequisite evidence: do not require its + own completion during receipt generation. Only after successful lock completion may the local + auditor emit final evidence and the independent reviewer write an inert + `EDGEZERO_BUILD_CONTAINER_PUBLISHER_PREREQUISITE` value with `source-revision:null`, the final + gate plus authenticated descriptor receipt, and exact completed `rotation-history`. Test this ordering without a circular + prerequisite and reject carrying any earlier `S` across rotation. + The comment binds `Q_f`; it never requires final main to + equal old `G` or `Q_d`. Add a + live prerequisite fixture proving a publisher queued behind the waiting lock starts no build or + push. A canceled/expired lock may release concurrency only after the release-state variable and + absent tag policy keep later publishers fail-closed. +- [ ] Split the publisher into `build-and-verify` and `update-pin`. + `build-and-verify` has no environment, permissions only contents/read, actions/read, and packages/write, + no App key, and after anonymous verification generates 32 OS-CSPRNG bytes, exposes the 64-lowercase- + hex `approval-challenge`, and writes exact `{challenge,S,D,tag,run-id,run-attempt}` to its job + summary. Capture that build step's context-derived attempt as canonical positive-u32 string + `build-attempt`; outputs are only `{S,D,protocol,tag,approval-challenge,build-attempt}`. + `update-pin` has the protected environment, no image build, initial + actions/read and contents/read, and executes approval/updater helpers only from a separate checkout + of repository variable `EDGEZERO_BUILD_CONTAINER_GATE_SHA`, whose value is `G`. + `build-and-verify` separately checks out `S` only to validate the isolated release request and + manifested-byte equality, then uses `stage-build-context.sh` to build solely from copied `G` + inputs. After acquiring shared concurrency and before registry login/build, require release state + exact `enabled`; parse the step-local canonical + `vars.EDGEZERO_BUILD_CONTAINER_PUBLISHER_PREREQUISITE`; and require its non-null source to equal + `S` and its gate to equal active `G`; verified history also requires the authenticated receipt's + gate and required-workflow descriptor SHAs to equal `G`. Bootstrap descriptor equality is an + explicitly trusted manual administrative assertion. The single-variable writer, not the publisher, + recomputes the outer evidence digest; the publisher never follows an attachment or caller URL. + Add a live fixture that queues a publisher behind rotation, changes all three + variables while it waits, and proves it observes post-concurrency values or fails before build. + Enumerate complete unfiltered rotation history; parse exact positive integer ids, run numbers, + and attempts plus valid `created_at`; select the unique greatest documented per-workflow + `run_number`, never id or `updated_at`. Serialize the exact current `{run-attempt,run-id,run-number}` + array in ascending numeric run-number order and bind its SHA-256. Require selected run detail to + reproduce listed identity/creation/current attempt. After validating detail/jobs/approval/main, + repeat the complete enumeration byte-identically and repeat selected detail without drift. The + second stable snapshot is the guard linearization point; work entering FIFO concurrency later is + checked by the next publisher rather than claimed to atomically block this one. Bind its current + API attempt, run number, complete-history digest, and the exact + `rotation-history` prerequisite record. Require completed/success, + its exact-attempt successful `assert-exact-rotation-context` step, matching approved receipt, + final active gate/descriptor, and final-head ancestry/tree equality. Check receipt freshness at + original verification-step completion, not publication time. Only reviewed bootstrap evidence + plus empty history allows no rotation. Test failed/canceled/timed-out/skipped/waiting rotations + with state still enabled, a later-numbered failure after older success, numeric-id/run-number + disagreement, successful prior attempt followed by failed rerun, a later rerun of an older run id, + changed history between passes, selected-detail drift, a changed or deleted previously recorded run, + truncated pages, queued new rotation before and after the defined linearization point, + and valid later publication. Record workflow-run deletion by a repository administrator as an + accepted privileged risk: deleting a newer unrecorded failed run can make an older successful run + appear latest and revive its otherwise matching record. No repository workflow receives run- + deletion authority, and the contract does not claim this deletion remains detectable. The + automatic retention removal of any previously snapshotted run changes the digest, blocks, and + requires a fresh greater-numbered successful rotation plus reviewed record. The + actions/read wrapper allows only the design's exact history/run/approval/ + attempt-jobs/main-ref GET routes; add these fixed routes and fixtures to the local policy auditor + and record producer as well, including the exact `/repos/stackpop/edgezero/git/ref/heads/main` + positive fixture and the policy PAT's `Contents:read` grant. No registry login, Docker build, + or mutation can precede this guard; + structural tests reject missing actions/read, extra grants, conditional/late guards, and masks. + A candidate Dockerfile + or unmanifested path is never in the Docker context. Each job invokes exactly one fixed + `assert-exact-publisher-context` step from `G` before sensitive work and verifies tag event/ref, + `github.sha==github.workflow_sha==S`, run id/attempt/tag, active gate, and enabled release state. +- [x] Pin checkout to `actions/checkout@v7.0.1` and App-token creation to + `actions/create-github-app-token@v3.2.0`. + The App token is requested only after approval-gate success and only for repository `edgezero` with explicit contents/write + and pull-requests/write. Require returned installation id to equal the protected variable and rely + on mandatory post-step revocation. +- [x] Add a static workflow test rejecting package-delete endpoints, `delete:packages`, + package-admin tokens, cleanup jobs, candidate helper execution, and any token before anonymous + image verification. + +### 6.5 Land and configure `G` + +- [ ] Run all Task 0-2 focused tests, Rust checks, the pinned actionlint `1.7.12` compatibility + wrapper, shellcheck, and + `zizmor --offline` from a clean checkout. Run every current format/test CI matrix job. +- [ ] Obtain an independent security review of the exact gate-owned path manifest, workflow source, + API allowlists, App-token ordering, and fail-closed/no-op markers. +- [ ] Merge the gate-only PR through the repository's existing protected process. Record the exact + default-branch commit as `G`. This bootstrap is a human-reviewed trust-root operation; + candidate-controlled checks are not evidence for it. + +**Gate checkpoint:** stop before opening the source candidate. + +- [ ] Set repository variable `EDGEZERO_BUILD_CONTAINER_GATE_SHA` to full SHA `G` while + `EDGEZERO_BUILD_CONTAINER_RELEASE_STATE` is absent or otherwise not `enabled`. Configure secret-free + environment `build-container-gate-rotation-lock` for protected main only, with required + reviewers, self-review and administrator bypass disabled, no custom protection App, and no + workflow reference to any environment secret or variable. + Set repository variables `EDGEZERO_BUILD_CONTAINER_PUBLISHER_APP_ID`, + `EDGEZERO_BUILD_CONTAINER_PUBLISHER_BOT_ID`, and + `EDGEZERO_BUILD_CONTAINER_PUBLISHER_BOT_LOGIN` to the independently verified dedicated App and + bot identity; require the App id to equal the protected-environment App id and pin-branch + ruleset Integration actor. Configure active organization ruleset + `edgezero-build-container-required-workflow` with target + `branch`, no bypass actors, repository-id condition exactly `[]`, ref-name include + exactly `["refs/heads/main"]`, no ref excludes, and exactly one `workflows` rule containing + `do_not_enforce_on_create=false` and exactly one required-workflow descriptor + `{repository_id:,path:".github/workflows/build-container-ci.yml",sha:G}`, + with no `ref`. Enable repository immutable releases, then require the versioned REST endpoint + to return HTTP 200 with `enabled` exactly boolean `true` and `enforced_by_owner` present as a + boolean; record both fields without requiring a byte-exact or one-field JSON object. +- [ ] Configure repository ruleset `edgezero-build-container-main` at target `branch`, enforcement + `active`, no bypass actors, include exactly `refs/heads/main`, exclude none, and exactly two + rules. Its pull-request rule allows only squash, dismisses stale reviews, requires code owners, + requires a distinct last-push approval, two approvals, and resolved threads. Its merge queue is + exactly `check_response_timeout_minutes:60`, `grouping_strategy:ALLGREEN`, + `max_entries_to_build:1`, `max_entries_to_merge:1`, `merge_method:SQUASH`, + `min_entries_to_merge:1`, and `min_entries_to_merge_wait_minutes:0`. Add fake payloads for every + missing/wrong parameter and prove each fails. Configure both image-tag and action-version-tag + creation-only/team-bypass and update/delete/no-bypass ruleset pairs with exact names, repository + source, tag target, ref include/no-exclude conditions, rule arrays, and actors; protected environment; dedicated GitHub + App; App-only canonical pin-branch ruleset; publisher App/bot repository variables; audit + identities; and final package policy from design Section 8. +- [ ] Only after every preceding control and the required-workflow descriptor are live and verified, + require publisher-prerequisite variable absence. Through the separate manually controlled and + reviewed repository-variable POST procedure, create the canonical inert record with + `previous-value-sha256:null`, `gate-sha==G`, empty-history bootstrap state, and + `source-revision:null`; read it back exactly before enabling later PATCHes. The writer does not + participate in bootstrap and must require its prerequisite GET to return 200. The record has no + `required-workflow-sha` member; require the trusted local auditor and independent reviewer to + confirm live equality among its `gate-sha`, the active-gate variable, and required-workflow + descriptor immediately before and after the write. The writer itself checks only the active-gate + variable and does not parse the opaque audit or query organization rulesets. Later changes use + only the local single-variable writer; its PAT is never stored in Actions. Set + `EDGEZERO_BUILD_CONTAINER_RELEASE_STATE` to exact `enabled` only after that final readback and all + control readbacks succeed, then verify it once more before running configuration preflight. +- [ ] From a clean detached checkout at exact `G`, run the prerequisite verifier in configuration-only mode and preserve independently reviewed + evidence for the exact ruleset payload, repository variable, merge queue, environment, App, + audit-token screens, and team. Reopen or synchronize the later source PR after activation so its + authoritative required-workflow run is not stale. + +### 6.6 Prove gate rotation and recovery before release + +- [ ] Add fixture/integration tests for a gate-update PR. Old `G` must require the protected base tree + to match old `G` only on manifested paths, classify exactly `mode=gate-update`, accept changes only in the union of old and + candidate manifests, validate canonical candidate manifest and CODEOWNERS coverage as inert data, + reject mixed release-request/pin/non-gate changes, and never execute a candidate helper. +- [ ] Exercise the activation state machine with fake configuration APIs: quiesce publication + by dispatching at protected `Q_d` using old-`G` code and waiting until it holds publication concurrency; + set release state `disabled::`, remove the sole tag deployment policy, merge + through the one-entry queue, record `G'`, run the clean + detached `G'` suite and exact post-merge assertion, update the repository variable and + organization descriptor after updating the marker with `G'`, verify both plus the base manifested + tree, restore and locally verify tag policy, set state enabled, emit a fresh policy receipt, + and approve the lock with the exact evidence-bound comment. Only after successful lock + verification refresh the publisher prerequisite record. Every intermediate pointer mismatch, publisher that starts + behind the lock, or malformed state/comment fails closed. +- [ ] Repeat the full sequence after `G -> S -> B` and after later non-gate commits, including + credential-smoke refresh at `Q != G`, activation to `G'`, final `Q_f != Q_d`, and rollback to a + new main commit rather than back to old `G`. Reject changed gate bytes, pointer disagreement, + wrong dispatch context, and helper reselection after the gate variable changes. +- [ ] Exercise rollback at every activation step. Release stays disabled; both pointers restore to old + `G`; ordinary work remains blocked while base contains `G'`; and a separately reviewed old-`G` + `mode=gate-rollback` must restore the manifested tree before release policy returns. Require + current base to be a valid failed `G'` tree, proposed manifested bytes to equal old `G`, diff to + stay within the two-manifest union, no release/pin/non-gate change, both pointers already old + `G`, release disabled, queue merge, and generic exact-head push evidence. If old `G` cannot + validate either side, require a new manual trust-root review with release disabled. Add fixtures + for every interrupted pointer/policy state. No bypass or mixed-pointer operating mode is + permitted. Restore presence, Git mode, and bytes across the old/new manifest union, including + required absence of newly introduced paths; preserve unrelated release, pin, and app files. + +**Gate:** do not tag or publish from `G`. The next task cannot alter a gate-owned path. + +## 7. Task 3: Build, merge, and publish source revision `S` + +**Source-candidate files:** + +- Create only `.github/docker/build-app-cli/release-request.json`. +- No gate-owned, image-context, pin, or unrelated file changes. The Dockerfile, complete context, + publisher, and all credential-bearing helpers already exist at `G`. + +### 7.1 Qualify the isolated release request + +- [ ] Create byte-canonical RFC 8785 JCS with no trailing newline. The file contains this one data + line; the Markdown fence line break is not file content: + +```text +{"gate-sha":"","provenance-protocol":1,"release-tag":"build-container-v"} +``` -- [ ] **Step 5: Commit** +- [ ] From a clean detached `G`, use the gate-owned staging helper to create the canonical fresh + context, build the local image with the exact Dockerfile/arguments and revision label `G`, and + capture local image/config identity `L` through BuildKit `--iidfile`. Run the full gate-owned + leaf-platform, label, protocol, validator, and toolchain verifier against `L`. This bootstrap + image is local only, is not published, and is not the future digest `D`; no preexisting pinned + `G` image is assumed. +- [ ] Invoke only `write-release-request` by immutable local identity `L`, under the exact + networkless/credential-free/read-only `release-request-write` profile, with full active `G` and + the next never-moved tag. Copy its sole output into the candidate branch and remove its fresh + output parent. Test duplicate/extra/reordered keys, whitespace/newline, wrong G/protocol/tag, + leading-zero release number, existing output, cleanup failure, wrong/tagged local image, and any + second changed path. No shell or `jq` is a second canonical encoder. + +- [ ] Require the organization workflow at exact `G` to run both stable jobs. Verify its workflow + repository/path/SHA and candidate head SHA. The local job must verify the candidate's complete + gate/context tree equals `G`, stage context only from clean `G`, build with the candidate head as + revision label, and run the trusted verifier suite. Candidate Dockerfile, helper, and context + paths are never read. +- [ ] Require linux/amd64 leaf config, exact labels, checksum-pinned Fastly/sccache bytes, exact + installed command versions/paths, actual wasm compile, validator self-test, deterministic + archive bytes, every malformed fixture, controlled startup-loader behavior, and read-only/non- + root runtime with no network/capabilities and bounded memory/pids. +- [ ] Run the exact `write-expected`, package, validate, and binary-smoke mount profiles from + design Section 5 with fixed `/usr/bin/env`, exact sorted placeholder argv, and only each profile's + post-`env -i` target environment. No shell or `jq` may produce expected identity. No + `/work/package` mount may appear. +- [ ] Take the exact pre-dispatch credential metadata snapshots and verify protected `{G,Q}` dispatch + identity/ancestry/manifest equality. Execute the bounded credential smoke: temporarily add literal + `main` as the sole extra environment deployment policy, dispatch with body `ref:"main"` and the + candidate PR number, exact head repository, and current full head SHA, approve and complete the + smoke, then remove `main`. Require the exact candidate-bound `run-name`, run event + `workflow_dispatch`, path, `head_sha=Q`, and exactly one successful + `assert-exact-g-dispatch-context` step that resolves the PR API and compares all three inputs; + then require final tag-only policy, exact snapshot equality, and credential updates strictly + before run `created_at`. Record `{G,Q}` and exact run/attempt/job evidence. A new candidate + commit, changed active gate, or credential change makes this evidence stale. + +### 7.2 Qualify and merge `S` + +- [ ] After policy restoration, an independent maintainer captures the administrator-bypass/final-policy + PNG. Enter the candidate in the mandatory merge queue. Require the final `merge_group` + execution of the organization workflow from `G` to pass. The queue payload must still have + single-entry build/merge limits and every exact review parameter. +- [ ] Merge only through that queue. Record the resulting default-branch full SHA as `S`. + Wait for the repository-local `build-container-ci.yml` latest-attempt push run. Its run API + record must have event `push`, exact path, and `head_sha=S`; both stable jobs must have + `head_sha=S`, success, and exactly one successful `assert-exact-main-push-context` step. The + immutable step internally proves `refs/heads/main`, `event.after==github.sha==S`, + `github.workflow_sha==S`, and active gate `G`. +- [ ] From a clean detached checkout at `G`, run the full release-prerequisite verifier after merge using the candidate PR number and exact + `G`/`S`. Attach canonical evidence and the byte-identical PNG to the merged PR. + A maintainer other than the verifier and screenshot reviewer recomputes the digest, reviews all + API evidence, writes the exact `S`-bound publisher-prerequisite variable through the local + single-variable writer, verifies readback, and only then authorizes tag creation. + +**Release checkpoint 1:** no tag exists until exact-`S` push evidence and the three-person +preflight review pass. + +### 7.3 Publish digest `D` and propose the pin + +- [ ] A preflight-verified active member of the creation ruleset's sole bypass team + `edgezero-build-container-releasers` creates protected tag + `build-container-vN` at exactly `S`. +- [ ] `build-and-verify` checks out full history without persisted credentials; proves + `HEAD==S`, clean tracked/index/untracked/submodule state immediately before and after + release-request validation. It separately checks out clean exact `G`, proves every manifested + byte at `S` equals `G`, and stages a fresh context containing only canonical files copied from + `G`. Build with that context and gate-owned Dockerfile, `--platform linux/amd64`, exact + source/protocol args, + `--provenance=false`, `--sbom=false`, and BuildKit metadata output. +- [ ] Authenticate GHCR only through a fresh `DOCKER_CONFIG` outside build context. Pipe + `GITHUB_TOKEN` to login; never pass it as build arg, secret mount, image environment, or + context file. Parse `D` only from + `containerimage.digest` in the metadata file. +- [ ] Run trusted `G` verification while authenticated, remove local credentials/reference, + then pull and run `repository@D` with a new empty Docker config. The anonymous check must + issue a registry request. A private package fails before `update-pin`. +- [ ] On first publication, stop at the expected private-package failure. An operator makes the GHCR + package public, then reruns the exact `G` prerequisite verifier in package-present mode and + attaches its public-visibility/repository-link evidence. The independent reviewer replaces and + reads back the same-`S` publisher-prerequisite variable before rerunning the same tag. Evidence + from the failed run attempt does not carry forward. Rerun the build and downstream job, not + only the failed pin job; every qualifying attempt produces a fresh challenge. + +**Release checkpoint 2:** after the image is public and anonymously verified, the environment approver +captures a fresh PNG and enters the exact design comment for the current +`{challenge,D,run-id,run-attempt,S,tag,png-sha256,reviewed-at}` before approving `update-pin`. + +- [ ] Check out exact `G` without persisted credentials, then require `release-approval-gate.sh` to + pass before App-token minting, including build-output/current/API/comment attempt equality. + Then mint the exact repository-scoped App token, verify installation id, and invoke only + `G/update-image-pin-pr.sh`. +- [ ] Generate the exact five-field image record plus canonical JCS release-evidence record through + gate-owned typed writers. Use branch `edgezero-build-container-pin/` and exact title from the + design. Enforce protected-base and every-open-PR source ancestry. Use recorded remote OID and + exact force-with-lease; never blind force or accept caller-provided evidence bytes. +- [ ] Use the design's exact single-line `edgezero-build-container-pin-v1` PR body containing only + `evidence-url`, `image-digest`, `release-tag`, `source-pr`, and `source-revision`. Keep protocol, + platform, anonymous-verification result, and approval run-attempt evidence in the canonical + release-evidence record rather than duplicating them in the PR body. Never include credential + material or an AI byline. +- [ ] Require pin CI from protected `G` to recompute current-base ancestry; verify both files are the + only changed paths and agree; verify exact App bot author/id, head repository, protected branch, + PR title, and pin-branch ruleset; then invoke `verify-build-container-publication.sh` to poll and + verify the bound publisher run, attempt, both exact context steps, and current approval comment + against the evidence file. Only afterward + anonymously verify the exact digest against labels, platform, tools, validator, target, + protocol, and package visibility. Candidate scripts, PR-body claims, and mutable tags are + forbidden. + +**Release checkpoint 3:** confirm the pin PR changes only the image/evidence pair, was authored and +pushed through the dedicated App's protected source branch, binds the successful publisher run and +approval, proposes a source not older or incomparable with the current base or another proposal, and +passed both protected jobs on its final merge-queue candidate. + +## 8. Task 4: Merge and verify pin baseline `B` + +- [ ] Merge the pin-only PR through the mandatory queue and record the resulting full commit as + baseline `B`, not final action revision `P`. +- [ ] Confirm the App push caused the required organization workflow to materialize and every latest + attempt passed. Confirm deletion or a syntactically valid but unverifiable/older/incomparable pin + fails in dedicated test PRs. +- [ ] From a clean checkout at `B`, rerun all Task 0-3 suites: ```bash -git add .github/actions/deploy-core/tests/run.sh .github/actions/deploy-core/tests/check-image-pin.test.sh -git commit -m "build-cache container: gate the build-container digest pin in the contract suite" +test -z "$(git symbolic-ref -q HEAD || true)" +test "$(git rev-parse HEAD)" = "$B" +test -z "$(git status --short --untracked-files=all --ignore-submodules=none)" +test "$(awk '$1 == "rust" { print $2 }' .tool-versions)" = "$(rustc --version | awk '{ print $2 }')" +tools=$(mktemp -d) +trap 'rm -rf "$tools"' EXIT +INSTALL_DIR="$tools" scripts/install-yq.sh 4.53.3 +INSTALL_DIR="$tools" ACTIONLINT_VERSION=1.7.12 scripts/install-actionlint.sh 1.7.12 +cargo install zizmor --version 1.16.3 --locked --root "$tools" +export PATH="$tools:$tools/bin:$PATH" +test "$(awk '$1 == "nodejs" { print $2 }' .tool-versions)" = "$(node --version | sed 's/^v//')" +npm --prefix docs ci --ignore-scripts +cargo test --locked --manifest-path .github/tools/edgezero-provenance-validator/Cargo.toml +cargo fmt --manifest-path .github/tools/edgezero-provenance-validator/Cargo.toml --all -- --check +cargo clippy --locked --manifest-path .github/tools/edgezero-provenance-validator/Cargo.toml \ + --workspace --all-targets --all-features -- -D warnings +cargo fmt --all -- --check +cargo clippy --locked --workspace --all-targets --all-features -- -D warnings +cargo test --locked --workspace --all-targets +cargo check --locked --workspace --all-targets --features "fastly cloudflare spin" +cargo check --locked -p edgezero-adapter-spin --target wasm32-wasip2 --features spin +bash .github/actions/deploy-core/tests/run.sh +.github/actions/deploy-core/tests/check-action-pins.sh +.github/actions/deploy-core/tests/check-doc-action-pins.sh +scripts/run-actionlint.sh +git ls-files -z '*.sh' | xargs -0 shellcheck -S warning +zizmor --offline .github/workflows .github/actions +npm --prefix docs run format +npm --prefix docs run lint +npm --prefix docs run build +./scripts/check_no_placeholder_pins.sh +./scripts/check_no_legacy_typed_reads.sh +cargo run --locked -q --bin check_no_nested_app_config --features nested-app-config-check -- \ + examples/app-demo crates/edgezero-cli/src/templates +cargo test --locked -p edgezero-cli --features nested-app-config-check --bin check_no_nested_app_config +cargo test --locked -p edgezero-adapter-fastly --all-targets --features cli +cargo test --locked -p edgezero-cli --test generated_project_builds -- --ignored +cargo clippy --locked -p edgezero-adapter-fastly --features cli --all-targets -- -D warnings +cargo clippy --locked -p edgezero-adapter-fastly --no-default-features --lib -- -D warnings +cargo fmt --manifest-path examples/app-demo/Cargo.toml --all -- --check +cargo clippy --locked --manifest-path examples/app-demo/Cargo.toml \ + --workspace --all-targets --all-features -- -D warnings +cargo test --manifest-path examples/app-demo/Cargo.toml --locked --workspace --all-targets +test -z "$(git status --short --untracked-files=all --ignore-submodules=none)" ``` ---- - -## Self-Review - -- **Spec coverage (container scope only):** §3.7 image contract → Tasks 2/3; digest = `platform-id` → Tasks 2/3; single-manifest amd64 → Task 3 (`--platform linux/amd64`, single-arch); baked toolchain `1.95.0` → Task 2 + verify; digest pinned/checked (§5) → Tasks 1/4. The *use* of the container (reusable workflow, launcher, provenance) is sub-plans 2–4, out of scope here. -- **Placeholder scan:** the only intentional placeholder is the all-zero digest, which Task 3 overwrites and Task 4 forbids in a release — flagged, not silent. -- **Type consistency:** `check-image-pin.sh ` contract is used identically in Tasks 1, 3, 4; the `image.json` keys (`repository`/`tag`/`digest`) match across Tasks 1–4. - -## Downstream sub-plans (not written yet) - -2. Cached build path (reusable workflow + `prepare`/`compile` split + **owned `actions/cache` restore+save with the four-root prune** + config/source closure, spec §3.4/§3.8). 3. Provenance (JSON Schema + procedural validation, `validate-app-cli-provenance`, `compute-app-cli-identity`, `ExpectedIdentity`). 4. Consumer integration (`active-version-fastly`, per-consumer `ExpectedIdentity` inputs, the Docker launcher, production-only recovery). Each is its own plan; sub-plan 2 consumes this container's digest as `platform-id`. +- [ ] Require every current hosted format/test matrix job, including all wasm clippy/test legs. Local + commands do not replace runner-backed checks. +- [ ] Pull `repository@digest` anonymously after merge and rerun the complete image verifier + from the committed record. +- [ ] Record `{G,S,D,B,protocol,tag}`, workflow/ruleset ids, action versions plus their reviewed + resolved commits, tool checksums, and + evidence digests in the release record. + +**Gate:** downstream plans build on `B` and never use `S` as an action ref or derive +a digest from the mutable tag. + +## 9. Task 5: Runbook and downstream-plan handoff + +- [ ] Document publication concurrency accurately: one running and at most 100 pending runs under + `queue: max`; a run rejected at capacity publishes no pin and must be rerun. +- [ ] For every `update-pin` attempt, record run id, run attempt, exact approval comment, API + reviewer login, challenge, image digest, source, tag, review time, PNG basename/digest, and final evidence attachment. + Earlier-attempt or pre-`S` evidence is invalid. Require producer-derived `build-attempt` equality + before minting; partial reruns that inherit an older build fail and require a full build rerun. +- [ ] Record each rotation's `{old-G,Q_d,new-G,Q_f}` and preserve image-release gate history + separately. Credential-smoke evidence records `{G,Q}`, exact pre-dispatch metadata snapshots, + run creation cutoff, and final unchanged metadata; neither current main nor completion time + substitutes for these bindings. +- [ ] Document that GHCR has no enforceable per-version retention lock and repository automation has no + package-deletion credential or endpoint. Manual administrator deletion is accepted operational + risk; recovery is a new source/image verification/pin release. +- [ ] Document rollback as selecting an earlier reviewed exact action version containing its + corresponding pin. + Never regress protected-main `image.json` and never move a release tag. +- [ ] Review and approve these four sibling plans before implementing post-`B` behavior: + `2026-08-20-build-cache-actions.md` (cache key, restore/save authorization truth table, + sccache lifecycle/audit), `2026-08-20-build-cache-provenance.md` (typed expected producer, + package/validate handoff and two-job topology), + `2026-08-20-build-cache-launcher-providers.md` (container profiles, source freeze, + nested-project Fastly `bin`/`pkg` output and cleanup, provider lifecycle), and + `2026-08-20-build-cache-consumer-adoption.md` (consumer workflow, docs, migration, and + final `P`/`V` qualification plus documentation revision `R`). +- [ ] Each follow-on plan must assign the design's deferred fixtures and tests before final action + revision `P`: workspace/suffix vectors, format-independent cache tree bounds and entry count, + exact cache action versions, cache lookup/save truth tables, sccache response-loss semantics, + seven-day expiry as documented behavior, arbitrary app-written regular cache data under the + disclosure acknowledgement, both fixed host cache paths and overlap cleanup, container-only + metadata preflight, mount/environment matrices, empty Cargo-config policy, path confinement, implicit nested + `bin`/`pkg` ownership and cleanup, exact app-env allow/deny boundaries, controlled-loader argv, + artifact identity, and consumer recomputation. +- [ ] In the consumer plan, keep the four prepublication documents at the gated placeholder while + candidate `H` is tested through exact version `C`. Designate `P` only after the exact-main local + suite and immutable candidate-version hosted suite pass, then select and publish immutable `V` + at `P`. Only after the literal-`V` smoke passes, merge documentation-only `R` adding the exact + `{V,P}` record and replacing every placeholder with `V`; the preinstalled dual-state scanner + then remains permanently in released mode. Every concrete third-party and final EdgeZero + `uses:` ref is a reviewed exact patch version; no major/minor tag, branch, or commit SHA appears. + +## 10. Completion review + +Before declaring this plan complete, run two independent reviews: + +1. **Contract review:** compare every file and test with design v6.39 Sections 2 through 10. Verify one + expected/package/validate wire authority, protected gate and image source `G`, isolated release + request `S`, staged gate-only Docker context, API-visible exact post-merge `S` proof, forward-only + pin ancestry, no tag runtime pull, no placeholder image digest/checksum, no `/work/package` + convention, fixed GNU `env -S` closed-environment launch, literal `ubuntu-24.04` job selection, + first-step hosted-Linux/X64 assertions, and no legacy `--stage` guidance. +2. **Release-adversary review:** test candidate workflow/helper substitution, forbidden + major/minor/branch/SHA action refs, third-party exact-tag movement as recorded risk, immutable + EdgeZero release enforcement, private + package state, stale/older/incomparable pin PRs, malformed BuildKit metadata, indexes, wrong + platform/labels/versions/protocol, deleted pin, classifier/completion failure, workflow-source + mismatch, missing bypass fields, API redirect/path/header/version confusion, approval reruns, token + ordering, gate rotation/recovery, actionlint queue compatibility, publication queue overflow, and + concurrent release attempts. + +The container plan is complete only when protected gate `G`, source `S`, verified +digest `D`, and pin baseline `B` are recorded and all repository gates pass. The +four remaining plans may then implement caching and designate final action revision `P` only +after their complete contract suites pass. diff --git a/docs/superpowers/plans/2026-08-20-build-cache-launcher-providers.md b/docs/superpowers/plans/2026-08-20-build-cache-launcher-providers.md new file mode 100644 index 00000000..69a73c11 --- /dev/null +++ b/docs/superpowers/plans/2026-08-20-build-cache-launcher-providers.md @@ -0,0 +1,250 @@ +# Build Cache Launcher and Provider Implementation Plan (plan 4 of 5) + +> **Execution:** Start after plan 3 is merged and its provenance gate revision is active. Add the +> launcher/provider adversarial tests to a new gate revision before changing action behavior. + +**Goal:** Run the validated app CLI through closed container profiles, freeze every source byte that +can reach a credentialed provider command, and preserve the parent deploy lifecycle without ambient +host state or the legacy `--stage` spelling. + +**Spec:** `docs/superpowers/specs/2026-08-20-edgezero-deploy-build-caching-design.md` v6.39 Sections +2, 5, 6.1, 6.6, 7.2, and 9. The parent deploy spec remains normative where the addendum does not +expressly replace it. + +## 1. File and ownership boundaries + +- Modify the provider composites under `.github/actions/{deploy-fastly,healthcheck-fastly, +rollback-fastly,config-push-fastly}` and their shared `.github/actions/deploy-core` helpers. +- Create `.github/actions/active-version-fastly/action.yml`; preserve plan 3's no-output + `.github/actions/validate-app-cli-provenance/action.yml` interface while extending its shared runner + with provider profiles. +- Keep JSON/environment validation, source inventory, output-root ownership, container argv, binary + recheck, token-ordering, and cleanup in shared typed or narrowly scoped helpers. Provider composites + may select profiles; they may not reconstruct mount lists or environments ad hoc. +- Do not modify the protocol JSON/archive encoder in this plan. A protocol change returns to plan 3 + and requires a new image/gate/pin sequence. + +## 2. Gate update first + +- [ ] Add structural fixtures for every operation/profile pair, including exact image digest, network, + uid/gid, read-only root, capabilities, security options, tmpfs, mounts, environment names, + resource limits, timeout, command, and token presence. +- [ ] Add hostile source/output fixtures for deleted or modified tracked paths, submodule drift, + escaping symlinks, mount substitution, overlapping roots, tracked descendants, hardlinks, + sparse files, special files, nested mounts, inode replacement, cleanup races, and undeclared + generated output. +- [ ] Add provider lifecycle fixtures for production, staging, first deploy, unhealthy deploy, + rollback, stale rollback refusal, lost version, cancellation, config push, and mutation output. +- [ ] Prove candidate changes cannot weaken the profile tables, source-freeze checks, binary recheck, + token boundary, or exclusive `--staging` spelling, then land and activate the new gate revision + using plan 1's post-`B` rotation procedure. Bind dispatch `{G,Q_d}` by ancestry and manifested-byte + equality, keep the lock jobs on captured old-`G` helpers, and record final `Q_f` separately from + the historical image-release gate identity. + +## 3. Closed input and environment construction + +- [ ] Consume plan 2's sole duplicate-rejecting `app-env` decoder with the exact v1 limits; do not + introduce a provider-local parser. Require JSON object only, + valid UTF-8 and at most 65,536 raw bytes before parsing, at most 64 entries, at most 32,768 + aggregate UTF-8 bytes, 1..127-byte ASCII names matching + `[A-Za-z_][A-Za-z0-9_]*`, values at most 8,192 UTF-8 bytes with no NUL/C0/DEL, and + ASCII-case-insensitive exact/prefix/target-tool deny rules from the design. +- [ ] Start every target operation with the exact GNU `/usr/bin/env -S` expansion-before-`-i` + protocol. Emit only the fixed `PATH`, action-owned `HOME`/`TMPDIR`, the operation's exact + Rust/Cargo variables, validated `app-env`, selected typed `EDGEZERO_*` values, and the one + provider token required by that profile. Sort final names by ascending ASCII bytes; create the + exact `-i NAME=${NAME}` placeholder string without literal values. Reject duplicate final names + and prove caller `PATH`, shell startup variables, wrappers, flags, ambient workflow variables, + inherited image variables, Docker `HOSTNAME`, and seeded poison do not survive. Cover spaces, + quotes, backslashes, dollar signs, `#`, `=`, literal `${...}`, and non-ASCII value bytes without + recursive expansion or splitting. +- [ ] Consume plan 2's shared empty Cargo-config and toolchain checks over the cwd, every ancestor through git root, + all copied enclosing workspace directories, and fresh `CARGO_HOME`. Reject either Cargo config + filename and legacy credentials before app code starts. +- [ ] Require the explicit `rust-toolchain` input to equal the pinned image toolchain and confine all + path dependencies beneath git root. Test exact, missing, inferred-only, nested-workspace, and + mismatch cases. + +## 4. Runner and mount profiles + +- [ ] Extend plan 2's closed operation enum with the provider variants; do not add caller-provided + Docker flags, mounts, environment names, network settings, entrypoint, or command prefix. Supply + only reviewed profile data, absolute operation executables, and separately validated arguments + to plan 2's canonical launcher. Reject newline/NUL/control-bearing scalar inputs before logging. +- [ ] Use plan 2's env-file serializer, placeholder builder, Docker create/start/attach lifecycle, + timeout, and cleanup unchanged. Add provider-profile tests for create/start/attach failure, + env-file replacement, placeholder/env-file disagreement, newline/NUL values, container-name + collision, inspectable lifetime, and mandatory removal/reconciliation. Require exact sorted + placeholder/env-file name equality and prove no Docker or runtime argv element is constructed by + inserting a token or app-env value. +- [ ] Implement the exact mount table from design Section 5.3. Never mount all of `RUNNER_TEMP`, the + original checkout writable, the Docker socket, host credential directories, GitHub file-command + files, or a cache in a token-bearing operation. +- [ ] Enforce the fixed hardened runtime: pinned digest, linux/amd64, uid/gid 1001, read-only root, + dropped capabilities, `no-new-privileges`, operation-local tmpfs, and the exact design table's + bridge/none network, memory/no-extra-swap, pid, and wall-time values. Reject Docker host, + container-sharing, caller-selected network, resource, and timeout flags. +- [ ] Before every app-binary launch, reopen and verify the confined regular path, device/inode, + SHA-256, size, mode 0755, and link count one. Dynamic binaries run only by direct argv through + the fixed environment launcher, which directly executes + `/lib64/ld-linux-x86-64.so.2 --inhibit-cache --glibc-hwcaps-mask '' --library-path +/opt/edgezero/runtime-lib `; for static binaries it directly executes the binary. Never use a shell, + `PATH` lookup, implicit kernel interpreter launch, `ld.so.cache`, default library directory, + preload file, or hardware-capability substitution. +- [ ] Cover static/dynamic success, wrong interpreter, dependency replacement, hwcaps/default/cache/ + preload substitution, inode swap, symlink/hardlink replacement, malformed ELF, and the explicit + non-claim for post-startup `dlopen` and child-process behavior. + +## 5. Source freeze and generated outputs + +- [ ] Define `deploy-fastly` and `config-push-fastly` as the only source-bearing public actions. Give + each the design's exact repository/ref/id/workspace/cwd/package/bin/toolchain inputs plus required + sensitive string `app-checkout-token`, supplied from a GitHub secret or masked same-job + App-token output and masked before use. App private keys are not consumer-action inputs. At + action start, independently materialize one action-private authority with plan 2's trusted + object-first helper and remove the checkout credential channel. Export Copy B for + `deploy-fastly` or the short-lived Copy I described below for `config-push-fastly`, then verify + all five supplied `CallerExpectedIdentity` fields through the closed metadata profile before + application code, provider-token creation, or provider-token injection. Never accept or output + an authority path, descriptor, copy path, or opaque handle from another action. Source-free + actions accept none of these materialization inputs and no checkout token. +- [ ] For `deploy-fastly`, build Copy B with plan 2's trusted exporter from that action-local, + credential-free authority. It is a faithful, private, `.git`-free, non-hardlinked, recursive, + non-sparse copy containing only tracked files and initialized submodules. Keep the authority + read-only and verify repository id, exact HEAD, index/worktree cleanliness, gitlinks, permitted + no-filter or pinned-LFS materialization, modes, symlink targets, and full inventory before and + after app-controlled work. Create one Copy B per invocation; reuse it only among that + `deploy-fastly` invocation's internal operations, and never share it or its output roots with + another action. +- [ ] Make `config-push-fastly` the explicit no-Copy-B exception. It executes no app code and mounts + only its independently materialized credential-free frozen authority read-only for config push. + Before identity calculation it separately exports a private Copy I, runs only plan 2's closed + metadata profile against that read-only copy, and destroys Copy I before provider-token creation; + it never mounts the authority in metadata preflight. Record and + verify authority HEAD, index/worktree state, full inventory, and selected tracked + manifest/file-config identity before token creation, immediately before container start, and + after the command; apply equivalent identity checks to an action-owned inline config. Add + substitution/race fixtures at every boundary and fail on any authority, manifest, or config + change. +- [ ] Run `deploy-fastly` identity metadata against its own read-only Copy B before app execution, and + run `config-push-fastly` identity metadata against only its short-lived Copy I. Add negative + fixtures proving neither path mounts the authority, that Copy I cannot enter config push or any + provider/build profile, and that both copies are action-local and cleaned on every exit. +- [ ] Parse `generated-output-paths` as the exact bounded canonical JSON array. Reject raw input over + 65,536 bytes, duplicates, overlap, root/dot/git paths, tracked path ancestors, existing roots, + non-UTF-8 or noncanonical + segments, components over 255 bytes, joined host paths over 4,096 bytes, escaping parents, and + any parent reached through a symlink. +- [ ] Resolve the selected `fastly.toml` before app code runs and add exactly the implicit + `/bin` and `/pkg` roots. Apply the same collision, + absence, parent-confinement, and ownership rules; do not substitute workspace-root paths. +- [ ] Create every root empty with mode 0700, record its device/inode, and expose repository write + access only through the corresponding nested writable bind mount beneath read-only + `/work/repo`. Audit after every app-controlled command and immediately before each token-bearing + command. +- [ ] Accept beneath roots only real directories and non-sparse regular uid/gid-1001 single-link + files. Reject symlinks, hardlinks, sparse files, devices, sockets, FIFOs, mounts, ownership + changes, root replacement, and new paths elsewhere. Across all roots enforce checked totals of + at most 2,147,483,648 logical bytes and 100,000 descendants plus the component/path bounds. + Compare all other Copy B bytes/modes/gitlinks to the frozen authority. +- [ ] Implement descriptor-relative, no-follow cleanup on success and failure. Remove only recorded + action-owned trees, then verify roots are absent and both original and Copy B satisfy their final + inventories. Cleanup or post-cleanup failure is fatal; a preexisting root is never adopted. + +## 6. Credential-free app build and parent target cache + +- [ ] Under `build-mode: always`, run exactly one credential-free app-build profile before provider + deploy. It receives the validated binary, Copy B, fresh Cargo home, validated app environment, + declared/implicit output roots, and one target directory mounted at `/work/target`, but no + provider token or sccache. With parent `deploy-fastly.cache:true`, that directory is the fixed host + path `${RUNNER_TEMP}/edgezero-deploy-fastly-target-v1`; with cache disabled, it is a fresh directory + beneath the invocation-private workspace and is never the fixed cache root. +- [ ] If the parent `deploy-fastly.cache` option is enabled, restore its exact-key Cargo target cache + only after plan 2's lookup-eligibility predicate passes, audit it under the parent's contract, + and save it after successful credential-free app-build and source/output audit only when plan + 2's full protected-event save predicate passes. Cross-repository use without disclosure + acknowledgement fails before restore. Save must finish before token minting or injection and is + never retried or attempted after any token-bearing command. Commit independent lookup/save + truth-table integration tests for this cache family. Require real non-symlinked `RUNNER_TEMP`, + target-root absence before mode-0700 creation, recorded device/inode/uid/gid, no adoption or + nested mount, descriptor-relative no-follow audit/cleanup, and verified absence on every exit. + A concurrent same-job invocation fails on the existing root; a sequential invocation starts only + after prior verified cleanup. The host path never includes a checkout or invocation identifier. +- [ ] Add a cache-disabled target fixture that proves no lookup/save occurs, the fixed parent target + root is neither created nor adopted, only the fresh invocation-private target is mounted at + `/work/target`, and that private target is removed on success and every failure path. +- [ ] Under `build-mode: never`, perform no target-cache restore/save and no credential-free build. + Provider deploy may still compile with the token for either mode; never claim app-build prevents + that compile and never expose its token-bearing outputs to a cache. +- [ ] Test cache hit/miss/save warning, build failure, audit failure, cancellation, and token-order + traces. Every path either saves before token introduction or performs no save. + +## 7. Provider lifecycle actions + +- [ ] Make the first executable step of every public provider action call plan 2's sole + runner-eligibility helper with step-local values bound directly from `runner.environment`, + `runner.os`, and `runner.arch`. Require exact `github-hosted`, `Linux`, and `X64` before artifact + download, source materialization, Docker, token handling, or mutation. Reject caller-input/env + substitution, missing values, and self-hosted Linux/X64 fixtures. Contract tests require this to + remain the first executable internal action step with no `if`, continuation, or failure masking + and with step-local empty `BASH_ENV` and `ENV`; reject inherited/nonempty startup bindings; + every later protected operation requires guard success. An `if: always()` cleanup may remove + recorded private paths and named containers; required reconciliation additionally requires + exact guard-success and `mutation-attempted` conditions. Reject every other non-cleanup + always-run path, including an arbitrary step that copies those conditions. The helper receives no + `job.workflow_*`, `job.check_run_id`, app, action, cache, or provider identity. Support and hosted + fixtures require a caller's step-based job containing the action reference to declare literal + `runs-on: ubuntu-24.04`; the composite cannot observe that label and never treats it as security + evidence. +- [ ] Make every public provider action accept the named artifact, trusted producer + `action-version`, and complete `CallerExpectedIdentity`; require its own action repository/ref to + equal that version, derive PlatformIdentity locally, independently download/validate/smoke the + artifact, and remove its private workspace with `if: always()`. The two source-bearing actions + also accept exactly the action-local materialization inputs from Section 5 and independently + verify caller identity before use. No action accepts a host binary or authority path, exposes + one as a public output, or trusts another action's platform or materialization state. The + validation-only action has no outputs and removes its extracted binary before success returns. +- [ ] `active-version-fastly` is source-free and token-bearing. Return an empty version only when the + provider response confirms a first production deploy; reject malformed, ambiguous, or absent + state otherwise. +- [ ] `deploy-fastly` validates one binary and reuses it only within that action invocation. Preserve + the parent's production/staging sequence, rollback target capture, healthcheck order, + reconciliation, and output semantics. Production probes are tokenless; staging probes receive + only the token required for the staged endpoint. +- [ ] `healthcheck-fastly` remains source-free. Split production and staging token profiles and reject + an unexpected token in production rather than silently ignoring it. Enforce retry 1..20, delay + 0..300 seconds, per-attempt timeout 1..300 seconds, checked total-budget arithmetic, and the + 3,600-second maximum before launch. +- [ ] `rollback-fastly` remains source-free, validates the rollback target against current provider + state, publishes `mutation-attempted` immediately before launch, and reconciles cancellation or + lost output according to the parent contract. +- [ ] `config-push-fastly` mounts its action-local frozen repository read-only, confines the selected + manifest and file-backed config directly from that credential-free authority without creating + Copy B, or creates exactly one action-owned read-only inline file. Derive only the typed named + config overlay; `no-env` exposes none. Assign the pre-token, pre-start, and post-command + authority/manifest/config identity checks from Section 5 to this action and prove replacement + races fail. +- [ ] Every mutating action sets `mutation-attempted` host-side before starting the mutating CLI, + names the container, forwards termination within bounded time, and runs post-cancellation + reconciliation. No mutation can precede provenance, binary, source, output-root, and token-order + checks. +- [ ] Remove every implementation, fixture, and document path that accepts or emits legacy `--stage`. + Add a repository-wide negative test and use only `--staging` for staged CLI invocations. + +## 8. Verification and merge + +- [ ] Run deploy-core and every provider action suite; protocol and image tests; source/output hostile + fixtures; shellcheck; `scripts/run-actionlint.sh`; zizmor; repository Rust tests; and docs/pin + checks. +- [ ] Run Docker-backed integration tests for every profile and provider lifecycle on linux/amd64, + including read-only root/non-root behavior, exact mount/environment snapshots, controlled + loader argv, network denial, signal handling, cleanup, and mutation reconciliation. +- [ ] Review logs, summaries, outputs, cache contents, and artifacts for token/app-env/path leakage. + Redaction is not proof: tests assert the sensitive bytes were never supplied to disallowed + processes or persistence surfaces. +- [ ] Merge through the one-entry queue, record the resulting commit and active gate revision, and + hand both to plan 5. Do not designate final action revision `P` yet. + +**Gate:** provider actions execute only an independently validated artifact under a closed profile; +no credentialed command can consume source or generated output that escaped the frozen inventory, +and no post-token byte can enter either cache family. diff --git a/docs/superpowers/plans/2026-08-20-build-cache-provenance.md b/docs/superpowers/plans/2026-08-20-build-cache-provenance.md new file mode 100644 index 00000000..729d4640 --- /dev/null +++ b/docs/superpowers/plans/2026-08-20-build-cache-provenance.md @@ -0,0 +1,268 @@ +# Build Cache Provenance Implementation Plan (plan 3 of 5) + +> **Execution:** Start after plan 2 is merged and its gate revision is active. Rotate the gate before +> landing any new protected test/helper, then keep implementation changes separate. + +**Goal:** Make the reusable workflow the sole build-only producer of a deterministic protocol-1 app +CLI artifact and make every consumer independently validate exact caller and platform identity before +the binary can execute. + +**Spec:** `docs/superpowers/specs/2026-08-20-edgezero-deploy-build-caching-design.md` v6.39 Sections +3, 5.1, 5.3, 5.4, 6, 7, and 9. + +## 1. Fixed actions and boundaries + +- Pin upload to exact `actions/upload-artifact@v7.0.1` and download to exact + `actions/download-artifact@v8.0.1`. +- The artifact payload is the validator-produced deterministic ustar file. Artifact service wrapping + is transport only and is never parsed as provenance. +- The producer accepts no provider token/input and performs no provider mutation. The app checkout + token is host-only and absent before any container starts. +- `expected.json` has one typed producer: baked `edgezero-provenance-validator write-expected`. + Shell, jq, workflow expressions, and generic JSON writers do not encode either protocol document. +- Validation remains two invocations: trusted parse/extract, host output check, then hardened + credential-free binary smoke. +- Every expected-write, provenance-package, provenance-validate, and binary-smoke target uses the + design's exact `/usr/bin/env -S` placeholder protocol; no profile relies on Docker/image environment + replacement alone. +- Plan 1 already implemented and baked the protocol owner, schema, canonical golden/malformed + fixtures, and compiled fixture manifest into pinned image `D`. This plan integrates those bytes and + may add only host-side workflow/action fixtures outside the canonical image context. It must not + change the protocol crate, schema, baked fixtures, Dockerfile, image-context manifest, or `image.json`. + A defect in any of those stops this plan and requires a new gate/source/image/pin cycle before work + resumes. + +## 2. Gate update and integration fixtures + +- [ ] Hash and consume the already reviewed independent golden bytes for exact `expected.json`, + `app-cli-meta.json`, deterministic ustar, extracted binary, and every schema/duplicate/JCS/path/ + size/ELF failure. Add host-side integration assertions that do not call the production encoder as + their oracle and do not edit or duplicate the baked fixture authority. +- [ ] Add workflow/action structural tests for build-only permissions, exact-version action pins, + literal `runs-on: ubuntu-24.04`, checkout credential removal, artifact-name uniqueness, no + platform output, typed identity output, exact EdgeZero/app checkout separation, no app-relative + local action resolution, exact mount split, cleanup on every exit, and no provider input or secret. +- [ ] Add the complete token/App authentication truth table and structural checks for the exact + producer-side token action after bootstrap/source verification and before source fetching. + Reject absent, partial, or mixed credentials; arbitrary API/owner/repository lists; a mint step + in a job-level caller; cross-job token forwarding; private-key/container leakage; and fallback + or scope broadening. Test single-repository contents-read minting, identity failure, expired + tokens, failed materialization, and mandatory post-step revocation. +- [ ] Parse the reusable workflow and require its first executable step to be the fixed inline + producer bootstrap with step-local `BASH_ENV: ""` and `ENV: ""`. Bind the three runner fields, four `job.workflow_*` fields, + `job.check_run_id`, and `app-ref` directly from their exact contexts or declared input; validate + the exact runner/workflow/version/SHA/generation contract before any checkout, cache restore/ + save, artifact work, Docker, repository code, or credential use. Then require the EdgeZero + action-source checkout, a second fixed inline checkout-verification step, and plan 2's three- + field runner helper in that exact order before app checkout or other work. The verifier uses + only inline commands and runner tools to inspect the checkout, executes or sources no file from + it, and proves its exact root/repository/HEAD/clean/content contract before any local action or + helper executes. Negative fixtures cover missing, reordered, late, skipped, continued, or + failure-masked assertions, caller-env-derived, differently cased, malformed, and self-hosted + values, missing/inherited/nonempty step-local `BASH_ENV` or `ENV`, local execution before + verification, and non-cleanup always-run paths; a `runs-on` label + or host-command check never substitutes for the context predicate. +- [ ] Add repository-wide structural tests that candidate revision `H` replaces + `.github/actions/build-app-cli/action.yml` with the exact fail-closed retirement stub: its first + executable step calls the runner helper without `if`, continuation, or failure masking; its next + step always fails with migration guidance, and it has no producer input/output, build helper, + cache, Docker, or artifact-upload path. Reject any + executable repository workflow or runnable documentation producer call to that composite at + `H`. Plan 5 replaces their legacy topology while constructing `H`, but leaves the exact gated + version placeholder so the revised examples remain explicitly non-runnable. Revision `R` only + adds the published `{V,P}` record and substitutes literal `V`; immutable older exact versions + remain historical behavior, not a supported compatibility path in `H` or `V`. +- [ ] Add substitution fixtures: caller-supplied expected JSON, alternate schema/fixtures, candidate + validator, shell-generated JSON, tar implementation, binary execution in parser container, + writable parser input, and a second downloaded artifact must all fail. +- [ ] Land and activate this gate update using the plan-1 rotation and rollback procedure before + modifying producer or consumer actions. Prove its changed paths are outside + `image-context-paths.txt`; otherwise stop for a new image release. Dispatch at post-`B` snapshot + `Q_d`, prove active `G` ancestry and manifested-byte equality, retain old-`G` lock helpers across + activation, and record `Q_f` separately. Preserve the historical image-release gate identity. + +## 3. Caller identity and authority export + +- [ ] Consume plan 2's sole identity-calculation helper over a validated authority + checkout; do not add another identity implementation. Require exact + repository ID from authenticated GitHub API, full lowercase source SHA, workspace/cwd + containment, tracked regular lockfile, and credential-free locked Cargo metadata agreement from + plan 2's container-only `metadata-preflight`; reject host Cargo or an alternate profile. + Derive the package version from that same metadata result; no caller input may override it. +- [ ] Produce exact bounded package/bin names and the design's length-framed workspace hash. Reject + non-UTF-8/escaping paths, symlink roots, malformed repository ID/SHA, duplicate outputs, and + submodule mismatch. +- [ ] Invoke plan 2's trusted authority materializer: fetch exact app/submodule commits without a + worktree, inspect all committed filter/config/submodule policy before it can execute, then + checkout and materialize only through the absolute verified Git LFS binary. Remove credentials + and their host channel before Copy A or any container exists. Produce tracked/submodule-only, + `.git`-free, non-hardlinked Copy A. Prove forbidden filters/hooks/origins are never reached and + the token never appears in Copy A, argv, container environment, mounted data, logs, artifacts, or cache; + reverify the read-only authority after compilation. + +## 4. Expected identity and package + +- [ ] Derive `PlatformIdentity` only from the invoking action revision's validated `image.json`. + Reject tags, indexes, malformed records, mismatched protocol, and caller overrides. +- [ ] Invoke `write-expected` in the exact expected-write profile with only fresh `/work/expected` and + tmpfs. Require canonical `/work`, the baked schema, create-new/no-replace publication, exactly one + regular output, and complete host cleanup after abnormal exit. Invoke it through the design's + fixed `/usr/bin/env -S` placeholder protocol and prove the target receives only `PATH`, `HOME`, + and `TMPDIR` despite image/Docker poison variables. +- [ ] Select `cached-compile` only when validated boolean `cache:true`; select `uncached-compile` for + the default `cache:false`. Compile the named app CLI in that selected profile and retain its + exact host digest, size, mode, path, and single-link identity. Invoke `package` with either + profile's output plus read-only expected input and a fresh `/work/packaged`; require exactly one + deterministic `artifact.tar`. Use the exact three-variable environment launcher and test cache-on + and cache-off producer/package paths independently. +- [ ] Run `package` twice over identical inputs and compare bytes. Validate archive member order, + headers, modes, owner fields, checksums, padding, end blocks, size caps, metadata digest/ELF + closure, and no extra filesystem entry. +- [ ] Validate `app-cli-artifact` as 1..128 ASCII bytes matching the design regex and unique in the + run. Upload exactly the literal file path with `archive:true`, `compression-level:0`, + `include-hidden-files:false`, `if-no-files-found:error`, and `overwrite:false`; omit + `retention-days` to preserve the repository/organization default. Require nonempty artifact + id/digest outputs and no wildcard/multiple path. Add a structural regression rejecting forced + one-day retention. Add a two-phase hosted qualification fixture: the producer uploads and records + its exact run/artifact identity, a protected consumer job remains unapproved without occupying a + runner, and an independent reviewer releases it only after validated elapsed time exceeds 24 + hours while the effective repository retention still covers the artifact. The resumed job must + download and validate that original artifact. Expired artifacts fail explicitly; do not rebuild + silently during deployment. + +## 5. Reusable workflow interface + +- [ ] Implement the exact design input set and defaults on a job with literal + `runs-on: ubuntu-24.04`. Make the fixed inline producer bootstrap its first executable step. + Before checkout, validate booleans, numeric `timeout-minutes` as an integer in 1..120, full + lowercase `app-ref`, canonical positive `job.check_run_id`, exact context-derived + `runner.environment:github-hosted`, `runner.os:Linux`, and `runner.arch:X64`, all four exact + workflow identity properties, the canonical stable action version parsed from + `job.workflow_ref`, and its resolved workflow SHA. The bootstrap has no `if` or + `continue-on-error`, cannot mask failure, and success-gates every later non-cleanup step. +- [ ] Declare required string inputs for repository/ref/id/workspace/package/bin/artifact, optional + string defaults for working directory/suffix/app env, boolean defaults for cache/disclosure, the + required `rust-toolchain`, numeric timeout default, optional empty `app-checkout-client-id`, and + optional secrets `app-checkout-token`/`app-checkout-private-key` exactly as specified. Parse the + received numeric timeout as an integer in 1..120 and reject every undeclared compatibility, + platform, provider, ambient-environment, Cargo, and arbitrary-argument surface. +- [ ] In the checkout-independent bootstrap, require `job.workflow_repository` and + `job.workflow_file_path` to identify the exact EdgeZero reusable workflow. Require + `job.workflow_ref` to name canonical exact stable version `V` in published use or the + distinct exact patch version `C` only in the disposable release fixture; `C` has no prerelease + suffix and its GitHub Release must report `prerelease:true`. Require `job.workflow_sha` to be + the ref's resolved full SHA and require canonical positive `job.check_run_id`. Only after that + step passes, use exact `actions/checkout@v7.0.1` with persisted credentials disabled to check + out only `stackpop/edgezero` at ref `job.workflow_sha` into a fixed private action-source root. + As the next executable step, use only fixed inline workflow commands and runner tools to verify + the real fixed root, exact repository identity and HEAD, clean tree, and absence of submodule, + LFS, sparse, and untracked content. Execute no checked-out path before that verification. Then + run plan 2's three-field runner helper, validate Section 7.1's exclusive authentication mode, + and, only in App mode, mint with `actions/create-github-app-token@v3.2.0`, `client-id`, the single + parsed owner/repository, contents-read permission, literal GitHub API origin, and mandatory + revocation. Pass only the masked token to plan 2's prevalidated object-first + materializer for the app at full `app-ref` in a distinct authority root. Verify authority and + Copy A export contracts, root separation, and that every local composite/helper path resolves + beneath the verified EdgeZero root rather than app data. +- [ ] Wire plan 2's already gated cache primitive and exact restore/save action versions into this + workflow. Exercise cache-off, cold, warm, corrupt-restore, save-denied, and warning-only save + through the non-public hosted harness; preserve evidence for exactly one Cargo compile/build + invocation after metadata preflight and for token absence. Actual tagged public workflow + qualification is a separate plan-5 checkpoint after `C` exists; harness results cannot satisfy it. +- [ ] Replace the legacy `.github/actions/build-app-cli` producer with the gated retirement stub and + migrate `.github/workflows/deploy-action.yml` plus its producer fixtures away from local + composite calls. Exercise reusable-workflow behavior through the trusted non-public harness until + plan 5 can run literal candidate `C`; no repository workflow may retain an alternate producer. + Structural tests require literal `runs-on: ubuntu-24.04` on every step-based job containing a + public EdgeZero action reference and no `runs-on` or `steps` on its job-level reusable-workflow + caller. +- [ ] Expose only `artifact-name`, trusted `action-version`, resolved `action-revision`, and + CallerExpectedIdentity fields. Never expose the host artifact + path, container ref, platform digest/protocol, checkout token, cache path, or provider state. +- [ ] Test matrix legs with unique artifact names and independent identity comparison. Reject shared + aggregate outputs, duplicate names in one run, empty names, and cross-leg identity reuse. + +## 6. Consumer identity and action validation primitives + +- [ ] Create `.github/actions/compute-app-cli-identity/action.yml` as the public identity action that + wraps plan 2's sole identity-calculation helper. Create + `.github/actions/validate-app-cli-provenance/action.yml` as the no-output public validation + action. Implement the shared private artifact/expected/parse/extract/recheck/smoke helpers that + later provider actions call. Implement only the expected-write, provenance-package, + provenance-validate, and binary-smoke runner profiles here; plan 4 adds provider profiles. +- [ ] Make the first executable step of both public actions call plan 2's sole runner-eligibility + helper with step-local values bound directly from `runner.environment`, `runner.os`, and + `runner.arch`. Reject missing/malformed/self-hosted values before token handling, authority + materialization, artifact download, or Docker execution; caller inputs and caller `env` cannot + substitute these bindings. The helper receives no `job.workflow_*`, `job.check_run_id`, app, + action, cache, or provider identity. Contract tests require this to remain the first executable + internal action step with no `if`, continuation, or failure masking and with step-local empty + `BASH_ENV` and `ENV`; every later non-cleanup + internal step remains success-gated. Support and hosted fixtures require a caller's step-based + job containing the action reference to declare literal `runs-on: ubuntu-24.04`; the composite + cannot observe that label and never treats it as security evidence. +- [ ] Give `compute-app-cli-identity` exactly the required string inputs `action-version`, + `app-repository`, `app-ref`, `app-repo-id`, `workspace-root`, `app-cli-package`, `app-cli-bin`, + and `rust-toolchain`, optional string `working-directory` default `.`, and required sensitive + string input `app-checkout-token`, supplied from a GitHub secret or masked same-job App-token + output and masked before + use. Require its runner action repository/ref to equal the supplied exact version. Materialize + one action-private authority with plan 2's gated object-first helper, remove the credential + channel, export a private faithful `.git`-free/non-hardlinked Copy I, recompute identity through + plan 2's closed metadata profile with only that copy mounted read-only, and clean both authority + and Copy I on every exit. Never expose Copy I to another profile or action. Output exactly + `app-repo-id`, `source-revision`, `app-cli-package`, `app-cli-bin`, and `workspace-id`; never + output a path, descriptor, opaque authority handle, token, or platform field. +- [ ] In each consuming action, create a private action workspace, derive local PlatformIdentity, + download exactly one named artifact with current repository/run id, `merge-multiple:false`, + `skip-decompress:false`, and `digest-mismatch:error`, and reject an invalid name, token, pattern, + artifact id, foreign run/repository selection, or second payload. Require the destination to + contain exactly one regular single-link `artifact.tar` before validation. +- [ ] In each consuming action, invoke baked `write-expected` into a fresh expected directory from + only the consumer-verified caller fields and locally derived platform fields before invoking + `validate`. Reject caller-supplied JSON/platform values, stale or preexisting output, duplicate + output, substitution, and cross-action expected-file reuse; clean the directory on every exit. +- [ ] Consume plan 2's sole canonical launcher for every profile in this plan. Add only closed + operation variants and profile data; do not duplicate env-file serialization, placeholder argv, + Docker create/start/attach, timeout, or cleanup logic. +- [ ] In the consumer job, invoke `compute-app-cli-identity` once with the exact source inputs and + checkout token from a secret or masked same-job App-token output, then compare every typed + output with the reusable-workflow outputs before + invoking any provider action. Pass those verified caller fields to later actions; do not retain + or pass an authority path because the identity action destroys its authority before returning. + Source-free actions do not materialize source or mount Copy B; each compares artifact metadata + with the supplied verified caller fields and recomputes PlatformIdentity locally, which no + workflow output can substitute. Pass producer `action-version`; every action requires its own + `github.action_repository/ref` to equal `stackpop/edgezero@` before work. +- [ ] Run `validate` with read-only tar/expected/schema, fresh `/work/validated`, no network/credential/ + repository/Cargo/cache/binary execution, the exact `/usr/bin/env -S` closed-environment launch, + and the exact design memory/pid/10-minute limits. + Host-check exactly one + mode-0755 regular single-link output with recorded digest and size. +- [ ] Start a new container for binary smoke with no network or credential and only the validated + binary plus tmpfs. Use the fixed environment launcher to directly execute controlled-loader argv + for dynamic binaries or the binary for static binaries, with only `PATH`, `HOME`, and `TMPDIR` + at target-command entry. Recheck path/device/inode/digest/size/mode/link count before every later + use. +- [ ] Ensure `if: always()` cleanup removes the private workspace and operation output parents without + following links. Any cleanup or post-cleanup verification failure is fatal. +- [ ] Keep the extracted path, digest, size, mode, device, and inode in action-private step state only; + expose no host binary path as a reusable-workflow or composite-action output. Make + `validate-app-cli-provenance` validation-only with no outputs and prove its path is absent after + successful cleanup. + +## 7. Verification and merge + +- [ ] Run all protocol crate tests, malformed/golden fixtures, producer/consumer contract tests, + artifact upload/download tests, shellcheck, `scripts/run-actionlint.sh`, zizmor, and repository + Rust/docs checks. +- [ ] Exercise corrupted transport, missing artifact, wrong artifact, replayed CallerExpectedIdentity, + locally changed image pin, parser crash/timeout/SIGKILL, output replacement race, and smoke + timeout. Every failure occurs before provider mutation. +- [ ] Merge through the one-entry queue and record the commit plus active gate revision for plan 4. + Do not designate `P` until launcher/provider and adoption plans pass. + +**Gate:** one deterministic archive and one typed expected identity cross the job boundary; the +consumer job recomputes caller identity without exporting authority state, and every consuming action +independently derives platform identity, checks both groups, and extracts without executing untrusted +bytes. diff --git a/docs/superpowers/plans/2026-09-05-build-cache-execution.md b/docs/superpowers/plans/2026-09-05-build-cache-execution.md new file mode 100644 index 00000000..80948a6f --- /dev/null +++ b/docs/superpowers/plans/2026-09-05-build-cache-execution.md @@ -0,0 +1,526 @@ +# Build Cache Implementation Evidence + +Implementation branch: `feature/build-app-cli-cache` in the existing +`edgezero-actions-improve` worktree. The implementation recorded below follows design v6.29; +the subsequent v6.30, v6.31, v6.34, v6.35, v6.36, v6.37, v6.38, and v6.39 design reconciliations are recorded separately at +the end. + +## Version Reference Review + +On 2026-09-05, the upstream release API reported the following releases as neither +draft nor prerelease. Anonymous `git ls-remote` confirmed each tag and its peeled +commit, with no same-named branch. Executable YAML uses the version column, not +the recorded commit. Existing workflow major-version choices are preserved. + +| Release | Resolved Commit | +| -------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------ | +| [actions/checkout v6.1.0](https://github.com/actions/checkout/releases/tag/v6.1.0) | `d23441a48e516b6c34aea4fa41551a30e30af803` | +| [actions/checkout v7.0.1](https://github.com/actions/checkout/releases/tag/v7.0.1) | `3d3c42e5aac5ba805825da76410c181273ba90b1` | +| [actions/cache v5.1.0](https://github.com/actions/cache/releases/tag/v5.1.0) | `caa296126883cff596d87d8935842f9db880ef25` | +| [actions/cache v6.1.0](https://github.com/actions/cache/releases/tag/v6.1.0) | `55cc8345863c7cc4c66a329aec7e433d2d1c52a9` | +| [actions/upload-artifact v7.0.1](https://github.com/actions/upload-artifact/releases/tag/v7.0.1) | `043fb46d1a93c77aae656e7c1c64a875d1fc6a0a` | +| [actions/download-artifact v8.0.1](https://github.com/actions/download-artifact/releases/tag/v8.0.1) | `3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c` | +| [actions/setup-node v6.5.0](https://github.com/actions/setup-node/releases/tag/v6.5.0) | `249970729cb0ef3589644e2896645e5dc5ba9c38` | +| [actions-rust-lang/setup-rust-toolchain v1.17.0](https://github.com/actions-rust-lang/setup-rust-toolchain/releases/tag/v1.17.0) | `166cdcfd11aee3cb47222f9ddb555ce30ddb9659` | +| [github/codeql-action v4.37.9](https://github.com/github/codeql-action/releases/tag/v4.37.9) | `cdf488f595d80d6e07e03d4674febd5ab45fa938` | +| [actions/configure-pages v6.0.0](https://github.com/actions/configure-pages/releases/tag/v6.0.0) | `45bfe0192ca1faeb007ade9deae92b16b8254a0d` | +| [actions/upload-pages-artifact v5.0.0](https://github.com/actions/upload-pages-artifact/releases/tag/v5.0.0) | `fc324d3547104276b827a68afc52ff2a11cc49c9` | +| [actions/deploy-pages v5.0.1](https://github.com/actions/deploy-pages/releases/tag/v5.0.1) | `368f82528645a54fb793d4d04e342629a3f51346` | +| [actions/create-github-app-token v3.2.0](https://github.com/actions/create-github-app-token/releases/tag/v3.2.0) | `bcd2ba49218906704ab6c1aa796996da409d3eb1` | + +The four actionlint 1.7.12 archive digests in plan 1 were independently compared +with the [upstream checksum file](https://github.com/rhysd/actionlint/releases/download/v1.7.12/actionlint_1.7.12_checksums.txt). +The macOS arm64 archive was downloaded, verified, and installed only under a +temporary tools directory for local verification. + +## Hosted Prerequisites + +Read-only inspection found no build-container environments or repository Actions +variables, no organization App installations, immutable releases disabled, and no +required build-container workflow or tag/pin rulesets. Main's existing ruleset +does not satisfy the reviewed two-approval, code-owner, last-push, and merge-queue +requirements. + +The current credential cannot inspect organization rulesets/Actions policy or +GHCR package state with the scopes required by the design. These are not negative +proofs of feature availability or package absence. The dedicated audit credentials, +App, approvals, protected baseline `G`, source release `S`, and image pin baseline +`B` remain required before plans 2 through 5 can be executed. No release, ruleset, +credential, environment, or repository-policy mutation has been performed. + +## Task 0 Verification + +Implemented exact stable version references, structural YAML and Markdown gates, +offline state checks plus the hosted release-binding verifier, and the exact +actionlint 1.7.12 compatibility wrapper. The protected `G` checkout and required +workflow integration remain Task 2 work; today's candidate-owned workflow is not +a protected release approval authority. + +Verification on 2026-09-05: + +- Action suite: 301 passed, zero failed, four Linux-only tests skipped on macOS. +- Documentation contract suite: 12 passed, including inherited CI/event/token + isolation, committed-snapshot selection, and cross-boundary rename rejection. +- Repository scanners: 40 external executable refs and 39 external documentation + refs, bootstrap state. +- Pinned actionlint, ShellCheck, and repository-wide offline zizmor: passed. +- Workspace tests, fmt, all-feature Clippy, feature compilation, and Spin WASM + compilation: passed. The workspace tests need localhost binding permission; + sandbox-only execution failed those existing server tests and was rerun with + that permission enabled. +- Documentation Prettier, ESLint, and VitePress production build: passed. +- Two independent read-only reviewers verified the flow-map/alias-key fixes, + scanner transition and credential boundaries, and CI input coverage. No + remaining reproduced Task 0 blocker was reported. + +No hosted GitHub workflow result or Linux-only local test is claimed by this +record. The standalone protocol crate is a separate Task 1 tranche. + +## JSON Protocol Tranche + +Task 1 Section 5.1 now has the standalone crate, closed expected/metadata types, +typed constructors, exact canonical encoders, a Draft 2020-12 schema, and valid +and invalid wire fixtures. The root Cargo workspace and dependency graph are +unchanged. Production dependencies are exact serde 1.0.228 and serde_json 1.0.150; +schema conformance uses dev-only +[jsonschema 0.54.0](https://docs.rs/jsonschema/0.54.0/jsonschema/) with default +features disabled, including external HTTP/file resolution. + +Fifteen colocated tests cover duplicate/escaped keys, unknown and missing fields, +required null, alternate byte encodings, malformed surrogates, field types and +bounds, schema constraints, container derivation, complete identity equality, +UTF-8 ordering, duplicate preservation, exact 64-KiB acceptance, and overflow. +Failing skeleton tests were observed before implementation. Full standalone tests, +fmt, and strict Clippy passed; both read-only reviewers reported no remaining +blocker for this tranche. CI now explicitly tests the standalone manifest with +the repository's Rust version. + +Archive/extraction, ELF/loadability, the CLI/capability corpus, container gate and +publisher, image release/pin, and plans 2 through 5 are not implemented by this +tranche. No runnable or published caching release is claimed. + +## Hosted Identity Blocker + +Task 0 was pushed as `b3589741814a76d5598ff17aab42f2cff0f67811` to +[PR 347](https://github.com/stackpop/edgezero/pull/347). +Its [static-checks job](https://github.com/stackpop/edgezero/actions/runs/34013647236/job/101433619629) +failed at the strict synthetic-merge parent assertion. GitHub's PR and run APIs +reported base `37f1a137bc856358bb4d69c499b61129d29c192e`, while the current main ref +and synthetic merge `fd99c1e1a73d211be84d4077decbbb80441a5e59` use first parent +`593fc9282a1c56e12bae15f91eef2162f4b6a1b7`; its second parent is the exact pushed +head. The scanner now reports expected and observed parent identities on failure. +It still enforces design Section 8's literal event-base contract. No branch merge, +rebase, gate bypass, or event-selection design amendment has been performed. + +The user has been asked to choose a branch refresh or a reviewed event-selection +amendment, and to identify the maintainer who will provision release prerequisites +and independent approvals. These hosted checkpoints remain open. + +## v6.30 Design Reconciliation + +The reviewed amendment selects the authenticated synthetic first parent for PR comparisons, retaining +payload-base ancestry and exact event/head identity checks. Plan 1 explicitly schedules the existing +scanner's implementation and hosted regression follow-up before `G` is frozen. The executable +selector has not been changed by this documentation correction, so the known hosted failure is not +claimed fixed. + +The existing caching design and all five existing plans have been reconciled for Bookworm ELF and +loader aliases, post-`B` gate rotation, credential scope/freshness, publisher attempt binding, artifact +retention, and App-authenticated migration. The original deployment spec/adoption companion now live +under `docs/superpowers/specs/`; the original deployment plan is under `docs/superpowers/plans/`. +References and the scanner's four-document bootstrap allowlist follow those moves without widening +the placeholder exception to other internal documents. No new image, gate activation, source tag, +action release, or completed caching implementation is asserted by these edits. + +Follow-up review also made rotation policy observations explicitly local-auditor/reviewer evidence, +normalized numeric REST attempt fields before string comparisons, and required publishers to verify +the latest evidence-bound rotation attempt succeeded. The rotation receipt precedes lock completion; +the final publisher prerequisite record follows it, avoiding a circular approval prerequisite. + +Local verification for the documentation correction and relocation: + +- Documentation scanner: 39 external references, bootstrap state; 13 scanner tests passed. +- Action contract suite: 301 passed, zero failed, four Linux-only checks skipped on macOS. +- Docs formatting, ESLint, VitePress build, and the pinned actionlint workflow check passed. +- Workspace tests, formatting, all-feature Clippy, the combined Fastly/Cloudflare/Spin check, and + the Spin `wasm32-wasip2` check passed. The network-fetching generated-workspace test stays ignored. + +These are local regression checks, not hosted rotation, credential, image, or release evidence. + +## v6.31 Design Reconciliation + +The reviewed correction defines the publisher-prerequisite repository variable, its canonical inert +and source-bound records, the separately credentialed single-variable local writer, and the exact +post-rotation/post-merge lifecycle. It replaces numeric run-id ordering with unique latest creation +time, closes first-shell startup variables, fixes the parent target-cache path/lifecycle and metadata +execution profile, completes the gate manifest, and assigns the missing cache/disclosure/retention +tests. The metadata profile uses an identity-only consumer copy when no action-local Copy A or Copy B +exists; it never mounts the authority. The fixed parent target root is reserved for cache-enabled +builds; cache-disabled builds use and clean a fresh invocation-private target. The exact-version +`uses:` policy is unchanged. + +This record does not claim those future publisher, rotation, cache, metadata, or hosted qualification +tasks are implemented. Task 0 selector, whole-document placeholder, and hosted API-contract fixes are +tracked separately. Their scanner implementation confines subject Git and hosted release-verifier +subprocesses to the v6.31 allowlisted environments. The selector and placeholder paths pass local and +hosted contract suites; the transition verifier passes its adversarial fixtures in hosted CI. The real +immutable-release transition remains a separate consumer-plan Section 9 checkpoint at documentation +revision `R`, after stable release `V` exists. + +Local v6.31 reconciliation verification on 2026-09-07: + +- The focused documentation-gate suite passed 14 tests, including whole-document placeholders, + advanced-base synthetic merges, unavailable/shallow/replaced/grafted history, exact API response + metadata, and poisoned curl/Git child environments. The repository scanner passed with 39 external + references in bootstrap state. +- The complete deploy-core action suite passed 302 tests with zero failures and five platform-specific + skips on macOS. Pinned actionlint 1.7.12 passed through the repository compatibility wrapper; zizmor + 1.16.3 reported no findings under the committed suppression policy. +- Docs Prettier, ESLint, and VitePress build passed. Workspace tests, Rust formatting, all-target/all- + feature Clippy with warnings denied, the combined Fastly/Cloudflare/Spin check, and the Spin + `wasm32-wasip2` check passed. The standalone provenance validator passed 15 tests, formatting, and + strict Clippy. +- Independent design/plan, implementation-compliance, and code-quality reviews reported no remaining + local issue. PR 347's hosted `static-checks` job passed 319 action-contract tests, including the + reproduced advanced-base selector fixture and release-verifier adversarial fixtures. Task 0 is + complete. The real hosted release API/ref proof remains required at documentation revision `R`; it + cannot run in bootstrap because the design requires the release record to remain absent through `P`. + +## Archive/Extraction Protocol Tranche + +Task 1 Section 5.2 now has the sole protocol-1 archive encoder/parser and atomic binary extractor. +The encoder emits the exact two-member deterministic ustar form from design Section 6.3. The parser +rejects noncanonical headers, alternate numeric encodings, extensions and non-regular members, +renamed/duplicate/extra/out-of-order members, malformed sizes and checksums, nonzero padding, +incorrect end blocks, trailing bytes, and checked-arithmetic or seek failures. Metadata allocation is +bounded to 64 KiB; binary payloads are skipped and extracted through bounded 8-KiB I/O rather than +loaded into memory. + +The committed archive corpus includes one byte-exact golden archive and malformed category fixtures. +Review corrected the embedded-NUL vector so it preserves the canonical member name before post-NUL +garbage. Because the eleven-octal-digit protocol field cannot encode a `u64` overflow, the former +misnamed overflow tar is now the maximum-size vector and a synthetic `Read + Seek` fixture exercises +near-`u64::MAX` offset overflow directly. Separate bounded readers prove zero/oversized metadata and +aggregate-size failures occur before payload I/O. + +The extractor requires a fresh canonical empty output parent, writes one create-new temporary sibling, +flushes and verifies it, and publishes with no replacement. Linux uses the required +`renameat2(RENAME_NOREPLACE)` path; the macOS test path uses an atomic no-replace hard-link publication +followed by source removal. Checked cleanup covers pre- and post-publication handled failures, reports +cleanup failure without hiding the primary failure, and preserves independently created collision +sentinels. Success requires one regular mode-0755, link-count-one `app-cli`. The output parent remains +the invocation-private trusted directory from design Sections 6.5 and 6.6; no stronger concurrent +same-uid adversary is claimed. + +TDD evidence was recorded before implementation and during review hardening: the initial focused suite +failed on the unimplemented encoder/parser/extractor; bounded-I/O, temporary-ownership, fixture +semantics, canonical-path, and exact-seek tests each failed for their intended missing behavior before +the corresponding production or fixture correction. Final local verification on 2026-09-08: + +- Focused archive/extraction tests: 23 passed, zero failed. +- Complete standalone validator tests: 38 passed, zero failed; formatting and all-target/all-feature + Clippy with warnings denied passed. +- The standalone validator compiled for `x86_64-unknown-linux-gnu`; repository workspace tests, + formatting, all-target/all-feature Clippy, and the combined Fastly/Cloudflare check passed. +- Two-stage read-only review approved Section 5.2 specification compliance and code quality after + checked cleanup, no-replace collision, inclusive-boundary, canonical-parent, and adversarial seek + coverage was added. + +The Linux publication branch is compile-checked locally and is exercised by hosted Linux tests after +this tranche is pushed; this local record does not claim a macOS runtime executed `renameat2`. +ELF/loadability, CLI/capability integration, image publication, and the later plans remain open. + +## ELF/Loadability Protocol Tranche + +Task 1 Section 5.3 now implements the protocol-1 ELF parser and recursive startup-closure resolver. +It accepts only ELF64 little-endian x86-64 objects with the exact header, program-header, object-role, +interpreter, dynamic-table, string-table, tag, flag, SONAME, and dependency-name profile in design +Section 6.4. Program-header ranges and virtual-to-file mappings use checked arithmetic and bounded +8-KiB reads. Ambiguous, partial, unreadable, non-file-backed, or contradictory mappings fail closed. + +Dynamic dependency resolution is confined to `/opt/edgezero/runtime-lib`, except that the fixed loader +basename resolves to the already validated `/lib64/ld-linux-x86-64.so.2`. The resolver validates every +flat-directory object and alias, rejects symlinks, hard links, subdirectories, reserved names, missing +closure members, mixed architectures, and `/etc/ld.so.preload`, and terminates cycles by device/inode. +Direct `DT_NEEDED` values remain duplicate-preserving and are byte-sorted only for metadata. SHA-256 +and size are measured from the opened primary file. The structural launch contract fixes the dynamic +loader options and records that the claim covers startup only, not later `dlopen` or child processes. + +TDD evidence was recorded before implementation and during review hardening. The initial focused +suite failed because the ELF API and SHA-256 dependency did not exist. Subsequent focused failures +proved missing preload rejection, malformed `PT_LOAD` memory bounds, partial mapping ambiguity, and +unbounded repeatable `DT_NEEDED` retention before those behaviors were implemented. A metadata- +impossible dependency count now fails after at most 21,846 entries, and dynamic entries are scanned in +bounded chunks rather than with one seek per entry. Characterization tests cover existing rejection +paths that required no production change. Final local verification on 2026-09-08: + +- Focused ELF/loadability tests: 32 passed, zero failed. +- Complete standalone validator tests: 70 passed, zero failed; formatting and all-target/all-feature + Clippy with warnings denied passed. +- Repository workspace tests passed after the production changes; `git diff --check` passed. +- Independent specification-compliance and code-quality reviews approved the tranche after preload, + mapping-overlap, closed-tag boundary, empty-dynamic-table, and denial-of-service coverage was added. + +The real GNU-target CLI, pinned Bookworm loader/libc closure, and application-directed `dlopen` runtime +fixture remain the explicit Task 2 image-verification gate. Section 5.3 does not invoke `ldd`, a loader, +or an inspected artifact. CLI/capability integration, image publication, and the later plans remain open. + +## CLI/Capability Protocol Tranche + +Task 1 Section 5.4 now provides the synchronous provenance-validator executable with +`write-expected`, `write-release-request`, `package`, `validate`, and `self-test` commands. Typed +arguments enforce protocol `1`, the canonical release tag, fixed release-request path, work-root +confinement, and create-new/no-replace publication. The package and validation paths compose the +reviewed JSON, archive, and ELF protocols; validation publishes mode-0755 output without executing +the application binary. + +The self-test uses a compiled, bounded manifest of exact fixture paths, SHA-256 values, and expected +outcomes. Its valid golden archive is a coherent package containing a static x86-64 ELF and canonical +metadata, not merely a syntactically valid tar. The fixture identities are: + +- `app-cli`: 2,048 bytes, + `6b25433eed518a44b19e8c821749f4dd07156d0962727ed27c15f8816c3c1c96`. +- `static-meta.json`: 710 bytes, + `8ad2590f982dacf2272e8afb1f88b9e910349f43aab38675bac9cd43eb2e47d2`. +- `archive.tar`: 5,120 bytes, + `bb041c59d4c24ecc41f3f329040aef224a8afd5477ee515b59c6ae94d5c258f1`. + +TDD evidence was observed before implementation and during review hardening. The initial process +suite failed because the binary and command API did not exist. Later focused failures demonstrated +missing canonical-root and traversal checks, publication-identity checks, staged-archive rehashing, +integrated archive tamper rejection, coherent golden-fixture equality, and bounded collision-watcher +shutdown before those behaviors were added. Final local verification on 2026-09-08: + +- Complete standalone validator tests: 76 unit and 21 process/integration tests passed; standalone + formatting and all-target/all-feature Clippy with warnings denied passed. +- Repository formatting, all-target/all-feature Clippy, workspace tests, the combined + Fastly/Cloudflare/Spin check, and the Spin `wasm32-wasip2` check passed. +- Documentation install, Prettier, ESLint, and VitePress build passed. Placeholder-pin, + legacy-typed-read, nested-app-config, action-reference, and documentation-reference scanners + passed. The complete deploy-core action suite passed 302 tests with five macOS skips. +- Fastly adapter tests passed 287 tests; both required Fastly Clippy modes passed. The generated + consumer workspace test passed, and the app-demo workspace passed formatting, strict Clippy, and + all 33 tests. +- Independent specification-compliance and code-quality reviews approved the tranche after staged + identity/cleanup, fixture coherence, process-path coverage, and watcher-lifecycle hardening. + +The Linux no-replace branch is compile-checked but still requires hosted execution of +`renameat2(RENAME_NOREPLACE)`. Positive literal-`/work` CLI round trips, the real GNU-target CLI, +pinned Bookworm startup closure, controlled loader execution, and abnormal host cleanup remain Task +2 container/image-verification obligations. This tranche does not claim an image publication, +release, or cache-enabled deployment. + +## Pin and Release-Evidence Record Tranche + +Task 2 Section 6.1 now has the five-field image-record validator, exact ten-field canonical +release-evidence validator, and sole typed pair writer. The validator performs a bounded regular-file +check and consumes `jq --stream` events before ordinary object construction, so duplicate and escaped +duplicate top-level keys cannot be hidden by last-value-wins parsing. It enforces exact fields and +types, the fixed GHCR repository, nonzero lowercase image digest and source revision, informational +release-tag grammar, protocol `1`, canonical positive u32/u64 run strings, GitHub login grammar, +calendar-valid fresh UTC review time, exact no-newline JCS evidence bytes, and all cross-record +identities. + +Only typed `runtime-ref`, `source-revision`, and `provenance-protocol` modes expose record data. The +runtime reference is always `repository@digest`; there is no tag accessor or tag-based pull output. +The pair writer accepts individual trusted scalar flags, fixes schema version internally, emits both +records through create-new hard-link publication in one physical parent, and rolls back a one-sided +publication only when it still owns that inode. It rejects raw JSON, duplicate/unknown/missing flags, +split parents, normalized protocol spellings, and either pre-existing destination. No placeholder +`image.json` or `image-release-evidence.json` was created. + +TDD evidence was observed before implementation. The corrected initial image-contract run had 23 +failures for missing fields, duplicate/unknown acceptance, absent typed modes, invalid digest/source/ +protocol/tag acceptance, and absent pair validation. The writer suite failed because the typed writer +did not exist. Subsequent review-driven tests exposed the portable `wc -c` whitespace assumption and +then passed after normalization. Final local verification on 2026-09-08: + +- Image record and typed-output contract: 44 passed, zero failed. +- Release-evidence, pair, and writer contract: 66 passed, zero failed. +- Bash syntax and ShellCheck at warning severity passed for both production scripts and both focused + tests. +- The complete deploy-core action suite passed 304 tests with zero failures and five expected macOS + skips. Action-reference, documentation-reference, and placeholder-pin scanners passed. +- Hosted CI for the preceding CLI/capability commit `525a18d1` passed every reported job, including + Rust CodeQL, all four wasm Clippy/test legs, static checks, smoke tests, formatting, and workspace + tests. + +The paired validator requires both current records and the writer cannot partially add or replace a +pair. Git range-level atomic add/change/delete classification remains the explicit Section 6.5 +classifier obligation. Image creation, toolchain/runtime verification, publication, protected gate +activation, and caching integration were open at that checkpoint. The following tranche completes +local image creation and runtime verification; publication, gate activation, and caching integration +remain open. + +## Build Image and Runtime Verification Tranche + +Task 2 Section 6.2 now has the closed staged image context, pinned multi-stage Dockerfile, immutable- +identity local and published-image verifier modes, and an exact runtime-capability verifier. The image +contains the standalone validator, protocol schema/corpus, Rust 1.95.0 toolchain with exactly the GNU +host and `wasm32-wasip1` targets, Fastly CLI 15.1.0, sccache 0.10.0, and the reviewed Bookworm startup +closure. Version checks combine strict stable-SemVer extraction with byte-exact complete command +output; target inspection compares the complete sorted set and compiles the committed wasm fixture. + +The measured runtime closure is four regular, single-link ELF64 little-endian x86-64 shared objects: + +- `/lib64/ld-linux-x86-64.so.2` (GNU OSABI): + `02bcda52c1a5dfc236f94d9e5255b4a0e26347d8a372a5223b650e31f291ce3c`. +- `libc.so.6` (GNU OSABI): + `6b4a45352fd0c540a9c7c718f35ce8c8e46a4e482f9d3885a910c32d1a0e1421`. +- `libgcc_s.so.1` (System-V OSABI): + `2bd1552c47799ef67e701e81d4383061fd76059868e446e63560f0dd0d5ec14e`. +- `libm.so.6` (GNU OSABI): + `7f2ca87f652f56b094462474b076749e90e689d0ecb9cb63c7679820b271b4e7`. + +The closure verifier requires exact member count, bytes, ELF role, machine, SONAME, direct needed +set, supported dependency interpreter metadata, and absent `/etc/ld.so.preload`. The loader has no +`PT_INTERP`, has exact SONAME `ld-linux-x86-64.so.2`, and has no `DT_NEEDED`. Existing isolated ELF +tests separately reject primary/dependency alias collisions, prove the fixed loader alias resolves to +the already validated interpreter, ignore cache/default/hwcaps substitutions, and record `dlopen` as +outside the startup-only claim. + +The image verifier compiles a committed real GNU Rust CLI with the selected image toolchain, packages +it through the provenance validator, validates it into a separate profile, and invokes only the +validated output through the fixed loader arguments. Every container uses a named create/inspect/ +start/remove lifecycle, immutable linux/amd64 identity, uid/gid 1001, read-only root, no capabilities, +no-new-privileges, no network, bounded memory/pids/time, profile-specific mounts, and only home/temp +tmpfs. Before start, the verifier compares Docker's persisted `Path`/`Args`, removes the mode-0600 +single-link env file, and verifies absence. The real environment probe preserves empty and special +UTF-8 values without recursive expansion or resplitting and proves inherited image/Docker variables +are absent after `env -i`. + +Final local verification on 2026-09-08: + +- Toolchain/runtime command fixtures: 38 passed, zero failed. +- Published/local image verifier fixtures: 52 passed, zero failed. +- Dockerfile/context contract: 13 passed, zero failed; isolated context staging: 26 passed, zero + failed. +- Complete deploy-core action suite: 308 passed, zero failed, five platform-specific checks skipped + on macOS. +- Complete standalone validator: 76 unit and 21 process/integration tests passed. The complete + repository workspace test suite, repository and standalone formatting, strict all-target/all- + feature Clippy, and the combined Fastly/Cloudflare feature check passed. +- Documentation Prettier, ESLint, and VitePress production build passed. +- Shell syntax and ShellCheck at warning severity passed. A real linux/amd64 image build completed, + and the complete verifier passed against its immutable local image ID, including actual Docker + env-file deletion, persisted process inspection, real GNU compile/package/validate/smoke, golden + deterministic archive equality, and every malformed archive fixture. + +No image has been published and no gate SHA, release tag, digest pin, release evidence, protected +workflow, or cache-enabled action is claimed by this tranche. Section 6.3 and later Task 2 work remain +open, and the same image verification must run again against the eventual protected/published +identities. + +## v6.34 Design Reconciliation + +Plan-only reconciliation recorded on 2026-09-10. **Implementation status: pending.** This section does +not claim that any v6.34 helper, test, workflow, hosted proof, or release operation has been +implemented or rerun, and it does not supersede the historical v6.30/v6.31 evidence above. + +- At this reconciliation, normative plan references and completion reviews targeted design v6.34 while + preserving historical reconciliation references. +- The four prerequisite-auditor CLIs now require `--policy-token-review-png`; the separate variable + writer requires `--writer-token-review-png`. Policy-token, writer-token, and administrator-bypass + screenshots require canonical absolute regular non-symlink PNGs, the exact signature, and the + 8..10,485,760-byte bound; each digest is recomputed and bound to its canonical review/evidence. +- Auditor evidence is canonical bounded JSON, but the downstream writer treats its 1..1,048,576 raw + bytes as opaque and binds only their SHA-256 in the separate small JCS transition record. +- The prerequisite record now has exactly the v6.34 six-field shape without + `required-workflow-sha`, retains exact rotation history, requires live gate/descriptor equality, + and binds verified history to the exact second approval-line JSON object bytes using the digest from + the first line. Bootstrap is a separate manually reviewed repository-variable POST; the writer is + PATCH-only, requires an existing-variable GET 200, performs exact-byte idempotence before predecessor + CAS, and permits same-gate source clear/inert refresh only when verified history advances for rollback. +- API plans now freeze GET/POST/PATCH/DELETE status and body contracts, literal ordered list queries, + bounded synthesized pagination, Link/count/duplicate/truncation failures, exact check names, and + `app_id=15368`. The deployment-protection-rules endpoint is a scalar no-query GET. +- The pin updater now carries the exact v6.34 CLI, App-token REST allowlist, PR bodies, evidence URL, + exact `chore(actions): pin build container for ` title, private Git/askpass/worktree flow, + conditional absent-target fetch, immediate pre-create absence recheck, force-with-lease push, and + final remote/API readback contract. +- Repository-administrator workflow-run deletion is an accepted privileged risk. The plan does not + claim a newer unrecorded failed run remains detectable after deletion, and no repository workflow + receives run-deletion authority. +- The typed image/release-record writer plan now includes its exact CLI and current bytes, private + mode-0700 outside-repository output parent, mode-0644 atomic pair, silence, cleanup, and focused tests. +- The yq plan now pins 4.53.3's exact release URL and four platform hashes and includes + `install-yq.test.sh` in the planned and gate-manifest surfaces. +- Final qualification now checks all untracked files without ignoring submodules and verifies Node + exactly against `.tool-versions` before `npm ci`. +- The literal gate manifest remains unfrozen until every helper and test exists; only then is its exact + sorted list copied as a fenced block into the plan and required byte-equal to `gate-paths.txt`. No + final list is invented by this reconciliation. + +## v6.35 Design Reconciliation + +Plan-only reconciliation recorded on 2026-09-10. **Implementation status: pending.** This section does +not claim that the v6.35 rotation-history or descriptor-flow changes are implemented or verified. + +- At this reconciliation, normative plan references and completion reviews targeted design v6.35. +- Verified rotation history binds exact `created-at` and `updated-at` values. Auditors and publishers + enumerate complete unfiltered history, select the unique greatest `updated_at`, and require selected + run detail to reproduce the listed identity, timestamps, and current attempt. Tests include rerunning + an older run id after a newer successful run and list/detail read races. +- The trusted local auditor, not the Actions publisher or opaque-record writer, verifies the live + organization required-workflow descriptor. The writer independently checks only the live active-gate + variable. For verified history, the publisher authenticates descriptor equality through the exact + approved rotation receipt; bootstrap descriptor equality remains an independently reviewed auditor + snapshot. + +## v6.36 Design Reconciliation + +Plan-only reconciliation recorded on 2026-09-10. **Implementation status: pending.** This section does +not claim that the v6.36 history snapshot or trust-boundary changes are implemented or verified. + +## v6.37 Design Reconciliation + +The publication verifier now requires a final exact run-detail reread after jobs and approvals, which +is its explicit current-attempt linearization point. Full repository checks exclude partial/promisor +clones and object alternates and disable lazy object fetching. The writer review's `token-id` is the +screenshot-bound credential inventory id rather than `/user.id`, and the local variable writer is an +operator-serialized procedure whose predecessor digest is explicitly not an atomic GitHub API CAS. +Implementation and focused adversarial verification of these corrections remain part of Task 2. + +## v6.38 Design Reconciliation + +The tag-triggered publisher lacked an authenticated ingress for the pin updater's required source PR +and exact evidence-comment URL. The release procedure now pre-creates an inert stable candidate-PR +comment, passes that URL to release audit, attaches the resulting evidence to the same comment, and +binds both PR and URL into an incompatible schema-version-2 prerequisite record. Inert records carry +nulls for the source revision, source PR, and evidence URL; release-bound records carry all three. + +## v6.39 Design Reconciliation + +The pin updater now resolves the reviewed real GitHub App bot identity through +`GET /users/[bot]`; installation tokens do not call the user-token-only `/user` endpoint. +The workflow installation-ID guard and final PR-author checks remain mandatory. Existing proposal +commits may be reconciled from an ancestor main parent, record blobs must be mode 0644, and a +same-source digest replacement verifies the closed old PR at the post-push branch OID. + +- The publisher now selects the greatest documented per-workflow `run_number`, never `updated_at`, and + binds a canonical digest of every current `{run-attempt,run-id,run-number}` tuple. Any rerun, including + an older run id, invalidates the record; the writer cannot refresh a changed snapshot for the same + selected run, so recovery requires a fresh successful dispatch. +- Two byte-identical complete history reads plus a repeated selected-run detail establish the guard's + explicit post-concurrency linearization point. Work entering FIFO concurrency afterward is later work + for the next publisher; the plan no longer claims ordinary REST reads atomically block it. +- Bootstrap review and descriptor evidence are explicitly a manual administrative trust-root procedure. + Machine rejection covers malformed, stale, and cross-release records; authenticated rotation review + starts with the first verified rotation receipt. + +## v6.39 Protected Gate Implementation + +The local Task 2 Sections 6.3 and 6.4 implementation is complete. This supersedes the historical +"implementation pending" status in the v6.34-v6.37 reconciliation notes without changing their design +history. The implementation includes the frozen gate manifest and exact CODEOWNERS expansion, protected +classifier and event-range selection, required CI workflow, release-prerequisite auditor, prerequisite +writer, approval gate, App-token boundary, pin updater, publisher structural checker, publication +verifier, rotation-lock verifier, publisher workflow, rotation workflow, and their adversarial fixture +suites. + +Local completion does not activate the trust root or publish an image. Task 2 Section 6.5 and later +hosted steps remain open: merge and record `G`, configure repository rules, variables, environments, +and required workflows, obtain live queue-capacity and post-concurrency freshness evidence, execute the +credential smoke, publish and anonymously verify the image, merge the pin PR, and record `{G,S,D,B}`. +The cache primitive, producer/consumer provenance integration, provider lifecycle integration, and +application-repository adoption remain the separate follow-on plans 2 through 5. diff --git a/docs/specs/edgezero-deploy-action-implementation-plan.md b/docs/superpowers/plans/edgezero-deploy-action-implementation-plan.md similarity index 95% rename from docs/specs/edgezero-deploy-action-implementation-plan.md rename to docs/superpowers/plans/edgezero-deploy-action-implementation-plan.md index 358be59f..7ec0562b 100644 --- a/docs/specs/edgezero-deploy-action-implementation-plan.md +++ b/docs/superpowers/plans/edgezero-deploy-action-implementation-plan.md @@ -1,8 +1,13 @@ # EdgeZero Deploy GitHub Actions Implementation Plan +> Prepublication: these examples use `` and are not runnable +> until the caching release and adoption migration are complete. Public action and +> workflow references require one exact stable `vMAJOR.MINOR.PATCH` version, never a +> SHA, branch, major/minor tag, or prerelease. Third-party tag movement is accepted. + **Status:** Revised plan (layered, adapter-independent) -**Spec:** `docs/specs/edgezero-deploy-github-action.md` +**Spec:** `docs/superpowers/specs/edgezero-deploy-github-action.md` ## Scope @@ -36,21 +41,21 @@ This design **supersedes** the monolithic Fastly action from #303 (`.github/actions/deploy/`). That branch is not the base; its scripts are a reference to port from. Most transfer with light changes: -| Existing `.github/actions/deploy/` | New home | Disposition | -| -------------------------------------------- | ----------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `scripts/common.sh` | `deploy-core/scripts/` | Reuse ~as-is (annotation escaping, helpers). | -| `scripts/cleanup.sh` | `deploy-core/scripts/` | Reuse. | -| `scripts/write-summary.sh` | `deploy-core/scripts/` | Reuse; update summary field names. | -| `scripts/validate-inputs.sh` | `deploy-core/scripts/` | Reuse; move Fastly-specific allowlist to the wrapper. | -| `scripts/resolve-project.sh` | `deploy-core/scripts/` | Reuse + split Git root vs Cargo workspace root. | -| `scripts/install-rust.sh` | dropped | Replaced by `actions-rust-lang/setup-rust-toolchain@v1` in deploy-fastly (toolchain from resolve output + wasm target). build-app-cli keeps `rustup` for dynamic app-resolved toolchain install. | -| `scripts/run-edgezero.sh` | `deploy-core/scripts/` | Adapt to invoke `` from the artifact + provider-env. | -| `tests/run.sh` | `deploy-core/tests/` | Reuse the harness; add new cases. | -| `scripts/install-fastly.sh`, `versions.json` | `deploy-fastly/` | Move (provider-specific install + checksum). | -| `scripts/install-edgezero.sh` | → `build-app-cli` | Rewrite: build the **app's** CLI package, not the monorepo CLI. | -| `action.yml` (one composite) | `build-app-cli/` + `deploy-fastly/` | Split into build + wrapper; engine is sourced scripts. | -| `.github/workflows/deploy-action.yml` | same path | Rewrite: de-Python, repin actions to tags. | -| cache `uses: actions/cache@` | `actions/cache@v4` | Repin to readable tag. | +| Existing `.github/actions/deploy/` | New home | Disposition | +| -------------------------------------------- | ----------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `scripts/common.sh` | `deploy-core/scripts/` | Reuse ~as-is (annotation escaping, helpers). | +| `scripts/cleanup.sh` | `deploy-core/scripts/` | Reuse. | +| `scripts/write-summary.sh` | `deploy-core/scripts/` | Reuse; update summary field names. | +| `scripts/validate-inputs.sh` | `deploy-core/scripts/` | Reuse; move Fastly-specific allowlist to the wrapper. | +| `scripts/resolve-project.sh` | `deploy-core/scripts/` | Reuse + split Git root vs Cargo workspace root. | +| `scripts/install-rust.sh` | dropped | Replaced by `actions-rust-lang/setup-rust-toolchain@v1.17.0` in deploy-fastly (toolchain from resolve output + wasm target). build-app-cli keeps `rustup` for dynamic app-resolved toolchain install. | +| `scripts/run-edgezero.sh` | `deploy-core/scripts/` | Adapt to invoke `` from the artifact + provider-env. | +| `tests/run.sh` | `deploy-core/tests/` | Reuse the harness; add new cases. | +| `scripts/install-fastly.sh`, `versions.json` | `deploy-fastly/` | Move (provider-specific install + checksum). | +| `scripts/install-edgezero.sh` | → `build-app-cli` | Rewrite: build the **app's** CLI package, not the monorepo CLI. | +| `action.yml` (one composite) | `build-app-cli/` + `deploy-fastly/` | Split into build + wrapper; engine is sourced scripts. | +| `.github/workflows/deploy-action.yml` | same path | Rewrite: de-Python, repin actions to tags. | +| cache `uses: actions/cache@` | `actions/cache@v6.1.0` | Repin to readable tag. | ## Implementation phases @@ -257,8 +262,8 @@ reference to port from. Most transfer with light changes: - No CLI-build script here — CLI build lives entirely in `build-app-cli`. 7. **CI workflow (`.github/workflows/deploy-action.yml`) — no Python** - - Pin third-party actions to readable released tags (`actions/checkout@v4`, - `actions/cache@v4`, artifact upload/download at released tags). + - Pin third-party actions to readable released tags (`actions/checkout@v7.0.1`, + `actions/cache@v6.1.0`, artifact upload/download at released tags). - Run `actionlint` from a pinned release binary (no `go run @`). - Run `zizmor` from a pinned release binary or `cargo install zizmor --locked` (no `pip`). diff --git a/docs/superpowers/specs/2026-08-20-edgezero-deploy-build-caching-design.md b/docs/superpowers/specs/2026-08-20-edgezero-deploy-build-caching-design.md new file mode 100644 index 00000000..70ad9330 --- /dev/null +++ b/docs/superpowers/specs/2026-08-20-edgezero-deploy-build-caching-design.md @@ -0,0 +1,3002 @@ +# EdgeZero Deploy Actions - Build Caching Spec + +**Status:** Design (proposed) - v6.39 + +**Related:** `docs/superpowers/specs/edgezero-deploy-github-action.md`, +`docs/superpowers/plans/edgezero-deploy-action-implementation-plan.md`, +`docs/superpowers/specs/edgezero-deploy-adoption-guide.md`, `docs/guide/deploy-github-actions.md` + +## 1. Problem + +`build-app-cli` compiles the application's native CLI without caching. A cross-repository deployer +therefore recompiles the full dependency graph on every run. The solution must also work for real +EdgeZero applications whose crates are public Git dependencies, not only crates.io packages. + +The design must preserve the existing deploy, staged deploy, healthcheck, rollback, and config-push +contracts. It must not expose provider credentials to app CLI compilation or to restored cache data. + +## 2. Scope and trust model + +- The application repository and the app code being compiled are trusted. This includes `build.rs`, + proc macros, manifest commands, and any native tools they invoke. +- Cache writes run only for authorized deployer events and protected refs. The deployer repository + owns the repository-scoped GitHub Actions cache and trusts every workflow allowed to write its + default-branch cache scope. +- The app checkout token and provider token are trusted credentials, but they have disjoint uses. + The checkout token is host-only. The provider token exists only in the minimum provider operation + that requires it. Neither credential enters the cached-compile container or `SCCACHE_DIR`. + Optional checkout-App private-key material is also host-only and is used only to mint the + producer's read-only checkout token under Section 7.1; it is never an application environment value. +- The reusable workflow is the only supported artifact producer. It is build-only: it accepts no + provider inputs and performs no provider mutation. Candidate revision `H` replaces the legacy + `.github/actions/build-app-cli` producer with a non-producing, fail-closed retirement stub whose + first executable step enforces the hosted-runner predicate and whose next step always fails with + migration guidance. The stub has no producer inputs, outputs, artifact upload, or build path. +- Artifact provenance is a consistency and loadability check. It is not producer authentication or + an attestation. A malicious producer can create a self-consistent archive. Attestation remains out + of scope. +- Repository administrators can mutate Actions variables and remain trusted administrative actors. + Publication nevertheless consumes `EDGEZERO_BUILD_CONTAINER_PUBLISHER_PREREQUISITE` only through + the closed record and update protocol in Section 8. A malformed, stale, or cross-release value fails + before registry authentication or image build. Before the first verified rotation, independent review + of bootstrap-history evidence is a manual administrative trust-root procedure and is not + distinguishable from arbitrary canonical evidence by the publisher. After rotation, the + authenticated rotation receipt makes the selected rotation review machine-verifiable. +- Caching has an accepted correctness risk: sccache can miss undeclared filesystem or environment + inputs, including changed `app-env` values, read by `build.rs` or proc macros. A stale object can + pass digest, ELF, and smoke checks. + `cache: true` explicitly accepts this risk; v1 does not and cannot generally detect it. +- v1 supports only the standard GitHub-hosted `ubuntu-24.04` `linux/amd64` runner label. Every + step-based repository-owned job in this design and every published step-based consumer job + containing a `steps[*].uses` reference to a public EdgeZero action uses literal + `runs-on: ubuntu-24.04`; larger, custom-image, and other hosted labels are unsupported even when + their runner context is Linux/X64. A caller job with `jobs..uses` is not step-based, must omit + `runs-on`, and delegates runner selection to the called workflow. v1 fails closed on self-hosted + runners and does not target GitHub Enterprise Server. The GitHub Actions service and selected + runner runtime are trusted infrastructure. The in-job predicate prevents EdgeZero work after + context reports a self-hosted runner; it cannot prove the caller selected the supported label, + retract a secret a caller already supplied to that runner, or defend against a malicious runner + implementation. +- Image publication trusts the digest-pinned official Rust base, Rustup's manifest/checksum chain, + Debian's signed package archive, and checksum-pinned Fastly/sccache release assets. Candidate source + cannot change their coordinates or verification steps. The build is not claimed byte-reproducible + across time: captured leaf digest `D` plus post-build tool/capability verification is the release + identity. +- Host source materialization trusts the exact checksum-pinned official Git LFS release asset and the + bounded GitHub release redirect path defined in Section 5.2. It never trusts a runner-preinstalled + Git LFS binary or a repository-selected download coordinate. + +## 3. Terminology and identity + +### 3.1 Caller and platform identity + +`CallerExpectedIdentity` is the caller-controlled identity that both producer and consumer verify: + +- `app-repo-id`: canonical decimal GitHub repository id, verified against `app-repository` through + the GitHub REST API. +- `source-revision`: the full lowercase 40-hex commit SHA checked out from the app repository. +- `app-cli-package`: Cargo package name. +- `app-cli-bin`: binary name. +- `workspace-id`: the canonical workspace identity described below. + +`PlatformIdentity` is action-controlled: + +- `platform-id`: the `sha256:<64-lowercase-hex>` image manifest digest from the local action + revision's `.github/docker/build-app-cli/image.json`. +- `container-ref`: `@` from that same file. +- `provenance-protocol`: the exact protocol integer from that same file. + +Every EdgeZero action derives `PlatformIdentity` locally. Callers cannot provide or override it, and +the reusable workflow does not expose it as an output. Artifact metadata contains both identity +groups so the consumer action can compare caller values and its locally derived platform values. + +The app checkout token is used host-side by the reusable producer, the public +`compute-app-cli-identity` action, and each source-bearing provider action to verify and materialize a +private repository independently. It is never copied into a container, exported working copy, +artifact, or cache, and no materialized authority path or handle crosses a public action boundary. + +### 3.2 Canonical hashes + +Identity hashes use SHA-256 over a fixed-order, length-framed byte encoding. Each UTF-8 field is +encoded as `:`, where the length is ASCII decimal with no leading zeroes. + +Paths are relative to `git-root`, `/`-separated, have no empty, `.`, or `..` segment, and have no +trailing slash. The root is represented by the single byte `.`. Paths are hashed byte-exactly with +no Unicode normalization; non-UTF-8 paths fail closed. + +`workspace-root` must canonicalize beneath `git-root`; `working-directory` must canonicalize beneath +`workspace-root`; and credential-free `cargo metadata --locked` from `working-directory` must report +that exact workspace root. Its `Cargo.lock` must be a tracked regular file. A caller-provided root is +never trusted without those checks. +The metadata command runs only through the closed `metadata-preflight` container profile in Section +5.3 after checkout credential removal. No host Cargo process or ambient Cargo/Git +credential/configuration participates in this agreement. + +- `workspace-id` is SHA-256 over the concatenation + `::` with no separator or + newline, and is rendered as `sha256:<64-lowercase-hex>`. +- `cache-key-suffix` is valid UTF-8 from 0 through 255 bytes with no Unicode control character. + `suffix-hash` is SHA-256 over `:` with no newline and is rendered as + 64 lowercase hexadecimal characters without a `sha256:` prefix. + +Committed golden vectors cover framing, both digest renderings, the root representation, empty and +255-byte suffixes, rejected oversized/control-character suffixes, and byte-distinct NFC and NFD paths +that must produce different hashes. + +### 3.3 Workflow and action revisions + +`app-ref` must be a full lowercase 40-hex commit SHA. Branches, tags, abbreviated SHAs, and the +legacy `--stage` spelling are unsupported. Staged provider operations use only `--staging`. + +Every non-local external action and reusable workflow reference in this repository and in documented +consumer workflows must use an exact patch-version tag of the form +`v..`, with canonical decimal components and no leading zero except the value +zero itself. Major-only tags, minor-only tags, prereleases, build metadata, branches, commit SHAs, +abbreviated SHAs, and floating names such as `main` or `latest` are not accepted. Docker action refs +remain digest-only. All EdgeZero references in one consumer workflow use one exact action version. +Third-party refs and released EdgeZero repository/documentation surfaces must name stable releases. +Before the first stable EdgeZero release exists, the four prepublication adoption documents may use +literal `` under the dual-state documentation gate defined below; those +examples are intentionally non-runnable until documentation revision `R`. The plan-5 disposable +release fixture alone may use distinct candidate `C`, which has the same exact patch-version grammar +but belongs to a GitHub Release whose `prerelease` field is `true`; `C` never contains a SemVer +prerelease suffix. That exception qualifies candidate commit `H` before final `V` is published. + +In this document, pinning a non-local GitHub action or reusable workflow always means the exact +patch-version tag above, never a commit SHA. Commit SHAs remain mandatory only where they identify +source or execution provenance: `app-ref`, `job.workflow_sha`, `action-revision`, protected-head +commit `Q`, rollout commits `G`/`S`/`B`/`H`/`P`/`R`, and the organization required-workflow descriptor +bound to gate commit `G`. +SHA-256 values remain content identities for images, archives, binaries, fixtures, and hashes; they +are not GitHub `uses:` refs. + +This is an explicit usability tradeoff, not a claim that ordinary Git tags are immutable. A +third-party publisher can move or delete a version tag or create a same-named branch that introduces +symbolic-ref ambiguity; those supply-chain risks are accepted for the separately reviewed, trusted +actions listed by the implementation plans. Initial review must still prove a published stable +release tag exists, no same-named branch exists, and the recorded resolved commit is the reviewed +release commit. EdgeZero's own `V` is +stronger: repository immutable releases must be enabled, `V` must be a published immutable release +whose target is executable commit `P`, and the no-bypass action-version tag ruleset must prohibit +update and deletion. A release version is never silently retargeted; a correction receives a new +patch version. + +Inside the called workflow: + +- `job.workflow_repository` and `job.workflow_file_path` must identify the expected EdgeZero + reusable workflow. +- `job.workflow_ref` must be exactly + `stackpop/edgezero/.github/workflows/build-app-cli.yml@refs/tags/` where the action + version is stable `V` or, only in the release fixture, candidate `C`; +- `job.workflow_sha` must be a full lowercase 40-hex commit SHA and is the resolved executable + revision for that invocation (`H` under `C`, final `P` under `V`). It is not compared textually with the tag-bearing + `job.workflow_ref`. + +The reusable job declares literal `runs-on: ubuntu-24.04`. Its first executable step is a fixed, +checkout-independent producer bootstrap assertion. Through step-local bindings directly from the +GitHub contexts and the declared `app-ref` input, that step validates the exact runner triplet from +Section 5.1, all four workflow properties above, the canonical action version encoded by +`job.workflow_ref`, full lowercase `app-ref`, and a canonical positive `job.check_run_id`. It runs +before any checkout, cache or artifact action, Docker command, source materialization, credential +use, or other repository-supplied executable. It cannot call or source a repository helper because +the trusted EdgeZero checkout does not yet exist. The step has no `if` or `continue-on-error`, does +not mask command failure. All later protected operations require bootstrap success; the only +post-failure exceptions are the bounded cleanup and marker-gated recovery paths in Section 5.1. + +These hosted-runner context properties identify the workflow that defines the current job. They are +part of the hosted-only v1 floor. + +`Q` denotes the exact protected-default-branch commit whose post-merge `push` run is being examined. +It is generic: during this rollout it may be a gate candidate `G'`, release source `S`, pin baseline +`B`, an implementation commit, or final action revision `P`. The generic main-push assertion proves +workflow/context identity at `Q`; a release check separately proves that `Q=S` when qualifying the +image source. `H` is reserved for the final action candidate defined in Section 8. + +`T` denotes the exact subject-head commit selected by the protected event-to-range contract in Section 8. For `pull_request`, `T=M`, the authenticated synthetic merge commit. For `merge_group`, `T` is the +payload head SHA, which equals `github.sha`. For protected-main `push`, `T=Q`. `T` is an execution-local +identity and does not rename any rollout commit; in particular, only the final action candidate is `H`. + +After that bootstrap passes, the reusable job uses `actions/checkout@v7.0.1` only to place repository +`stackpop/edgezero` at `ref: job.workflow_sha` in a fixed private action-source directory with +`persist-credentials:false`. Its next executable step is a second fixed inline assertion that uses +only workflow-literal commands and runner tools to inspect the fixed checkout path; it never executes +or sources a file from that checkout. It proves the root is real and non-overlapping, repository +identity is exact, HEAD is `job.workflow_sha`, the tree is clean with no submodule, LFS, sparse, or +untracked content. Structural tests separately prove this verifier immediately follows checkout and +that no earlier step executes, sources, or resolves a path from the checkout. Only then may the +workflow invoke the checked-out shared runner helper or any other local action or helper. + +The trusted Section 5.2 materializer then fetches the application at `app-ref` into a distinct fixed +private authority root without initially creating a worktree, proves the committed filter/submodule +policy, and only then checks out materialized bytes. Every local composite/helper invocation resolves +beneath the verified EdgeZero root. No `./...` action or helper path may resolve against the +application authority, and neither root may overlap, contain, or symlink into the other. The app +token is removed from Git configuration and the credential channel before the workflow exports +tracked files and supported submodules into non-hardlinked Copy A. The authority remains read-only +and is verified before and after use; Copy A contains no `.git`, checkout credential, ignored file, +or untracked file. + +## 4. Cache design + +### 4.1 Cached data and fixed paths + +For the reusable workflow's native `build-app-cli` compile, `CARGO_TARGET_DIR` is fresh on every run +and is never cached; only `SCCACHE_DIR` is archived. The pinned image supplies +`/usr/local/bin/sccache` v0.10.0, and cached compilation sets its absolute path as `RUSTC_WRAPPER`. + +This section defines `build-app-cli.cache`, the reusable workflow's native CLI compilation cache. +It does not replace the parent's distinct `deploy-fastly.cache`: under `build-mode: always`, the +consumer may restore and save that exact-key Cargo target cache only around the credential-free +`app-build` profile below, before any provider token is introduced. `build-mode: never` receives no +target-cache restore or save. + +The sccache host path is the fixed `${RUNNER_TEMP}/edgezero-sccache-v1`, emptied before restore and +mounted at constant `/work/sccache`. When and only when the parent `deploy-fastly.cache` is enabled, +its target-cache host path is independently fixed at +`${RUNNER_TEMP}/edgezero-deploy-fastly-target-v1`, emptied before restore and mounted at constant +`/work/target`. With that cache disabled, `app-build` instead creates a fresh target directory beneath +the invocation-private action workspace, mounts it at `/work/target`, performs no restore/save, and +removes it on every exit; it neither creates nor adopts the fixed target-cache root. `actions/cache` +includes the archived host path in its cache version, so neither fixed path may contain an invocation +id, checkout path, or random suffix. A second overlapping invocation in the same job fails the absent- +root check rather than sharing either fixed directory. Sequential invocations may reuse a fixed path +only after the prior invocation's verified cleanup. The fixed in-container paths and `/work/repo` cwd +also avoid path-only misses in compiler and Cargo cache keys. + +The cache contains compiled outputs, indexes, and replayable compiler stdout/stderr. Diagnostics can +contain paths, source excerpts, warnings, and compile-time values. Dependency sources, Cargo registry +or Git checkouts, `.crate` archives, credentials, and `CARGO_HOME/bin` are not intentionally cached. + +The compile environment has no dependency credentials. Both cached and uncached builds therefore +support only anonymously fetchable crates.io and public Git dependencies. `cache:false` selects a +separate `uncached-compile` profile: after the required metadata preflight, Cargo has exactly one +compile/build invocation with a fresh target/Cargo home and +no sccache mount, process, wrapper, socket, or `SCCACHE_*` variable. It is not the cached profile with +cache steps merely skipped. + +### 4.2 Keys, restore, and save + +The cache family is exactly: + +```text +edgezero-sccache-v1-- +``` + +The primary key is `-`, where generation is `job.check_run_id`. The only restore +prefix is `-`. `app-cli-artifact` does not affect cache identity; it is unique only because +GitHub artifact names share a run-level namespace. + +Each successful writer creates a new immutable entry. Concurrent jobs in one family restore the +newest available entry and fork from it. Their results are not merged, so only one lineage may remain +the newest. This lost warmth is accepted. + +There is no cache reservation or fail-closed save protocol. Standard `actions/cache/save` is +best-effort and save failures are warnings. GitHub cache restore, cache absence, and cache save +availability never determine build success. A failure of the compiler-wrapper process itself can +still fail compilation as described below. + +GitHub cache storage and eviction are repository-global. The rolling generations can evict unrelated +workflow caches. Entries not accessed for seven days may be removed. This cost and eviction behavior +is accepted; v1 performs no cache deletion. + +### 4.3 Restore and runtime failure contracts + +For `cache:true`, the sequence is: + +1. Authenticate `app-repository`, verify its actual repository id and the authority checkout, then + validate cache eligibility from that verified id, including the cross-repository disclosure + acknowledgement, before creating the cache root or invoking a cache action. The unverified + `app-repo-id` input can never obtain the same-repository exemption. +2. Empty the stable host cache directory. +3. Restore the newest matching cache. +4. Audit restored data. On restore or audit failure, clear the directory and continue cold. +5. Start sccache, zero its statistics, and compile once. +6. Capture `sccache --show-stats --stats-format=json` and stop the server. +7. Audit the stopped directory again. +8. Save only when the compile succeeded, stop succeeded, the final audit passed, the captured + `cache_write_errors` count is zero, and the save-authorization predicate below is true. + +`cache: true` authorizes lookup and restore, not publication. Saving is permitted only when all of the +following action-derived conditions hold: the event is exactly `push` or `workflow_dispatch`, +`github.ref_protected` is the boolean `true`, `github.event.repository.fork` is the boolean `false`, +the event repository id equals `github.repository_id`, the workflow identity checks in Section 3.3 +passed, and the disclosure requirement in Section 4.4 passed. Pull-request events +(including `pull_request_target`), `merge_group`, forks, +unprotected refs, missing context, malformed values, and caller-supplied substitutes are restore-only +only after lookup eligibility passed. A cross-repository request without +`disclosure-acknowledged:true` is an input error before restore, not a restore-only row. The cache plan +commits separate lookup-eligibility and save-authorization truth tables; no input can override either +computed decision. + +The captured sccache statistics wire is the single JSON value emitted by exact v0.10.0 command +`sccache --show-stats --stats-format=json`. The parser rejects duplicate keys, trailing data, +non-UTF-8, non-object roots, unknown/missing top-level or `stats` fields, wrong types, negative or +non-integer counters, and any integer outside `u64`. Its closed v0.10.0 schema is independently +derived from the pinned `ServerInfo`/`ServerStats` source. The top level has exactly `stats`, +`cache_location`, `cache_size`, `max_cache_size`, `use_preprocessor_cache_mode`, and `version`. +`stats` has exactly these 22 keys: + +```text +compile_requests +requests_unsupported_compiler +requests_not_compile +requests_not_cacheable +requests_executed +cache_errors +cache_hits +cache_misses +cache_timeouts +cache_read_errors +non_cacheable_compilations +forced_recaches +cache_write_errors +cache_writes +cache_write_duration +cache_read_hit_duration +compilations +compiler_write_duration +compile_fails +not_cached +dist_compiles +dist_errors +``` + +`cache_errors`, `cache_hits`, and `cache_misses` are objects with exactly `counts` and `adv_counts`; +each is a map from a 1..255-byte UTF-8 key without Unicode controls to a `u64`. `not_cached` and +`dist_compiles` are maps with the same key and value bounds. The three duration fields are objects +with exactly canonical nonnegative integer `secs` and `nanos`, where both fit `u64` and +`nanos<1_000_000_000`. Every other `stats` field is a `u64`. + +For this managed local-disk profile, `cache_location` is exactly +`Local disk: "/work/sccache"`, `cache_size` is a `u64` no greater than 2,147,483,648, +`max_cache_size` is integer `2147483648`, `use_preprocessor_cache_mode` is false, and `version` is +exactly `0.10.0`; null optional-size values are rejected after a successful compile. A committed +schema and golden output enumerate the same fields and bounds. The implementation reads +`stats.cache_write_errors` only after the entire closed document validates. + +Storage lookup and decompression failures that sccache v0.10 treats as misses remain misses. +`SCCACHE_IGNORE_SERVER_IO_ERROR=1` is set because it covers selected client/server response failures; +it is not described as covering startup, connection, extraction, or every backend error. Any other +sccache error follows pinned v0.10 behavior. An ordinary compiler failure is surfaced once and is +never retried by the cache layer. + +If `sccache --stop-server` fails, the action skips save with a warning. If cache write errors are +non-zero, the build may still succeed but save is skipped with a warning. Restore, save, and cache +absence never cause an action-level retry. After metadata preflight, the action invokes exactly one +Cargo compile/build command. Pinned sccache v0.10 may itself fall back to a local compiler invocation +after `CompileStarted` when the server response is lost; this internal fallback is accepted +pinned-client behavior and is not described or tested as single compiler-process execution. + +### 4.4 Cache audit and disclosure + +`SCCACHE_CACHE_SIZE=2G` is the managed sccache capacity. The independent hard tree bound is +2,147,483,648 bytes: the audit sums `st_size` for every regular file with checked integer arithmetic +and rejects a greater total. It also rejects sparse files (`st_blocks * 512 < st_size`), more than +100,000 descendants, a path longer than 4,096 bytes, or a single path component longer than 255 +bytes. Directory `st_size` values do not contribute. These are filesystem-tree limits, not a claim +about `actions/cache`'s host-selected tar, zstd, framing, or wire size. The selected cache action and +host archiver are outside the trusted data parser; an upload-size or archiver failure remains the +warning-only save failure defined above. The cache implementation plan pins +`actions/cache/restore@v6.1.0` and `actions/cache/save@v6.1.0` and tests these format-independent tree +bounds. + +Before use after restore and before save, the audit requires: + +- the canonical audited root is exactly `SCCACHE_DIR`; it is the same recorded device/inode as the + action-created mode-0700 real directory, is owned by uid/gid 1001, and is not itself a mount; +- every entry is a regular file or directory beneath that root; +- no symlink, socket, FIFO, device, mount escape, or special file exists, and every regular file has + `nlink == 1` (directory link counts are not constrained); +- ownership is the expected container uid/gid; +- layout and record names match the pinned v0.10 format fixtures; +- the logical-byte, non-sparse-file, path-length, and entry-count bounds above all pass. + +The application is trusted, but cached compilation shares a writable uid and `SCCACHE_DIR` with app +code. App code can therefore place arbitrary bytes in that directory. The audit constrains shape and +size, not authorship or semantic content. The cache is not content-authenticated and the disclosure +acknowledgement covers the entire archived directory, compiler diagnostics, and app-written bytes +that satisfy the audit. + +Every cross-repository cached build requires `disclosure-acknowledged: true` before either cache +family is restored or saved; equality between the authenticated application repository id and event +repository id is the only exemption. The +parent `deploy-fastly.cache` target cache uses the same lookup-eligibility rule and the same +action-derived protected-event save predicate as the sccache family. A denied parent save remains +restore-only only when lookup was eligible. Its existing key/content/audit contract remains owned by +the parent deploy spec. + +At invocation start, each selected fixed host cache root must be absent beneath a real, non-symlinked +`${RUNNER_TEMP}`; the action creates it, records its device/inode/owner/mode, and never adopts a +preexisting path. Restore and pre-use audits remain confined to the recorded root. After the single +save attempt or any earlier terminal path, descriptor-relative no-follow cleanup removes that recorded +tree and verifies absence. Cleanup failure fails the action even when restore/save failure itself was +warning-only, because a dirty fixed root could contaminate another invocation in the job. + +## 5. Container execution + +### 5.1 Image and runner + +The EdgeZero image is public and anonymously pullable by digest and is a leaf `linux/amd64` image +manifest rather than an OCI index. It is built from a digest-pinned base and contains: + +- the exact Rust toolchain from `.tool-versions` and an installed `wasm32-wasip1` target; +- exact pinned Fastly CLI and sccache versions with checksum-verified downloads; +- `git`, `jq`, `tar`, `curl`, CA certificates, and a C toolchain; +- the project-owned provenance validator and its protocol/schema assets. + +Runtime containers use a read-only root filesystem, uid/gid 1001, dropped capabilities, +`no-new-privileges`, no GitHub file-command channels, explicit mounts, and operation-specific network, +memory, pid, and timeout limits. + +Every step-based repository-owned workflow job specified by this design declares literal +`runs-on: ubuntu-24.04`. Published consumer examples and tested adoption workflows declare the same +literal label on every step-based job containing a `steps[*].uses` reference to a public EdgeZero +composite action. A job-level reusable-workflow caller with `jobs..uses` has no `steps` and must +omit `runs-on`; the called EdgeZero producer owns its literal runner label. A step-based caller job +that uses a different standard, larger, or custom-image runner label is outside the v1 compatibility +contract. The label selects the supported host image family; it is not security evidence and is not +observable from inside a composite action. + +Before checkout or any other credential use, cache access, artifact transfer, app-source +materialization, Docker execution, provider-token handling, or provider mutation, every +repository-owned workflow job performs a fixed first-executable-step bootstrap that requires +context-derived `runner.environment == "github-hosted"`, `runner.os == "Linux"`, and +`runner.arch == "X64"`. The reusable producer combines that predicate with the invocation checks in +Section 3.3. The bootstrap is inline because no repository helper is trusted before checkout; +structural tests freeze its step position, context bindings, and exact comparisons. It is +unconditional, has no `if` or `continue-on-error`, and cannot mask a failed command. No later +protected operation may run unless the guard succeeded. Normal steps retain default success gating. +Every shell-backed bootstrap and public-action runner-eligibility step sets step-local `BASH_ENV` and +`ENV` to the empty string in workflow/composite metadata. This metadata binding occurs before Bash +startup; unsetting either name inside the script body is too late. Structural and hosted fixtures +prove caller workflow/job/action environment values cannot execute a startup file before the guard or +replace its three context-derived values. +An `if: always()` cleanup may only remove previously recorded action-private paths or named +containers and revoke an already-created ephemeral credential; it may not interpret, execute, +upload, save, or otherwise consume artifact/source/cache contents, start a container, create a +credential, or perform provider mutation. A required recovery or post-mutation reconciliation path +may run after an operational failure only when its condition conjunctively proves guard success and +the protocol-specific mutation/transition marker; guard failure can reach cleanup only. + +The sole shared runner-eligibility helper accepts and validates only those three runner-context +values. It does not accept or validate workflow, action, application, cache-generation, or provider +identity. Composite metadata binds the values into private names for every public action's first +executable step; they are not action inputs and caller `env` cannot replace the step-local bindings. +The reusable producer calls the same helper immediately after its verified EdgeZero checkout and +before app checkout, cache, artifact, or Docker work, as a checked-source consistency check in +addition to its authoritative inline bootstrap. A missing, empty, differently cased, or otherwise +different value fails closed. Every public action's helper step is unconditional, has no `if` or +`continue-on-error`, cannot mask helper failure, and gates all later protected internal operations +under the same cleanup/recovery rules. `runs-on`, Docker availability, uname output, and architecture +alone are not proof of a GitHub-hosted runner. A job-level protected environment can be resolved +before steps begin; the predicate does not claim to run before that GitHub control-plane event. + +### 5.2 Working-copy topology + +There are three independent exported-copy roles because GitHub jobs do not share filesystems. The +producer workflow and every consumer-side public action that needs repository source independently +create a private **authority checkout** at the exact app SHA through the object-first trusted +materializer defined below. The public identity action also creates its own short-lived authority for +identity calculation. The helper may use the app token only through its bounded host credential +channel; no application-controlled command, filter, hook, or worktree operation runs before policy +validation. No authority directory, descriptor, path, or opaque handle is accepted from or returned +to another public action. After credential removal and source validation, a trusted exporter creates +the applicable action-local copy from that authority checkout: + +- **Copy A, producer build job:** a private faithful copy used only for producer metadata preflight and + cached or uncached native CLI compilation. It is mounted read-only; Cargo target, Cargo home, + sccache, home, and temporary output live in separate action-owned paths. The reusable workflow + uploads its CLI artifact; Copy A is then discarded. +- **Copy B, source-bearing consumer action:** a fresh private faithful copy exported from that + action's independently materialized authority. Its repository view is mounted read-only except for + the action-created nested output-root mounts, so generated files can flow from app build to + `fastly compute deploy` without granting general source write access. `deploy-fastly` creates one + Copy B at invocation start and may reuse it only for metadata preflight and its internal app-build + and deploy operations; its active-version operation does not mount the copy. No authority, Copy B, + or generated root is shared across public action invocations. Source-free actions create no + authority or Copy B. Copy A never crosses into the consumer job. +- **Copy I, identity-only consumer action:** a short-lived private faithful copy used only where an + action has no Copy A or Copy B when it computes identity. `compute-app-cli-identity` and + `config-push-fastly` each export their own Copy I after credential removal, mount it read-only only + for `metadata-preflight`, and destroy it before returning or entering the config-push profile. It is + never an artifact, output, authority substitute, build input, provider input, or cross-action + handoff. A source-bearing deploy action uses its own Copy B for metadata instead of creating Copy I. + +`config-push-fastly` is the sole source-bearing Copy-B exception: it executes no application code and +creates no Copy B. It independently materializes its own credential-free frozen authority and mounts +that authority read-only so its selected tracked manifest and file-backed config retain +repository-relative semantics. Before token creation and again immediately before container start, +the host records and verifies authority HEAD, index/worktree state, full tracked/submodule inventory, +and the selected manifest/config device, inode, digest, size, mode, and link count. It repeats those +checks after the command and before cleanup. Inline config is action-owned and receives the same +identity checks. Any authority or selected-file change, replacement, or race fails; fixtures exercise +replacement between every check and launch boundary. + +Each copy preserves the entire repository layout, enclosing workspaces, parent Cargo config, sibling +path dependencies, file modes, symlink targets, and initialized submodule state. It includes tracked +files and initialized submodules only; ignored and untracked detritus, `.git` directories/files, LFS +object stores, checkout credentials, and action metadata are absent. Every regular file has a newly +created inode, so hardlinks to the authority are forbidden. The authority is made read-only +after export and remains the freeze authority. + +Protocol 1 permits only paths with no Git filter or `filter=lfs`; any custom clean/smudge/process +filter, required filter other than LFS, or submodule using one fails. Before authority checkout, a +trusted host helper installs Git LFS 3.7.1 from exact asset +`git-lfs-linux-amd64-v3.7.1.tar.gz`, whose SHA-256 is +`1c0b6ee5200ca708c5cebebb18fdeb0e1c98f1af5c1a9cba205a4c0ab5a5ec08`. The closed canonical +gate-owned `.github/actions/deploy-core/host-tools.json` contains the following single data line with +no terminating newline; the Markdown fence line break is not file content: + +```text +{"git-lfs":{"asset":"git-lfs-linux-amd64-v3.7.1.tar.gz","sha256":"1c0b6ee5200ca708c5cebebb18fdeb0e1c98f1af5c1a9cba205a4c0ab5a5ec08","size":5524590,"version":"3.7.1"},"schema-version":1} +``` + +The download URL is constructed in trusted code as +`https://github.com/git-lfs/git-lfs/releases/download/v3.7.1/git-lfs-linux-amd64-v3.7.1.tar.gz`, not +read from data. Every hop is HTTPS, the initial host is exactly `github.com`, redirects are bounded +to three and may terminate only at `release-assets.githubusercontent.com`, and no credential is sent +on the public download. The final response must be HTTP 200 with identity content encoding and exactly +one decimal `Content-Length: 5524590`; the streaming receiver rejects an absent, duplicate, malformed, +or different length, more than 5,524,590 received bytes, early EOF, or trailing data. An unexpected +host, downgrade, redirect count, checksum, archive layout, or installed `git-lfs version` also fails +before authority materialization. + +The trusted materializer creates fresh Git object repositories with system/global configuration, +configuration includes, credential helpers, template hooks, and hook execution disabled. While no +application-controlled process is running, it uses the app token only through a noninteractive, +non-logging, host-only credential channel scoped to each canonical GitHub repository origin and +fetches exact app/submodule commits into object databases without creating a worktree or initializing +a submodule. Before any checkout, it recursively inspects the committed trees, `.gitattributes`, +`.lfsconfig`, `.gitmodules`, gitlinks, and submodule target trees using trusted Git plumbing. It +rejects `.lfsconfig`; any custom clean/smudge/process filter; any required filter other than LFS; any +repository/local/global/system `lfs.*` URL or transfer override; non-GitHub origin/submodule URLs; and +inconsistent, missing, or unlisted submodule commits. This validation may fetch a verified canonical +submodule origin but never creates its worktree or runs a filter, hook, or repository command. + +Only after the complete recursive object graph passes does the helper create the authority worktrees +with every filter disabled, including automatic LFS smudging. It invokes the absolute verified Git +LFS 3.7.1 binary directly to fetch and materialize the exact permitted LFS objects, checks out exact +submodule commits, runs `git lfs fsck --objects` in each repository, and rejects any worktree file +that remains a valid LFS pointer. It then removes the credential channel and every credential before +export. The exporter copies materialized worktree bytes, not pointer blobs. Tests prove forbidden +filter commands, hooks, and non-GitHub origins are never contacted or executed and cover absent/ +corrupt objects, pointer residue, nested submodules, credential cleanup, and configuration races. + +### 5.3 Mount profiles + +`run-app-cli-in-container` has a maximum allowlist and a closed profile for each operation. It never +mounts all of `RUNNER_TEMP`. + +| In-container path | Mode | Allowed operations | Source | +| --------------------------- | --------------- | -------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- | +| `/work/repo` | read-only | metadata-preflight, cached-compile, uncached-compile, app-build, provider-deploy | Copy A, Copy B, or identity-only Copy I as constrained below | +| `/work/repo` | read-only | config-push | credential-free frozen authority; no Copy B | +| `/work/repo/` | writable | app-build, provider-deploy | action-created declared/implicit directory | +| `/work/target` | writable | cached-compile, uncached-compile, app-build, provider-deploy | fresh or parent target cache as specified below | +| `/work/cargo-home` | writable, fresh | metadata-preflight, cached-compile, uncached-compile, app-build, provider-deploy | operation-specific directory | +| `/work/sccache` | writable | cached-compile only | stable host cache directory | +| `/work/input/app-cli` | read-only | provenance-package only | exact binary produced by the selected cached-compile or uncached-compile profile | +| `/work/input/artifact.tar` | read-only | provenance-validate only | downloaded artifact | +| `/work/expected` | writable, fresh | expected-write | empty host expected-identity output directory | +| `/work/release` | writable, fresh | release-request-write | empty host release-request output directory | +| `/work/input/expected.json` | read-only | provenance-package, provenance-validate | validator-generated expected identity | +| `/work/packaged` | writable, fresh | provenance-package only | empty host archive-output directory | +| `/work/validated` | writable, fresh | provenance-validate only | empty host output directory | +| `/work/bin/app-cli` | read-only | binary-smoke and provider operations | validated binary | +| `/work/config/inline.toml` | read-only | config-push only | optional action-owned inline config file | +| `/work/home`, `/work/tmp` | writable tmpfs | all operations | operation-local tmpfs | + +Profiles: + +- `metadata-preflight`: Copy A in the producer, Copy B in `deploy-fastly`, or Copy I + only in `compute-app-cli-identity` and `config-push-fastly`, mounted read-only with fresh Cargo home + and tmpfs; no authority, target, sccache, provider token, checkout token, Git + credential/configuration, writable repository, or other host mount. It + invokes exact `cargo metadata --locked --format-version 1` from validated `working-directory`, with + bridge network only for anonymously readable crates.io and public Git dependencies. The launcher + captures one bounded JSON document on stdout; stderr is diagnostic only. The trusted host helper + duplicate-rejects and parses that document, validates the selected package/bin/workspace/path- + dependency contract, and never invokes host Cargo. +- `cached-compile`: Copy A, fresh target and Cargo home, sccache, tmpfs; no token. +- `uncached-compile`: Copy A, fresh target and Cargo home, and tmpfs; no token, sccache mount, + wrapper, socket, or sccache process. +- `app-build`: validated CLI, Copy B, fresh Cargo home, and the parent + `deploy-fastly.cache` target directory when enabled. It runs ` build` for + `build-mode: always`, has no provider token and no sccache mount, and saves the parent target cache + before any provider operation. +- `provider-deploy`: Copy B, fresh target/Cargo home, validated CLI, tmpfs, provider token; + never sccache and never a writable cache. Fastly deploy may compile application source with the + token for both `build-mode` values. A prior `app-build` is a credential-free validation/prebuild and + does not claim to suppress this recompile; its parent target cache was already saved before the + token appeared and is never saved again afterward. +- `expected-write`: trusted baked validator, fresh writable `/work/expected`, and tmpfs; no + repository, app binary, target, Cargo, cache, network, or token. It converts typed identity scalars + into the only supported `expected.json` encoding. +- `release-request-write`: trusted baked validator, fresh writable `/work/release`, and tmpfs; no + repository, app binary, target, Cargo, cache, network, or token. It converts typed release scalars + into the only supported `release-request.json` encoding. +- `provenance-package`: trusted baked validator, the exact compiled binary read-only at + `/work/input/app-cli`, read-only expected-identity JSON, fresh writable `/work/packaged`, and tmpfs; + no repository, target, Cargo, package, cache, app-binary execution, network, or token. +- `provenance-validate`: trusted baked validator, read-only tar, fresh writable output directory, + read-only expected-identity JSON, and tmpfs; no repository, app-binary execution, token, Cargo, + target, package, or cache mount. +- `binary-smoke`: validated binary only plus tmpfs; no network, token, repository, Cargo, target, + package, cache, or validator output write access. +- `self-test`: no host bind mount. It reads only the image-owned validator, schema, and exact fixture + directory and writes only to `/work/home` and `/work/tmp` tmpfs; no network, repository, app + binary, token, Cargo, target, package, cache, or output bind mount. +- `provider-read`: validated binary and tmpfs. `active-version` receives the provider token. + Production healthcheck receives no token; staging healthcheck receives the token needed for the + staged endpoint. +- `provider-rollback`: validated binary and tmpfs plus the provider token; no repository, Cargo, + target, package, or cache mount. +- `config-push`: validated binary, frozen repository read-only, tmpfs, provider token, and the + enumerated app-config overlay. A selected manifest and file-backed app config must canonicalize + beneath the frozen repository; inline config is one fresh host file mounted at the exact path + above. It receives no writable repository, package, Cargo, target, or sccache mount. + +The parent deploy spec remains normative for production/staging lifecycle semantics, rollback target +capture, mutation signaling, healthcheck ordering, and recovery. This addendum expressly supersedes +the parent's app-CLI metadata shape, caller override, archive member naming/ordering, system-tar +packaging/extraction, and artifact-validation rules, in addition to changing isolation and mounting. +Every staged CLI invocation uses `--staging`, never `--stage`. + +Network and resource limits are closed by operation. An enabled network is Docker's ordinary isolated +bridge, never host or another container's namespace. Memory and memory-plus-swap limits are equal, so +no operation receives additional swap: + +| Operations | Network | Memory | Pids | Hard wall timeout | +| -------------------------------------------------- | ------- | ------- | ---- | ----------------- | +| metadata-preflight | bridge | 1 GiB | 128 | 10 minutes | +| cached-compile, uncached-compile | bridge | 6 GiB | 512 | `timeout-minutes` | +| app-build, provider-deploy | bridge | 6 GiB | 512 | 60 minutes | +| expected-write, release-request-write | none | 256 MiB | 32 | 60 seconds | +| provenance-package, provenance-validate, self-test | none | 2 GiB | 64 | 10 minutes | +| binary-smoke | none | 512 MiB | 64 | 60 seconds | +| active-version, provider-rollback, config-push | bridge | 1 GiB | 128 | 10 minutes | +| production/staging healthcheck | bridge | 1 GiB | 128 | computed below | + +`timeout-minutes` is a canonical decimal integer from 1 through 120 and defaults to 30. Healthcheck +`retry` is 1..20, `retry-delay` is 0..300 seconds, and per-attempt `timeout` is 1..300 seconds. Checked +arithmetic computes `retry * timeout + (retry - 1) * retry-delay + 30` seconds; the value must be at +most 3,600 and becomes the container hard wall timeout. The supervisor sends TERM on expiry or runner +cancellation, waits at most 10 seconds, then sends KILL and enters the parent reconciliation path. +Timeout, OOM, resource-limit, and forced-kill outcomes are failures; they never relax cleanup, +mutation, cache-save, or reconciliation rules. + +### 5.4 Constructed environments + +Every target operation starts with `env -i` and receives a closed allowlist. `PATH` is +`/usr/local/bin:/usr/local/cargo/bin:/usr/bin:/bin`. + +The exact environment-launch protocol is part of protocol 1. The container runtime entrypoint is +`/usr/bin/env`. Its first two arguments are literal `-S` and one split-string argument consisting of +literal `-i` followed by one `NAME=${NAME}` assignment for every name in the selected operation +profile, sorted by ascending ASCII bytes and separated by one ASCII space. The next runtime argument +is the operation's absolute executable path, followed by its already validated arguments as separate +argv elements. There is no shell, `env -v`, caller-selected entrypoint, command prefix, bare +environment name, or argv element constructed by inserting an environment value. The split-string +argument contains only literal names and `${NAME}` placeholders. A runtime argument may +coincidentally have the same bytes as a value; byte inequality is not the security assertion. + +GNU `env -S` expands each `${NAME}` from the initial Docker environment before processing `-i`, then +clears that environment, installs only the expanded assignments, and directly executes the absolute +operation command. Validated names make the placeholder grammar unambiguous. Expansion bytes are not +recursively expanded or resplit; tests cover spaces, quotes, backslashes, dollar signs, `#`, `=`, +literal `${...}` text, and non-ASCII values. The target process therefore cannot observe inherited +image variables such as `RUST_VERSION` or Docker-created variables such as `HOSTNAME`. The image and +publisher verifier treat `/usr/bin/env` with these exact `-S` expansion-before-clear semantics as a +required runtime capability. + +- metadata preflight: `PATH`, `RUSTUP_HOME=/usr/local/rustup`, exact `RUSTUP_TOOLCHAIN`, fresh + `CARGO_HOME`, `HOME`, and `TMPDIR`; no `CARGO_TARGET_DIR`, application environment, token, Git + credential/configuration, compiler wrapper, flags, or `SCCACHE_*` value. +- cached compile: `PATH`, `RUSTUP_HOME=/usr/local/rustup`, `RUSTUP_TOOLCHAIN`, fresh `CARGO_HOME`, + fresh `CARGO_TARGET_DIR`, `RUSTC_WRAPPER=/usr/local/bin/sccache`, `SCCACHE_DIR`, + `SCCACHE_CACHE_SIZE=2G`, `SCCACHE_IGNORE_SERVER_IO_ERROR=1`, `CARGO_INCREMENTAL=0`, empty + `CARGO_ENCODED_RUSTFLAGS`, `HOME`, `TMPDIR`, and validated `app-env`. +- uncached compile: the same Rustup/Cargo, `HOME`, `TMPDIR`, and validated `app-env` values, but no + `RUSTC_WRAPPER`, `SCCACHE_*`, or sccache socket variable. +- app build: the Rustup/Cargo variables above except every sccache variable and wrapper, the + operation's action-owned target paths, `HOME`, `TMPDIR`, the validated `app-env` map, and + validated `EDGEZERO_MANIFEST` when selected; no provider token. +- provider deploy: the Rustup/Cargo variables above except every sccache variable and wrapper, plus + `FASTLY_API_TOKEN`, the operation's enumerated `EDGEZERO_*` variables, validated `app-env`, and + validated `EDGEZERO_MANIFEST` when the caller selected a manifest. +- expected writing, release-request writing, provenance packaging, provenance validation, binary smoke, and self-test: + `PATH`, `HOME`, `TMPDIR` only. `self-test` argv is exactly + `edgezero-provenance-validator self-test --fixtures /usr/local/share/edgezero/provenance-fixtures`. +- provider operations: `PATH`, `HOME`, `TMPDIR`, only the token required by that operation, and only + explicitly named `EDGEZERO_*` variables plus validated `app-env`. Config push also receives its + selected validated overlay names unless `no-env` was selected. + +Non-credential application configuration is explicit rather than ambient. `app-env` is a JSON object +input (default `{}`). Its raw representation is valid UTF-8 and at most 65,536 bytes before parsing, +and duplicate keys are rejected before object construction. It has at most 64 entries and at most +32,768 UTF-8 bytes across names and values. Every value is a JSON string; numbers, booleans, null, +arrays, and objects fail. A name is 1..127 ASCII bytes matching +`[A-Za-z_][A-Za-z0-9_]*`; a value is at most 8,192 UTF-8 bytes and contains no NUL, C0 control, or DEL. +Reserved-name comparison is ASCII-case-insensitive. The exact deny set is: + +- exact names `PATH`, `HOME`, `TMPDIR`, `TMP`, `TEMP`, `PWD`, `OLDPWD`, `SHELL`, `BASH_ENV`, `ENV`, + `CDPATH`, `IFS`, `GLOBIGNORE`, `SHELLOPTS`, `BASHOPTS`, `CC`, `CXX`, `AR`, `AS`, `LD`, `NM`, + `OBJCOPY`, `OBJDUMP`, `RANLIB`, `STRIP`, `CFLAGS`, `CXXFLAGS`, `CPPFLAGS`, `LDFLAGS`, `RUSTFLAGS`, + `RUSTDOCFLAGS`, `RUSTC`, `RUSTDOC`, `ARFLAGS`, `CXXSTDLIB`, `CXXSTDLIB_STATIC`, + `CRATE_CC_NO_DEFAULTS`, `CC_KNOWN_WRAPPER_CUSTOM`, `CC_SHELL_ESCAPED_FLAGS`, + `CC_ENABLE_DEBUG_OUTPUT`, `NUM_JOBS`, `MAKEFLAGS`, `MFLAGS`, `LIBRARY_PATH`, `CPATH`, + `C_INCLUDE_PATH`, `CPLUS_INCLUDE_PATH`, `GCC_EXEC_PREFIX`, `COMPILER_PATH`, `PKG_CONFIG`, + `PKG_CONFIG_PATH`, `PKG_CONFIG_LIBDIR`, and `PKG_CONFIG_SYSROOT_DIR`; +- prefixes `GITHUB_`, `RUNNER_`, `ACTIONS_`, `EDGEZERO_`, `FASTLY_`, `CARGO_`, `RUST_`, `RUSTC_`, + `RUSTDOC_`, `RUSTUP_`, `SCCACHE_`, `CRATE_CC_`, `CXXSTDLIB_`, `LD_`, `DYLD_`, and `BASH_FUNC_`; and +- target- or build-kind-qualified native-tool names matching either + `(CC|CXX|AR|AS|LD|NM|OBJCOPY|OBJDUMP|RANLIB|STRIP|ARFLAGS|CFLAGS|CXXFLAGS|CPPFLAGS|LDFLAGS)_.+` or + `.+_(CC|CXX|AR|AS|LD|NM|OBJCOPY|OBJDUMP|RANLIB|STRIP|ARFLAGS|CFLAGS|CXXFLAGS|CPPFLAGS|LDFLAGS)`. + This includes cc-rs forms such as `CC_`, `_CC`, `HOST_CC`, `TARGET_CC`, and their + target/build-kind flag equivalents. + +The caller is responsible for passing no credential under an otherwise allowed application name; +cross-repository cache disclosure covers compile-time values. Only the exact validated names are +added to operations that execute app code or the app CLI. Config-push's separately derived typed- +config overlay remains subject to its own prefix and `no-env` rules. This explicit input replaces the +parent's ambient workflow-`env` behavior and is a documented adoption migration. + +At target-command entry, caller `PATH`, `RUSTC`, `RUSTDOC`, compiler wrappers, Rust flags, +native-tool variables, ambient application variables, unlisted `EDGEZERO_*` variables, inherited +image variables, and Docker-created variables are absent. Cached and uncached profiles both prove +those variables cannot be reintroduced through `app-env`. + +No Docker CLI or runtime argv element is constructed by inserting an environment value. The action +writes the already validated operation environment to one fresh mode-0600, single-link, action-owned +env file outside every checkout/cache/output root. It contains exactly one `NAME=value` line per +profile name in the same ascending ASCII order as the split string, with no comments, blank lines, +bare names, duplicates, or extra names. The action invokes `docker create --env-file ` with the +fixed `/usr/bin/env` entrypoint and placeholder-only argv above for a uniquely named container, then +deletes and verifies absence of the env file before `docker start --attach`. File serialization rejects +newline and NUL in every name/value; provider inputs whose token format permits either are therefore invalid. For +token-bearing profiles, the final source/output/binary checks occur immediately before this env file +is created. Create/start/attach failure still triggers named-container removal, env-file removal, +private-workspace cleanup, and any required mutation reconciliation. Values may exist in the isolated +runner's Docker container metadata while that container exists, but the launched target receives only +the post-`env -i` profile. No other container receives Docker-socket access, and removal is mandatory +before the action completes. + +Protocol 1's Cargo-config allowlist is empty. Before compilation, the action rejects `.cargo/config`, +`.cargo/config.toml`, and legacy `.cargo/credentials*` at the cwd, every ancestor through `git-root`, +and every enclosing workspace directory copied into `/work/repo`; the fresh action-owned +`CARGO_HOME` must contain none of those files. Cargo environment controls are absent under the closed +environment above. `Cargo.lock` must be a tracked regular file. Path dependencies may resolve +anywhere beneath `git-root` and must not escape it. The parent toolchain resolver still runs, but v1 +requires its result (including an explicit `rust-toolchain` input) to equal the exact toolchain baked +in `image.json`'s image; a mismatch fails before container launch. Alternate toolchains or Cargo +configuration require a separate protocol revision and remain out of scope. + +## 6. Source freezing and provenance + +### 6.1 Freeze and pre-token verification + +The authority checkout must be full, recursive, non-sparse, satisfy the no-filter-or-pinned-LFS +contract in Section 5.2, and start clean: `HEAD` equals `source-revision`, no tracked/index or +untracked modification exists, and every initialized submodule is clean at its recorded gitlink. +Credentials are removed before export, and Copy A or Copy B must pass the trusted export-inventory +comparison before any application command. Before and after app-controlled commands, the authority's +repository id, HEAD, clean state, filter policy, LFS object/materialization state, and recursive +submodule state must remain unchanged. + +Immediately before any token-bearing operation that mounts Copy B, executes repository source, or +consumes its derived package, compare the complete Copy B inventory with the frozen source. This runs +whether or not `app-build` ran and, when it did, runs after that credential-free build: + +- every tracked file's bytes and executable mode, every symlink target, and every gitlink commit must + match, including deletion detection; +- no new path may exist except at or beneath a validated declared output root; +- each output root must canonicalize beneath the repository, must not be `.`, `.git`, or a symlink, + and must not equal or be an ancestor of any tracked path; +- output roots must not overlap each other, and each parent segment must remain confined beneath the + repository; +- every output-root entry must be a directory or regular file owned by the container uid/gid; regular + files must have `nlink == 1` and must not be sparse (`st_blocks * 512 < st_size` fails), and + symlinks, sockets, FIFOs, devices, mounts, and hardlinks fail; +- across all output roots, checked arithmetic permits at most 2,147,483,648 summed regular-file + `st_size` bytes and 100,000 descendants. Existing component and joined-path bounds remain in force. + +The declaration authority is the protected caller's `generated-output-paths` JSON-array input to +`deploy-fastly` (default `[]`). The raw input is valid UTF-8 and at most 65,536 bytes before parsing; +the array has at most 32 unique entries. Each entry is 1..1,024 UTF-8 +bytes, uses `/` separators, is already in repository-relative lexical normal form, and has no empty, +`.`, `..`, NUL, control-character, or backslash component; every component is at most 255 UTF-8 bytes. +The joined absolute host path is at most 4,096 bytes. Each nearest existing parent is resolved before +app code runs, must be a real directory reached without a symlink, and must canonicalize beneath the +repository. Declared roots must be pairwise nonoverlapping, contain no tracked path, and be absent +initially. The action creates each as an empty mode-0700 directory, records its device/inode as action- +owned, and grants Copy B its only repository write access through those directory mounts. It audits +the closed file-type, ownership, hardlink, and confinement rules above after every app-controlled +command and immediately before every token-bearing operation. + +Action-owned target and Cargo-home paths outside the repository are implicit and cannot be +overridden. The current Fastly CLI changes cwd to the resolved project directory containing the +selected `fastly.toml` and writes both `bin/main.wasm` and `pkg/.tar.gz` there. Therefore +`/bin` and `/pkg`, not workspace-root paths, are two +additional implicit output roots. The manifest is resolved before any app process; both roots obey +the same absent-before-create, mount, audit, and ownership contract and callers do not repeat them. +The action keeps all output roots only until the last operation that consumes them, then a trusted +descriptor-relative cleanup routine removes each recorded action-owned tree without following links +and verifies the original checkout and Copy B again. Cleanup runs on success and ordinary failure; +cleanup or post-cleanup verification failure fails the action. A preexisting root fails before any +provider mutation, so cleanup never adopts or deletes caller-owned content. Any application whose +credential-free build writes elsewhere must declare every additional root; the action never infers +permission from an observed mutation. + +This permits declared generated output while preventing a credential-free build step from rewriting +source that a later token-bearing compile would execute. Source-free lifecycle actions such as +healthcheck and rollback do not receive Copy B and do not run this inventory comparison; they verify +artifact/caller/platform identity instead. Config-push verifies repository id, HEAD, cleanliness, and +confined selected files on its read-only checkout. The consumer repeats the checks applicable to each +mounted source profile before and after provider commands. + +### 6.2 Protocol-1 JSON contract + +Protocol 1 has two closed JSON documents. Both are UTF-8 RFC 8785 JCS bytes with no BOM, surrounding +whitespace, or trailing newline. Duplicate object keys are rejected while parsing, before an object +or generic JSON value is constructed. Unknown and missing fields, wrong JSON types, noncanonical JCS +bytes, and values outside the bounds below fail closed. The committed JSON Schema 2020-12 file checks +the local shape; procedural validation enforces canonical bytes, duplicate rejection, cross-field +relationships, and exact expected-versus-observed identity. + +`expected.json` contains exactly the identity the protected caller and local action computed: + +```json +{ + "caller": { + "app-cli-bin": "edgezero", + "app-cli-package": "edgezero-cli", + "app-repo-id": "123456", + "source-revision": "<40-lowercase-hex>", + "workspace-id": "sha256:<64-lowercase-hex>" + }, + "platform": { + "container-ref": "ghcr.io/stackpop/edgezero-build-app-cli@sha256:<64-lowercase-hex>", + "platform-id": "sha256:<64-lowercase-hex>", + "provenance-protocol": 1 + }, + "schema-version": 1 +} +``` + +`app-cli-meta.json` contains exactly the same identity plus observed binary data: + +```json +{ + "abi": { + "interpreter": "/lib64/ld-linux-x86-64.so.2", + "machine": "x86_64", + "needed": ["libc.so.6"] + }, + "app-cli-version": "0.1.0", + "binary-sha256": "sha256:<64-lowercase-hex>", + "binary-size": 123, + "caller": { + "app-cli-bin": "edgezero", + "app-cli-package": "edgezero-cli", + "app-repo-id": "123456", + "source-revision": "<40-lowercase-hex>", + "workspace-id": "sha256:<64-lowercase-hex>" + }, + "platform": { + "container-ref": "ghcr.io/stackpop/edgezero-build-app-cli@sha256:<64-lowercase-hex>", + "platform-id": "sha256:<64-lowercase-hex>", + "provenance-protocol": 1 + }, + "schema-version": 1 +} +``` + +The examples are line-wrapped for review; the wire fixtures contain compact JCS bytes. Field rules +are exact: + +- `schema-version` and `provenance-protocol` are JSON integers equal to `1`. Protocol 1 does not + evolve them independently; an incompatible JSON, archive, or ELF rule requires both to change. +- `app-repo-id` is a string containing the canonical nonzero decimal representation of a `u64`: no + sign and no leading zero. +- `source-revision` is a nonzero full lowercase 40-hex commit SHA. +- `app-cli-package`, `app-cli-bin`, and `app-cli-version` are 1 through 255 UTF-8 bytes, contain no + Unicode control character, and contain neither `/` nor `\\`. The package and binary values must + equal the validated Cargo package and target names; `app-cli-version` must equal that package's + version from the same credential-free locked Cargo metadata result. Before the host mounts the + compiled file at the fixed `/work/input/app-cli` path, it requires the source basename to equal + `app-cli-bin`. +- `workspace-id`, `platform-id`, and `binary-sha256` use + `sha256:<64-lowercase-hex>` and reject the all-zero digest. +- `container-ref` is exactly + `ghcr.io/stackpop/edgezero-build-app-cli@`; no tag or alternate repository is valid. +- `binary-size` is a JSON integer from 1 through 536,870,912 and equals the exact + `app-cli-bin` member payload length. `binary-sha256` equals SHA-256 over those exact payload bytes, + with no header or padding bytes included. +- `abi.machine` is exactly `x86_64`; `abi.interpreter` is either the exact string defined in Section + 6.4 or JSON null; and `abi.needed` preserves every direct `DT_NEEDED` occurrence, including + duplicates, sorted by UTF-8 bytes. Each entry is 1 through 255 bytes and is a basename containing + no slash, backslash, dollar sign, NUL, or control character. + +`expected.json` is at most 16 KiB and `app-cli-meta.json` is at most 64 KiB. The validator compares +the complete `caller`, `platform`, and `schema-version` values for equality. The artifact is a +consistency record, not producer authentication. + +### 6.3 Protocol-1 ustar contract + +The protocol crate is the only archive encoder and decoder. The producer must not construct metadata +with `jq` or archives with system `tar` or a general-purpose tar library. It emits deterministic POSIX +ustar with exactly two regular members in order: literal `app-cli-meta.json`, then literal +`app-cli-bin`. The caller's binary name remains in metadata and is not used as an archive path. + +Every 512-byte header is byte-exact: + +- `name` is the member name followed by NUL bytes to width 100; `prefix`, `linkname`, `uname`, and + `gname` are all NUL bytes; +- `mode` is `0000644\0` for metadata and `0000755\0` for the binary; +- `uid`, `gid`, `devmajor`, and `devminor` are `0000000\0`; `mtime` is `00000000000\0`; +- `size` is eleven lowercase octal digits with leading zeroes followed by NUL; +- `chksum` is six lowercase octal digits with leading zeroes, NUL, and space; its unsigned-byte sum + is computed with all eight checksum bytes replaced by spaces; +- `typeflag` is ASCII `0`, `magic` is `ustar\0`, `version` is `00`, and bytes 500 through 511 are NUL. + +Base-256 numbers, alternate octal padding, embedded-NUL garbage, PAX/GNU extensions, sparse records, +links, special files, extra or duplicate members, renamed paths, and traversal are rejected. Payload +padding through the next 512-byte boundary is all zero. Exactly two all-zero end blocks follow the +binary payload, followed immediately by EOF; extra zero blocks or any trailing byte fail. The sum of +the two logical payload sizes is at most 512 MiB, and the metadata payload is nonempty and at most 64 +KiB. Overflow in any size, offset, padding, or checksum calculation fails before reading or writing. + +### 6.4 Protocol-1 ELF and loader profile + +Protocol 1 intentionally models one conservative immutable runtime rather than general Linux loader +behavior. The primary app binary and every parsed dependency must be ELF64, little-endian, and +`EM_X86_64`; metadata records the machine as `x86_64`. The primary is `ET_EXEC` or `ET_DYN` and may +contain at most one `PT_INTERP`. If it has an interpreter or any `DT_NEEDED`, it must use exactly +`/lib64/ld-linux-x86-64.so.2`; it is treated as static only when both are absent. A resolved library +must be `ET_DYN` and may have its own `DT_NEEDED` entries. It may contain at most one `PT_INTERP`, +whose value, if present, is exactly that same interpreter path. This permits Bookworm's executable +`libc.so.6` metadata; dependency `PT_INTERP` never starts another interpreter or adds a dependency. +The interpreter object itself has no `PT_INTERP`. + +Program headers are the sole loader-visible authority. In addition to the ELF magic, `EI_CLASS` is +`ELFCLASS64`, `EI_DATA` is `ELFDATA2LSB`, `EI_VERSION` and `e_version` are `EV_CURRENT`, `e_ehsize` +is exactly 64, `EI_OSABI` is exactly `ELFOSABI_SYSV` (0) or `ELFOSABI_GNU` (3), `EI_ABIVERSION` is zero, +every `EI_PAD` byte is zero, +`e_flags` is zero, and `e_phentsize` is exactly 56. `e_phnum` is nonzero and is not `PN_XNUM`; extended +program-header numbering is rejected rather than consulting section header zero. ELF and +program-header sizes, counts, offsets, virtual-address mappings, additions, and multiplications are +checked before access. Section headers may be absent and never affect validation; conflicting section +data is ignored because the runtime loader does not use it for this contract. A static primary has no +`PT_DYNAMIC`. Every dynamic primary, +interpreter, and library has exactly one bounded `PT_DYNAMIC`; multiple segments fail. Its entry width +is the ELF64 width, it contains a terminating `DT_NULL`, and every remaining byte in that segment is +zero. Missing termination or a nonzero trailing entry fails. Its complete nonempty range must have +`p_filesz == p_memsz` and map into exactly one readable, file-backed `PT_LOAD`, with +`dynamic.p_offset == load.p_offset + (dynamic.p_vaddr - load.p_vaddr)` under checked arithmetic. +Reject ambiguous mappings and any contradiction between file-offset bytes and the loader-visible +virtual-address bytes, before extraction. Bounds checks on two different tables are not equivalence. + +Protocol 1 defines loader-visible string tags as exactly `DT_NEEDED`, `DT_SONAME`, `DT_RPATH`, +`DT_RUNPATH`, `DT_AUDIT`, `DT_DEPAUDIT`, `DT_CONFIG`, `DT_AUXILIARY`, and `DT_FILTER`. If any of these +tags exists, the table has exactly one `DT_STRTAB` and one `DT_STRSZ`. Their complete nonempty range +must map into exactly one readable `PT_LOAD` file range. Duplicate or conflicting table tags, +unmapped/overlapping ranges, an out-of-range string offset, or a string without NUL before `DT_STRSZ` +fails. `PT_INTERP` follows the same bounded-range rules, contains exactly one trailing NUL, and +contains no interior NUL. Every accepted dynamic string is valid UTF-8 and has no NUL or control +character before its terminator. + +Only `DT_NEEDED` may add a dependency, but `DT_SONAME` can determine which already-loaded object +satisfies it. A primary must not have `DT_SONAME`. For a flat-directory library, an optional +`DT_SONAME` must equal its exact filename basename; it is nonempty, bounded to 255 UTF-8 bytes, and +contains neither `/`, `\\`, nor `$`. The interpreter's optional SONAME is exactly +`ld-linux-x86-64.so.2`. The primary name `app-cli` and interpreter basename are reserved and cannot +name a flat-directory library. No two distinct objects may claim the same filename/SONAME alias. +`DT_RPATH`, `DT_RUNPATH`, `DT_AUDIT`, `DT_DEPAUDIT`, `DT_CONFIG`, `DT_AUXILIARY`, `DT_FILTER`, and +`DT_POSFLAG_1` are always rejected. + +The accepted dynamic-tag vocabulary is numeric and closed; symbolic constants are labels only. It is +exactly core values `0..14`, `16..28`, `30`, and `32..37`; GNU values `0x6ffffef5` +(`DT_GNU_HASH`), `0x6ffffef6` (`DT_TLSDESC_PLT`), `0x6ffffef7` (`DT_TLSDESC_GOT`), `0x6ffffff0` +(`DT_VERSYM`), and `0x6ffffff9..0x6fffffff` (`DT_RELACOUNT` through `DT_VERNEEDNUM`); and x86-64 +values `0x70000000`, `0x70000001`, and `0x70000003` (`DT_X86_64_PLT`, `DT_X86_64_PLTSZ`, and +`DT_X86_64_PLTENT`). Rejected string/acquisition tags above remain rejected even though their values +fall outside this allowlist. Every other value, including future standard, OS-specific, GNU, or +processor-specific tags, fails until a protocol revision explicitly adds it. + +For accepted `DT_FLAGS` (value `30`), no bit outside mask `0x0000001e` may be set; this allows only +`DF_SYMBOLIC`, `DF_TEXTREL`, `DF_BIND_NOW`, and `DF_STATIC_TLS`. For accepted `DT_FLAGS_1` +(`0x6ffffffb`), no bit outside mask `0x5eff976f` may be set. This mask deliberately excludes +`DF_1_LOADFLTR`, `DF_1_ORIGIN`, `DF_1_NODEFLIB`, `DF_1_CONFALT`, `DF_1_ENDFILTEE`, +`DF_1_GLOBAUDIT`, and `DF_1_WEAKFILTER`; every undefined bit also fails. Multiple `DT_FLAGS` or +`DT_FLAGS_1` entries fail rather than combining masks. Apart from repeatable `DT_NEEDED` and the +all-zero bytes after the first `DT_NULL`, every accepted tag appears at most once; duplicate +`DT_SONAME`, table, size, relocation, version, flag, initialization, hash, or x86-64 tags fail. +Accepted non-string tags describe relocation, symbol, version, initialization, or hash tables but do +not participate in protocol identity or dependency discovery. + +Protocol 1 metadata describes the primary's load-time ELF closure: its exact `PT_INTERP` and the +recursively traversed `DT_NEEDED` entries. It does not certify later application-directed `dlopen` or +child-process behavior, and the security model does not represent an application binary as trusted +merely because this structural profile passes. `DT_NEEDED` values containing `/`, `\\`, or `$` fail. +Rejecting `$` prevents glibc's `$ORIGIN`, `${ORIGIN}`, `$LIB`, `${LIB}`, `$PLATFORM`, and +`${PLATFORM}` expansion from making startup resolution differ from the validator's literal lookup. + +The image build copies the complete reviewed x86-64 startup-library closure, except the interpreter, +into the flat directory +`/opt/edgezero/runtime-lib`; that directory contains regular files only and no subdirectory, symlink, +or duplicate basename. A dynamic primary's `PT_INTERP` is exactly +`/lib64/ld-linux-x86-64.so.2`. The validator preserves duplicate direct `DT_NEEDED` values for +metadata, sorts them bytewise, and resolves every dependency basename only as +`/opt/edgezero/runtime-lib/`, with one fixed interpreter-alias exception below. +A missing, escaping, non-regular, multiply linked, or duplicate +candidate fails. Recursive inspection uses a device/inode visited set so dependency cycles terminate, +and every transitive library satisfies this same profile. The interpreter resolves inside the +immutable image root, is a regular `ET_DYN` file with no `PT_INTERP`, and is parsed and recorded as a +member of the validator's visited runtime closure but is not added to the primary's +`abi.needed` metadata array merely because it is the interpreter. A literal `DT_NEEDED` value +`ld-linux-x86-64.so.2` resolves to that same already-validated interpreter object, never a second +flat-directory copy; if directly needed by the primary, the literal value remains in `abi.needed`. +The image verifier checks every flat-directory object's role and aliases. It rejects any reserved +basename, filename/SONAME mismatch, or cross-object alias collision before qualifying the image. + +The final image has no `/etc/ld.so.preload`. Every dynamic `binary-smoke` and provider-action launch +uses the fixed environment-launch protocol through the container runtime's argv/entrypoint API, +without a shell. After constructing the closed environment, `/usr/bin/env` directly executes the +validated interpreter with exact arguments `--inhibit-cache`, `--glibc-hwcaps-mask`, the empty-string +mask, `--library-path`, `/opt/edgezero/runtime-lib`, then `/work/bin/app-cli` and the validated +operation arguments. Thus `/etc/ld.so.cache`, default-directory precedence, and hardware-capability +subdirectories cannot select a different object for the validated startup closure. For a static +primary, `/usr/bin/env` directly executes the binary, which has no `PT_INTERP` or `PT_DYNAMIC`. The +verifier never invokes `ldd` and never infers trust from loader output. Tests include preload presence, cache-only libraries, +hardware-capability alternates, default-directory duplicates, wrong interpreter, missing flat-closure +members, and an application-directed `dlopen` fixture demonstrating that such runtime behavior is +outside the metadata claim rather than silently certified. Before gate/image freeze, compile a real +GNU-target Rust CLI with the selected toolchain and require package, validate, and controlled-loader +smoke against the image's actual libc/interpreter closure. Synthetic fixtures alone are insufficient. +Include GNU/System-V OSABI acceptance, dependency interpreter metadata, SONAME collisions, the fixed +interpreter alias, and contradictory `PT_DYNAMIC` mappings in the regression corpus. The Debian +[Bookworm libc package](https://packages.debian.org/bookworm/amd64/libc6/download) and +[glibc loader](https://sources.debian.org/src/glibc/2.36-9%2Bdeb12u8/elf/dl-load.c/) are reference +evidence; the frozen image's measured bytes remain the acceptance authority. + +### 6.5 Protocol-owner CLI + +The synchronous `edgezero-provenance-validator` binary owns both encoding and validation. It has no +Tokio dependency and never executes an app binary. Its stable credential-free interface is: + +```text +edgezero-provenance-validator write-expected \ + --work-root /work \ + --app-repo-id \ + --source-revision <40-lowercase-hex> \ + --app-cli-package \ + --app-cli-bin \ + --workspace-id sha256:<64-lowercase-hex> \ + --platform-id sha256:<64-lowercase-hex> \ + --provenance-protocol 1 \ + --output /work/expected/expected.json + +edgezero-provenance-validator write-release-request \ + --work-root /work \ + --gate-sha <40-lowercase-hex> \ + --provenance-protocol 1 \ + --release-tag build-container-v \ + --output /work/release/release-request.json + +edgezero-provenance-validator package \ + --work-root /work \ + --binary /work/input/app-cli \ + --schema /usr/local/share/edgezero/provenance.schema.json \ + --expected /work/input/expected.json \ + --app-cli-version \ + --archive /work/packaged/artifact.tar + +edgezero-provenance-validator validate \ + --work-root /work \ + --archive /work/input/artifact.tar \ + --schema /usr/local/share/edgezero/provenance.schema.json \ + --expected /work/input/expected.json \ + --output /work/validated/app-cli + +edgezero-provenance-validator self-test \ + --fixtures /usr/local/share/edgezero/provenance-fixtures +``` + +`--work-root` is required for output-producing commands and must canonicalize to `/work` in the +container. Every caller-selected input and output parent for those commands must canonicalize beneath +it, except the trusted baked schema path, which must be the exact literal path shown above and must +resolve to the image-owned regular file. `self-test` accepts only the exact baked fixture path shown +above, which must resolve to the image-owned fixture directory. `write-expected` accepts only the typed bounded scalars shown above, fixes `schema-version` to +`1`, derives `container-ref` from the fixed repository plus `platform-id`, and atomically publishes +canonical `expected.json`; shell, `jq`, and generic JSON encoders are not supported producers. +`write-release-request` accepts only the exact typed gate SHA, protocol, and release-tag scalars, +fixes the three-key JCS shape from Section 8, and create-new publishes only the literal basename +`release-request.json` in a fresh `/work/release` output directory. It is the sole supported producer +of that file. +`package` validates canonical expected identity, inspects and resolves the source ELF inside the +pinned image, generates canonical metadata, and atomically publishes the deterministic archive. +`validate` performs the inverse checks and atomically publishes exactly one mode-0755 regular output +file. Each output parent is a fresh canonical directory, must be writable and empty, and the final +file must have link count one. + +The implementation writes a create-new temporary sibling, flushes and validates it, then performs a +Linux no-replace atomic rename to the final basename. Handled errors remove the temporary file before +return. SIGKILL, OOM, runner cancellation, or a container timeout may prevent in-process cleanup; the +host therefore removes the entire action-owned output parent after every abnormal/nonzero exit and +verifies it is absent before reporting failure or retrying. On success the host requires exactly the +one final file and no temporary sibling. `self-test` verifies a compiled manifest of exact fixture +paths, SHA-256 values, and expected valid/invalid outcomes; missing, extra, or changed fixtures fail. + +### 6.6 Split validation boundary + +Validation is deliberately two container invocations: + +1. **Trusted parse/extract:** `provenance-validate` runs the baked project-owned validator. It strictly + parses ustar and JSON, validates schema/JCS/duplicates, verifies identity/digest/size/ELF metadata, + proves required libraries resolve in the image, and extracts exactly one binary to + `/work/validated/app-cli`. The host then verifies the output directory contains only that regular, + non-linked file with the expected mode, size, and digest. +2. **Untrusted execution:** `binary-smoke` starts a new hardened container with only the verified + binary mounted read-only and tmpfs. It runs `--help` with no network or credentials and bounded + memory, pids, and wall time. + +The untrusted app binary never shares a writable mount with the parser/extractor. Within one composite +action invocation, the trusted validation step records the host path, digest, size, mode, device, and +inode only in action-private state for its later steps. No public action or reusable-workflow output +exposes a host binary path; the path becomes invalid when the mandatory end-of-action cleanup removes +the private workspace. + +The validator, schema, malformed fixtures, valid golden archive, and all required capabilities must +exist and pass before any image digest can be published. Golden tests cover both JSON documents, +JCS, duplicate keys, schema rejection, byte-exact ustar encoding and parsing, traversal/link/special- +file rejection, header and padding normalization, size limits, ELF inspection, dependency resolution, +exact extraction, and output-directory confinement. Repeated `package` runs over the same inputs must +produce byte-identical archives, and `validate` must accept that golden output. + +## 7. Reusable workflow and action contract + +### 7.1 Reusable workflow + +Inputs: + +- `app-repository`, `app-ref`, `app-repo-id`, `working-directory` (default `.`), `workspace-root`, + `app-cli-package`, `app-cli-bin`, `app-cli-artifact`, `rust-toolchain`; +- `cache` (default `false`), `cache-key-suffix`, `disclosure-acknowledged`, and `timeout-minutes` + (default 30), plus `app-env` (default `{}`); +- optional string `app-checkout-client-id` (default empty string); +- optional secrets `app-checkout-token` and `app-checkout-private-key`. + +The reusable-workflow input schema is exact. `app-repository`, `app-ref`, `app-repo-id`, +`workspace-root`, `app-cli-package`, `app-cli-bin`, `app-cli-artifact`, and `rust-toolchain` are +required strings. +`working-directory`, `cache-key-suffix`, and `app-env` are optional strings with defaults `.`, the +empty string, and `{}`. `cache` and `disclosure-acknowledged` are boolean inputs defaulting to false. +`timeout-minutes` is a number input defaulting to 30 and must be an integer in the range already +defined. Both checkout secrets are declared `required:false`; exactly one complete authentication +mode below is mandatory. No compatibility alias, platform/container input, +provider input, generic environment map, or arbitrary Cargo/action argument is accepted. + +Checkout authentication has exactly two mutually exclusive modes: + +- **token:** nonempty `app-checkout-token`, empty client id, and absent/empty private key. Same-repository + callers may explicitly pass their `GITHUB_TOKEN`; private cross-repository callers supply a token + authorized for the complete materialized source graph; +- **App:** empty checkout token, nonempty `app-checkout-client-id`, and nonempty + `app-checkout-private-key`. The client id is 1..128 ASCII bytes matching `[A-Za-z0-9._-]+` and is + forwarded as the token action's `client-id`, not interpreted as a repository or URL. No partial, + mixed, implicit, or fallback mode is accepted. + +After the unconditional producer bootstrap and verified EdgeZero checkout, but before app fetching, +cache/artifact access, or Docker, a trusted host step validates this mode and parses `app-repository` +as one GitHub `owner/name` with no URL, separator injection, or extra repository. In App mode, the +workflow invokes exact `actions/create-github-app-token@v3.2.0` with that owner, the single app +repository name, `permission-contents:read`, `skip-token-revoke:false`, and literal GitHub API origin +`https://api.github.com`. The private key is supplied only to this host action. The masked token is +passed only to the authority materializer; no token or key is a job/workflow output, container value, +artifact, cache entry, or command-line argument. The materializer verifies repository ID and exact +source identity as usual, removes its credential channel before export, and the token action's post +step revokes an App token on exit. Token mode does not revoke a caller-owned token. + +App mode supports the private app repository and anonymously readable external GitHub submodules; +private submodules outside that repository require token mode with explicit graph-wide read access. +It never silently widens the installation token's repository scope. Consumer jobs mint their own +fresh App tokens after environment approval, immediately before each source-bearing invocation when +an earlier token could have expired. Consumers pass masked same-job token outputs to the identity +and source-bearing actions, not App private keys. No token is transported through `needs` or an +artifact. The [token action's documented lifetime and revocation](https://github.com/actions/create-github-app-token/tree/v3.2.0) +are part of the migration contract. + +The workflow verifies its hosted identity and materializes its resolved action source before it +processes application input. The EdgeZero source checkout, app authority checkout, and Copy A use +three distinct fixed children of a fresh workspace. After token removal, the trusted exporter creates +Copy A from the validated authority checkout under Section 5.2; checkout never writes directly into +Copy A. Every local action/helper is invoked only from the verified EdgeZero checkout at +`job.workflow_sha`; application paths cannot shadow action code. + +`app-cli-artifact` is 1..128 ASCII bytes matching +`[A-Za-z0-9][A-Za-z0-9._-]{0,127}` and must be unique among artifact uploads in the workflow run. It +does not partition the cache. The workflow has no provider inputs. Checkout persists no credentials. + +The producer pins `actions/upload-artifact@v7.0.1`, uploads the one literal `artifact.tar` path with +`archive:true`, `compression-level:0`, `include-hidden-files:false`, `if-no-files-found:error`, +`overwrite:false`, and omits `retention-days` so the repository/organization default applies. It +requires nonempty artifact id/digest outputs. Adopters must configure that default to cover their +maximum approval, retry, and recovery window within GitHub's retention limits; the workflow cannot +extend an expired artifact. Tests preserve the existing producer's no-forced-one-day behavior and +cover a consumer approved more than 24 hours after upload. The ZIP +wrapper and service digest are transport checks, not protocol provenance. Each consumer pins +`actions/download-artifact@v8.0.1`, supplies exact `name`, private destination `path`, current repository and +run id, `merge-multiple:false`, `skip-decompress:false`, and `digest-mismatch:error`, and supplies no +cross-repository token, pattern, or artifact id. It then requires that the destination contains only +one regular single-link `artifact.tar`; archive parsing remains the protocol validator's job. + +Artifact readership is the caller repository's readership, independently of source-repository +visibility and `cache`. Adoption must review this exposure for private-app/public-deployer workflows +even when caching is off; the cache disclosure acknowledgement is not an artifact access control. + +Outputs are `artifact-name`, `action-version`, `action-revision`, plus every +`CallerExpectedIdentity` field: `app-repo-id`, `source-revision`, `app-cli-package`, `app-cli-bin`, +and `workspace-id`. `action-version` is exact `V` or release-fixture `C` parsed from +`job.workflow_ref`, and +`action-revision` is exact `job.workflow_sha`. It does not output `platform-id`, `container-ref`, or +protocol. + +The consumer job invokes public action `stackpop/edgezero/.github/actions/compute-app-cli-identity` +at the same exact `action-version` as the producer and every provider action. Its exact required +string inputs are `action-version`, `app-repository`, `app-ref`, `app-repo-id`, `workspace-root`, +`app-cli-package`, `app-cli-bin`, and `rust-toolchain`; `working-directory` is an optional string +defaulting to `.`, and `app-checkout-token` is a required sensitive string input supplied by the +caller from a GitHub secret or a masked same-job App-token output and masked before use. The action requires +`github.action_repository==stackpop/edgezero` and `github.action_ref==action-version`, independently +materializes and validates a short-lived authority through Section 5.2, removes the credential +channel, exports a private Copy I, computes identity through the closed `metadata-preflight`, and +cleans both paths on every exit. It outputs exactly the five +`CallerExpectedIdentity` fields and no authority path, descriptor, opaque handle, credential, +platform field, or other host state. The job compares every output with the reusable-workflow output +before invoking another EdgeZero action. + +Every later EdgeZero action receives `action-version`, requires the same runner-provided action +repository/ref equality, receives the consumer-verified `CallerExpectedIdentity`, and derives +`PlatformIdentity` from its local action files. Immutable release enforcement binds that version to +the producer's recorded `action-revision`; the consumer does not accept either value as a +caller-authored replacement. A source-bearing action does not trust the identity action's destroyed +authority: it independently repeats materialization and caller-identity verification under its own +action lifetime as defined below. + +Matrix callers use unique artifact names and compare identity per leg. Shared workflow outputs are +not used to aggregate matrix results. + +### 7.2 Provider actions + +Every provider action accepts `app-cli-artifact`, producer `action-version`, and +`CallerExpectedIdentity`, verifies its own repository/version context, derives local +`PlatformIdentity`, downloads exactly the named artifact into an action-private workspace, and runs +the full validation sequence itself. Before `validate`, it invokes the baked validator's +`write-expected` command with the supplied, already consumer-verified caller fields plus its locally +derived platform fields into a fresh action-private expected directory. It never accepts expected +JSON, a platform field, or a host binary path from the caller, and never reuses an expected file from +another action invocation. Before each subsequent container launch, the action rechecks the +validated path is the same confined regular file with the recorded digest, size, mode, and single +link. The private workspace is removed with `if: always()`. + +Every provider action also accepts the validated `app-env` JSON object (default `{}`); no provider +action inherits ambient application variables. + +The source-bearing actions are exactly `deploy-fastly` and `config-push-fastly`. In addition to the +common inputs, both require string inputs `app-repository`, `app-ref`, `app-repo-id`, +`workspace-root`, `app-cli-package`, `app-cli-bin`, and `rust-toolchain`, accept optional string +`working-directory` defaulting to `.`, and require sensitive string input `app-checkout-token`, which +the caller supplies from a GitHub secret or a masked same-job App-token output and the action masks +before use. Each uses those inputs to +independently materialize an action-local authority, removes the checkout credential channel before +any application code, provider-token creation, or provider-token injection, and recomputes all five +caller-identity fields. A mismatch with supplied `CallerExpectedIdentity` fails before artifact +execution or provider mutation. Cleanup removes and verifies the action-local authority and any Copy +B on every exit. No source-free action accepts repository-materialization inputs or +`app-checkout-token`. + +`deploy-fastly` additionally accepts `generated-output-paths`. It reuses one validated +binary for its `active-version`, optional credential-free `app-build`, and provider deploy operations +within that invocation. `active-version-fastly` is also a source-free action with inputs +`app-cli-artifact`, `CallerExpectedIdentity`, `fastly-service-id`, and `fastly-api-token`; it outputs +`version`, where an empty value is success only for a confirmed first production deploy. + +`validate-app-cli-provenance`, `deploy-fastly`, `active-version-fastly`, `healthcheck-fastly`, +`rollback-fastly`, and `config-push-fastly` all apply this handoff. An identity or path mismatch fails +before app code or provider mutation. `validate-app-cli-provenance` is validation-only and has no +outputs; success means that its independent download, parse/extract, host check, and binary smoke all +completed before its private workspace was removed. + +`config-push-fastly` validates and confines the selected repository/manifest/config file and derives +the exact named app-config environment overlay before container launch. Inline config is written to +one fresh host file and mounted read-only. `no-env` exposes no app-config overlay. + +Mutation actions publish `mutation-attempted` host-side before launching the mutating CLI. Named +containers receive bounded signal forwarding and post-cancellation reconciliation as specified by the +parent deploy contract. + +## 8. Image publication and compatibility + +Protocol 1 selects the official `rust:1.95.0-slim-bookworm` image and pins its `linux/amd64` leaf +manifest, not its multi-platform index. The digest resolved from the official registry on 2026-08-31 +is `sha256:6f9e63259f12e1e599296f5ecfed2bae46de4af0ee0525dd8b89c046e236d5c5`; implementation must +re-resolve and compare it immediately before committing the Dockerfile. The exact sccache asset is +`sccache-v0.10.0-x86_64-unknown-linux-musl.tar.gz` from the upstream v0.10.0 release, with upstream +checksum `1fbb35e135660d04a2d5e42b59c7874d39b3deb17de56330b25b713ec59f849b`. The v0.10.0 release has no +GNU Linux client asset; the static musl client is the reviewed Linux x86-64 artifact. Changing either +base digest or tool asset requires a new source revision and image digest. + +The validator lives at `.github/tools/edgezero-provenance-validator` as a self-contained Cargo +workspace: its package manifest contains its own `[workspace]`, it has its own committed lockfile, +and it has no path dependency outside that directory. The staged Docker context excludes the root +workspace `Cargo.toml` and `Cargo.lock`; it includes the complete validator directory plus the other +explicit image assets only. The Dockerfile invokes one exact +`cargo build --locked --release --manifest-path +.github/tools/edgezero-provenance-validator/Cargo.toml`. Gate tests run +`cargo metadata --locked --manifest-path` inside the staged context and then the exact Docker build, +so a manifest that names an absent workspace member or path dependency cannot pass. + +`image.json` is a reviewed record with exactly these typed fields: + +```json +{ + "repository": "ghcr.io/stackpop/edgezero-build-app-cli", + "tag": "build-container-v1", + "digest": "sha256:<64-lowercase-hex>", + "image-source-revision": "<40-lowercase-hex>", + "provenance-protocol": 1 +} +``` + +`tag` is informational. Runtime pulls use only `repository@digest`. + +The pin PR changes exactly `image.json` and canonical `image-release-evidence.json` together. The +latter is UTF-8 RFC 8785 JCS with no BOM, surrounding whitespace, or trailing newline. It contains +the following single data line; the Markdown fence line break is not file content: + +```text +{"approval-challenge":"<64-lowercase-hex>","approver-login":"","image-digest":"sha256:<64-lowercase-hex>","release-tag":"build-container-v","reviewed-at":"","run-attempt":"","run-id":"","schema-version":1,"screenshot-sha256":"sha256:<64-lowercase-hex>","source-revision":"<40-lowercase-hex>"} +``` + +Its values equal the current approval record and `image.json`; run identifiers are strings to avoid +JSON number precision loss. A gate-owned typed writer is the sole producer. Unknown/duplicate/missing +keys, wrong types/order/JCS bytes, invalid bounds, or cross-file mismatch fail. Runtime actions do not +read this evidence file. Archival pin validation requires exact timestamp grammar and a valid calendar +instant but does not compare `reviewed-at` with its later wall clock. The successful publisher approval +step is the durable proof that the review was fresh when it authorized mutation. + +The typed pair writer is invoked exactly as: + +```text +write-image-release-record.sh --image-path --evidence-path --repository ghcr.io/stackpop/edgezero-build-app-cli --release-tag --image-digest --source-revision --provenance-protocol 1 --approval-challenge <64-lowercase-hex> --approver-login --reviewed-at --run-attempt --run-id --screenshot-sha256 +``` + +The two absent output paths are distinct direct children of one canonical mode-0700 private directory +outside every Git repository. The writer accepts only the typed scalars above, invokes the gate-owned +pair validator, publishes both mode-0644 files atomically without replacement, emits no stdout on +success, and removes a partial pair on failure. `image.json` has exactly these compact bytes: + +```text +{"digest":"","image-source-revision":"","provenance-protocol":1,"repository":"ghcr.io/stackpop/edgezero-build-app-cli","tag":""} +``` + +The evidence output has exactly the ten-field JCS bytes shown above. The updater copies those verified +bytes into its private Git worktree; the pair writer itself never writes inside a repository. + +The rollout has five relevant commits and two action-release tags: + +- `G` is the protected gate and image-source baseline. It contains the protocol validator, schema, + golden/malformed fixtures, image verifier, fail-closed classifier, release-policy verifier, + approval gate, pin-PR updater, publisher contract checker, publisher evidence verifier, exact + Dockerfile, root `.dockerignore`, + canonical image-context manifest, and both `.github/workflows/build-container-ci.yml` and + `.github/workflows/publish-build-container.yml`, plus the gate-rotation lock workflow and verifier. + Repository variable + `EDGEZERO_BUILD_CONTAINER_GATE_SHA` equals `G`. Organization ruleset + `edgezero-build-container-required-workflow` has target `branch`, active enforcement, no bypass + actors, conditions containing repository id `[]` and ref-name include + `["refs/heads/main"]` with no excludes, and exactly one `workflows` rule. That rule has + `do_not_enforce_on_create=false` and exactly one descriptor with the EdgeZero repository id, the + literal workflow path, full SHA `G`, and no `ref`. The workflow checks out `G` separately from + candidate source and executes only gate code from `G`; candidate files are data under test. A + change to a gate-owned or image-context path must land as a separately reviewed new `G`, and the + ruleset must be updated to that SHA, before any dependent release request. +- `S` is the full release-source commit. It descends from `G`, changes exactly the canonical + `release-request.json` described below, and leaves every gate-owned and image-context byte equal to + `G`. The publisher uses a trusted `G` helper to construct a fresh Docker context containing only the + manifested paths copied from the verified `G` checkout; it never builds a candidate Dockerfile or + admits an unmanifested repository path. The image has OCI label + `org.opencontainers.image.revision=S`, + `org.opencontainers.image.source=https://github.com/stackpop/edgezero`, and a protocol label + matching the baked validator. The final image overrides inherited source/revision labels, and + verification requires all three exact values. +- `B` is the baseline revision created after the pin PR commits the verified digest and `S` to + `image.json` plus its bound `image-release-evidence.json`, and permanent pin CI is enabled. +- `H` is the protected-main commit proposed as the final action revision. It contains the unchanged + reviewed pin plus the cache, provenance, launcher, and consumer implementation. The prepublication + adoption documents still contain only the explicit action-version placeholder allowed by the + bootstrap documentation gate. +- `C` is a unique canonical exact patch-version tag published as an immutable + GitHub Release at `H` with `prerelease:true`. Its tag has no prerelease suffix. The complete local + suite runs from a clean detached `H`; the complete hosted cross-repository/provider suite invokes + `C`. A failure leaves `C` immutable and requires a new commit plus a newly selected unused `C`. +- `P` is defined as `H` only after the exact-`H` local suite and complete hosted suite through `C` + pass. Before that point, no text may call `H` final revision `P`. +- `V` is a canonical exact stable action version `v..`, distinct from `C`, + selected only after `H` qualifies as `P`. An immutable stable release `V` is published at `P`, then + a final hosted identity and consumer smoke uses literal `V`. The release API and remote refs must + both resolve `C` and `V` to `P`. +- `R` is the later protected-main documentation revision. It changes only tracked Markdown adoption + surfaces and `docs/.edgezero-action-release.json`, replacing the bootstrap placeholder with literal + `V` and activating the documentation gate's released state. It does not alter action/workflow code + and is not a new action revision; published consumers pin `V`, never `P`, `C`, a major/minor tag, or + a branch. + +`docs/.edgezero-action-release.json` is absent through `P`. Revision `R` adds it as UTF-8 RFC 8785 +JCS with no BOM, surrounding whitespace, or trailing newline. It contains the following single data +line; the Markdown fence line break is not file content: + +```text +{"action-revision":"

","action-version":"","schema-version":1} +``` + +The action revision and version obey their exact grammars and must match the already published stable +immutable release and peeled remote tag. Unknown, duplicate, missing, reordered, or mistyped fields +fail. + +`release-request.json` is UTF-8 RFC 8785 JCS with no BOM, surrounding whitespace, or trailing +newline. It contains the following single data line; the Markdown fence line break is not file +content: + +```text +{"gate-sha":"","provenance-protocol":1,"release-tag":"build-container-v"} +``` + +`gate-sha` is lowercase full hex and must equal the active gate variable and required-workflow SHA; +the decimal has no sign or leading zero. The source PR changes this file and no other path. Its tag is +unused until the reviewed operator creates that exact protected tag at `S`. A changed Dockerfile, +`.dockerignore`, image manifest, validator input, workspace lockfile, or other repository image-context +byte is a gate update, never an ordinary `S` candidate. + +The reviewed operator creates this file using a local image built solely from the canonical context +staged from clean `G`. The build records local image/config identity `L` from BuildKit's `--iidfile`, +and the gate-owned verifier requires `L` to be a leaf `linux/amd64` image with revision label `G`, the +expected protocol, and the complete validator/toolchain contract. The operator then invokes +`write-release-request` by immutable local identity `L`, with no network, credentials, repository +mount, or tag lookup and with only the closed `release-request-write` profile. This local bootstrap +image is not published and is not digest `D`; no already-published or pinned `G` image is assumed. + +There is no literal same-commit requirement between image source and pin record. Compatibility is +enforced by digest, image labels, and exact `provenance-protocol`. Changing the validator/archive +contract requires a protocol bump and a new image before the actions using that protocol are pinned. + +The gate owns a canonical path manifest and `.github/CODEOWNERS`; the latter assigns every manifested +path and itself to `@stackpop/edgezero-build-container-gate-reviewers`. The no-bypass default-branch +ruleset requires code-owner review, at least two approving reviews, dismissal of stale approvals, and +the merge queue. A gate-update PR changes only paths in the union of the old and candidate canonical +manifests. Old `G` validates the candidate manifest's canonical sorted form, validates candidate +`CODEOWNERS` coverage, classifies the change as `mode=gate-update`, executes only old-`G` static and +subject-data checks, and never runs candidate gate code. A mixed gate/non-gate change fails. If that PR +passes the old gate and required human reviews, merging it creates candidate `G'`, not `S`. Until +activation, the protected base's manifested bytes differ from active `G`, so every ordinary candidate +and release preflight fails. + +Protected-main dispatches distinguish workflow snapshot `Q` from active gate `G`. Dispatch uses only +`ref:"main"` in `stackpop/edgezero`, never a raw SHA or caller-selected branch. Require the exact +workflow repository/path at `refs/heads/main`, boolean `github.ref_protected:true`, and +`github.sha==github.workflow_sha==Q`. Both gate pointers must equal `G`; `G` is an ancestor of or +equal to `Q`; and every gate-owned/image-context path, including the manifests, has identical +presence, Git mode, and bytes at `Q` and `G`. Non-gate differences are permitted. The operator proves +this before dispatch and the trusted helper repeats it against the actual context-bound `Q`, read +only as subject data; every repository helper executes from the separately verified clean `G` +checkout. Before approving a secret-bearing dispatch, the independent reviewer also verifies its +actual run head and gate-owned workflow bytes. A stale/mismatched snapshot is rejected before +approval rather than replaced with a later main SHA. Neither range consumer runs in dispatch mode. + +To activate `G'`, dispatch the gate-owned rotation-lock workflow under that contract with both gate +pointers still equal old `G`. Call its immutable dispatch head `Q_d`; it may already include `S`, +`B`, or other non-gate commits. Both lock jobs capture and retain this old `G` and `Q_d`; the waiting +approval job never reselects helper code from a gate variable changed during activation. It uses the exact repository-global +`edgezero-build-container-publication` concurrency group with `cancel-in-progress:false` and +`queue:max`; after all older publishers finish, its unprivileged acquire job records its run id and its +second job waits for independent approval on environment `build-container-gate-rotation-lock`. The +waiting workflow holds the concurrency group. Before first use, a live fixture must prove that a +publisher dispatched behind this waiting job remains pending and starts no build/push step. + +While the lock is held, the operator verifies no older publication is active or pending ahead of it, +sets repository variable `EDGEZERO_BUILD_CONTAINER_RELEASE_STATE` from `enabled` to +`disabled::`, removes the release environment's tag policy, and verifies release is +disabled. The operator then merges the gate update through the one-entry queue. From clean detached +`G'`, run the full gate suite and require the generic post-merge main-push assertion at `Q=G'`; update +the disabled marker to `disabled:::`; then update both +`EDGEZERO_BUILD_CONTAINER_GATE_SHA` and the organization required-workflow descriptor SHA to `G'`. +Either intermediate mismatch fails all required runs. Verify the base's complete manifested tree +equals `G'`, restore the sole tag policy, produce independently reviewed policy-restoration evidence, +and set release state back to exact `enabled`. Only then may a reviewer enter the canonical rotation +evidence comment and approve the waiting lock job, whose captured old-`G` helper verifies its own +run/context, the final protected head `Q_f`, and the authenticated policy receipt defined below before +releasing concurrency. Require `Q_d` ancestor-or-equal to `Q_f`; final manifested bytes and both +pointers must identify activated `G'` or restored old `G` as appropriate. The comment's `head-sha` +is `Q_f`, not `Q_d`; neither final main nor a rollback commit must equal the old gate commit. No +ruleset bypass is used for a gate update. + +If activation fails before both pointers and all post-activation checks agree, keep release disabled +and restore both pointers to old `G` while the lock remains held. Because the base then still contains +`G'`, ordinary work remains blocked. Old `G` has a distinct `mode=gate-rollback`: both active pointers +must equal old `G` and release must remain disabled; the current base must be exactly the failed `G'` +tree that old `G` validates as a canonical gate update; the proposed head's complete manifested tree +must be byte-identical to old `G`; changed paths must be confined to the union of the `G'` and old-`G` +manifests; and no release request, pin record, or non-gate path may change. Merge that separately +reviewed rollback through the one-entry queue, require generic main-push evidence at the resulting +head `Q_f`, verify the base's manifested tree and both pointers equal old `G`, and only then restore +release policy. Restoration checks presence, mode, and bytes over the old/new manifest union, +including absence of paths introduced only by `G'`; unrelated release/pin and application paths are +preserved. +If old `G` cannot validate either the current `G'` tree or the exact restoration, recovery is a manual +trust-root operation requiring the same independent review as bootstrap, and release remains disabled +throughout. No mixed `{variable, descriptor, base manifest}` state is a degraded operating mode. +If manual recovery cannot finish before the lock run expires, leave release state disabled and the tag +policy absent before canceling it; every later publisher must acquire concurrency and fail closed on +the disabled state before image build or push. + +`build-container-gate-rotation-lock` has no secret or variable and no enabled GitHub App custom +protection rule. It permits only protected `main`, disables administrator bypass, requires a nonempty +reviewer set with self-review prevention, and is referenced with `deployment:false`. Its one approval +comment has exactly these two LF-separated lines, with no trailing LF: + +```text +edgezero-gate-rotation-v1 {"evidence-sha256":"sha256:<64-lowercase-hex>","head-sha":"","lock-run-id":"","new-gate-sha":"","old-gate-sha":"","result":"activated|rolled-back","reviewed-at":""} +edgezero-gate-rotation-policy-v1 {"audited-at":"","dispatch-sha":"","gate-sha":"","head-sha":"","lock-run-attempt":"","lock-run-id":"","policy-sha256":"sha256:<64-lowercase-hex>","release-state":"enabled","required-workflow-sha":""} +``` + +Both compact JSON objects use the shown key order/string types and no extra whitespace. The first +line's `evidence-sha256` hashes the exact UTF-8 JSON object bytes on the second line, excluding its +prefix and any newline. `policy-sha256` hashes the complete canonical local policy-audit evidence, +which the operator attaches to the gate-update or rollback PR for independent review. The local +auditor alone reads repository variables and organization/environment policy through the existing +policy-token allowlist; that administrative PAT remains local-only. Repository variables are not +anonymous public API data, and the waiting job must not treat its `vars` context as a live reread. + +After final restoration, the local auditor repeats every policy/pointer check, binds the full audit +to `{Q_d,Q_f,old-G,new-G,result,lock-run-id,lock-run-attempt}`, and emits the receipt only on success. +This rotation audit deliberately does not require completion of its own still-waiting lock run; it +is not yet the publisher's prerequisite record. The independent reviewer verifies the attached audit, +recomputes both digests, verifies restored tag policy and enabled state, and supplies the two-line +receipt through GitHub's authenticated environment-approval comment, not through a caller input, +artifact, or arbitrary URL. The reviewer differs from the operator and lock-run actor. Private-policy +observations are authenticated reviewer evidence, not live API observations made by the waiting +helper; this does not claim an administrative policy lock. + +Only after the waiting helper succeeds and the rotation run reaches completed/success can the local +auditor fetch that exact run/attempt and emit refreshed publisher prerequisite evidence. Its +`gate-sha` is the observed repository gate variable; `required-workflow-sha` is the observed descriptor +SHA; both equal `G'` for activation or old `G` for rollback. A final independent review verifies the +completed-lock evidence and then writes the inert record with `source-revision:null` through the +single-variable local writer defined below. The waiting-run receipt is never used as its own +completion proof. + +The waiting helper has only `actions:read` and `contents:read`; its credential wrapper permits only +the exact no-redirect GETs for its current run, that run's complete non-paginated approval history, +and `/repos/stackpop/edgezero/git/ref/heads/main`, with the fixed REST headers below. It requires the +exact rotation workflow/dispatch identity at captured `Q_d`, current run id/attempt, and exactly one +current-run protocol comment with an approved `build-container-gate-rotation-lock` review. API run +ids/attempts must be positive in-range JSON integers, canonically rendered for comparison with +receipt/context strings. The helper verifies the receipt digest and all bound identities, requires +both `head-sha` fields to equal the API's exact current main ref `Q_f`, and independently checks +`Q_d` ancestry and the final manifested tree from exact-SHA Git objects. It never calls variable, +environment-policy, or organization-ruleset APIs. `audited-at` and `reviewed-at` use the release +approval's exact time grammar; require run creation <= audit <= review <= check time, with both +audit and review at most 15 minutes old. Missing, duplicate, rejected, malformed, stale, mismatched, +or earlier-attempt evidence fails. Any observed head/policy/pointer change requires a new local audit +and approval; superseded/rejected protocol comments require a fresh lock dispatch rather than an +ambiguous second current-run record. An activated result requires the final manifested tree and +receipt pointers at `G'`; rollback requires the exact old-`G` restoration. Both require the receipt's +literal enabled state and the independently reviewed restored tag policy before concurrency releases. +The waiting job performs this check in exactly one unconditional, success-gating step named +`assert-exact-rotation-context`, with no continuation or failure masking. A failed, canceled, or +timed-out workflow releases GitHub concurrency regardless of this contract; therefore publishers +also enforce the completed-rotation prerequisite below. Restoring `enabled` alone cannot authorize +publication after a failed check. + +The dedicated repository ruleset `edgezero-build-container-main` has target `branch`, enforcement +`active`, no bypass actors, and ref-name conditions including exactly `refs/heads/main` with an empty +exclude list. Its rules are exactly: + +- `pull_request` with `allowed_merge_methods:["squash"]`, + `dismiss_stale_reviews_on_push:true`, `require_code_owner_review:true`, + `require_last_push_approval:true`, `required_approving_review_count:2`, and + `required_review_thread_resolution:true`; and +- `merge_queue` with `check_response_timeout_minutes:60`, `grouping_strategy:"ALLGREEN"`, + `max_entries_to_build:1`, `max_entries_to_merge:1`, `merge_method:"SQUASH"`, + `min_entries_to_merge:1`, and `min_entries_to_merge_wait_minutes:0`. + +The one-entry build and merge limits prevent a passing merge-group result from authorizing a +different batched tree. Missing, extra, or changed semantic rule fields fail the prerequisite audit. + +The two release-tag repository rulesets both have source type `Repository`, source +`stackpop/edgezero`, target `tag`, active enforcement, and ref-name conditions including exactly +`refs/tags/build-container-v*` with an empty exclude list. Ruleset +`edgezero-build-container-tag-immutability` has no bypass actors and exactly `update` with +`update_allows_fetch_and_merge:false` plus `deletion` rules. Ruleset +`edgezero-build-container-tag-creation` has exactly one bypass actor, the numeric team id for +`edgezero-build-container-releasers` with type `Team` and mode `always`, and exactly one `creation` +rule. It has no update or deletion rule. Missing, extra, defaulted, or changed source, target, +condition, actor, mode, parameter, or rule fails the prerequisite audit. + +Two additional repository rulesets apply the same creation/immutability split to action releases. +Both have source type `Repository`, source `stackpop/edgezero`, target `tag`, active enforcement, and +ref-name conditions including exactly `refs/tags/v*` with no excludes. +`edgezero-action-version-tag-immutability` has no bypass actors and exactly `update` with +`update_allows_fetch_and_merge:false` plus `deletion`; `edgezero-action-version-tag-creation` has only +the same reviewed releaser Team actor in `always` mode and exactly `creation`. Repository immutable +releases are enabled. The broader `v*` ruleset protects distinct exact patch versions `C` and `V`, +while the release procedure separately enforces their canonical grammars, absence, release states, +and commit targets. + +The action-release operator uses a short-lived fine-grained personal access token selected only for +repository `stackpop/edgezero`, expiring within 24 hours, with exactly repository `Contents:write` +and `Workflows:write`, implicit metadata read, and organization `Members:read`; every other repository +or organization grant is disabled. A classic PAT, installation token, `GITHUB_TOKEN`, broader +repository selection, extra grant, or token shared with Actions is invalid. The operator preserves +the fine-grained token settings as review evidence, supplies the token to a local non-logging helper +through a private descriptor rather than argv or environment, and destroys it after release. + +That helper permits no redirect and only requests with +`Accept: application/vnd.github+json`, `X-GitHub-Api-Version: 2026-03-10`, +`User-Agent: edgezero-action-release/1`, and its authorization header: +`GET /user`, `GET /organizations//team//memberships/`, +`POST /repos/stackpop/edgezero/releases`, `PATCH /repos/stackpop/edgezero/releases/`, and +`GET /repos/stackpop/edgezero/releases/`. The membership response must be active. POST +creates a draft with exact tag, full target commit, no assets, and required prerelease boolean; PATCH +changes only `draft` to false. GET verifies author, tag, target, draft/prerelease/immutable state, and +release id. Any other method, host, path, query, body field, redirect, credential type, or response +shape fails. Anonymous remote-ref resolution and release-attestation verification are separate +read-only checks. + +Repository ruleset `edgezero-build-container-pin-branches` has source type `Repository`, source +`stackpop/edgezero`, target `branch`, active enforcement, conditions including exactly +`refs/heads/edgezero-build-container-pin/*` with no excludes, and exactly one bypass actor: the +dedicated publisher App's numeric integration id, type `Integration`, mode `always`. Its rules are +exactly `creation`, `update` with `update_allows_fetch_and_merge:false`, and `deletion`. Thus only that +App can create, move, or delete a canonical pin branch. A pin branch is exactly +`edgezero-build-container-pin/` and its PR title is exactly +`chore(actions): pin build container for `; any different head repository, branch, author, +integration, title, or changed-path set fails required pin CI. + +Publication order is: + +1. Land `G`, then configure and verify the exact active organization ruleset and its sole + required-workflow descriptor + `{repository_id:,path:".github/workflows/build-container-ci.yml",sha:G}` + and an active default-branch ruleset that requires the merge queue with no bypass actor. Configure + the protected release and rotation-lock environments, split tag-creation/immutability rulesets, + dedicated GitHub App, publisher App/bot identity repository variables, and repository permissions + while release state is absent or not `enabled`. After every control and descriptor readback passes, + manually create and read back the inert bootstrap prerequisite record, then set release state to + exact `enabled` last. Prove the rotation workflow holds publication concurrency before the first + gate update. The one-time + bootstrap of `G` is explicitly a human-reviewed trust-root operation; + candidate-controlled checks are not represented as independent proof of `G`. +2. Open the isolated `release-request.json` candidate, run it through the organization-required + workflow from `G`, complete the credential smoke, and merge it only through the verified merge + queue. The resulting default-branch commit is `S`. Before tagging, require the repository-local + workflow's latest-attempt run API record to have event `push`, path + `.github/workflows/build-container-ci.yml`, and `head_sha=S`. Require both stable jobs to report + `head_sha=S`, success, and exactly one successful step named `assert-exact-main-push-context`. That + immutable step invokes only the `G` helper and internally asserts `github.ref==refs/heads/main`, + `github.event.after==github.sha==github.workflow_sha==S`, and active gate SHA `G`; the REST API + does not expose those context fields, so the step result is the external evidence. A PR or + `merge_group` result cannot substitute for this exact post-merge run. The first package may not + exist yet; its public-visibility gate occurs after its first push and before a pin PR. +3. The publisher verifies `S`'s manifested bytes equal `G`, constructs a fresh context solely from + the clean `G` checkout and canonical context manifest, builds with the gate-owned Dockerfile, pushes + by protected release tag, and captures digest `D` from BuildKit's metadata output. +4. Verify `D` is a leaf linux/amd64 image, labels identify `S` and protocol, exact tool versions and + target are installed, validator capability tests pass, and runtime works read-only/non-root. +5. Ensure the GHCR package is public and linked to `stackpop/edgezero`, then prove an anonymous pull + and smoke by `D`. The first release stops here until an operator changes package visibility, reruns + the `G` preflight in package-present mode, attaches its evidence, and reruns the same tag. +6. Open or update an idempotent App-authored PR on the exact protected pin branch, committing + `image.json = {D, S, protocol}` plus its canonical run/approval evidence record. Required pin CI + verifies App/branch/run/approval origin and re-verifies the image before merge; merging the passing + PR creates baseline `B`. +7. Select currently absent canonical patch version `C` for candidate qualification. Implement the + remaining executable plans on top of `B`; keep the four prepublication adoption documents at the + exact bootstrap placeholder. Merge the executable candidate through the queue and record the + resulting protected-main commit as candidate `H`. +8. From a clean detached checkout of exact `H`, rerun the complete pin, actionlint, zizmor, schema, + fixture, container, Rust, documentation, and contract suites. A locally authenticated active member + of the exact releaser team authorized by the creation ruleset creates a draft for `C` with exact + target `H`, no assets, and `prerelease:true`, then publishes it. Require release API fields + `draft:false`, `prerelease:true`, and `immutable:true`, remote peeled ref `C==H`, authenticated + operator identity equal to release author, and recorded team membership plus release attestation. + Run the complete hosted cross-repository/provider suite with every EdgeZero workflow/action ref + equal to literal `C`. Only after both suites pass, designate `H` as `P`, select a distinct currently + absent stable version `V`, and repeat the same actor/draft/publish/evidence procedure for `V` at + `P`, with `prerelease:false`. Verify API and remote-ref resolution and run a final hosted identity/ + consumer smoke with literal `V`. A candidate failure requires a new commit and unused `C`; no `V` + has yet been selected. A post-publication verification failure cannot retarget `V` and is corrected by a new patch + release. Deletion of the GitHub Release object, or mutation of its title, notes, prerelease, or + latest metadata by an actor with sufficient repository privilege, remains an accepted availability/ + discovery risk; the no-bypass tag rules still prevent tag deletion or retargeting, and GitHub's + immutable-release tombstone prevents tag-name reuse. Preserve both generated release attestations + in the release evidence and verify release existence and required state during the final audit. +9. Only after the literal-`V` smoke passes, open documentation-only revision `R`. It adds the exact + action-release record bound to `{V,P}`, replaces every bootstrap placeholder in tracked Markdown + with literal `V`, and changes no executable, workflow, action metadata, gate-owned, or non-document + path. The already active dual-state gate verifies the stable immutable release and remote ref, + applies final-mode documentation checks, and rejects deletion/downgrade of the release record. + Merge `R` through the queue, run the final documentation build/pin scan on protected main, and + record `R`; no action release or action revision changes at this step. + +Gate baseline `G` contains `.github/workflows/build-container-ci.yml`. The active organization ruleset +uses its exact repository id, path, and SHA `G`; it uses neither a branch nor a candidate-controlled +ref. A repository PR cannot substitute its own workflow or helper implementation. The workflow +supports `pull_request`, `merge_group`, protected-default-branch `push`, and a manual +`workflow_dispatch` credential-smoke mode, with no workflow-level path filter. It exposes two stable +required job names on every candidate and grants only workflow-level `contents:read`, `actions:read`, +and `pull-requests:read`; neither job references an environment or mutation credential: + +- `build-container-local` computes the documented image-input path set. It builds and smokes the local + image when relevant and otherwise runs an explicit successful not-applicable step. +- `build-container-pin` detects every add, change, or deletion of either pin-record file. When + relevant, both must exist and be the only changed paths; it validates both structures and their + equality, verifies the exact pin branch/PR title/head repository and dedicated App bot id/login, + validates the bound publisher run/attempt and approval evidence described below, anonymously pulls + the exact digest, and runs the complete published-image verifier. Otherwise it explicitly succeeds + as not applicable. + +Each organization-required job parses `github.workflow_ref` and requires its repository and path to be +exactly `stackpop/edgezero/.github/workflows/build-container-ci.yml`; it also requires +`github.workflow_sha` to equal repository variable `EDGEZERO_BUILD_CONTAINER_GATE_SHA`. The variable +is a full SHA and equals `G` and the ruleset descriptor SHA. On a protected-default-branch push, each +stable job instead requires exactly one successful `assert-exact-main-push-context` step whose trusted +helper proves `github.ref==refs/heads/main` and `github.event.after==github.sha==github.workflow_sha==Q`; +the job still checks out gate code at the variable's active `G`. Each job checks out +`G` and the candidate revision into distinct roots and invokes only the protected gate's classifier and +verification driver. Candidate scripts are never sourced or +executed as gate authority. Each job performs its own fail-closed change classification from the +candidate base and head so a failed shared classifier cannot skip a required job. The local-image set +is not a hand-maintained approximation of a root context: it is the release request plus the complete +canonical image-context manifest, and every manifested image path is also gate-owned. Classification +output is exactly two lines in fixed order: `mode=ordinary`, `mode=gate-update`, or +`mode=gate-rollback`, followed by `relevant=true` or `relevant=false`. Gate-update and gate-rollback +always report relevant true. An unconditional terminal assertion rejects missing, duplicate, or +malformed output and proves exactly one of the relevant ordinary, gate-update, gate-rollback, or not- +applicable branches ran; an invalid classifier can never make all conditional paths disappear behind +a green job. +Contract tests pin pull-request, merge-group, and push ranges, protected workflow/helper provenance, +event triggers, job names, context-manifest closure, deletion handling, output validation, and +explicit no-op behavior. The existing path-filtered +`deploy-action.yml` remains separate. Thus required checks always materialize without running Docker +on unrelated changes, and no later syntactically valid pin can bypass image, platform, label, +protocol, public-access, target, validator, or exact-version checks. + +Before classification, each stable job enumerates `T`'s complete tree from the full subject repository +and selects every direct child of `.github/workflows/` whose name ends in `.yml` or `.yaml`, plus every +file anywhere in the tree whose basename is exactly `action.yml` or `action.yaml`. The trusted driver +requires each selected entry to be a regular blob with an unambiguous path using only +`[A-Za-z0-9._/+-]`, extracts its +exact bytes into a fresh directory outside both repositories, and invokes only `G`'s +`.github/actions/deploy-core/tests/check-action-pins.sh` with that complete explicit file list. The +scanner parses candidate YAML as inert data, accepts external GitHub refs only at exact stable patch +versions and Docker actions only by digest, and must emit exactly +`action reference policy passed ( external references)` followed by one +LF. The count must be positive. The driver consumes that line only to reject a vacuous/malformed scan and never +threads it to a later step. The scanner never +executes or resolves a candidate local action. Enumeration, extraction, parser, or policy failure stops +both required jobs even when the container change itself is not relevant. Candidate-controlled +`deploy-action.yml` checks are defense in depth, not the protected authority for this policy. + +For a pin candidate, trusted `G` invokes its runtime verifier exactly as: + +```text +verify-build-container-publication.sh --gate-root --subject-root --gate-sha --candidate-sha --image-json --evidence-json +``` + +The verifier requires both clean, full, separate repositories at their exact top levels. A full +repository is non-shallow and non-sparse, has no promisor or partial-clone configuration, has no +on-disk or environment-provided object alternates, and runs all Git reads with lazy object fetching +disabled. The two repositories have distinct Git common directories and object stores. The verifier requires +the gate checkout at `G` and subject checkout at `T`, and requires the two regular, non-symlink input +files to be the exact blobs extracted from `T`. It reads `GITHUB_TOKEN` only from its environment, +emits no stdout on success, and performs no mutation. It validates the canonical records and their +cross-file identities before any request. + +The verifier polls the evidence-selected same-repository workflow run immediately and at most 30 +times. Every response must be an exact successful GET response under the gate REST contract and must +retain the immutable run, source, tag, workflow, and attempt identity. A non-completed response +consumes one poll; the verifier waits exactly 10 seconds before the next poll, never after the final +poll. Every request uses exact curl connection timeout 10 seconds and total timeout 30 seconds; curl +timeout is an immediate terminal failure. HTTP, header, media-type, JSON, or identity failures are not +retried. The selected run must complete successfully with event `push`, raw API `path` exactly +`.github/workflows/publish-build-container.yml@`, `head_sha=S`, +`head_branch=`, and the exact attempt. The verifier does not strip, normalize, or accept a +branch/ref spelling in that `path` field. + +After completion, the verifier fetches exactly +`GET /repos/stackpop/edgezero/actions/runs//attempts//jobs?per_page=100&page=1` +once. The response has `total_count:2`, a two-element `jobs` array, and exactly the successful +`build-and-verify` and `update-pin` jobs; no second page can exist under that count. Each job contains exactly one +successful `assert-exact-publisher-context` step; that trusted `G` step internally proves the +protected tag ref, `github.sha==github.workflow_sha==S`, active gate, release state enabled, run +id/attempt, and tag. The verifier then fetches the run's non-paginated approvals once. It parses every +protocol-prefixed review claiming the current run id and attempt, requires exactly one, requires that +review to be approved for `build-container-release`, and compares every comment field and API reviewer +login with the evidence file. It then repeats the exact run-detail GET and requires byte-identical +immutable identity fields plus the same completed/successful current `run_attempt`. This final read is +the publication verifier's linearization point: a rerun that starts before it completes changes the +attempt or state and fails; a rerun started after it completes is later work and cannot retroactively +invalidate the already authenticated pin candidate. Earlier-attempt reviews remain inert history. Missing, pending, +duplicate, malformed, foreign, or contradictory evidence fails before image pull. The pin check never +accepts a candidate-provided check name or PR body as publisher evidence. + +For ordinary image or pin candidates, each job also compares every path in the active gate manifest +between the protected base and its `G` checkout and fails on any mismatch. Gate-update and the exact +recovery-only gate-rollback are the only exceptions. For gate update, old `G` must classify the +candidate's manifested-path change explicitly, verify the base +still equals old `G`, require every changed path to be in the union of old and candidate manifests, +require no changed release request or pin, run the protected static/subject-data gate-update checks, +and record that mode in its terminal marker. Candidate gate scripts are not executed in either gate +mode. Gate rollback obeys the exact failed-`G'`-to-old-`G` restoration contract above. An +ordinary release request must change exactly `release-request.json`; its `gate-sha` is `G`, its +protocol is 1, and every repository image-context input in `S` is byte-identical to `G`. + +The protected workflow also exposes non-required job `build-container-release-preflight` only for a +`workflow_dispatch` request whose body uses `ref:"main"` and satisfies the protected-dispatch +`{G,Q}` contract above. A workflow dispatch ref is a branch or tag name, not a raw commit SHA. +Its required typed inputs are a +same-repository candidate PR number, exact head repository, and full lowercase head SHA. Its +`run-name` is exactly +`build-container-release-preflight pr= repo= sha=<40-lowercase-hex>`, making the +claimed binding API-visible. The job uses environment +`{name: build-container-release, deployment: false}`, checks out only exact `G` into a private gate +root, and runs no candidate repository script. Its fixed step `assert-exact-g-dispatch-context` +invokes only the protected `G` helper, uses the read-only `GITHUB_TOKEN` to fetch the named PR, and +requires the three inputs to equal the current PR number/head repository/head SHA as well as event `workflow_dispatch`, `github.ref==refs/heads/main`, +`github.sha==github.workflow_sha==Q`, active gate `G`, `G` ancestry, and complete manifested-byte +equality at `Q`. It records gate `G` separately from workflow/dispatch revision `Q`. After the +environment reviewer verifies the actual dispatch snapshot and approves it, +`actions/create-github-app-token@v3.2.0` consumes the exact stored +App variable and private-key secret with repository `edgezero` and explicit `contents:write` and +`pull_requests:write`. The job requires its installation-ID output to equal the stored expected ID, +reads only `stackpop/edgezero` with the token, and lets the action's mandatory post step revoke the +token. A successful check run from the GitHub Actions App proves the protected environment's stored credential, rather +than only an operator's local copy, can mint the publisher's exact token before `S`. + +The final environment policy is tag-only, so the smoke uses a bounded transition. An administrator +temporarily adds one custom branch deployment policy equal to the literal protected default-branch +name `main`, dispatches the workflow with body `ref:"main"` under the verified `{G,Q}` contract, +then removes that branch +policy without changing the App variables or secret. The final preflight requires the environment to +be back to its sole `build-container-v*` tag policy and the successful workflow run API record to have +event `workflow_dispatch`, path `.github/workflows/build-container-ci.yml`, `head_sha=Q`, and exact +display title/run-name for the current candidate. Its job +must contain exactly one successful `assert-exact-g-dispatch-context` step and identify the exact +candidate PR input, `stackpop/edgezero` head repository, and current PR head SHA. The step's success is +the API-visible evidence for the internal workflow-SHA assertion. Before dispatch, the local policy +auditor snapshots the three approved environment-variable records as +`{name,value,created_at,updated_at}` and private-key secret metadata as +`{name,created_at,updated_at}`, never its value. Every `updated_at` must strictly precede the +identified smoke run's `created_at`, and `created_at <= updated_at` for each credential record. +Compare validated RFC 3339 instants, not strings; equality at the cutoff, malformed/missing times, +or ambiguous records fail. The run's creation time is a conservative bound before secret ingestion, +not a claim that GitHub exposes the ingestion instant; environment secrets are read at job start. +After successful smoke and restoration of tag-only policy, and again at final preflight, all four +records must equal their pre-dispatch snapshots. Evidence binds the snapshots and cutoff to exact +candidate head, gate `G`, dispatch revision `Q`, smoke run id/attempt, and successful job id. Any new candidate commit, +changed gate, changed credential record, deletion/recreation, or mixed-attempt evidence requires a +fresh dispatch, not reuse of an old successful smoke. Run `updated_at` is not a completion timestamp; +use the exact successful job's `completed_at` when recording completion. These checks do not claim a +transactional lock against later administrator changes. The temporary branch policy +is literal `main`, never a wildcard or caller-supplied branch. + +Repository-administrator bypass of environment protection is disabled. GitHub's documented REST +environment representation does not expose that switch, so neither the helper nor its fake-API tests +claim to verify it automatically. After the bounded credential smoke is complete and the final +tag-only policy is restored, an independent maintainer who is not the preflight verifier opens the +repository's `build-container-release` environment settings and captures a PNG showing the repository, +environment name, disabled administrator-bypass control, and final deployment-policy list. +The verifier supplies that file plus the reviewer's login and RFC 3339 review time to the preflight. +The file must be a canonical absolute-path regular non-symlink file of 8 through 10,485,760 bytes and +begin with exact PNG signature bytes `89 50 4e 47 0d 0a 1a 0a`. The helper rejects a non-PNG file, a +reviewer equal to the verifier, a future review time, or evidence +whose recorded candidate head SHA differs from the current PR head; it records that SHA, the literal +basename, and `sha256:<64-lowercase-hex>` file digest under `environment.administrator-bypass` with +`allowed:false`, `verification:"manual-ui"`, `reviewer`, and `reviewed-at`. A separately +authenticated operator attaches the byte-identical PNG with the canonical evidence and digest to the +candidate PR. Release checkpoint 1 requires a maintainer other than the verifier and recorded reviewer +to recompute the attachment digest and confirm the screenshot visibly proves the disabled setting. +Any subsequent environment-policy change or new candidate commit invalidates this manual evidence. + +Before designating or tagging `S`, an operator runs the repository-owned preflight with a dedicated +policy-audit token, a separate package-audit token, the candidate PR number, the expected App and +installation IDs, and the App private key from a local file. The helper is executed only from a clean, +detached checkout whose `HEAD` is exact gate SHA `G`; it verifies that condition before reading a +credential. The policy-audit token is a short-lived fine-grained personal access token owned by the +verified active `stackpop` organization-owner login, selected for only `stackpop/edgezero`, with +repository permissions `Actions:read`, `Checks:read`, `Contents:read`, `Environments:read`, `Pull requests:read`, +`Variables:read`, implicit `Metadata:read`, and `Administration:write`, plus organization permissions `Members:read` +and `Administration:write`. The two administration grants are unavoidable for the reviewed GitHub API: +repository ruleset bypass actors are hidden without ruleset write access, and organization required- +workflow ruleset inspection requires organization administration access. This is an administrative +credential even though the helper is read-only; the contract does not claim that GitHub exposes a +machine-verifiable complete grant set for a supplied fine-grained PAT. + +Before use, a second operator records the token's selected repository, exact displayed grants, +expiration, and a screenshot digest in the prerequisite evidence. The helper authenticates the +expected verifier login and uses the policy token only through one wrapper whose literal allowlist is +`GET` on these routes, with only documented pagination and filter query keys: + +```text +/user +/orgs/stackpop/memberships/{verifier-login} +/orgs/stackpop/teams/edgezero-build-container-releasers/memberships/{verifier-login} +/orgs/stackpop/actions/permissions +/orgs/stackpop/rulesets +/orgs/stackpop/rulesets/{ruleset-id} +/repos/stackpop/edgezero +/repos/stackpop/edgezero/actions/permissions +/repos/stackpop/edgezero/immutable-releases +/users/{publisher-bot-login} +/repos/stackpop/edgezero/pulls/{candidate-pr} +/repos/stackpop/edgezero/rulesets +/repos/stackpop/edgezero/rulesets/{ruleset-id} +/repos/stackpop/edgezero/actions/variables/EDGEZERO_BUILD_CONTAINER_GATE_SHA +/repos/stackpop/edgezero/actions/variables/{approved-repository-variable-name} +/repos/stackpop/edgezero/environments/build-container-release +/repos/stackpop/edgezero/environments/build-container-release/deployment-branch-policies +/repos/stackpop/edgezero/environments/build-container-release/deployment_protection_rules +/repos/stackpop/edgezero/environments/build-container-release/variables/{approved-variable-name} +/repos/stackpop/edgezero/environments/build-container-release/secrets/EDGEZERO_BUILD_CONTAINER_APP_PRIVATE_KEY +/repos/stackpop/edgezero/commits/{candidate-sha}/check-runs +/repos/stackpop/edgezero/actions/runs/{run-id} +/repos/stackpop/edgezero/actions/runs/{run-id}/jobs +/repos/stackpop/edgezero/actions/runs/{run-id}/approvals +/repos/stackpop/edgezero/actions/workflows/rotate-build-container-gate.yml/runs +/repos/stackpop/edgezero/actions/runs/{run-id}/attempts/{run-attempt}/jobs +/repos/stackpop/edgezero/git/ref/heads/main +``` + +Queries are a closed byte-level contract. Scalar-object GETs, selected run and approval GETs, selected +pull-request GETs, exact-ref GETs, and release-id GETs have no query. The only list query forms are: + +```text +/orgs/stackpop/rulesets?per_page=100&page= +/repos/stackpop/edgezero/rulesets?per_page=100&page= +/repos/stackpop/edgezero/environments/build-container-release/deployment-branch-policies?per_page=100&page= +/repos/stackpop/edgezero/actions/runs//jobs?per_page=100&page= +/repos/stackpop/edgezero/actions/runs//attempts//jobs?per_page=100&page= +/repos/stackpop/edgezero/actions/workflows/rotate-build-container-gate.yml/runs?event=workflow_dispatch&per_page=100&page= +/repos/stackpop/edgezero/commits//check-runs?check_name=&filter=latest&app_id=15368&per_page=100&page= +/orgs/stackpop/packages?package_type=container&per_page=100&page= +/installation/repositories?per_page=100&page= +/repos/stackpop/edgezero/pulls?state=all&base=main&sort=created&direction=asc&per_page=100&page= +``` + +The deployment-protection-rules route is a scalar no-query GET whose one response must contain +`total_count:0` and an empty `custom_deployment_protection_rules` array. `` is +exactly one of `build-container-release-preflight`, +`build-container-local`, or `build-container-pin`. Query order and spelling are exact; duplicate keys, +encoding aliases, fragments, response-supplied continuation URLs, and every unlisted key or value fail. +Pagination starts at canonical page `1`, synthesizes only the next exact URL, advances by one, and is +bounded to 100 pages and 10,000 unique items. An optional RFC 8288 `Link` header may contain only +same-origin URLs whose path and non-page query bytes equal the current list contract; every relation +target must have the mathematically correct canonical page, no relation may repeat, and a `next` +relation must equal the synthesized next URL. Duplicate item IDs, repeated page payloads, inconsistent +`total_count`, a `next` relation after a short or count-complete page, a missing `next` before an +incomplete declared count, or 100 items on page 100 fails as truncation. Endpoint-specific one-page +contracts, including the publisher's exact-attempt two-job query, remain exactly page 1 and reject any +continuation. + +Every GitHub REST request made by a gate helper, including the approval gate and bounded App-token +test, invokes curl with exact transport controls `--disable --silent --show-error --connect-timeout 10 +--max-time 30 --max-redirs 0` and sets exact non-authorization headers +`Accept: application/vnd.github+json`, +`X-GitHub-Api-Version: 2026-03-10`, and `User-Agent: edgezero-build-container-gate/1`. A missing or +different value fails before network access. Authorization is added only by the credential-specific +wrapper. Responses must report the selected API version, use the expected JSON media type, and obey +the endpoint's exact success status; a redirect or silent fallback is failure. Specifically, every +response must contain `X-GitHub-Api-Version-Selected: 2026-03-10`. A response with a body must parse +its `Content-Type` to media type exactly `application/json`, with no charset or charset `utf-8`, and +must contain exactly one complete JSON value. GET succeeds only with 200. Approved token, release, and +pull-request POSTs succeed only with 201 and one JSON value. Approved release and pull-request PATCHes +succeed only with 200 and one JSON value. Token revocation and the publisher-prerequisite variable +PATCH succeed only with 204 and an empty body. A 204 response has no JSON media-type requirement and +must omit a nonempty response body. + +Trusted release helpers use the following common CLI contract. Nonsecret scalars are explicit flags; +credentials are read only through the named environment variables; structured inputs and outputs are +regular, non-symlink files at already canonical absolute paths. Unknown, missing, empty, or duplicate +flags fail before credential access. Exit status is 0 for success, 1 for a failed contract, and 2 for +usage or unavailable required tooling. Success emits no stdout. Diagnostics on stderr never contain a +credential, authorization header, JWT, token response, private key, API body containing a token, or a +secret-derived hash. Every output path must be absent, lie directly beneath a canonical private output +directory outside every repository, and is published atomically without replacement. Canonical JSON +outputs have no trailing LF; the explicitly two-line rotation comment has one separating LF and no +trailing LF. Failure removes unpublished temporary output. + +The local prerequisite auditor has exactly these modes and interfaces: + +```text +verify-release-prerequisites.sh configuration --gate-root --gate-sha --smoke-run-id --smoke-run-attempt --expected-app-id --expected-installation-id --expected-team-id --expected-bot-id --expected-bot-login --policy-token-review-json --policy-token-review-png --administrator-bypass-png --administrator-bypass-reviewer --administrator-bypass-reviewed-at --evidence-out + +verify-release-prerequisites.sh release --gate-root --gate-sha --candidate-pr --evidence-url --source-revision --merge-group-sha --merge-group-run-id --merge-group-run-attempt --smoke-run-id --smoke-run-attempt --push-run-id --push-run-attempt --expected-app-id --expected-installation-id --expected-team-id --expected-bot-id --expected-bot-login --package-auditor-login --package-state --policy-token-review-json --policy-token-review-png --administrator-bypass-png --administrator-bypass-reviewer --administrator-bypass-reviewed-at --evidence-out --publisher-prerequisite-out + +verify-release-prerequisites.sh rotation-review --gate-root --old-gate-sha --new-gate-sha --dispatch-sha --final-head-sha --lock-run-id --lock-run-attempt --operator-login --result --policy-token-review-json --policy-token-review-png --evidence-out --approval-comment-out + +verify-release-prerequisites.sh rotation-complete --gate-root --gate-sha --lock-run-id --lock-run-attempt --policy-token-review-json --policy-token-review-png --evidence-out --publisher-prerequisite-out +``` + +The auditor reads `EDGEZERO_RELEASE_POLICY_AUDIT_TOKEN` in every mode, +`EDGEZERO_RELEASE_PACKAGE_AUDIT_TOKEN` only in `release`, and +`EDGEZERO_RELEASE_APP_PRIVATE_KEY_FILE` only in `configuration` and `release`. The last value names an +owner-only mode-0600 regular local file; key bytes never enter argv or the environment. The canonical +policy-token review is UTF-8 RFC 8785 JCS with no BOM, surrounding whitespace, or trailing LF and has +exactly these bytes apart from placeholder substitution: + +```text +{"expires-at":"","organization-grants":{"administration":"write","members":"read","other-displayed":"none"},"repository-grants":{"actions":"read","administration":"write","checks":"read","contents":"read","environments":"read","metadata":"read","other-displayed":"none","pull-requests":"read","variables":"read"},"resource-owner":"stackpop","reviewed-at":"","reviewer-login":"","schema-version":1,"screenshot-sha256":"sha256:<64-lowercase-hex>","selected-repositories":["stackpop/edgezero"],"subject-login":"","token-id":""} +``` + +`other-displayed:"none"` means every grant displayed by GitHub other than the named keys is shown as +no access; an omitted, additional, renamed, or differently valued displayed grant fails. The review +instant and expiration are valid UTC calendar instants, the reviewer differs from the subject, the +review is not future, and the token remains unexpired at verification. The auditor requires the +policy-token review PNG to be a canonical absolute-path regular non-symlink file of 8 through +10,485,760 bytes, to begin with the exact PNG signature bytes +`89 50 4e 47 0d 0a 1a 0a`, and recomputes its digest against `screenshot-sha256`. The administrator- +bypass PNG is a distinct input and cannot satisfy the policy-token review. + +`evidence-out` is the complete human-review attachment produced only by the trusted auditor. It is a +nonempty UTF-8 JSON object of at most 1,048,576 bytes, serialized by that gate revision as RFC 8785 JCS +with no BOM, surrounding whitespace, or trailing LF. Its members are gate-versioned audit detail, not +a wire protocol consumed by another helper: after publication, downstream code treats the exact file +as opaque bytes and validates only its SHA-256. The exact small `publisher-prerequisite-out` record is +the machine transition protocol. It contains the evidence digest and prior-variable digest defined +below and is excluded from its own evidence digest. A rotation approval-comment output is the exact +two-line rotation protocol defined above. `configuration` emits no release-bound prerequisite; +`release` is used for both first-`S` and same-`S` package recovery. +Before invoking `release`, the separately authenticated operator creates one inert placeholder comment +on the candidate PR and supplies its stable URL. The URL must be exactly +`https://github.com/stackpop/edgezero/pull/#issuecomment-`. +The auditor includes that URL in its evidence and release-bound prerequisite output but never creates +or edits a comment. After the auditor succeeds, the operator edits that same comment to attach the +exact evidence and review PNG; the independent final reviewer verifies the attachment bytes and URL +before invoking the writer. The placeholder has no protocol authority and tag creation remains blocked +until the reviewed record is written. This two-step procedure gives the tag-triggered publisher an +authenticated source for the updater link without an ambient variable or an unbounded commit-to-PR +lookup. + +The separately controlled variable writer is invoked exactly as: + +```text +write-publisher-prerequisite.sh --gate-root --gate-sha --evidence-json --publisher-prerequisite-json --writer-token-review-json --writer-token-review-png +``` + +It reads only `EDGEZERO_PUBLISHER_PREREQUISITE_WRITE_TOKEN`, emits no file, and authenticates the +expected active organization member from a UTF-8 RFC 8785 JCS writer-token review with no BOM, +surrounding whitespace, or trailing LF. That input has exactly these bytes apart from placeholder +substitution: + +```text +{"expires-at":"","organization-grants":{"members":"read","other-displayed":"none"},"repository-grants":{"metadata":"read","other-displayed":"none","variables":"write"},"resource-owner":"stackpop","reviewed-at":"","reviewer-login":"","schema-version":1,"screenshot-sha256":"sha256:<64-lowercase-hex>","selected-repositories":["stackpop/edgezero"],"subject-login":"","token-id":""} +``` + +`other-displayed:"none"` has the same closed-world meaning as in the policy-token review. The review +instant, expiration, reviewer separation, and current validity checks are identical. The writer +records `token-id` as the positive numeric fine-grained-token inventory identifier visible in the +reviewed GitHub UI. It is screenshot-bound review evidence, not `/user.id`; `/user.id` identifies the +subject account and must not be compared with `token-id`. +The writer +requires the writer-token review PNG to satisfy the same canonical path, regular-file, size, signature, +and digest checks; no other screenshot input is interchangeable. The +writer treats `evidence-json` as nonempty opaque bytes of at most 1,048,576 bytes, recomputes the outer +evidence digest, validates the closed prerequisite record, GETs and validates the current variable, +and requires its exact-byte SHA-256 to equal the record's `previous-value-sha256`. It does not parse audit +members or derive machine state from prose evidence. Its network allowlist is exactly user GET, +membership GET, active-gate variable GET, prerequisite-variable GET, exact prerequisite-variable +PATCH, and post-write prerequisite-variable GET. + +The publisher approval helper is invoked exactly as: + +```text +release-approval-gate.sh --gate-root --gate-sha --run-id --run-attempt --build-attempt --source-revision --release-tag --image-digest --approval-challenge <64-lowercase-hex> --approval-out +``` + +It reads only `GITHUB_TOKEN`, permits only the current-run and non-paginated approvals GETs, and writes +the exact ten-field `image-release-evidence.json` JCS record using the authenticated reviewer login and +unique current-attempt approval comment. That file is the updater's only approval input. It applies the +15-minute freshness test immediately before App-token minting; later readers do not re-age the record. + +The pin updater is invoked exactly as: + +```text +update-image-pin-pr.sh --gate-root --gate-sha --repository-root --source-revision --release-tag --image-digest --provenance-protocol 1 --approval-json --source-pr --evidence-url --expected-bot-id --expected-bot-login +``` + +It reads only `EDGEZERO_BUILD_CONTAINER_APP_TOKEN`, accepts no caller-produced image/evidence JSON, +validates the gate-produced approval file, and invokes `G`'s typed record writer. The evidence URL is +exactly `https://github.com/stackpop/edgezero/pull/#issuecomment-`. Git access is +limited to `https://github.com/stackpop/edgezero.git`; the token travels through a private askpass file, +never argv or a URL. Every branch push uses the recorded remote OID, including an empty expected OID +for creation, with exact force-with-lease and remote readback. The helper emits no file or stdout; +created, updated, already-current, already-merged, and mutation-free superseded outcomes are proved by +the final remote/API state. + +The updater's App-token REST allowlist is exactly `GET /users/`, +`GET /repos/stackpop/edgezero`, the fully paginated pull-list query above, selected +`GET /repos/stackpop/edgezero/pulls/`, `POST /repos/stackpop/edgezero/pulls`, and selected +`PATCH /repos/stackpop/edgezero/pulls/`. GET and PATCH require 200 with one complete JSON +value; POST requires 201 with one complete JSON value. The branch is exactly +`edgezero-build-container-pin/`, the title is exactly +`chore(actions): pin build container for `, and the +body is this single line with no trailing LF: + +```text +edgezero-build-container-pin-v1 {"evidence-url":"","image-digest":"","release-tag":"","source-pr":"","source-revision":""} +``` + +Create uses exactly +`{"base":"main","body":"","draft":false,"head":"edgezero-build-container-pin/","title":"chore(actions): pin build container for "}`. +Close uses exactly `{"state":"closed"}`. Reopen or metadata reconciliation uses exactly +`{"base":"main","body":"","state":"open","title":"chore(actions): pin build container for "}`. +No other request body, PR mutation, issue/comment endpoint, or GraphQL call is permitted. +The expected publisher bot login has exact `[bot]` form; the public user response must have +that login, the expected numeric bot id, and `type:"Bot"`. An installation token is not treated as a +user token and the updater never calls `GET /user`. The protected workflow first requires the token +action's installation-ID output to equal the reviewed installation variable, and the updater then +requires every selected or mutated PR's final author to equal the resolved bot identity. Together with +the exact repository response, this binds the token use without inventing an unsupported installation- +token user endpoint. + +After authenticating the App bot and repository identities, the updater records the remote `main` and +target pin-branch OIDs, fully classifies every matching PR/branch and source ancestry, and performs no +remote mutation if any state is malformed, ambiguous, incomparable, or supersedes `S`. It creates a +mode-0700 private temporary clone from the supplied clean full repository, sets origin to the one exact +HTTPS URL, always fetches named `main` without tags or submodules, and fetches the named pin branch only +when its recorded OID is nonempty. It requires every fetched OID to equal the recorded value and, for +creation, repeats the exact ref lookup and requires absence immediately before the empty-lease push. A +private detached worktree starts at recorded main. The updater invokes the typed +pair writer into a separate private output directory, installs exactly those two mode-0644 files at +`.github/docker/build-app-cli/{image.json,image-release-evidence.json}`, stages exactly those paths, +and creates at most one unsigned, hook-free commit with message equal to the PR title and fixed bot +name/email derived from the authenticated login. It rejects any other staged or worktree change. +An existing pin commit may be based on an earlier main commit only when that parent is an ancestor of +the newly recorded main; reconciliation rebuilds the proposed commit on current recorded main. Existing +pin and evidence entries must be regular mode-0644 blobs. For same-source digest replacement, pushing +the replacement branch necessarily advances the old PR's live head; the updater closes and verifies +that PR at the new branch OID before creating and verifying the replacement PR. + +The only push is `HEAD:refs/heads/edgezero-build-container-pin/` with +`--force-with-lease=refs/heads/edgezero-build-container-pin/:`; `` is +empty only when the ref was absent. Push uses `--porcelain --no-verify`, no tags or submodules, and a +mode-0700 askpass helper with terminal prompting disabled. The updater immediately reads the remote ref +back and requires the new commit OID before creating, reopening/reconciling, or closing PRs. It then +GETs every mutated or selected PR and requires exact final author, repository, base, head, title, body, +state, merge, and commit identities. An already-current remote/API state is a mutation-free success. + +The rotation verifier has exactly two modes: + +```text +verify-gate-rotation-lock.sh waiting --gate-root --old-gate-sha --dispatch-sha --run-id --run-attempt --run-actor-login + +verify-gate-rotation-lock.sh publisher --gate-root --gate-sha --source-revision --publisher-prerequisite-json +``` + +Both modes read only `GITHUB_TOKEN` and emit no file or stdout. `waiting` permits only current-run, +non-paginated approvals, and exact-main-ref GETs; it derives `Q_f`, `G-prime`, result, receipt digests, +and final gate from the unique authenticated approval rather than caller flags. `publisher` permits +only the fully paginated rotation-run history, selected current run, selected non-paginated approvals, +selected exact-attempt jobs, and exact-main-ref GETs; it enforces the supplied prerequisite record and +the latest completed rotation contract before registry authentication. + +`{approved-variable-name}` is exactly one of `EDGEZERO_BUILD_CONTAINER_APP_ID`, +`EDGEZERO_BUILD_CONTAINER_APP_INSTALLATION_ID`, or +`EDGEZERO_BUILD_CONTAINER_RELEASE_TEAM_ID`. +`{approved-repository-variable-name}` is exactly one of +`EDGEZERO_BUILD_CONTAINER_RELEASE_STATE`, `EDGEZERO_BUILD_CONTAINER_PUBLISHER_APP_ID`, +`EDGEZERO_BUILD_CONTAINER_PUBLISHER_BOT_ID`, `EDGEZERO_BUILD_CONTAINER_PUBLISHER_BOT_LOGIN`, or +`EDGEZERO_BUILD_CONTAINER_PUBLISHER_PREREQUISITE`. +Release state obeys the exact state grammar above, App/Bot IDs are canonical positive decimals, and +the bot login equals the independently verified dedicated App bot account. The user lookup must +return that exact login, numeric id equal to `EDGEZERO_BUILD_CONTAINER_PUBLISHER_BOT_ID`, and +`type:"Bot"`. All other numeric +placeholders are canonical positive decimals; SHA and login placeholders must equal values already +validated from the candidate or authenticated API response. Paginated list calls require +`per_page=100` and a canonical positive `page`; check-run +calls also +fix the documented app/latest filters used by the verifier. No redirect is followed. A request with +any other credential, method, path, placeholder value, query key/value, or fixed header fails before +network access; fake-API and static tests cover the complete allowlist. The package-audit token +is a classic personal access token belonging to an active `stackpop` organization owner. Its granted +normalized OAuth-scope set is exactly `{read:org,read:packages}`; the helper verifies the +authenticated login, active owner membership, and returned `X-OAuth-Scopes` header before using that +same token for every package query. Neither local token is stored in GitHub Actions. They are supplied +only as `EDGEZERO_RELEASE_POLICY_AUDIT_TOKEN` and +`EDGEZERO_RELEASE_PACKAGE_AUDIT_TOKEN`, respectively. The helper rejects byte-equal token values +before making an API request and never logs either value. The policy- and package-token wrappers never +issue a mutation request. The helper's only non-GET requests create and revoke the two distinct local +audit/probe tokens described below; it never mutates persistent repository settings, packages, pull +requests, rulesets, or comments. It requires all of the following and emits canonical evidence for a +separately authenticated operator to attach to the candidate PR: + +- environment `build-container-release` has administrator bypass disabled, a nonempty + `required_reviewers` rule with `prevent_self_review=true`, uses custom deployment policies, and has + no GitHub App custom deployment-protection rule and exactly one deployment policy, type `tag`, with + name `build-container-v*`. The protection-rules endpoint must return HTTP 200, `total_count:0`, and + an empty `custom_deployment_protection_rules` array; its separately supplied + administrator-bypass evidence satisfies the manual contract above; +- organization and repository Actions permission responses both have + `sha_pinning_required:false`; if an enterprise override still rejects exact version tags, the + hosted exact-version prerequisite fails and release is blocked. Consumer repositories must likewise + permit version-tag action refs; +- an active organization ruleset with no bypass actor has exactly one required-workflow descriptor: + the EdgeZero repository id, `.github/workflows/build-container-ci.yml`, full SHA `G`, and no ref; + `do_not_enforce_on_create` is false. Repository ruleset `edgezero-build-container-main` has the exact + target, enforcement, ref conditions, no-bypass state, pull-request parameters, and seven merge-queue + parameters defined above; no default or omitted field may weaken them. `.github/CODEOWNERS` assigns + the canonical gate-owned path manifest and every listed path to the exact gate-reviewer team. The + protected workflow's source repository, path, workflow SHA, and candidate SHA are recorded. The + exact App-only pin-branch ruleset is active and its Integration actor id equals the dedicated + publisher App id. The immutable-releases endpoint returns HTTP 200; parsed field `enabled` is + exactly boolean `true`, and `enforced_by_owner` is present as a boolean and recorded. Additional + response fields do not change this decision. Four active repository + tag rulesets have the exact source/target/ref/rule objects defined above: the image and action + creation/immutability pairs. Each immutability ruleset prohibits update and deletion and has no + bypass actor. Each creation ruleset prohibits creation and has exactly one bypass actor: team + `edgezero-build-container-releasers`, with the numeric ID stored in + `EDGEZERO_BUILD_CONTAINER_RELEASE_TEAM_ID` and bypass mode `always`; it contains no update or deletion + rule. The verifier actor is an active member of that team. The candidate's successful + required-workflow run is a `merge_group` + run for its final queue merge candidate, uses that exact protected workflow source, and contains + successful `build-container-local` and `build-container-pin` jobs from the GitHub Actions App; +- protected-environment variables `EDGEZERO_BUILD_CONTAINER_APP_ID` and + `EDGEZERO_BUILD_CONTAINER_APP_INSTALLATION_ID` equal the reviewed numeric IDs, environment variable + `EDGEZERO_BUILD_CONTAINER_RELEASE_TEAM_ID` equals the ruleset's reviewed team ID, and secret metadata + includes `EDGEZERO_BUILD_CONTAINER_APP_PRIVATE_KEY` without exposing its value. The four records + match the pre-dispatch snapshots, and every `updated_at` strictly precedes the smoke run's + `created_at` under the freshness contract above. Repository variable + `EDGEZERO_BUILD_CONTAINER_GATE_SHA` equals `G`, the ruleset descriptor SHA, and the smoke's verified + gate identity. Its workflow SHA is separately recorded as dispatch `Q`, with verified `G` ancestry + and gate-owned byte equality. Repository release state equals `enabled`; publisher App id equals + the environment App id and pin-branch bypass actor; and publisher bot id/login identify the verified + App bot account used for pin PRs. The auditor parses and records the current canonical publisher- + prerequisite value as the expected prior state for the separately reviewed writer; malformed, + missing, wrong-gate, or impossible lifecycle state blocks evidence production; +- an App JWT made from that key identifies the expected dedicated App; the expected installation is + active on account `stackpop`, uses selected repositories, grants exactly `contents:write`, + `pull_requests:write`, and implicit `metadata:read`, and its repository list is exactly + `stackpop/edgezero`. Prove that last invariant with a separate installation-wide metadata-only + audit token, without either repository selector in its mint request. Its returned permissions are + exactly `metadata:read`; fully paginated repository enumeration has consistent `total_count:1` + and exactly the verified EdgeZero repository id and full name. Duplicate entries, extra/private + repositories, inconsistent counts, truncated pagination, or broader permissions fail. Revoke this + token successfully before minting the distinct publisher probe. A repository-restricted token can + never prove installation-wide selection; +- an installation token can be minted for only the EdgeZero repository ID with explicit + `contents:write` and `pull_requests:write`, its response reports only those requested permissions + plus implicit metadata read, it can read `stackpop/edgezero`, and it is revoked before the helper + exits; and +- the candidate's `build-container-release-preflight` check run completed successfully, came from the + same protected workflow and GitHub Actions App integration, and belongs to a `workflow_dispatch` run + whose run record has exact path, `head_sha=Q`, and exact candidate-bound display title; whose + separately supplied input PR, head repository, and head SHA are independently resolved through the + PR API by the trusted step and equal the current candidate values; and whose sole + `assert-exact-g-dispatch-context` step succeeded. It records the expected installation ID without + exposing a token. After queue merge, the repository-local workflow's latest-attempt `push` run for + exact `S` has the required event/path/head fields, both stable jobs succeeded for `head_sha=S`, and + each has exactly one successful `assert-exact-main-push-context` step before the release tag is created; + and +- repository/package identity and the absent-before-first-push or public-and-repository-linked package + state are the exact release state expected by the invocation. Absence is established only by a + successful, fully paginated organization-container-package listing made with the verified active + organization owner's package-audit token and containing no exact name match; a listing made by any + other identity, a GET 404, or an authorization failure is never absence. + +The package-audit token has its own GET-only wrapper limited to `/user`, +`/orgs/stackpop/memberships/{package-login}`, the fully paginated +`/orgs/stackpop/packages?package_type=container&per_page=100&page={page}` listing, and +`/orgs/stackpop/packages/container/edgezero-build-app-cli`. The App JWT wrapper permits only +`GET /app`, `GET /app/installations/{expected-installation-id}`, and +`POST /app/installations/{expected-installation-id}/access_tokens` with exactly one of two typed +request kinds. `InstallationMetadataAudit` has body `{"permissions":{"metadata":"read"}}` and +omits both `repositories` and `repository_ids`. `EdgeZeroPublisherProbe` has body +`{"repository_ids":[E],"permissions":{"contents":"write","pull_requests":"write"}}`, where `E` +is the independently verified positive EdgeZero repository ID encoded as a JSON integer. Neither +kind accepts caller-supplied permission maps or selectors. The audit token's wrapper permits only +fully paginated `GET /installation/repositories?per_page=100&page={page}` and +`DELETE /installation/token`; the publisher-probe wrapper permits only +`GET /installation/repositories?per_page=100&page={page}`, `GET /repos/stackpop/edgezero`, and +`DELETE /installation/token`. Both responses are redacted; evidence records purpose, effective +permissions, repository identities, and successful revocation separately, never token bytes. Tokens +cannot be exchanged between wrappers, stored in Actions, or reused by the publisher. Every exit +attempts revocation of any minted token; verification or revocation failure blocks progression. +The installation-wide metadata audit is the sole exception to repository-bounded local token minting, +not permission to broaden the publisher's mutation token. No audit or probe credential in the +preceding paragraph may call a persistent repository, organization, package, pull-request, comment, +or ruleset mutation endpoint. The separately reviewed prerequisite-record writer below is the sole +exception and can mutate only its one named repository variable. + +The publisher's authenticated ingress is repository variable +`EDGEZERO_BUILD_CONTAINER_PUBLISHER_PREREQUISITE`. Its value is UTF-8 RFC 8785 JCS with no BOM, +surrounding whitespace, or trailing newline and has exactly this closed shape: + +```text +{"evidence-sha256":"sha256:<64-lowercase-hex>","evidence-url":null,"gate-sha":"<40-lowercase-hex>","previous-value-sha256":null,"rotation-history":{"state":"bootstrap-no-rotation"},"schema-version":2,"source-pr":null,"source-revision":null} +``` + +The initial bootstrap record is created manually through the separately reviewed repository-variable +creation procedure; this PATCH-only writer never observes or creates an absent variable. In every +record submitted to the writer, `previous-value-sha256` is exactly +`sha256:<64-lowercase-hex>` over the prior variable's exact UTF-8 bytes. `source-revision` is either a +full lowercase source SHA or JSON null for an inert record that cannot authorize a tag. When rotation +history is nonempty, `rotation-history` is exactly +`{"created-at":"","evidence-sha256":"sha256:<64-lowercase-hex>","history-sha256":"sha256:<64-lowercase-hex>","run-attempt":"","run-id":"","run-number":"","state":"verified"}`. +Unknown, duplicate, missing, reordered, mistyped, noncanonical, zero, or out-of-range values fail. +For every inert record, `source-revision`, `source-pr`, and `evidence-url` are all null. For every +release-bound record, `source-revision` is non-null, `source-pr` is a canonical positive-u64 decimal +string, and `evidence-url` is the exact URL above with the same PR number. Those three fields change +together; a partial tuple fails. The publisher passes the authenticated non-null pair directly to the +pin updater. A same-source package-recovery refresh may replace the evidence digest and comment URL but +must preserve the source PR number. +The outer evidence digest covers the complete opaque prerequisite-evidence attachment reviewed for +that record. That file does not contain the derived publisher-prerequisite record or its digest, +avoiding a self-reference. A release-bound record's byte-identical evidence file is attached to the +source PR, while an inert post-rotation record covers the completed-lock audit. The nested digest is +exactly the `evidence-sha256` value from the first approval line and therefore hashes only the second +line's exact compact JSON object bytes, excluding its prefix and every newline. It lets the publisher +bind the record to the latest completed rotation using live API evidence. When producing the opaque +audit, the trusted local auditor requires the record gate SHA to equal both the live active-gate +variable and the live required-workflow descriptor SHA. The writer independently requires the record +gate SHA to equal the live active-gate variable, but it neither parses that audit nor reads organization +rulesets. For verified rotation history, the publisher requires the authenticated rotation receipt's +`required-workflow-sha` and `gate-sha` to equal the record and active-gate SHA. In bootstrap history the +publisher relies on the manual administrative trust-root procedure for descriptor equality; neither +the evidence's review status nor the descriptor snapshot is machine-authenticated to the publisher. +The bootstrap writer remains a trusted administrative actor. A non-null source revision must equal the +exact isolated release source `S` whose post-merge push and prerequisite audit the outer evidence +records. + +The variable is provisioned before release in inert form. A successful gate rotation leaves the old +value stale while the lock runs; after the lock completes successfully, the local auditor emits a +reviewed replacement with the new active gate, exact latest rotation attempt, and null source revision. It +never carries an old `S` across a gate change. A later successful post-merge release audit emits a +replacement bound to its exact `S`. First-package visibility recovery repeats the audit and replaces +the same-`S` record with the new evidence digest before the tag run is retried. Under the manual review +procedure, failed, canceled, waiting, or unreviewed audits are never submitted to the writer. + +A separate local writer runs from a clean detached active `G` full repository with the same +non-shallow, non-sparse, no-promisor, no-partial-clone, no-alternates, no-lazy-fetch requirements +defined for the publication verifier. It receives +`EDGEZERO_PUBLISHER_PREREQUISITE_WRITE_TOKEN` only through its process environment. The token is a +short-lived fine-grained PAT selected only for `stackpop/edgezero`, with repository +`Variables:write`, implicit `Metadata:read`, organization `Members:read`, and no other grant. A second +operator records its selection, displayed grants, expiration, and screenshot digest. The writer +authenticates the expected active organization-member login, validates the opaque evidence digest and +canonical record locally, GETs the active-gate and existing prerequisite variables, and permits only +PATCH of +`/repos/stackpop/edgezero/actions/variables/EDGEZERO_BUILD_CONTAINER_PUBLISHER_PREREQUISITE` with a +body exactly `{"name":"EDGEZERO_BUILD_CONTAINER_PUBLISHER_PREREQUISITE","value":""}`. +The PATCH succeeds only with 204 and an empty body; the final GET must return the exact new value. It +cannot create/delete a variable, write another +name, or call a repository, workflow, ref, release, package, pull-request, comment, ruleset, or +environment mutation endpoint. The independent final reviewer, distinct from the verifier, controls +the writer invocation after recomputing the outer evidence digest and, only for verified nonempty +rotation history, the nested receipt digest. Missing review, token/grant mismatch, +unexpected prior state, API failure, or post-write readback mismatch blocks tagging or rerun. + +The writer is an operator-serialized single-writer procedure: no second writer invocation may overlap +the interval from its first prerequisite-variable GET through its final readback. GitHub's variable +PATCH endpoint provides no atomic compare-and-set primitive, so `previous-value-sha256` is a +predecessor check, not an enforceable CAS. A concurrent privileged writer can still race and overwrite +state; that operational violation is accepted and must be investigated from audit logs. The final +readback detects an overwrite that wins before readback but cannot prove that no later write occurred. + +The writer permits only these transitions. It first validates both records and, if the requested record +is byte-identical to the current value, returns success without applying the new record's predecessor +hash and without PATCH. Otherwise, `previous-value-sha256` must hash the exact +current value. +A gate change may produce only an inert record for the current active gate with verified nonempty +rotation history. If the prior record is verified, a gate change also requires the selected +`run-number` to increase. At one gate, an inert record may become release-bound, a release-bound record +may refresh evidence for the same source, and it may advance only to a source for which the old source +is a Git ancestor; every such transition preserves byte-identical rotation history and every non-null +source must descend from the current gate. A same-gate post-rollback transition may clear the source or +refresh an inert record. If the prior history is bootstrap, the first such transition must be to an +inert verified record for the first successfully completed rollback and makes no comparison to absent +predecessor rotation fields. If the prior history is verified, the selected `run-number` must increase, +the `run-id` must differ, and both the receipt and complete-history digests must differ. The writer never +orders by run id. Every other same-gate removal of a source, source regression, incomparable source, +stale or same-selected-run rotation identity, and carrying a source across a gate change fails before +PATCH. + +API failure, pagination truncation, ambiguity, extra bypass actor, extra repository or write +permission, credential failure, or evidence-post failure blocks release designation or publication. +After the first push creates the +package, publication stops until an operator makes it public and confirms it is linked to +`stackpop/edgezero` through a fresh package-present preflight from `G`. GHCR exposes no enforceable per-version retention lock, so this contract does not +claim one. Repository workflows contain no package-deletion endpoint or delete-scoped credential; +manual deletion by a package or organization administrator is an accepted operational risk that can +break existing digest-pinned consumers and requires an emergency rebuild plus new reviewed pin. The +workflow also verifies `S` is an ancestor of the protected default branch. All publication, +pin-record mutation, and gate rotation uses the exact repository-global concurrency group +`edgezero-build-container-publication` with `cancel-in-progress: false` and `queue: max`; therefore at +most one run executes the mutation path and up to 100 wait. A run rejected because that queue is full +publishes no pin and must be rerun after capacity is available. Pin branches remain exactly source- +derived as `edgezero-build-container-pin/` and updates are idempotent under explicit force-with- +lease. + +GitHub accepts `queue: max` and the four `job.workflow_*` reusable-workflow identity properties, but +pinned actionlint 1.7.12 predates both additions. The gate does not pretend the raw linter accepts +them. A gate-owned compatibility wrapper first uses pinned mikefarah yq 4.53.3 to require `queue` only +at workflow-level in exactly the publisher and rotation-lock workflows, with scalar `max`, exact +shared group, and literal `cancel-in-progress:false`. It also permits only +`job.workflow_repository`, `job.workflow_file_path`, `job.workflow_ref`, and `job.workflow_sha`, only +in the exact checked expressions/checkout ref locations of `.github/workflows/build-app-cli.yml`; a +misspelling, extra property, other workflow/location, alias, duplicate, or dynamic expression fails. + +The yq installer downloads only +`https://github.com/mikefarah/yq/releases/download/v4.53.3/yq__` and accepts exactly these +platform/digest pairs: `linux/amd64` = +`fa52a4e758c63d38299163fbdd1edfb4c4963247918bf9c1c5d31d84789eded4`, `linux/arm64` = +`578648e463a11c1b6db6010cbf41eafed6bee79466fcffa1bb446672cf7945ea`, `darwin/amd64` = +`b4ba1ecce3c47f00803f4f964de38394326c7a32eb6540616e04fb2935a0f08d`, and `darwin/arm64` = +`877de31753a4dd2401aa048937aa9a7fc4d5f6ce858cf31508c5802954297213`. Every other version, OS, +architecture, URL, asset spelling, or digest fails before installation. The scanner requires exact +`yq --version` output identifying 4.53.3; accepting an arbitrary yq v4 is not sufficient. + +After structural validation, the wrapper creates line-count-preserving temporary copies: it replaces +only those exact approved `job.workflow_*` scalar expressions with same-type constants and replaces +only the two approved `queue` lines with blank lines. It runs unfiltered actionlint 1.7.12 over those +copies and remaps any diagnostic to the real path/line; no `-ignore` rule or diagnostic filter is used. +Self-tests require raw 1.7.12 to emit exactly the reviewed unsupported-diagnostic set for canonical +queue and job-context fixtures, require the sanitized copies to pass, and require every malformed or +additional use to fail before sanitization. Remove each compatibility rewrite independently once a +reviewed actionlint release natively supports that syntax. + +After acquiring that group, every publisher requires +`EDGEZERO_BUILD_CONTAINER_RELEASE_STATE==enabled` and parses the exact step-local +`vars.EDGEZERO_BUILD_CONTAINER_PUBLISHER_PREREQUISITE` value only after the pending workflow has +acquired concurrency and its job is sent to a runner. The record's non-null source revision must equal +tag source `S`; its gate SHA must equal the active-gate variable. With verified rotation history, the +authenticated receipt's `gate-sha` and `required-workflow-sha` must also equal that same `G`; with +bootstrap history, descriptor equality remains a manual administrative trust-root assertion rather +than a machine-authenticated or live publisher read. A required hosted fixture queues a publisher +behind a rotation, changes all three +variables while it waits, and proves the publisher receives the post-rotation values or fails before +build. If GitHub does not preserve that evaluated-after-concurrency behavior, release is blocked and +the variable channel must be redesigned; a queued-run snapshot is not accepted. + +The same unconditional guard proves no rotation lock is active before image build or registry +authentication and verifies successful completion of the latest rotation, not merely absence of an +active run. With +`actions:read`, enumerate the rotation workflow's complete run history using only `per_page=100` +and positive `page`, without status/conclusion filters. Parse every run's `run_number` as a positive +u64 JSON integer, `run_attempt` as a positive u32 JSON integer, `id` as a positive u64 JSON integer, +and `created_at` as an RFC 3339 instant. GitHub documents `run_number` as increasing with each new run +of a workflow and unchanged by reruns; select the unique greatest `run_number` and never order by run +id or `updated_at`. Duplicate run ids or numbers, invalid/future creation times, incomplete pagination, +or malformed values fail. + +Serialize the complete snapshot in ascending numeric `run_number` order as RFC 8785 JCS with exact +entries `{"run-attempt":"","run-id":"","run-number":""}` +and no whitespace or trailing newline. `history-sha256` is SHA-256 over those exact array bytes. The +selected run-detail response must reproduce the listed id, run number, creation instant, and current +attempt. After validating its detail, jobs, approval, receipt, and main tree, enumerate the complete +history a second time and require byte-identical canonical snapshot bytes and digest, then GET selected +run detail again and require the same validated identity, attempt, status, conclusion, path, event, +head, and creation instant. Any change fails; no retry accepts mixed observations. The successful second +snapshot is the guard's linearization point. A new run or rerun observed before it changes the snapshot +and blocks. A rotation that enters the same FIFO concurrency group only after that point is ordered +behind the already-running publisher and is evaluated by the next publisher; the contract does not +claim that a later concurrent dispatch atomically blocks the current one. The prerequisite variable +records `rotation-history` +as exactly +`{"state":"bootstrap-no-rotation"}` when the history is empty, or +`{"created-at":"","evidence-sha256":"sha256:<64-lowercase-hex>","history-sha256":"sha256:<64-lowercase-hex>","run-attempt":"","run-id":"","run-number":"","state":"verified"}` +for the selected rotation. Empty current history is accepted only with the independently reviewed +bootstrap record; any existing rotation requires the verified form and exact matching current +creation instant/run number/run id/attempt/receipt digest and complete-history digest. A rerun of any +older id changes the complete-history digest even though its run number does not change. The writer may +not refresh the same selected rotation identity with that changed digest; recovery requires a fresh +successful dispatch with a greater run number. A deleted or changed previously recorded run is not +bootstrap. Automatic retention pruning of any snapshotted run likewise changes the digest and blocks +until a fresh successful rotation produces a greater selected run number and a new reviewed record. + +Require the selected rotation run to be the exact protected-main rotation workflow at its receipt's +`Q_d`, status `completed`, conclusion `success`, with exactly one successful +`assert-exact-rotation-context` step in its exact-attempt jobs and the matching approved receipt. +Validate the receipt's original time ordering/freshness against that step's completion time, not the +later publisher's clock; its `gate-sha` and `required-workflow-sha` values must equal the publisher's +active `G`. This authenticates the descriptor snapshot recorded by the rotation review but does not +claim a live publisher read of the organization ruleset. The +receipt's final `Q_f` must be an ancestor of or equal to current protected main with complete +gate-owned presence/mode/byte equality. A queued, waiting, failed, canceled, skipped, timed-out, +wrong-attempt, or mismatched rotation blocks before build or registry login even if state remains +`enabled`. Recovery requires a fresh successful rotation dispatch and refreshed prerequisite +evidence. Repository administrators can delete workflow runs; deleting a newer unrecorded failed run +can make an older successful run appear latest and revive its otherwise matching record. No repository +workflow receives run-deletion authority, and this privileged administrative action is an explicitly +accepted risk rather than a machine-enforced invariant. While the publisher holds concurrency no newer +rotation can execute. Runs or reruns present by the guard's second snapshot block unless already bound +by the exact record; work entering the FIFO group after that linearization point is later work and does +not invalidate the current publisher. +The read-only history wrapper permits only GETs for the fixed rotation-workflow runs route, the +selected current run and approvals, its exact `/attempts/{run-attempt}/jobs` route (fully paginated), +and the exact main ref. It uses the fixed REST headers and validated ids above, never a response URL +or a caller-selected workflow. The local policy auditor uses the same bounded history reads to +produce the prerequisite record. + +The publisher has two jobs. `build-and-verify` has only `contents:read`, `actions:read`, and +`packages:write` and does not reference the protected environment; it checks out without persisted credentials, proves +`HEAD == S` and the recursive checkout is clean immediately +before the gate-context build, pushes and anonymously verifies `D`, and exports only non-secret +`{S,D,protocol,tag,approval-challenge,build-attempt}` job outputs. `build-attempt` is the build +step's own `github.run_attempt`, rendered as a canonical positive-u32 decimal string and exported +unchanged by that job, never supplied by a caller or derived again in `update-pin`. +After anonymous verification it obtains 32 bytes +from the runner OS CSPRNG and renders `approval-challenge` as 64 lowercase hexadecimal characters. It +writes the exact challenge, `S`, `D`, tag, run id, and run attempt to the job summary so the approver +can inspect them; the challenge is public but unpredictable before this attempt reaches that point. It +builds only the freshly staged `G` context, which contains the reviewed `.dockerignore`. Only after +that job succeeds does `update-pin` start with +`environment: build-container-release`. That job does no image build, receives the non-secret outputs, +checks out without persisted credentials, mints the scoped App token, and performs only the pin branch +and PR mutation. Thus the environment's private key is unavailable to the gate-context build job. +Each publisher job has exactly one fixed `assert-exact-publisher-context` step, executed from the +active `G` checkout before sensitive work. It verifies tag event/ref, `github.sha==github.workflow_sha` +and equals release source `S`, exact run id/attempt/tag, active gate, and enabled release state; a +missing, duplicate, failed, candidate-resolved, or differently named assertion is fatal. +The publisher workflow itself is a gate-owned file unchanged from `G`; it checks out trusted helper +code at repository variable `EDGEZERO_BUILD_CONTAINER_GATE_SHA` into a separate root. Before image +build, the helper validates `S`'s isolated release request, verifies every gate/context path at `S` is +byte-identical to `G`, and copies only canonical manifested paths from the clean `G` root into a fresh +context outside both checkouts. The Dockerfile comes from `G`; candidate Dockerfiles, tools, +validators, context files, and post-install replacement steps are unreachable. The workflow executes +the approval gate and pin updater only from the same `G` root. The protected gate's structural +publisher checker rejects any candidate that changes this topology, permissions, ordering, action +pin, checkout source, context-construction source, or helper invocation. + +For gate-update and gate-rollback subject-data validation, old `G` invokes the network-free structural +checker exactly as: + +```text +check-build-container-publisher.sh --gate-root --subject-root --gate-sha --candidate-sha +``` + +The checker executes only from the clean, detached gate checkout at `G`. It reads the candidate's +workflow files and complete workflow-name set as Git blobs from the full subject checkout at `T`; it +never sources or executes candidate content. It validates `publish-build-container.yml`, +`rotate-build-container-gate.yml`, and the absence of any third workflow claiming their shared +concurrency group. It emits no stdout on success and makes no network request. Runtime publisher-run +and approval evidence is a separate responsibility of `verify-build-container-publication.sh`. + +Pin branches and PRs use a short-lived, protected-environment GitHub App installation token requested +for repository `edgezero` with explicit `contents:write` and `pull_requests:write`. The publisher +requires the token action's installation-ID output to equal +`EDGEZERO_BUILD_CONTAINER_APP_INSTALLATION_ID` before use. They do not use `GITHUB_TOKEN`: its push +does not create a new workflow run, so it cannot guarantee the automatic protected pin-check path. The branch updater records the remote OID and +uses an explicit force-with-lease; ambiguous, closed, superseded, and already-merged PR states follow +the fixture-tested fail-closed state machine in the implementation plan. It writes both pin records +through gate-owned typed encoders, uses the exact protected branch/title, and records its own run and +current approval values; it cannot accept a caller-supplied evidence file. + +Let `I` be `image-source-revision` in the protected default branch's current `image.json`; absence is +the first-pin state. Normal publication may propose `S` only when `I` is absent, `I==S`, or `I` is an +ancestor of `S`. An older or incomparable `S` fails before mutation. The publisher fully paginates all +open pin PRs whose author id/login equals the authenticated dedicated App and whose head repository is +exactly `stackpop/edgezero`, and fails if a matching pin branch or title is owned by another actor or +repository. It compares every proposed source with `S`: it closes older-source PRs when +superseding them; updates one exact `{S,D}` PR idempotently; closes and replaces one same-`S`, different-`D` +PR; treats a run older than an existing proposal as superseded success without mutation; and fails on +incomparable, malformed, multiple-same-source, or otherwise ambiguous state. Required pin +CI recomputes the `I`-to-`S` ancestry relation against the PR's current merge-queue base, so a stale +older PR cannot merge after a newer pin. Operational rollback never regresses default-branch +`image.json`; consumers select an earlier reviewed exact action version containing its corresponding +pin. + +The administrator-bypass screenshot is repeated at the protected-secret boundary. For every workflow +run attempt in which `update-pin` is eligible, including every same-tag rerun, its environment approver +waits for `build-and-verify` to succeed, opens the environment settings, and captures a fresh PNG that +visibly includes the repository, environment, disabled administrator-bypass control, and sole final +tag policy. The +approver computes its digest and enters exactly one line as the environment review comment before +approving the job: + +```text +edgezero-release-evidence-v1 {"challenge":"<64-lowercase-hex>","image-digest":"","png-sha256":"sha256:<64-lowercase-hex>","release-tag":"","reviewed-at":"","run-attempt":"","run-id":"","source-revision":""} +``` + +The JSON is compact, uses the shown key order and string types with no extra key or whitespace, and every +placeholder obeys its already defined syntax. `RFC3339-UTC` here is exactly a valid calendar instant +in `YYYY-MM-DDTHH:MM:SSZ` form, with no fractional seconds or offset spelling. In +`release-approval-gate.sh`, the exact `reviewed-at` instant is neither future nor more than 15 minutes +before the machine check performed immediately before App-token minting. Later typed-record, pin, and +publication verifiers check grammar, calendar validity, exact comment equality, and the successful +publisher context step but do not apply a new wall-clock age test to archived evidence. `update-pin` +initially has only `actions:read` and +`contents:read`. After checking out exact gate SHA `G` without persisted credentials and before +App-token minting, its trusted gate helper uses the current `GITHUB_TOKEN` only for exact no-redirect +`GET /repos/stackpop/edgezero/actions/runs/{github.run_id}` and +`GET /repos/stackpop/edgezero/actions/runs/{github.run_id}/approvals`, with the fixed REST headers +defined above. The approval endpoint is +non-paginated; the helper requires one complete, valid HTTP 200 JSON array. It requires the API run id and +`run_attempt` to equal `github.run_id` and `github.run_attempt`. Parse API `id` as a positive u64 +JSON integer and API `run_attempt` as a positive u32 JSON integer without lossy numeric coercion, +then render them as canonical decimal strings for comparison with context, output, and comment +strings. An API string, fractional number, zero, or out-of-range integer is rejected. Before any App-token minting, an +unconditional, success-gating check also requires the inherited `build-attempt` to be well-formed and +equal to that same current attempt. Missing/stale build outputs, failure masking, or a later check +fail the publisher contract. Thus rerunning only `update-pin` cannot reuse the previous build's +challenge; recovery requires rerunning the build and downstream job, with fresh challenge and +approval. Exactly one approved review for +`build-container-release` must have the exact current challenge, `D`, and remaining fields above, and +the API reviewer's login becomes the recorded approver. Every protocol-prefixed review claiming the +current run id and attempt is parsed: there must be exactly one, it must be approved and exact, and no +second current-attempt protocol record may exist. A rejected or mismatched current-attempt record, +missing or malformed history, environment bypass without the approval, stale or future time, or a +different challenge fails before any mutation credential exists. Records for earlier attempts remain +historical data but can never satisfy the current attempt. Only after proving the build executed in +the current attempt may the gate rely on that attempt's new CSPRNG challenge against predeclaration. +Tests cover failed-job-only and pin-job-only reruns, inherited attempt 1 at current attempt 2, +missing outputs, noncanonical/overflow attempt strings, realistic integer API responses and invalid +API types/ranges, and a full rerun producing new approval +evidence. This is enforced before minting, not deferred to the later pin PR's exact-attempt check. + +Only after that gate passes may `actions/create-github-app-token@v3.2.0` mint the App token. The release operator then +attaches a canonical record containing the API reviewer login and exact comment plus the byte-identical +PNG to the release evidence. The comment cryptographically binds the reviewer attestation to the PNG +bytes and run attempt; the screenshot's visible meaning remains a required human review, not a claim +of machine image interpretation. The initial private-package stop and every canceled or rerun attempt +require a new capture and approval. This check is required because the API-invisible setting cannot be +proven current by the pre-`S` helper. + +The repository's zizmor policy uses `ref-pin` for every non-local action. The structural pin scanner +is the stronger authority: it accepts only canonical exact stable `v..` refs, +strict Docker `sha256` digests, and the exact scanned workflow/action/documentation surfaces. It +rejects commit SHAs as well as floating major/minor tags and branches. + +Gate `G` includes the permanent dual-state documentation scanner from the start. It parses fenced +YAML in every tracked Markdown file. Its trusted workflow selects the comparison range from this +closed event table; every SHA is a full lowercase 40-hex object present in the full subject checkout, +and any other event or malformed/inconsistent field fails closed: + +- **`pull_request`:** let `A=github.event.pull_request.base.sha`, + `J=github.event.pull_request.head.sha`, and `M=github.sha`. + Require `github.event.pull_request.base.repo.full_name==stackpop/edgezero`, + `github.event.pull_request.base.ref==main`, + a canonical positive PR number, and `github.ref==refs/pull//merge`. Authenticate `M` through + this trusted event/context and exact-SHA subject materialization from `stackpop/edgezero`. Its + content-addressed commit must have exactly the ordered parents `[F,J]`, where all four SHAs are + nonzero, full, available commit objects. Require `A` ancestor-or-equal to `F`, then select + `base=F,candidate=M`. The payload base may lag the synthetic first parent; neither equality nor + comparison against the older `A` is required. Missing/shallow history, replacement refs/grafts, + wrong parent order/count, and reversed or incomparable ancestry fail closed; +- **`merge_group` `checks_requested`:** base is `github.event.merge_group.base_sha` and candidate is + `github.event.merge_group.head_sha`. Require base ref `refs/heads/main`, head ref equal to + `github.ref`, head ref beneath exact prefix `refs/heads/gh-readonly-queue/main/`, candidate equal to + `github.sha`, and base an ancestor of candidate; +- **protected-main `push`:** base is `github.event.before` and candidate is `github.event.after`. + Require `github.ref==refs/heads/main`, nonzero base, candidate equal to both `github.sha` and + `github.workflow_sha`, and base an ancestor of candidate. + +`workflow_dispatch` is a separate credential-smoke mode and invokes neither the change classifier nor +the documentation scanner. The “other event” rejection above applies whenever either range consumer +is invoked. + +The documentation scanner and each independent classifier use that exact selected pair for state +reads, changed paths, and protected-base manifest checks. PR diagnostics retain `{A,F,J,M}`. Never +substitute a moving main/API base, PR-head-only comparison, merge-base, or three-dot diff. Upstream +changes in `A..F` are not candidate changes; a release record already present at `F` still controls +the candidate's documentation state. Later main movement cannot change this event-bound range. +The selected `merge_group` and push pairs retain their existing contracts; neither is forced into +the PR's two-parent shape. + +Every subject-history Git subprocess receives only runner `PATH`, `LC_ALL=C`, `HOME=/dev/null`, +`TMPDIR=/tmp`, a ceiling at the subject's parent, `GIT_NO_REPLACE_OBJECTS=1`, +`GIT_CONFIG_NOSYSTEM=1`, `GIT_CONFIG_GLOBAL=/dev/null`, and `GIT_TERMINAL_PROMPT=0`, and every command +also uses `--no-replace-objects`. Repository/worktree/common-dir, namespace, index, object/alternate, +shallow-file, inline-config, replacement-ref-base, XDG, and arbitrary caller environment values are +absent from the child. + +The scanner compares the selected base and candidate states as follows: + +- **bootstrap:** `docs/.edgezero-action-release.json` is absent from both base and candidate. Only the + four named prepublication adoption documents may use literal `` for an + EdgeZero ref; every other external ref still obeys the exact stable-version rule; +- **transition:** the record is absent from base and added by the candidate. The candidate changes + only tracked Markdown plus that record, every EdgeZero ref equals its literal `V`, no placeholder + remains, and a fixed no-redirect versioned API/ref verifier proves `V` is a published + `draft:false`, `prerelease:false`, `immutable:true` release whose peeled tag equals recorded `P`; +- **released:** the record exists on the base and cannot be deleted. It is either byte-identical or a + documentation-only candidate atomically replaces it and all EdgeZero documentation refs with a + strictly greater canonical stable version whose immutable release/ref binding to its new `P` passes + the same hosted proof. Every candidate remains placeholder-free and every EdgeZero ref in each + fenced workflow equals the candidate record's `V`; downgrade, partial update, or non-document + change fails closed. + +The hosted release verifier reads its optional API token in the trusted parent and passes it only +through curl config stdin. The curl child environment is exactly runner `PATH` and `LC_ALL=C`; ambient +token, proxy, CA-bundle, home/XDG, curl-home, and arbitrary variables are absent. Its anonymous +release-ref Git child runs in a fresh temporary working directory/home with only `PATH`, that home and +ceiling, disabled system/global configuration, disabled terminal prompting, and replacement objects +disabled in both argv and environment. Fixtures inject each prohibited environment family and fail if +a child observes one. + +The first transition therefore happens only in `R`, after `V` exists. Candidate `H` never puts an +unpublished or retired version into protected-main documentation, no later PR can return to +bootstrap mode, and later action releases repeat the same post-release atomic record/documentation +update. During the queue, “candidate” means the synthetic pull-request or merge-group commit selected +above; `R` names only the resulting protected-main commit after the push range passes. + +## 9. Testing + +Required automated coverage includes: + +- cold, warm, uncached, corrupt-restore, stop-failure, write-error, audit-failure, and save-warning + cache paths, plus separate complete lookup-eligibility and protected-event save-authorization truth + tables for both cache families; +- both fixed host cache paths, absent-root/overlapping-invocation rejection, verified cleanup, + cache-disabled absence of the fixed parent target root and cleanup of its invocation-private target, + cross-host-checkout-path hits, nested workspace and sibling path deps, public Git dependencies, + concurrent generations, seven-day expiry as documented behavior, exactly + one Cargo compile/build invocation after metadata preflight, no action-level retry, and pinned + sccache response-loss fallback behavior; +- cache audit type/owner/path/layout/logical-byte/non-sparse/path-length/entry-count checks and + arbitrary app-written regular data disclosure; +- full source inventory, deleted/modified tracked paths, gitlinks, escaping symlinks, overlapping or + tracked-containing output roots, nested-project implicit Fastly `bin` and `pkg` roots, absent-root + precreation, special-file/hardlink rejection, descriptor-relative cleanup, caller-declared generated + output, undeclared output rejection, source-free lifecycle bypass of Copy B checks, and unchanged + original checkout; +- every environment and mount profile, including Copy A/Copy B/Copy I metadata source selection, + Copy I's identity-only lifetime and cleanup, the container-only credential-free metadata preflight, + rejection of authority or host Cargo/config/credential influence, token absence, production healthcheck tokenlessness, + staging token presence, credential-free `app-build`, every exact `app-env` name/value/count/size + boundary, sorted placeholder-only `env -S` argv, expansion-before-clear edge values, inherited/image/ + Docker variable removal, empty Cargo-config policy, config-push repo/config confinement, and + deploy-without-sccache; +- strict caller identity, full-SHA app refs, exact-version workflow/action refs, resolved workflow + SHA, exact GitHub-hosted Linux/X64 context checks in every public action and sensitive repository + job, step-local empty `BASH_ENV`/`ENV` before every shell-backed first guard, immutable EdgeZero + release enforcement, locally derived platform identity, matrix artifacts, + and consumer recomputation for private repositories; +- exact canonical metadata and expected JSON, typed `write-expected`, schema versions, duplicate keys, + byte-exact ustar + headers/padding/end blocks, deterministic package output, every accepted/rejected dynamic string and + object-acquisition tag, conservative ELF/loadability vectors, all provenance golden/malformed + fixtures, exact ELF header sizes/versions and extended-numbering rejection, dynamic-token rejection, + controlled direct-loader invocation, absent system preload, inhibited cache, flat runtime-library + closure, hardware-capability/default-path non-substitution, explicit `dlopen` non-claim, + every consumer independently writing fresh expected identity, provider actions independently + validating named artifacts and rechecking the binary + handoff, and the split parse/extract versus binary-smoke boundary; +- real pinned-image GNU Rust CLI acceptance against Bookworm libc, both accepted OSABIs, exact + dependency interpreter metadata, file/memory dynamic-table equivalence, filename/SONAME alias + rejection, and the single interpreter-alias resolution; +- complete producer token/App mode truth tables, host-only private-key/token handling, single-repo + contents-read minting, revocation, same-job consumer tokens after approval and after expiry, + default-retention artifacts consumed beyond 24 hours, and private-source artifact readership; +- exact Rust/Fastly/sccache versions, installed wasm target plus a minimal wasm compile, image labels, + leaf-manifest platform checks, anonymous pulls, always-materialized required container jobs, + protected gate/workflow identity, gate-owned staged build context and post-install replacement + resistance, release-request isolation, required-workflow descriptor and bypass checks, exact + merge-queue payload and single-entry behavior, API-visible exact-`S` push assertion-step evidence, + image-pin deletion and source-ancestry ordering, environment + reviewer/self-review/deployment-policy checks, per-attempt approval comment and token-ordering checks, + policy-API method/path/header/version allowlisting, canonical publisher-prerequisite record parsing, + writer credential/method/path/body confinement, inert/`S`-bound transition truth tables, post-write + readback, queued publisher observing post-concurrency variable values, documented greatest + `run_number` selection, complete run/attempt-history digest, two-pass snapshot stability, and old-run + rerun invalidation, App + installation/repository/permission/token- + scope checks, actionlint queue-compatibility isolation, publication concurrency and queue-overflow + cancellation, gate-rotation failure recovery, and release rerun/idempotency; +- post-`B` dispatch/activation/rollback using distinct gate, dispatch, and final-head identities; + authenticated, fresh local policy receipts without hosted variable-read credentials; + failed/canceled/timed-out rotation blocking publication even with release state still enabled; + lagging PR payload base with authenticated first-parent selection, upstream-only code changes and + release-state additions, immutable event-range behavior under later main movement, and independent + classifier/scanner use of that same base; installation-wide metadata audit rejecting an extra + private repository hidden from the bounded probe; credential replacement between ingestion and + completion; and inherited publisher build-attempt rejection before token minting; +- production/staging deploy, active-version, healthcheck, rollback, config push, mutation signaling, + cancellation, and the exclusive `--staging` spelling. + +Cold evidence starts from an empty audited cache root and, after zeroing statistics, requires +`cache_misses.counts["Rust"]>=1`, `cache_writes>=1`, and zero write errors. Warm evidence runs in a +new job with fresh target/Cargo-home directories, restores the recorded cold generation through the +sole family prefix, zeros statistics, and requires `cache_hits.counts["Rust"]>=1`; the rebuilt binary +digest must equal the cold binary digest. Default-off evidence proves no cache action or sccache +process ran. Dependency fetching remains online because source archives are not cached. Wall-clock +improvement is telemetry, not a pass/fail condition. + +## 10. Rollout and migration + +To publish final action revision `P`, exact version `V`, and its adoption documentation: + +1. Migrate every existing non-local external action and reusable workflow reference in the repository + to a reviewed exact stable patch-version tag, change the repository-wide pin gate accordingly, and + retain zizmor `ref-pin` as defense in depth. Record the accepted third-party tag-movement risk. +2. Implement and separately land the validator, schema, fixtures, protected classifier/verifier + helpers, publisher contract checker, and required workflow as gate baseline `G`. Configure the + organization required-workflow rule directly to gate commit `G` (this is not a consumer `uses:` + ref) and the mandatory default-branch merge queue. +3. Include the exact Dockerfile and complete image-context closure in `G`. Open an isolated canonical + `release-request.json` candidate, run it through the protected gate, complete the credential smoke + at protected dispatch snapshot `Q` under the verified `{G,Q}` contract, merge it only through the + merge queue as `S`, and require the API-visible exact post-merge `S` push + assertion-step evidence before tagging. Build only from the freshly staged `G` context. +4. Publish and anonymously verify the image, then merge the ancestry-checked pin PR as baseline `B`. +5. Select unused canonical patch version `C`. Land the reusable workflow, cache, provenance, + launcher, and consumer integration; replace the legacy direct-composite producer with its + fail-closed retirement stub; migrate repository integration coverage to the reusable workflow; + and leave prepublication adoption examples at the gated placeholder. Record resulting main commit + `H`. +6. Rerun the complete local suite from detached `H`; have a verified active releaser-team member use + a local credential to draft and publish immutable `C` at `H` with `prerelease:true`; and run the + complete hosted cross-repository/provider suite through literal `C`. On success designate `H=P`, + select unused stable `V`, use the same auditable actor procedure to draft and publish it at `P`, + verify both release/ref resolutions and attestations, and run the final literal-`V` hosted smoke. + A candidate failure follows the new-commit/new-`C` rules in Section 8. +7. After the literal-`V` smoke passes, merge documentation-only `R` through the protected queue. Add + the `{V,P}` action-release record and replace every gated placeholder with literal `V`; the + preinstalled dual-state gate proves the release/ref binding and permanently enters released mode. + Run the docs build and exact-version scan on protected main and record `R`. + +Caching remains off by default. Container execution and provenance validation are unconditional. + +## 11. Out of scope + +- Detecting sccache staleness from undeclared proc-macro or `build.rs` inputs. +- Authenticating the artifact producer or proving workflow-bound attestation. +- Caching dependency source archives, private dependency credentials, native-tool sccache wrapping, + self-hosted runners, alternate toolchains, non-default feature sets, or non-Fastly adapters. +- Cache lineage merging, family-local eviction, or action-managed cache deletion. +- Preventing a GHCR package administrator from manually deleting a supported image digest; GitHub does + not expose a per-version retention lock for this contract. + +## 12. History + +- **v6.17:** introduced the build-only reusable workflow, consumer deployment job, deploy-compile + profile, full working-copy verification, `job.check_run_id`, and explicit undeclared-input risk. +- **v6.18:** split trusted provenance extraction from untrusted binary execution; completed provider + mount/environment profiles; strengthened full-inventory source verification; made cache family and + warning-only saves coherent; corrected sccache error/size/audit contracts; made platform identity + action-derived; replaced impossible same-SHA publication with image source `S`, pin baseline `B`, + and final action revision `P`; made full-SHA external references normative; and made validator + capability fixtures a hard publication prerequisite. +- **v6.19:** froze the protocol-1 metadata and expected-identity JSON shapes, schema/version bounds, + byte-exact ustar encoding, conservative ELF/loader profile including exact dynamic-string and + object-acquisition semantics, and shared package/validate authority; selected the exact base and + sccache artifacts; moved release prerequisites before `S` with verifiable environment and + least-privilege App controls, including explicit manual evidence for the API-invisible administrator + bypass setting and an organization-owner package-audit identity; aligned zizmor with full-SHA policy; + removed unenforceable GHCR retention claims; and replaced path-filtered required image jobs with an + always-triggered workflow whose stable jobs explicitly succeed when not applicable. +- **v6.20:** made cache writes an action-derived protected-event decision; added the typed canonical + expected-identity producer; froze suffix and ELF edge cases; replaced candidate-controlled image + checks with an immutable-SHA organization required workflow and mandatory merge queue; required an + exact post-merge source check; split tag creation authority from no-bypass immutability; specified the policy-audit credential and request allowlist; bound + each protected-secret approval to a per-attempt CSPRNG challenge, image and screenshot digest before token minting; defined + forward-only pin ancestry and bounded publication queue semantics; and assigned current Fastly + `pkg` output to the source-freeze contract. +- **v6.21:** replaced archiver-dependent cache sizing with exact filesystem-tree bounds; froze the + app-environment, Cargo-config, generated-output, nested Fastly `bin`/`pkg`, cleanup, and controlled + loader contracts; moved every repository image-context input into gate `G` and made `S` an isolated release + request built from a staged trusted context; defined gate rotation and recovery, exact one-entry + merge-queue policy, API-visible dispatch/push assertion evidence, REST API headers/version, and the + narrowly scoped actionlint 1.7.12 `queue: max` compatibility check. +- **v6.22:** separated the self-referential action release from its documentation by defining + documentation-only revision `R`, whose concrete examples pin already-known full SHA `P`; froze the + reusable-workflow input, self-checkout, artifact transport, network/resource, and private binary- + state contracts; froze the organization required-workflow ruleset's repository/ref target; and made + the protected release environment's machine-verified absence of GitHub App custom deployment- + protection rules an explicit prerequisite for the non-deployment credential-smoke job. +- **v6.23:** replaced full-SHA action/workflow references with exact stable patch-version tags and an + immutable EdgeZero release process; removed the documentation-only self-reference workaround; + defined authority-checkout export and pinned-LFS rules, uncached compilation, pre-restore + disclosure, parent-cache authorization, closed sccache JSON statistics, consumer expected-file + production, candidate-bound credential-smoke evidence, standalone validator workspace/build + closure, exact release-request production, self-test isolation, and generated-output sparse/size + limits. +- **v6.24:** made exact version tags unambiguous for every non-local `uses:` reference while retaining + commit SHAs only for source/provenance identity; removed the last cache-action SHA-pinning + contradiction; assigned distinct notation to the current image source pin; froze the complete + sccache statistics schema and warm-evidence counters; and specified the exact checksummed host Git + LFS installation, authenticated materialization, and credential-removal sequence. +- **v6.25:** made release-candidate `uses:` refs exact patch versions without prerelease suffixes; + removed circular `P` qualification; matched the immutable-release settings response; distinguished + immutable tag identity from accepted privileged release-object deletion risk; and replaced + unprovable historical release-name absence with current ref/release absence plus creation success; + reserved `H` for the final action candidate and renamed the generic protected head `Q`; made every + normative JCS example a literal single line; closed Rust/native compiler overrides; moved filter/ + submodule validation before worktree creation; fixed Git LFS asset size/transfer bounds; defined + local bootstrap image `L`; assigned config-push authority checks, permanent documentation pin + enforcement, and the auditable action-release actor/procedure. +- **v6.26:** moved literal stable-version documentation into post-release revision `R` under a + one-way dual-state gate so failed candidates cannot strand unpublished refs on main; selected `V` + only after `H` qualifies as `P`; froze the local fine-grained release PAT permissions, API + allowlist, actor proof, and credential handling; and defined the public consumer identity action + plus per-invocation source materialization so no authority path crosses a public action boundary; + and closed the documentation scanner's pull-request, merge-group, and protected-push ranges. +- **v6.27:** froze the GNU `env -S` expansion-before-clear launch protocol so Docker/image-created + variables cannot violate closed target environments while no argv element is value-derived; + required a context-derived GitHub-hosted Linux/X64 check in every public action and sensitive + repository workflow job; retired the legacy direct-composite producer at candidate revision `H`; + and corrected provenance packaging to accept either compile profile's output. +- **v6.28:** separated the three-field public-action runner helper from producer workflow and cache + identity; added the checkout-independent producer bootstrap before credentialed checkout; and + fixed the v1 compatibility contract to literal standard GitHub-hosted `ubuntu-24.04` jobs while + retaining the context predicate as the self-hosted-runner security check. +- **v6.29:** required fixed inline checkout verification before any checked-out helper executes; + made workflow and composite runner guards structurally non-bypassable; propagated the first-step + and literal-runner contract into the protected gate workflow tests; and replaced "ordinary job" + with exact step-based-job and reusable-workflow-caller AST rules. +- **v6.30:** corrected Bookworm libc acceptance and loader alias rules, made dynamic-table mapping + equivalence explicit, separated protected dispatch head from gate revision, authenticated the + synthetic-merge comparison base, restored default artifact retention and App-based checkout + migration, separated installation-wide scope audit from bounded mutation-token smoke, bound + credential freshness to smoke ingestion, and rejected cross-attempt publisher output reuse before + token minting. Rotation consumes authenticated local policy receipts rather than inaccessible + public-variable reads, and API integer attempts are normalized before string comparisons. + Publication requires the latest evidence-bound rotation attempt to have completed successfully, + since a failing lock workflow alone cannot preserve GitHub concurrency. + The five existing plans carry the corresponding fixtures and hosted checkpoints; + this design revision does not claim those implementation changes are complete. +- **v6.31:** defined the publisher's authenticated prerequisite ingress as one canonical repository + variable with a separately reviewed local single-variable writer, inert post-rotation state, exact + release-source binding, and post-concurrency freshness fixture; replaced undocumented numeric run-id + ordering with unique latest RFC 3339 creation time; closed first-shell `BASH_ENV`/`ENV`, parent target- + cache path/lifecycle, and credential-free metadata execution profiles, including the identity-only + consumer copy required when Copy A/Copy B is unavailable; and assigned the corresponding + ownership and adversarial tests across the five plans. Exact stable patch-version `uses:` refs and + their accepted third-party tag-movement risk are unchanged. +- **v6.32:** separated the network-free publisher-workflow structural checker from the pin job's + read-only publisher-run and approval verifier; fixed both CLIs, the exact-attempt jobs endpoint, + bounded polling behavior, and current-attempt approval interpretation; and added the runtime + verifier plus the workflow-executed yq installer to the protected gate inventory. +- **v6.33:** reserved `H` for the final action candidate and bound protected helpers to generic + event-selected subject head `T`; added trusted candidate-blob action-reference enforcement; fixed + publisher run-path matching, jobs pagination/count closure, and request timeouts; and completed the + helper interfaces, gate inventory, dependency order, and reproducible verification commands. +- **v6.34:** separated opaque bounded prerequisite-audit attachments from the exact machine transition + record; added explicit independently hashed policy- and writer-token screenshots and prior-variable + binding; froze request status, pagination, check-filter, pin-updater REST/Git, release-record writer, + scanner-output, and yq asset contracts; and required complete untracked-file and Node-version checks + in final qualification. The small transition record retains latest rotation identity so an old + same-gate source cannot survive a rollback and authorize a queued publisher. +- **v6.35:** selects the latest rotation by unique greatest `updated_at` and binds both creation and + update instants so a rerun of an older run id cannot hide behind a newer run's creation time. It also + distinguishes the trusted local auditor's live organization-ruleset check from the publisher's + authenticated receipt check and removes the unimplementable claim that the Actions publisher reads + the live organization descriptor. +- **v6.36:** replaces undocumented `updated_at` ordering with documented monotonic-per-workflow + `run_number`, binds a digest of every current run attempt, and requires a stable two-pass history plus + final selected-detail read. The post-concurrency guard now has an explicit linearization point; + rotations entering the FIFO group later are checked by the next publisher. Bootstrap evidence review + is explicitly a manual administrative trust-root assertion rather than a machine-verifiable claim. +- **v6.37:** closes the publisher-verifier rerun race with a final run-detail linearization read; + defines full repositories to exclude partial/promisor clones and object alternates with lazy fetch + disabled; distinguishes reviewed fine-grained-token inventory ids from authenticated account ids; + and states the local prerequisite writer's operator-serialization requirement and accepted lack of + an atomic GitHub variable compare-and-set primitive. +- **v6.38:** binds the source PR number and stable evidence-comment URL into the versioned publisher + prerequisite record so a tag-triggered publisher can supply the pin updater without ambient state or + an unbounded lookup. Release audit now consumes a pre-created inert candidate-PR comment URL; the + operator attaches the exact generated evidence to that stable comment before independent review and + writer invocation. The incompatible prerequisite wire shape advances to schema version 2. +- **v6.39:** replaces the unsupported installation-token `GET /user` assumption with exact public + lookup of the reviewed `[bot]` identity, retaining the workflow's installation-ID guard and + final PR-author proof. It also defines same-source replacement head movement, permits reconciliation + from an older ancestor base, and requires existing pin records to be mode-0644 blobs. + +## 13. Deferred implementation mechanics + +Implementation plans may choose helper names and internal module boundaries. They must commit the +schema implementing Section 6.2, golden bytes and malformed fixtures for Sections 6.2 through 6.5, +sccache v0.10 layout/stats fixtures, exact cache tree-bound and entry-count vectors, release +versions/checksums, and command-level tests before exact action version `V` at final action revision +`P` is published. +Those are mechanics, not permission to weaken the contracts above. diff --git a/docs/specs/edgezero-deploy-adoption-guide.md b/docs/superpowers/specs/edgezero-deploy-adoption-guide.md similarity index 79% rename from docs/specs/edgezero-deploy-adoption-guide.md rename to docs/superpowers/specs/edgezero-deploy-adoption-guide.md index 20d0217c..7d189b58 100644 --- a/docs/specs/edgezero-deploy-adoption-guide.md +++ b/docs/superpowers/specs/edgezero-deploy-adoption-guide.md @@ -1,8 +1,13 @@ # EdgeZero Deploy Actions — Adoption Guide +> Prepublication: these examples use `` and are not runnable +> until the caching release and adoption migration are complete. Public action and +> workflow references require one exact stable `vMAJOR.MINOR.PATCH` version, never a +> SHA, branch, major/minor tag, or prerelease. Third-party tag movement is accepted. + **Status:** Adoption guide for any EdgeZero application repository -**Spec:** `docs/specs/edgezero-deploy-github-action.md` +**Spec:** `docs/superpowers/specs/edgezero-deploy-github-action.md` The layered deploy actions are for **any** EdgeZero application repository, not a single deployer. This guide describes the general adoption shape and then walks @@ -38,21 +43,21 @@ The app and its deploy workflow live in one repo. ```yaml jobs: deploy: - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 permissions: contents: read steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7.0.1 with: persist-credentials: false - id: cli - uses: stackpop/edgezero/.github/actions/build-app-cli@ + uses: stackpop/edgezero/.github/actions/build-app-cli@ with: app-cli-package: my-app-cli # the CLI crate in your app's workspace - id: deploy # so recovery/rollback can read steps.deploy.outputs.* - uses: stackpop/edgezero/.github/actions/deploy-fastly@ + uses: stackpop/edgezero/.github/actions/deploy-fastly@ with: app-cli-artifact: ${{ steps.cli.outputs.app-cli-artifact }} fastly-api-token: ${{ secrets.FASTLY_API_TOKEN }} @@ -72,43 +77,46 @@ and point both actions at it. > earlier `id: app-token` step produced `steps.app-token.outputs.token`. ```yaml -steps: - - name: Checkout deployer - uses: actions/checkout@v4 - with: - path: deployer - persist-credentials: false - - - name: Checkout application - uses: actions/checkout@v4 - with: - repository: stackpop/my-edgezero-app - # MUST be a trusted, immutable ref (a full commit SHA, or a protected tag) - # — never an arbitrary branch. Fastly's default `build-mode: never` means - # `fastly compute deploy` COMPILES the application while the API token is - # in scope, so untrusted code would run with your credentials (spec §10.1). - ref: ${{ inputs.ref }} - path: app - persist-credentials: false - # A private app repo is NOT readable with the deployer's default - # GITHUB_TOKEN. Supply a token scoped to the app repo — a GitHub App - # installation token (preferred) or a fine-grained PAT with - # `contents: read` on the app repo: - token: ${{ steps.app-token.outputs.token }} - - - id: cli - uses: stackpop/edgezero/.github/actions/build-app-cli@ - with: - app-cli-package: my-app-cli - working-directory: app - - - id: deploy # so recovery/rollback can read steps.deploy.outputs.* - uses: stackpop/edgezero/.github/actions/deploy-fastly@ - with: - app-cli-artifact: ${{ steps.cli.outputs.app-cli-artifact }} - working-directory: app - fastly-api-token: ${{ secrets.FASTLY_API_TOKEN }} - fastly-service-id: ${{ vars.FASTLY_SERVICE_ID }} +jobs: + deploy: + runs-on: ubuntu-24.04 + steps: + - name: Checkout deployer + uses: actions/checkout@v7.0.1 + with: + path: deployer + persist-credentials: false + + - name: Checkout application + uses: actions/checkout@v7.0.1 + with: + repository: stackpop/my-edgezero-app + # MUST be a trusted full commit SHA, never a branch or tag. Fastly's + # default `build-mode: never` means + # `fastly compute deploy` COMPILES the application while the API token is + # in scope, so untrusted code would run with your credentials (spec §10.1). + ref: ${{ inputs.ref }} + path: app + persist-credentials: false + # A private app repo is NOT readable with the deployer's default + # GITHUB_TOKEN. Supply a token scoped to the app repo — a GitHub App + # installation token (preferred) or a fine-grained PAT with + # `contents: read` on the app repo: + token: ${{ steps.app-token.outputs.token }} + + - id: cli + uses: stackpop/edgezero/.github/actions/build-app-cli@ + with: + app-cli-package: my-app-cli + working-directory: app + + - id: deploy # so recovery/rollback can read steps.deploy.outputs.* + uses: stackpop/edgezero/.github/actions/deploy-fastly@ + with: + app-cli-artifact: ${{ steps.cli.outputs.app-cli-artifact }} + working-directory: app + fastly-api-token: ${{ secrets.FASTLY_API_TOKEN }} + fastly-service-id: ${{ vars.FASTLY_SERVICE_ID }} ``` ### 2.3 Monorepo application @@ -119,29 +127,32 @@ subdirectory (which in a nested workspace may be the subdirectory itself, not th repo root), so a monorepo caches the right artifacts. ```yaml -steps: - - uses: actions/checkout@v4 - with: - persist-credentials: false - - - id: cli - uses: stackpop/edgezero/.github/actions/build-app-cli@ - with: - app-cli-package: api-cli - working-directory: apps/api - - - id: deploy # so recovery/rollback can read steps.deploy.outputs.* - uses: stackpop/edgezero/.github/actions/deploy-fastly@ - with: - app-cli-artifact: ${{ steps.cli.outputs.app-cli-artifact }} - working-directory: apps/api - manifest: edgezero.toml - # `cache` only takes effect with `build-mode: always` (the credential-free - # build that seeds the cache); with the Fastly default `never` it is a no-op. - build-mode: always - cache: true - fastly-api-token: ${{ secrets.FASTLY_API_TOKEN }} - fastly-service-id: ${{ vars.FASTLY_SERVICE_ID }} +jobs: + deploy: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v7.0.1 + with: + persist-credentials: false + + - id: cli + uses: stackpop/edgezero/.github/actions/build-app-cli@ + with: + app-cli-package: api-cli + working-directory: apps/api + + - id: deploy # so recovery/rollback can read steps.deploy.outputs.* + uses: stackpop/edgezero/.github/actions/deploy-fastly@ + with: + app-cli-artifact: ${{ steps.cli.outputs.app-cli-artifact }} + working-directory: apps/api + manifest: edgezero.toml + # `cache` only takes effect with `build-mode: always` (the credential-free + # build that seeds the cache); with the Fastly default `never` it is a no-op. + build-mode: always + cache: true + fastly-api-token: ${{ secrets.FASTLY_API_TOKEN }} + fastly-service-id: ${{ vars.FASTLY_SERVICE_ID }} ``` ## 3. Consumer requirements @@ -187,15 +198,14 @@ steps: `app-cli-package` — in a nested-workspace monorepo this may be your app subdirectory, not the repo root). `build-app-cli` requires it, and caching keys on it. -- Pin action references to readable released tags, or full SHAs for production - reproducibility. +- Pin action references to canonical exact stable patch-version tags. Every + EdgeZero reference in a workflow uses the same immutable release version. - Use least-privilege permissions (`contents: read`), protected environments, `timeout-minutes`, and appropriate concurrency. -- **Run on ephemeral runners** (GitHub-hosted, or self-hosted one-job-per-VM). The - lifecycle log and any inline config are removed by a best-effort `EXIT` trap; a - `SIGKILL`, runner shutdown, or hard timeout bypasses it, so on a persistent - self-hosted runner a hard kill can leave a mode-`600` credential-bearing file - behind. On such runners, post-kill temp hygiene is your responsibility. +- **Use standard GitHub-hosted `ubuntu-24.04` runners.** The caching release does + not support self-hosted, custom, or larger runners. Lifecycle logs and inline + config have best-effort cleanup; hard termination can bypass it, so the + ephemeral hosted runner remains part of the credential boundary. ## 4. Fastly staging lifecycle @@ -323,7 +333,7 @@ Workflow shape: `actions/checkout`. - Replace the legacy `fastly/*@v2` trio with `build-app-cli` + `deploy-fastly` + `healthcheck-fastly` + `rollback-fastly`. -- Pin action references to readable released tags, or full SHAs for production. +- Pin action references to canonical exact stable patch-version tags. - Read the version from `steps..outputs.fastly-version` (same concept as the legacy `fastly-version`). - Audit `TRUSTED_SERVER_CONFIG`; if still needed, keep config expansion in the diff --git a/docs/specs/edgezero-deploy-github-action.md b/docs/superpowers/specs/edgezero-deploy-github-action.md similarity index 96% rename from docs/specs/edgezero-deploy-github-action.md rename to docs/superpowers/specs/edgezero-deploy-github-action.md index a25412e1..e9a1ec4d 100644 --- a/docs/specs/edgezero-deploy-github-action.md +++ b/docs/superpowers/specs/edgezero-deploy-github-action.md @@ -1,5 +1,10 @@ # EdgeZero Deploy GitHub Actions — Layered, Adapter-Independent Spec +> Prepublication: these examples use `` and are not runnable +> until the caching release and adoption migration are complete. Public action and +> workflow references require one exact stable `vMAJOR.MINOR.PATCH` version, never a +> SHA, branch, major/minor tag, or prerelease. Third-party tag movement is accepted. + **Status:** Revised design (supersedes the Fastly-only v0 spec) **Date:** 2026-07-08 @@ -95,16 +100,15 @@ own CLI, with thin action wrappers — so the engine never grows provider logic. `zizmor`). No `python3` heredocs and no `pip install`. 9. **Pin third-party actions.** Every third-party `uses:` — in the repository's own _workflows_ and nested _inside a reusable action_ (`build-app-cli`, - `deploy-fastly`, …) — is pinned to a concrete, reviewable reference: a full commit - SHA or a released version tag, **including a movable major tag** (for example - `actions/checkout@v7` or `actions-rust-lang/setup-rust-toolchain@v1`). This is a - pinning policy, not an immutability guarantee: a version tag — a major tag such as - `@v4` especially — can be repointed by the action's publisher, so pin to a full - commit SHA where cryptographic immutability matters. A branch/floating ref - (`@main`, `@develop`, `@latest`) is rejected. `check-action-pins.sh` is the + `deploy-fastly`, …) — uses a canonical exact stable patch version (for example + `actions/checkout@v7.0.1` or `actions-rust-lang/setup-rust-toolchain@v1.17.0`). + Commit SHAs, major/minor tags, branches, prereleases, and build metadata are + rejected. Third-party tag movement is an accepted risk; EdgeZero releases use + immutable release versions. `check-action-pins.sh` is the repository-wide gate: it parses every workflow and action's YAML STRUCTURALLY (via `yq`, so a quoted, unicode-escaped, `!!str`-tagged, or multiline-scalar - `uses` cannot hide a floating ref) and accepts only a SHA or a version-tag shape. + `uses` cannot hide an invalid ref). Docker actions require lowercase SHA-256 + digests; local action paths remain local. `.github/zizmor.yml` sets `unpinned-uses` to `ref-pin` as defense-in-depth over the deploy action surface (zizmor cannot distinguish a tag from a branch, so the tag-vs-branch decision is the pin script's). @@ -424,37 +428,41 @@ versions via the API, not on application source. They need no Fastly CLI install A caller wires the trio; the actions carry no orchestration policy of their own: ```yaml -- id: cli - uses: stackpop/edgezero/.github/actions/build-app-cli@ - with: { app-cli-package: my-app-cli } - -- id: stage - uses: stackpop/edgezero/.github/actions/deploy-fastly@ - with: - app-cli-artifact: ${{ steps.cli.outputs.app-cli-artifact }} - deploy-to: staging - fastly-api-token: ${{ secrets.FASTLY_API_TOKEN }} - fastly-service-id: ${{ vars.FASTLY_SERVICE_ID }} - -- id: check - uses: stackpop/edgezero/.github/actions/healthcheck-fastly@ - with: - app-cli-artifact: ${{ steps.cli.outputs.app-cli-artifact }} - deploy-to: staging - domain: staging.example.com - fastly-version: ${{ steps.stage.outputs.fastly-version }} - fastly-api-token: ${{ secrets.FASTLY_API_TOKEN }} - fastly-service-id: ${{ vars.FASTLY_SERVICE_ID }} - -- if: >- - (failure() || cancelled()) && steps.stage.outputs.fastly-version != '' - uses: stackpop/edgezero/.github/actions/rollback-fastly@ - with: - app-cli-artifact: ${{ steps.cli.outputs.app-cli-artifact }} - deploy-to: staging - fastly-version: ${{ steps.stage.outputs.fastly-version }} - fastly-api-token: ${{ secrets.FASTLY_API_TOKEN }} - fastly-service-id: ${{ vars.FASTLY_SERVICE_ID }} +jobs: + deploy: + runs-on: ubuntu-24.04 + steps: + - id: cli + uses: stackpop/edgezero/.github/actions/build-app-cli@ + with: { app-cli-package: my-app-cli } + + - id: stage + uses: stackpop/edgezero/.github/actions/deploy-fastly@ + with: + app-cli-artifact: ${{ steps.cli.outputs.app-cli-artifact }} + deploy-to: staging + fastly-api-token: ${{ secrets.FASTLY_API_TOKEN }} + fastly-service-id: ${{ vars.FASTLY_SERVICE_ID }} + + - id: check + uses: stackpop/edgezero/.github/actions/healthcheck-fastly@ + with: + app-cli-artifact: ${{ steps.cli.outputs.app-cli-artifact }} + deploy-to: staging + domain: staging.example.com + fastly-version: ${{ steps.stage.outputs.fastly-version }} + fastly-api-token: ${{ secrets.FASTLY_API_TOKEN }} + fastly-service-id: ${{ vars.FASTLY_SERVICE_ID }} + + - if: >- + (failure() || cancelled()) && steps.stage.outputs.fastly-version != '' + uses: stackpop/edgezero/.github/actions/rollback-fastly@ + with: + app-cli-artifact: ${{ steps.cli.outputs.app-cli-artifact }} + deploy-to: staging + fastly-version: ${{ steps.stage.outputs.fastly-version }} + fastly-api-token: ${{ secrets.FASTLY_API_TOKEN }} + fastly-service-id: ${{ vars.FASTLY_SERVICE_ID }} ``` The condition is `failure() || cancelled()` because `failure()` alone does not run @@ -604,7 +612,8 @@ probe it, roll back on failure. ## 6. Execution flow (engine) -1. Verify the runner is Linux x86-64 (`ubuntu-latest` is the tested environment). +1. Verify the runner is Linux x86-64. The caching addendum requires standard + GitHub-hosted `ubuntu-24.04`, verified through the runner context. Self-hosted runners additionally require **Actions Runner 2.327.1+** — the wrappers use Node 24 actions (`download-artifact@v8`, `cache@v6`, `upload-artifact@v7`, `checkout@v7`), whose runtime ships only in that runner @@ -934,10 +943,9 @@ can print credential material — so that file is created with `mktemp` at mode `600` and removed by an `EXIT` trap on normal exit, failure, and cancellation. This is best-effort, NOT a guarantee: a `SIGKILL`, runner shutdown, or hard job-timeout bypasses the trap (and the composite cleanup step), so on a persistent -self-hosted runner a hard kill can leave the mode-`600` file behind. The supported -runner model is therefore an **ephemeral** runner (GitHub-hosted, or self-hosted -one-job-per-VM); on a persistent self-hosted runner, treat post-kill temp hygiene -as your responsibility. +self-hosted runner a hard kill can leave the mode-`600` file behind. The caching +release supports only standard GitHub-hosted `ubuntu-24.04`; self-hosted runners, +including one-job-per-VM runners, are outside the supported contract. Canonical lines are matched with a **fully anchored** pattern (`^=[0-9]+$`). A prefix match reads `version=15.2.0` as `15` and `version=12abc` as `12` — @@ -988,8 +996,9 @@ actions never construct error messages containing credentials. ## 14. Security requirements -1. Recommend readable released tags for third-party actions and, for production, - full commit SHAs of the EdgeZero action ref where reproducibility matters. +1. Require exact stable patch-version tags for third-party actions and one identical + immutable EdgeZero release version throughout each consumer workflow. Full SHAs + still identify application source and resolved execution, never external `uses:`. 2. Compile the CLI package the application provides, from the application checkout and its lockfile; do not build the EdgeZero monorepo CLI or the action's own revision. diff --git a/scripts/install-actionlint.sh b/scripts/install-actionlint.sh index def05d3a..29863f4f 100755 --- a/scripts/install-actionlint.sh +++ b/scripts/install-actionlint.sh @@ -8,10 +8,10 @@ # # Usage: # scripts/install-actionlint.sh -# ACTIONLINT_VERSION=1.7.7 scripts/install-actionlint.sh +# ACTIONLINT_VERSION=1.7.12 scripts/install-actionlint.sh # # Env overrides: -# ACTIONLINT_VERSION release version, e.g. 1.7.7 (no leading "v") +# ACTIONLINT_VERSION release version, e.g. 1.7.12 (no leading "v") # INSTALL_DIR install target (default: /usr/local/bin) # OS / ARCH override auto-detection (e.g. linux / amd64) set -euo pipefail @@ -48,10 +48,10 @@ archive="actionlint_${ACTIONLINT_VERSION}_${OS}_${ARCH}.tar.gz" # version/platform fails CLOSED rather than trusting the runtime origin. expected="" case "${ACTIONLINT_VERSION}:${archive}" in - 1.7.7:actionlint_1.7.7_linux_amd64.tar.gz) expected=023070a287cd8cccd71515fedc843f1985bf96c436b7effaecce67290e7e0757 ;; - 1.7.7:actionlint_1.7.7_linux_arm64.tar.gz) expected=401942f9c24ed71e4fe71b76c7d638f66d8633575c4016efd2977ce7c28317d0 ;; - 1.7.7:actionlint_1.7.7_darwin_amd64.tar.gz) expected=28e5de5a05fc558474f638323d736d822fff183d2d492f0aecb2b73cc44584f5 ;; - 1.7.7:actionlint_1.7.7_darwin_arm64.tar.gz) expected=2693315b9093aeacb4ebd91a993fea54fc215057bf0da2659056b4bc033873db ;; + 1.7.12:actionlint_1.7.12_linux_amd64.tar.gz) expected=8aca8db96f1b94770f1b0d72b6dddcb1ebb8123cb3712530b08cc387b349a3d8 ;; + 1.7.12:actionlint_1.7.12_linux_arm64.tar.gz) expected=325e971b6ba9bfa504672e29be93c24981eeb1c07576d730e9f7c8805afff0c6 ;; + 1.7.12:actionlint_1.7.12_darwin_amd64.tar.gz) expected=5b44c3bc2255115c9b69e30efc0fecdf498fdb63c5d58e17084fd5f16324c644 ;; + 1.7.12:actionlint_1.7.12_darwin_arm64.tar.gz) expected=aba9ced2dee8d27fecca3dc7feb1a7f9a52caefa1eb46f3271ea66b6e0e6953f ;; esac if [ -z "$expected" ]; then echo "error: no repo-pinned SHA-256 for $archive at actionlint ${ACTIONLINT_VERSION}; add its digest to install-actionlint.sh from the release checksums rather than trusting the origin" >&2 diff --git a/scripts/run-actionlint.sh b/scripts/run-actionlint.sh new file mode 100755 index 00000000..b1bcc8c2 --- /dev/null +++ b/scripts/run-actionlint.sh @@ -0,0 +1,97 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Validate the two reviewed syntax additions before adapting them for 1.7.12. +# Diagnostics are never filtered; only approved source lines are rewritten. +root=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd -P) +[[ "$(actionlint -version | sed -n '1p')" == 1.7.12 ]] || { + echo 'error: run-actionlint requires actionlint 1.7.12' >&2; exit 1; +} +[[ "$(yq --version)" == 'yq (https://github.com/mikefarah/yq/) version v4.53.3' ]] || { + echo 'error: run-actionlint requires mikefarah yq 4.53.3' >&2; exit 1; +} +tmp=$(mktemp -d) +trap 'rm -rf "$tmp"' EXIT +files=() +if [[ "$#" -gt 0 ]]; then + files=("$@") +else + find "$root/.github/workflows" -maxdepth 1 \( -type f -o -type l \) \( -name '*.yml' -o -name '*.yaml' \) -print0 >"$tmp/files" + while IFS= read -r -d '' file; do files+=("$file"); done <"$tmp/files" +fi +index=0 +status=0 +for input in ${files[@]+"${files[@]}"}; do + [[ -f "$input" && ! -L "$input" ]] || { echo "error: missing or symlink workflow $input" >&2; exit 1; } + file=$(cd -- "$(dirname -- "$input")" && pwd -P)/$(basename -- "$input") + relative=${file#"$root/"} + index=$((index + 1)) + source_copy="$tmp/$index.yml" + yq -o=json -I=0 '{"document": ., "aliases": [... | select(kind == "alias") | path], "duplicates": [.. | select(kind == "map") | to_entries | group_by(.key) | .[] | select(length > 1)], "nodes": [.. | {"path": path, "line": line, "kind": kind, "tag": tag, "style": style, "value": .}]}' "$file" >"$tmp/parsed" + if ! jq -se --arg file "$relative" ' + if length != 1 then error("expected one YAML document") else .[0] end + | if (.aliases | length) != 0 or (.duplicates | length) != 0 then error("aliases and duplicate keys are unsupported") else . end + | . as $tree + | [ .nodes[] | select(.path[-1] == "queue") ] as $queues + | if ($queues | length) > 0 then + if ($file != ".github/workflows/publish-build-container.yml" and $file != ".github/workflows/rotate-build-container-gate.yml") + or ($queues | length) != 1 or $queues[0].path != ["concurrency", "queue"] + or $queues[0].tag != "!!str" or $queues[0].value != "max" + or .document.concurrency.group != "edgezero-build-container-publication" + or .document.concurrency["cancel-in-progress"] != false + then error("unapproved concurrency.queue") else . end + else . end + | {workflow_repository: "EDGEZERO_WORKFLOW_REPOSITORY", workflow_file_path: "EDGEZERO_WORKFLOW_FILE_PATH", workflow_ref: "EDGEZERO_WORKFLOW_REF", workflow_sha: "EDGEZERO_WORKFLOW_SHA"} as $bindings + | [ .nodes[] | select(.kind == "scalar" and .tag == "!!str") + | select(.value | test("job\\s*(\\.|\\[).*workflow_")) + | . as $node + | if $file != ".github/workflows/build-app-cli.yml" then error("workflow identity outside producer") else . end + | [ $bindings | keys[] | select($node.value == ("${{ job." + . + " }}")) ] as $properties + | if ($properties | length) != 1 then error("unapproved workflow identity expression") else . end + | $properties[0] as $property + | if .path == ["jobs", "build", "steps", 0, "env", $bindings[$property]] then . + elif $property == "workflow_sha" and .path == ["jobs", "build", "steps", 1, "with", "ref"] + and $tree.document.jobs.build.steps[1].uses == "actions/checkout@v7.0.1" + and $tree.document.jobs.build.steps[1].with.repository == "stackpop/edgezero" + then . else error("unapproved workflow identity location") end + | {line, path, key: .path[-1], value: "edgezero-validated-identity"} + ] as $identities + | ($queues | map({line, path, key: "queue", value: null})) + $identities + | if any(.[]; . as $rewrite | any($tree.nodes[]; + .style == "flow" and .path == $rewrite.path[0:(.path | length)])) + then error("rewritten scalars cannot have flow-style ancestors") else . end + | if (group_by(.line) | any(length > 1)) then error("rewrites must occupy separate source lines") else . end + ' "$tmp/parsed" >"$tmp/rewrite-data"; then + echo "error: unsupported workflow compatibility syntax in $file" >&2 + exit 1 + fi + jq -r '.[] | [.line, .key, (.value // "")] | @tsv' "$tmp/rewrite-data" >"$tmp/rewrites" + awk -F '\t' ' + FILENAME == ARGV[1] { keys[$1]=$2; values[$1]=$3; next } + FNR in keys { + key=keys[FNR] + if ($0 !~ ("^[ ]*" key ":[ ]*[^ ]")) { print "error: rewrite requires a standalone scalar line" > "/dev/stderr"; exit 1 } + if (key == "queue") { print ""; next } + match($0, /[^ ]/) + print substr($0, 1, RSTART-1) key ": \"" values[FNR] "\"" + next + } + { print } + ' "$tmp/rewrites" "$file" >"$source_copy" + # The real source filename preserves actionlint project discovery and local + # reusable-workflow checks while input bytes come from the reviewed copy. + if actionlint -oneline -shellcheck='shellcheck -S warning' -stdin-filename "$file" - <"$source_copy" >"$tmp/diagnostics" 2>&1; then + : + else + status=1 + fi + # Literal prefix replacement preserves every diagnostic, including unknown ones. + while IFS= read -r line; do + case "$line" in + "$source_copy":*) printf '%s%s\n' "$file" "${line#"$source_copy"}" ;; + *) printf '%s\n' "$line" ;; + esac + done <"$tmp/diagnostics" +done +[[ "$index" -gt 0 ]] || { echo 'error: no workflows found' >&2; exit 1; } +exit "$status"