diff --git a/.github/scripts/ci-supply-chain-check.tests.sh b/.github/scripts/ci-supply-chain-check.tests.sh new file mode 100755 index 0000000..d169e10 --- /dev/null +++ b/.github/scripts/ci-supply-chain-check.tests.sh @@ -0,0 +1,3092 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +CHECKER="$ROOT_DIR/scripts/ci-supply-chain-check.sh" +TMP_ROOT="$(mktemp -d)" +trap 'rm -rf "$TMP_ROOT"' EXIT + +EXPECTED_SHA="2082c4762fea6d5cc4cd1f4a243eaacf07b12f576717d4c6b74828bd163cb563" +TINYGO_RELEASE_REPOSITORY="tinygo-org/"'tinygo' +TINYGO_RELEASE_REPOSITORY_CASE_VARIANT="TinyGo-Org/"'TinyGo' +TINYGO_RELEASE_NAMESPACE="${TINYGO_RELEASE_REPOSITORY}/releases/download/" +TINYGO_BINARY="/usr/local/bin/tinygo" +TINYGO_VERSION_COMMAND="$TINYGO_BINARY version" +VERIFIED_DEB="/tmp/goframe-tinygo-0.42.0-verified.deb" +CLEAN_PATH='/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin' +CLEAN_SHELL="/usr/bin/env -i PATH=$CLEAN_PATH /bin/bash --noprofile --norc -e -u -o pipefail {0}" +VERIFY_SHELL='/bin/bash --noprofile --norc -p -e -o pipefail {0}' +CLEANUP_COMMAND="trap '/usr/bin/sudo /usr/bin/rm -f $VERIFIED_DEB' EXIT" +STAGE_COMMAND="/usr/bin/sudo /usr/bin/install -o root -g root -m 0644 /tmp/tinygo.deb $VERIFIED_DEB" +CURL_COMMAND_NAME='cur''l' +WGET_COMMAND_NAME='wg''et' +FULL_ACTION_SHA="3d3c42e5aac5ba805825da76410c181273ba90b1" +FULL_ACTION_REF="actions/checkout@$FULL_ACTION_SHA # v7.0.1" +FULL_REUSABLE_REF="owner/repo/.github/workflows/build.yml@$FULL_ACTION_SHA # v1.2.3" +BASH_COMMAND_NAME='ba''sh' +SH_COMMAND_NAME='s''h' +PYTHON_COMMAND_NAME='python''3' +NODE_COMMAND_NAME='no''de' +PWSH_COMMAND_NAME='pw''sh' +POWERSHELL_COMMAND_NAME='power''shell' +EVAL_COMMAND_NAME='ev''al' +SHELL_CODE_OPTION='-''c' +SHELL_STDIN_OPTION='-''s' +PYTHON_MODULE_OPTION='-''m' +NODE_EVAL_OPTION='-''e' +NODE_PRINT_OPTION='-''p' +tests_run=0 + +write_tinygo_workflow() { + local path="$1" + local action_ref="$2" + local checksum="$3" + local verification_order="$4" + local include_checksum="$5" + local trust_root_mode="${6:-literal}" + local verifier_mode="${7:-trusted}" + local boundary_mode="${8:-protected}" + local smoke_mode="${9:-separate}" + local download_command verify_command verify_digest verify_input verify_target + local shell_template="$CLEAN_SHELL" + local cleanup_command="$CLEANUP_COMMAND" + local stage_command="$STAGE_COMMAND" + local install_target="$VERIFIED_DEB" + + mkdir -p "$(dirname "$path")" + case "$trust_root_mode" in + literal|variable-digest) + download_command="/usr/bin/$CURL_COMMAND_NAME -fsSL -o /tmp/tinygo.deb \"https://github.com/${TINYGO_RELEASE_NAMESPACE}v0.42.0/tinygo_0.42.0_amd64.deb\"" + ;; + variable-url|variable-both) + download_command="/usr/bin/$CURL_COMMAND_NAME -fsSL -o /tmp/tinygo.deb \"https://github.com/${TINYGO_RELEASE_NAMESPACE}v\${TINYGO_VERSION}/tinygo_\${TINYGO_VERSION}_amd64.deb\"" + ;; + *) + printf 'unsupported trust-root mode: %s\n' "$trust_root_mode" >&2 + exit 1 + ;; + esac + case "$boundary_mode" in + protected) ;; + legacy) + shell_template="" + cleanup_command="" + stage_command="" + install_target=/tmp/tinygo.deb + ;; + no-staging) + stage_command="" + install_target=/tmp/tinygo.deb + ;; + checksum-original) ;; + install-original) + install_target=/tmp/tinygo.deb + ;; + missing-cleanup) + cleanup_command="" + ;; + mutable-shell) + shell_template='bash {0}' + ;; + *) + printf 'unsupported boundary mode: %s\n' "$boundary_mode" >&2 + exit 1 + ;; + esac + verify_target="$VERIFIED_DEB" + case "$boundary_mode" in + legacy|no-staging|checksum-original) verify_target=/tmp/tinygo.deb ;; + esac + case "$trust_root_mode" in + literal|variable-url) + verify_digest="'$checksum'" + verify_input="'$checksum $verify_target'" + ;; + variable-digest|variable-both) + verify_digest='"$TINYGO_SHA256"' + verify_input="\"\$TINYGO_SHA256 $verify_target\"" + ;; + esac + case "$verifier_mode" in + unqualified) + verify_command="printf '%s %s\\n' $verify_digest $verify_target | sha256sum --check --strict -" + ;; + trusted) + verify_command="/usr/bin/env -i /usr/bin/sha256sum --check --strict - <<< $verify_input" + ;; + *) + printf 'unsupported verifier mode: %s\n' "$verifier_mode" >&2 + exit 1 + ;; + esac + { + printf 'name: fixture\n' + if [[ "$trust_root_mode" != "literal" ]]; then + printf 'env:\n' + printf ' TINYGO_VERSION: 0.42.0\n' + printf ' TINYGO_SHA256: %s\n' "$checksum" + fi + printf 'jobs:\n' + printf ' fixture:\n' + printf ' runs-on: ubuntu-latest\n' + printf ' steps:\n' + if [[ -n "$action_ref" ]]; then + printf ' - uses: %s\n' "$action_ref" + fi + printf ' # uses: actions/commented-out@v1\n' + if [[ "$smoke_mode" == "before-install" ]]; then + printf ' - name: Verify TinyGo\n' + printf ' shell: %s\n' "$VERIFY_SHELL" + printf ' run: %s\n' "$TINYGO_VERSION_COMMAND" + fi + if [[ -n "$shell_template" ]]; then + printf ' - name: Install TinyGo\n' + printf ' shell: %s\n' "$shell_template" + printf ' run: |\n' + else + printf ' - run: |\n' + fi + if [[ -n "$cleanup_command" ]]; then + printf ' %s\n' "$cleanup_command" + fi + printf ' %s\n' "$download_command" + if [[ -n "$stage_command" ]]; then + printf ' %s\n' "$stage_command" + fi + if [[ "$verification_order" == "after-install" ]]; then + printf ' /usr/bin/sudo /usr/bin/apt-get install -y %s\n' "$install_target" + fi + if [[ "$include_checksum" == "yes" ]]; then + if [[ "$verification_order" == "commented" ]]; then + printf ' # %s || exit 1\n' "$verify_command" + elif [[ "$verification_order" == "ignored" ]]; then + printf ' %s || true\n' "$verify_command" + elif [[ "$verification_order" == "warned" ]]; then + printf ' %s || echo warning\n' "$verify_command" + elif [[ "$verification_order" == "skipped" ]]; then + printf ' false && %s || exit 1\n' "$verify_command" + elif [[ "$verification_order" == "implicit" ]]; then + printf ' %s\n' "$verify_command" + else + printf ' %s || exit 1\n' "$verify_command" + fi + fi + if [[ "$verification_order" == "replaced" ]]; then + printf ' /usr/bin/cp /tmp/replacement.deb %s\n' "$install_target" + fi + if [[ "$verification_order" != "after-install" ]]; then + printf ' /usr/bin/sudo /usr/bin/apt-get install -y %s\n' "$install_target" + fi + case "$smoke_mode" in + inside) + printf ' %s\n' "$TINYGO_VERSION_COMMAND" + ;; + separate) + printf ' - name: Verify TinyGo\n' + printf ' shell: %s\n' "$VERIFY_SHELL" + printf ' run: %s\n' "$TINYGO_VERSION_COMMAND" + ;; + ambient) + printf ' - name: Verify TinyGo\n' + printf ' shell: %s\n' "$VERIFY_SHELL" + printf ' run: tinygo version\n' + ;; + missing|before-install) ;; + malformed) + printf ' - name: Verify TinyGo\n' + printf ' shell: %s\n' "$VERIFY_SHELL" + printf ' run: echo wrong command\n' + ;; + custom-shell) + printf ' - name: Verify TinyGo\n' + printf ' shell: %s\n' "$CLEAN_SHELL" + printf ' run: %s\n' "$TINYGO_VERSION_COMMAND" + ;; + *) + printf 'unsupported TinyGo smoke mode: %s\n' "$smoke_mode" >&2 + exit 1 + ;; + esac + } > "$path" +} + +make_fixture() { + local name="$1" + local action_ref="$2" + local checksum="${3:-$EXPECTED_SHA}" + local verification_order="${4:-before-install}" + local include_checksum="${5:-yes}" + local trust_root_mode="${6:-literal}" + local verifier_mode="${7:-trusted}" + local boundary_mode="${8:-protected}" + local smoke_mode="${9:-separate}" + local fixture="$TMP_ROOT/$name" + + write_tinygo_workflow "$fixture/.github/workflows/ci-core.yml" \ + "$action_ref" "$checksum" "$verification_order" "$include_checksum" "$trust_root_mode" "$verifier_mode" "$boundary_mode" "$smoke_mode" + write_tinygo_workflow "$fixture/.github/workflows/ci-browser-smoke.yml" \ + "" "$checksum" "before-install" "$include_checksum" "$trust_root_mode" "$verifier_mode" "$boundary_mode" "$smoke_mode" + write_tinygo_workflow "$fixture/.github/workflows/ci-wasm-size.yml" \ + "" "$checksum" "before-install" "$include_checksum" "$trust_root_mode" "$verifier_mode" "$boundary_mode" "$smoke_mode" + printf '%s' "$fixture" +} + +write_local_action() { + local fixture="$1" + local path="$2" + local metadata="$3" + local nested_ref="${4:-}" + local target="$fixture/${path#./}" + + mkdir -p "$target" + { + printf 'name: local fixture\n' + printf 'runs:\n' + printf ' using: composite\n' + printf ' steps:\n' + if [[ -n "$nested_ref" ]]; then + printf ' - uses: %s\n' "$nested_ref" + else + printf ' - shell: bash\n' + printf ' run: echo local\n' + fi + } > "$target/$metadata" +} + +write_local_workflow() { + local fixture="$1" + local path="$2" + local nested_ref="${3:-}" + local target="$fixture/${path#./}" + + mkdir -p "$(dirname "$target")" + { + printf 'name: reusable fixture\n' + printf 'on:\n' + printf ' workflow_call:\n' + printf 'jobs:\n' + printf ' fixture:\n' + printf ' runs-on: ubuntu-latest\n' + printf ' steps:\n' + if [[ -n "$nested_ref" ]]; then + printf ' - uses: %s\n' "$nested_ref" + else + printf ' - run: echo local\n' + fi + } > "$target" +} + +add_runtime_override() { + local file="$1" + awk ' + / - name: Install TinyGo/ && !inserted { + print " - run: |" + print " echo '\''TINYGO_VERSION=attacker-value'\'' >> \"$GITHUB_ENV\"" + print " echo '\''TINYGO_SHA256=attacker-digest'\'' >> \"$GITHUB_ENV\"" + inserted = 1 + } + { print } + ' "$file" > "$TMP_ROOT/runtime-override.yml" + mv "$TMP_ROOT/runtime-override.yml" "$file" +} + +add_path_poisoning() { + local file="$1" + awk ' + / - name: Install TinyGo/ && !inserted { + print " - run: |" + print " mkdir -p \"$RUNNER_TEMP/fake-bin\"" + print " cp /bin/true \"$RUNNER_TEMP/fake-bin/sha256sum\"" + print " echo \"$RUNNER_TEMP/fake-bin\" >> \"$GITHUB_PATH\"" + inserted = 1 + } + { print } + ' "$file" > "$TMP_ROOT/path-poison.yml" + mv "$TMP_ROOT/path-poison.yml" "$file" +} + +add_tinygo_path_poisoning() { + local file="$1" + + awk ' + / - name: Install TinyGo/ && !inserted { + print " - run: |" + print " mkdir -p \"$RUNNER_TEMP/fake-tinygo-bin\"" + print " cp /bin/true \"$RUNNER_TEMP/fake-tinygo-bin/tinygo\"" + print " echo \"$RUNNER_TEMP/fake-tinygo-bin\" >> \"$GITHUB_PATH\"" + inserted = 1 + } + { print } + ' "$file" > "$TMP_ROOT/tinygo-path-poison.yml" + mv "$TMP_ROOT/tinygo-path-poison.yml" "$file" +} + +add_bash_env_shadowing() { + local file="$1" + awk ' + / - name: Install TinyGo/ && !inserted { + print " - run: |" + print " printf '\''%s\\n'\'' '\''sha256sum() { return 0; }'\'' '\''printf() { return 0; }'\'' > \"$RUNNER_TEMP/bash-env\"" + print " echo \"BASH_ENV=$RUNNER_TEMP/bash-env\" >> \"$GITHUB_ENV\"" + inserted = 1 + } + { print } + ' "$file" > "$TMP_ROOT/bash-env-shadow.yml" + mv "$TMP_ROOT/bash-env-shadow.yml" "$file" +} + +add_bash_env_sudo_shadowing() { + local file="$1" + awk ' + / - name: Install TinyGo/ && !inserted { + print " - run: |" + print " printf '\''%s\\n'\'' '\''sudo() { cp /tmp/replacement.deb /tmp/tinygo.deb; /usr/bin/sudo \"$@\"; }'\'' > \"$RUNNER_TEMP/bash-env\"" + print " echo \"BASH_ENV=$RUNNER_TEMP/bash-env\" >> \"$GITHUB_ENV\"" + inserted = 1 + } + { print } + ' "$file" > "$TMP_ROOT/bash-env-sudo-shadow.yml" + mv "$TMP_ROOT/bash-env-sudo-shadow.yml" "$file" +} + +write_block_scalar_workflow() { + local path="$1" + local scalar="$2" + + mkdir -p "$(dirname "$path")" + { + printf 'jobs:\n' + printf ' scalar:\n' + printf ' steps:\n' + printf ' - run: %s\n' "$scalar" + printf ' echo "https://example.invalid"\n' + printf ' echo "uses: actions/example@v1"\n' + } > "$path" +} + +write_repository_file() { + local fixture="$1" + local path="$2" + local target="$fixture/$path" + shift 2 + + mkdir -p "$(dirname "$target")" + printf '%s\n' "$@" > "$target" +} + +write_shell_source() { + local fixture="$1" + local path="$2" + shift 2 + local target="$fixture/$path" + + mkdir -p "$(dirname "$target")" + { + printf '#!/usr/bin/env bash\n' + printf '%s\n' "$@" + } > "$target" +} + +write_extensionless_downloader_helper() { + local fixture="$1" + local shebang="$2" + local downloader="${3:-$CURL_COMMAND_NAME}" + + write_repository_file "$fixture" scripts/helper \ + "$shebang" \ + 'org=tinygo-org' \ + 'repo=tinygo' \ + 'url="https://github.com/$org/$repo/releases/download/v0.42.0/file"' \ + "$downloader \"\$url\"" + chmod +x "$fixture/scripts/helper" +} + +add_workflow_script_invocation() { + local file="$1" + local command="$2" + + printf '\n delegated-script:\n steps:\n - run: %s\n' "$command" >> "$file" +} + +add_verify_property() { + local file="$1" + local position="$2" + local property="$3" + local child="${4:-}" + + awk -v position="$position" -v property="$property" -v child="$child" ' + function emit_property() { + print " " property + if (child != "") { + print " " child + } + } + $0 == " - name: Verify TinyGo" { in_verify = 1 } + in_verify && position == "before" && $0 ~ /^ run:/ { + emit_property() + } + { print } + in_verify && position == "after" && $0 ~ /^ run:/ { + emit_property() + in_verify = 0 + } + ' "$file" > "$TMP_ROOT/verify-property.yml" + mv "$TMP_ROOT/verify-property.yml" "$file" +} + +add_install_property() { + local file="$1" + local position="$2" + local property="$3" + local child="${4:-}" + + awk -v position="$position" -v property="$property" -v child="$child" ' + function emit_property() { + print " " property + if (child != "") { + print " " child + } + } + $0 == " - name: Install TinyGo" { in_install = 1 } + in_install && position == "before" && $0 == " run: |" { + emit_property() + } + in_install && $0 == " - name: Verify TinyGo" { + if (position == "after") { + emit_property() + } + in_install = 0 + } + { print } + ' "$file" > "$TMP_ROOT/install-property.yml" + mv "$TMP_ROOT/install-property.yml" "$file" +} + +replace_verify_shell() { + local file="$1" + local shell="$2" + + awk -v shell="$shell" ' + $0 == " - name: Verify TinyGo" { in_verify = 1 } + in_verify && $0 ~ /^ shell:/ { + print " shell: " shell + in_verify = 0 + next + } + { print } + ' "$file" > "$TMP_ROOT/verify-shell.yml" + mv "$TMP_ROOT/verify-shell.yml" "$file" +} + +remove_verify_shell() { + local file="$1" + + awk ' + $0 == " - name: Verify TinyGo" { in_verify = 1 } + in_verify && $0 ~ /^ shell:/ { + in_verify = 0 + next + } + { print } + ' "$file" > "$TMP_ROOT/verify-shell.yml" + mv "$TMP_ROOT/verify-shell.yml" "$file" +} + +add_job_property() { + local file="$1" + local position="$2" + local property="$3" + local child="${4:-}" + + if [[ "$position" == before ]]; then + awk -v property="$property" -v child="$child" ' + { print } + $0 == " fixture:" { + print " " property + if (child != "") { + print " " child + } + } + ' "$file" > "$TMP_ROOT/job-property.yml" + mv "$TMP_ROOT/job-property.yml" "$file" + return + fi + + printf ' %s\n' "$property" >> "$file" + if [[ -n "$child" ]]; then + printf ' %s\n' "$child" >> "$file" + fi +} + +replace_fixture_runs_on() { + local file="$1" + local value="$2" + local child="${3:-}" + + awk -v value="$value" -v child="$child" ' + $0 == " fixture:" { in_fixture = 1 } + in_fixture && !replaced && $0 ~ /^ runs-on:/ { + if (value == "") { + print " runs-on:" + } else { + print " runs-on: " value + } + if (child != "") { + print " " child + } + replaced = 1 + next + } + { print } + ' "$file" > "$TMP_ROOT/runs-on.yml" + mv "$TMP_ROOT/runs-on.yml" "$file" +} + +remove_fixture_runs_on() { + local file="$1" + + awk ' + $0 == " fixture:" { in_fixture = 1 } + in_fixture && !removed && $0 ~ /^ runs-on:/ { + removed = 1 + next + } + { print } + ' "$file" > "$TMP_ROOT/runs-on.yml" + mv "$TMP_ROOT/runs-on.yml" "$file" +} + +add_unrelated_runner_jobs() { + local file="$1" + + printf '%s\n' \ + ' unrelated-container:' \ + ' runs-on: self-hosted' \ + ' container: attacker/unrelated:latest' \ + ' steps:' \ + ' - run: echo unrelated' \ + >> "$file" +} + +add_workflow_run_default() { + local file="$1" + + awk ' + $0 == "jobs:" { + print "defaults:" + print " run:" + print " shell: sh" + } + { print } + ' "$file" > "$TMP_ROOT/workflow-default.yml" + mv "$TMP_ROOT/workflow-default.yml" "$file" +} + +add_job_run_default() { + local file="$1" + + awk ' + { print } + $0 == " fixture:" { + print " defaults:" + print " run:" + print " shell: sh" + } + ' "$file" > "$TMP_ROOT/job-default.yml" + mv "$TMP_ROOT/job-default.yml" "$file" +} + +add_job_strategy_matrix() { + local file="$1" + + awk ' + { print } + $0 == " fixture:" { + print " strategy:" + print " fail-fast: false" + print " matrix:" + print " go:" + print " - 1.26.6" + } + ' "$file" > "$TMP_ROOT/job-strategy.yml" + mv "$TMP_ROOT/job-strategy.yml" "$file" +} + +add_unrelated_control_jobs() { + local file="$1" + + printf '%s\n' \ + ' prerequisite:' \ + ' runs-on: ubuntu-latest' \ + ' steps:' \ + ' - run: echo prerequisite' \ + ' unrelated:' \ + ' if: true' \ + ' continue-on-error: false' \ + ' needs: prerequisite' \ + ' runs-on: ubuntu-latest' \ + ' steps:' \ + ' - run: echo unrelated' \ + >> "$file" +} + +split_verify_into_new_job() { + local file="$1" + + awk ' + $0 == " - name: Verify TinyGo" { + print " verify-tinygo:" + print " runs-on: ubuntu-latest" + print " steps:" + } + { print } + ' "$file" > "$TMP_ROOT/cross-job-verify.yml" + mv "$TMP_ROOT/cross-job-verify.yml" "$file" +} + +reorder_verify_properties() { + local file="$1" + + awk ' + $0 == " - name: Verify TinyGo" { + name = " name: Verify TinyGo" + if ((getline shell_line) <= 0 || (getline run_line) <= 0) { + exit 1 + } + sub(/^ run:/, " - run:", run_line) + print run_line + print name + print shell_line + next + } + { print } + ' "$file" > "$TMP_ROOT/reordered-verify.yml" + mv "$TMP_ROOT/reordered-verify.yml" "$file" +} + +move_install_shell_after_run() { + local file="$1" + + awk ' + $0 == " - name: Install TinyGo" { in_install = 1 } + in_install && $0 ~ /^ shell:/ { + shell_line = $0 + next + } + in_install && $0 == " - name: Verify TinyGo" { + print shell_line + in_install = 0 + } + { print } + ' "$file" > "$TMP_ROOT/reordered-install.yml" + mv "$TMP_ROOT/reordered-install.yml" "$file" +} + +expect_pass() { + local name="$1" + local fixture="$2" + tests_run=$((tests_run + 1)) + if ! output="$("$BASH" "$CHECKER" "$fixture" 2>&1)"; then + printf 'not ok %d - %s\n%s\n' "$tests_run" "$name" "$output" >&2 + exit 1 + fi + printf 'ok %d - %s\n' "$tests_run" "$name" +} + +expect_fail() { + local name="$1" + local fixture="$2" + tests_run=$((tests_run + 1)) + if output="$("$BASH" "$CHECKER" "$fixture" 2>&1)"; then + printf 'not ok %d - %s unexpectedly passed\n%s\n' "$tests_run" "$name" "$output" >&2 + exit 1 + fi + printf 'ok %d - %s\n' "$tests_run" "$name" +} + +expect_fail_in_helper() { + local name="$1" + local fixture="$2" + local output + tests_run=$((tests_run + 1)) + if output="$("$BASH" "$CHECKER" "$fixture" 2>&1)"; then + printf 'not ok %d - %s unexpectedly passed\n%s\n' "$tests_run" "$name" "$output" >&2 + exit 1 + fi + if [[ "$output" != *'scripts/outer.sh:'* || + "$output" != *'execution without a bounded script target'* ]]; then + printf 'not ok %d - %s failed outside the nested-shell boundary\n%s\n' \ + "$tests_run" "$name" "$output" >&2 + exit 1 + fi + printf 'ok %d - %s\n' "$tests_run" "$name" +} + +test_clean_shell_boundary() { + local marker_dir="$TMP_ROOT/clean-shell-markers" + local fake_bin="$TMP_ROOT/clean-shell-fake-bin" + local bash_env="$TMP_ROOT/clean-shell-bash-env" + local child_script="$TMP_ROOT/clean-shell-child.sh" + local driver_script="$TMP_ROOT/clean-shell-driver.sh" + local failing_script="$TMP_ROOT/clean-shell-failing.sh" + local command_name + + mkdir -p "$marker_dir" "$fake_bin" + printf '%s\n' \ + 'mark_override() { : > "$P1_MARKER_DIR/$1"; }' \ + 'sudo() { mark_override sudo; return 99; }' \ + "$CURL_COMMAND_NAME() { mark_override $CURL_COMMAND_NAME; return 99; }" \ + 'sha256sum() { mark_override sha256sum; return 99; }' \ + 'printf() { mark_override printf; return 99; }' \ + 'function /usr/bin/env() { mark_override env; return 99; }' \ + > "$bash_env" + for command_name in sudo "$CURL_COMMAND_NAME" sha256sum printf; do + printf '%s\n' '#!/bin/sh' ": > \"$marker_dir/path-$command_name\"" 'exit 99' \ + > "$fake_bin/$command_name" + chmod +x "$fake_bin/$command_name" + done + printf '%s\n' \ + '[[ -z "${BASH_ENV+x}" ]]' \ + "[[ \"\$PATH\" == '$CLEAN_PATH' ]]" \ + "for function_name in sudo $CURL_COMMAND_NAME sha256sum printf /usr/bin/env; do" \ + ' ! declare -F "$function_name" >/dev/null' \ + 'done' \ + > "$child_script" + printf '%s\n' \ + "command /usr/bin/env -i PATH=$CLEAN_PATH /bin/bash --noprofile --norc -e -u -o pipefail \"$child_script\"" \ + > "$driver_script" + + tests_run=$((tests_run + 1)) + if ! BASH_ENV="$bash_env" P1_MARKER_DIR="$marker_dir" PATH="$fake_bin" \ + /bin/bash --noprofile --norc "$driver_script"; then + printf 'not ok %d - sanitized custom-shell boundary\n' "$tests_run" >&2 + exit 1 + fi + if find "$marker_dir" -type f -print -quit | grep -q .; then + printf 'not ok %d - parent BASH_ENV or PATH override entered clean shell\n' "$tests_run" >&2 + exit 1 + fi + printf 'ok %d - sanitized custom-shell boundary excludes parent BASH_ENV and PATH\n' "$tests_run" + + printf 'false\n' > "$failing_script" + printf '%s\n' \ + "command /usr/bin/env -i PATH=$CLEAN_PATH /bin/bash --noprofile --norc -e -u -o pipefail \"$failing_script\"" \ + > "$driver_script" + tests_run=$((tests_run + 1)) + if BASH_ENV="$bash_env" P1_MARKER_DIR="$marker_dir" PATH="$fake_bin" \ + /bin/bash --noprofile --norc "$driver_script"; then + printf 'not ok %d - sanitized custom-shell failure unexpectedly passed\n' "$tests_run" >&2 + exit 1 + fi + printf 'ok %d - sanitized custom-shell failure propagates\n' "$tests_run" +} + +test_ambient_sudo_interposition() { + local artifact="$TMP_ROOT/ambient-tinygo.deb" + local replacement="$TMP_ROOT/ambient-replacement.deb" + local consumed="$TMP_ROOT/ambient-consumed.deb" + local marker="$TMP_ROOT/ambient-sudo-ran" + local bash_env="$TMP_ROOT/ambient-bash-env" + local script="$TMP_ROOT/ambient-interposition.sh" + local cp_bin digest verify_command + + cp_bin="$(command -v cp || true)" + if [[ "$cp_bin" != /* || ! -x "$cp_bin" ]]; then + printf 'not ok - ambient sudo interposition oracle requires an absolute executable cp\n' >&2 + exit 1 + fi + + printf 'verified artifact\n' > "$artifact" + printf 'replacement artifact\n' > "$replacement" + if [[ -x /usr/bin/sha256sum ]]; then + digest="$(/usr/bin/sha256sum "$artifact" | awk '{print $1}')" + verify_command="/usr/bin/env -i /usr/bin/sha256sum --check --strict - <<< '$digest $artifact' || exit 1" + elif [[ -x /usr/bin/shasum ]]; then + digest="$(/usr/bin/shasum -a 256 "$artifact" | awk '{print $1}')" + verify_command="/usr/bin/printf '%s %s\\n' '$digest' '$artifact' | /usr/bin/shasum -a 256 --check - || exit 1" + else + printf 'skip - ambient sudo interposition oracle requires sha256sum or shasum\n' + return + fi + printf '%s\n' \ + 'sudo() {' \ + ' : > "$P1_MARKER"' \ + " \"$cp_bin\" \"\$P1_REPLACEMENT\" \"\$P1_ARTIFACT\"" \ + ' "$@"' \ + '}' \ + > "$bash_env" + printf '%s\n' "$verify_command" \ + "sudo \"$cp_bin\" \"\$P1_ARTIFACT\" \"\$P1_CONSUMED\"" \ + > "$script" + + tests_run=$((tests_run + 1)) + if ! BASH_ENV="$bash_env" P1_MARKER="$marker" P1_REPLACEMENT="$replacement" \ + P1_ARTIFACT="$artifact" P1_CONSUMED="$consumed" \ + /bin/bash --noprofile --norc "$script"; then + printf 'not ok %d - ambient sudo interposition oracle execution\n' "$tests_run" >&2 + exit 1 + fi + if [[ ! -f "$marker" ]] || ! /usr/bin/cmp -s "$replacement" "$consumed"; then + printf 'not ok %d - ambient sudo did not replace verified bytes\n' "$tests_run" >&2 + exit 1 + fi + printf 'ok %d - ambient sudo can replace bytes after verification\n' "$tests_run" +} + +test_verify_shell_boundary() { + local oracle_dir="$TMP_ROOT/verify-shell-oracle" + local target="$oracle_dir/tinygo" + local marker="$oracle_dir/interposed" + local bash_env="$oracle_dir/bash-env" + local target_script="$oracle_dir/run-target.sh" + local environment_script="$oracle_dir/check-environment.sh" + local output + + mkdir -p "$oracle_dir" + printf '%s\n' '#!/bin/sh' 'printf "real tinygo\\n"' > "$target" + chmod +x "$target" + printf 'function %s {\n' "$target" > "$bash_env" + printf '%s\n' \ + ' printf "fake tinygo\n"' \ + ' : > "$VERIFY_MARKER"' \ + ' return 0' \ + '}' \ + >> "$bash_env" + printf '%s\n' '"$VERIFY_TARGET" version' > "$target_script" + printf '%s\n' \ + '[[ "$VERIFY_ENV_SENTINEL" == "setup-go-visible" ]]' \ + 'printf "%s\n" "$VERIFY_ENV_SENTINEL"' \ + > "$environment_script" + + rm -f "$marker" + tests_run=$((tests_run + 1)) + if ! output="$(BASH_ENV="$bash_env" VERIFY_MARKER="$marker" \ + VERIFY_TARGET="$target" /bin/bash --noprofile --norc -e -o pipefail \ + "$target_script")"; then + printf 'not ok %d - ordinary Bash BASH_ENV interposition oracle execution\n' \ + "$tests_run" >&2 + exit 1 + fi + if [[ ! -f "$marker" || "$output" != 'fake tinygo' ]]; then + printf 'not ok %d - ordinary Bash did not load slash-named BASH_ENV function\n' \ + "$tests_run" >&2 + exit 1 + fi + printf 'ok %d - ordinary Bash loads slash-named BASH_ENV function\n' "$tests_run" + + rm -f "$marker" + tests_run=$((tests_run + 1)) + if ! output="$(BASH_ENV="$bash_env" VERIFY_MARKER="$marker" \ + VERIFY_TARGET="$target" /bin/bash --noprofile --norc -p -e -o pipefail \ + "$target_script")"; then + printf 'not ok %d - privileged Verify shell oracle execution\n' \ + "$tests_run" >&2 + exit 1 + fi + if [[ -e "$marker" || "$output" != 'real tinygo' ]]; then + printf 'not ok %d - privileged Verify shell allowed BASH_ENV interposition\n' \ + "$tests_run" >&2 + exit 1 + fi + printf 'ok %d - privileged Verify shell blocks BASH_ENV interposition\n' \ + "$tests_run" + + tests_run=$((tests_run + 1)) + if ! output="$(BASH_ENV="$bash_env" VERIFY_ENV_SENTINEL=setup-go-visible \ + /bin/bash --noprofile --norc -p -e -o pipefail "$environment_script")"; then + printf 'not ok %d - privileged Verify shell environment oracle execution\n' \ + "$tests_run" >&2 + exit 1 + fi + if [[ "$output" != setup-go-visible ]]; then + printf 'not ok %d - privileged Verify shell lost normal environment\n' \ + "$tests_run" >&2 + exit 1 + fi + printf 'ok %d - privileged Verify shell preserves normal environment\n' \ + "$tests_run" +} + +expect_pass "protected TinyGo job without conditional controls" \ + "$(make_fixture protected-job-controls "$FULL_ACTION_REF")" + +fixture="$(make_fixture execution-data-contexts "$FULL_ACTION_REF")" +write_repository_file "$fixture" .github/workflows/execution-data.yml \ + 'name: execution data' \ + 'env:' \ + " run: $CURL_COMMAND_NAME" \ + " shell: $CURL_COMMAND_NAME" \ + 'jobs:' \ + ' data:' \ + ' runs-on: ubuntu-latest' \ + ' steps:' \ + " - uses: actions/checkout@$FULL_ACTION_SHA # v7.0.1" \ + ' with:' \ + " run: $CURL_COMMAND_NAME" \ + " shell: $CURL_COMMAND_NAME" +expect_pass "run and shell keys in env and with are ordinary data" "$fixture" + +fixture="$(make_fixture action-execution-data-contexts "$FULL_ACTION_REF")" +write_repository_file "$fixture" .github/actions/data/action.yml \ + 'name: data' \ + 'inputs:' \ + ' run:' \ + " default: $CURL_COMMAND_NAME" \ + 'outputs:' \ + ' shell:' \ + " value: $CURL_COMMAND_NAME" \ + 'runs:' \ + ' using: composite' \ + ' steps:' \ + ' - shell: bash' \ + ' run: echo ok' +expect_pass "run and shell keys in Action inputs and outputs are ordinary data" "$fixture" + +fixture="$(make_fixture workflow-step-run-downloader "$FULL_ACTION_REF")" +write_repository_file "$fixture" .github/workflows/execution.yml \ + 'name: execution' \ + 'jobs:' \ + ' run:' \ + ' runs-on: ubuntu-latest' \ + ' steps:' \ + " - run: $CURL_COMMAND_NAME https://example.invalid/file" +expect_fail "workflow step run receives downloader policy" "$fixture" + +fixture="$(make_fixture workflow-step-shell-downloader "$FULL_ACTION_REF")" +write_repository_file "$fixture" .github/workflows/execution.yml \ + 'name: execution' \ + 'jobs:' \ + ' shell:' \ + ' runs-on: ubuntu-latest' \ + ' steps:' \ + " - shell: bash -c '$CURL_COMMAND_NAME https://example.invalid/file; bash \"\$1\"' -- {0}" \ + ' run: echo harmless' +expect_fail "workflow step shell receives downloader policy" "$fixture" + +fixture="$(make_fixture workflow-default-shell-downloader "$FULL_ACTION_REF")" +write_repository_file "$fixture" .github/workflows/execution.yml \ + 'name: execution' \ + 'jobs:' \ + ' shell:' \ + ' runs-on: ubuntu-latest' \ + ' steps:' \ + ' - run: echo harmless' \ + 'defaults:' \ + ' run:' \ + " shell: bash -c '$CURL_COMMAND_NAME https://example.invalid/file; bash \"\$1\"' -- {0}" +expect_fail "workflow defaults run shell receives downloader policy" "$fixture" + +fixture="$(make_fixture job-default-shell-downloader "$FULL_ACTION_REF")" +write_repository_file "$fixture" .github/workflows/execution.yml \ + 'name: execution' \ + 'jobs:' \ + ' shell:' \ + ' runs-on: ubuntu-latest' \ + ' steps:' \ + ' - run: echo harmless' \ + ' defaults:' \ + ' run:' \ + " shell: bash -c '$CURL_COMMAND_NAME https://example.invalid/file; bash \"\$1\"' -- {0}" +expect_fail "job defaults run shell receives downloader policy" "$fixture" + +fixture="$(make_fixture composite-run-downloader './.github/actions/composite')" +write_repository_file "$fixture" .github/actions/composite/action.yml \ + 'name: composite' \ + 'runs:' \ + ' using: composite' \ + ' steps:' \ + ' - shell: bash' \ + " run: $CURL_COMMAND_NAME https://example.invalid/file" +expect_fail "composite step run receives downloader policy" "$fixture" + +fixture="$(make_fixture composite-shell-downloader './.github/actions/composite')" +write_repository_file "$fixture" .github/actions/composite/action.yml \ + 'name: composite' \ + 'runs:' \ + ' using: composite' \ + ' steps:' \ + " - shell: bash -c '$CURL_COMMAND_NAME https://example.invalid/file; bash \"\$1\"' -- {0}" \ + ' run: echo harmless' +expect_fail "composite step shell receives downloader policy" "$fixture" + +for scalar in '|' '>' '|-' '>+' '|2-'; do + fixture="$(make_fixture "workflow-step-block-shell-$tests_run" "$FULL_ACTION_REF")" + write_repository_file "$fixture" .github/workflows/executable-shell.yml \ + 'name: executable shell' \ + 'jobs:' \ + ' shell:' \ + ' runs-on: ubuntu-latest' \ + ' steps:' \ + " - shell: $scalar" \ + " bash -c '$CURL_COMMAND_NAME https://example.invalid/file; bash \"\$1\"' -- {0}" \ + ' run: echo harmless' + expect_fail "workflow step executable shell block scalar $scalar" "$fixture" +done + +fixture="$(make_fixture workflow-default-block-shell "$FULL_ACTION_REF")" +write_repository_file "$fixture" .github/workflows/executable-shell.yml \ + 'name: executable shell' \ + 'defaults:' \ + ' run:' \ + ' shell: |' \ + " bash -c '$CURL_COMMAND_NAME https://example.invalid/file; bash \"\$1\"' -- {0}" \ + 'jobs:' \ + ' shell:' \ + ' runs-on: ubuntu-latest' \ + ' steps:' \ + ' - run: echo harmless' +expect_fail "workflow defaults executable shell block scalar" "$fixture" + +fixture="$(make_fixture job-default-block-shell "$FULL_ACTION_REF")" +write_repository_file "$fixture" .github/workflows/executable-shell.yml \ + 'name: executable shell' \ + 'jobs:' \ + ' shell:' \ + ' runs-on: ubuntu-latest' \ + ' defaults:' \ + ' run:' \ + ' shell: >' \ + " bash -c '$CURL_COMMAND_NAME https://example.invalid/file; bash \"\$1\"' -- {0}" \ + ' steps:' \ + ' - run: echo harmless' +expect_fail "job defaults executable shell folded scalar" "$fixture" + +fixture="$(make_fixture composite-block-shell './.github/actions/composite')" +write_repository_file "$fixture" .github/actions/composite/action.yml \ + 'name: composite' \ + 'runs:' \ + ' using: composite' \ + ' steps:' \ + ' - shell: |-' \ + " bash -c '$CURL_COMMAND_NAME https://example.invalid/file; bash \"\$1\"' -- {0}" \ + ' run: echo harmless' +expect_fail "composite executable shell block scalar" "$fixture" + +fixture="$(make_fixture data-block-shell "$FULL_ACTION_REF")" +write_repository_file "$fixture" .github/workflows/data-shell.yml \ + 'name: data shell' \ + 'env:' \ + ' shell: |' \ + ' harmless environment data' \ + 'jobs:' \ + ' data:' \ + ' runs-on: ubuntu-latest' \ + ' steps:' \ + " - uses: actions/checkout@$FULL_ACTION_SHA # v7.0.1" \ + ' with:' \ + ' shell: >' \ + ' harmless input data' +expect_pass "block scalar shell keys in env and with remain ordinary data" "$fixture" + +fixture="$(make_fixture inline-pwsh-shell "$FULL_ACTION_REF")" +write_repository_file "$fixture" .github/workflows/inline-shell.yml \ + 'name: inline shell' \ + 'jobs:' \ + ' shell:' \ + ' runs-on: windows-latest' \ + ' steps:' \ + ' - shell: pwsh' \ + " run: Write-Output 'ok'" +expect_pass "canonical inline executable shell remains accepted" "$fixture" + +fixture="$(make_fixture local-docker-action './.github/actions/docker')" +write_repository_file "$fixture" .github/actions/docker/action.yml \ + 'name: docker' \ + 'runs:' \ + ' using: docker' \ + ' image: Dockerfile' +write_repository_file "$fixture" .github/actions/docker/Dockerfile \ + 'FROM ubuntu:latest' \ + "RUN base=https://github.com/$TINYGO_RELEASE_REPOSITORY; \\" \ + " $CURL_COMMAND_NAME \"\$base/releases/download/v0.42.0/file\"" +expect_fail "local Docker Action runtime is unsupported" "$fixture" + +fixture="$(make_fixture local-docker-image-action './.github/actions/docker-image')" +write_repository_file "$fixture" .github/actions/docker-image/action.yml \ + 'name: docker image' \ + 'runs:' \ + ' using: docker' \ + ' image: docker://alpine:latest' +expect_fail "local Docker image Action runtime is unsupported" "$fixture" + +for runtime in node20 node24; do + fixture="$(make_fixture "local-$runtime-action" './.github/actions/javascript')" + write_repository_file "$fixture" .github/actions/javascript/action.yml \ + 'name: javascript' \ + 'runs:' \ + " using: $runtime" \ + ' main: dist/action' + write_repository_file "$fixture" .github/actions/javascript/dist/action \ + "const base = 'https://github.com/$TINYGO_RELEASE_REPOSITORY';" \ + "spawn('$CURL_COMMAND_NAME', [base + '/releases/download/v0.42.0/file']);" + expect_fail "local $runtime Action runtime is unsupported" "$fixture" +done + +for mode in unknown expression quoted missing duplicate; do + fixture="$(make_fixture "local-action-runtime-$mode" './.github/actions/runtime')" + case "$mode" in + unknown) + lines=('name: runtime' 'runs:' ' using: custom-runtime') + ;; + expression) + lines=('name: runtime' 'runs:' ' using: ${{ inputs.runtime }}') + ;; + quoted) + lines=('name: runtime' 'runs:' ' using: "composite"' ' steps:' ' - shell: bash' ' run: echo local') + ;; + missing) + lines=('name: runtime' 'runs:' ' steps:' ' - shell: bash' ' run: echo local') + ;; + duplicate) + lines=('name: runtime' 'runs:' ' using: composite' ' using: composite' ' steps:' ' - shell: bash' ' run: echo local') + ;; + esac + write_repository_file "$fixture" .github/actions/runtime/action.yml "${lines[@]}" + expect_fail "local Action $mode runs using contract" "$fixture" +done + +fixture="$(make_fixture direct-docker-action "$FULL_ACTION_REF")" +write_repository_file "$fixture" .github/workflows/docker.yml \ + 'name: docker' \ + 'jobs:' \ + ' docker:' \ + ' runs-on: ubuntu-latest' \ + ' steps:' \ + ' - uses: docker://alpine:latest' +expect_fail "direct docker Action runtime is unsupported" "$fixture" + +for primitive in source dot bash bash-option sh; do + fixture="$(make_fixture "unknown-extension-$primitive" "$FULL_ACTION_REF")" + write_repository_file "$fixture" scripts/bootstrap.inc \ + "base=https://github.com/$TINYGO_RELEASE_REPOSITORY" \ + "env $CURL_COMMAND_NAME \"\$base/releases/download/v0.42.0/file\"" + case "$primitive" in + source) command='source scripts/bootstrap.inc' ;; + dot) command='. scripts/bootstrap.inc' ;; + bash) command='bash scripts/bootstrap.inc' ;; + bash-option) command='bash -e scripts/bootstrap.inc' ;; + sh) command='sh scripts/bootstrap.inc' ;; + esac + add_workflow_script_invocation "$fixture/.github/workflows/ci-core.yml" "$command" + expect_fail "$primitive force-scans an unknown-extension shell target" "$fixture" +done + +for wrapper in command env env-assignment exec; do + fixture="$(make_fixture "wrapped-helper-$wrapper" "$FULL_ACTION_REF")" + write_repository_file "$fixture" scripts/bootstrap.inc \ + "base=https://github.com/$TINYGO_RELEASE_REPOSITORY" \ + "env $CURL_COMMAND_NAME \"\$base/releases/download/v0.42.0/file\"" + case "$wrapper" in + command) command='command bash scripts/bootstrap.inc' ;; + env) command='env bash scripts/bootstrap.inc' ;; + env-assignment) command='env FOO=bar bash scripts/bootstrap.inc' ;; + exec) command='exec bash scripts/bootstrap.inc' ;; + esac + add_workflow_script_invocation "$fixture/.github/workflows/ci-core.yml" "$command" + expect_fail "$wrapper wrapper preserves helper discovery" "$fixture" +done + +for command in \ + 'sudo bash scripts/bootstrap.inc' \ + 'timeout 30 bash scripts/bootstrap.inc' \ + 'nice bash scripts/bootstrap.inc' \ + 'nohup bash scripts/bootstrap.inc' \ + 'stdbuf -oL bash scripts/bootstrap.inc' \ + 'xargs bash scripts/bootstrap.inc' \ + 'arbitrary-wrapper bash scripts/bootstrap.inc'; do + fixture="$(make_fixture "unsupported-helper-indirection-$tests_run" "$FULL_ACTION_REF")" + write_repository_file "$fixture" scripts/bootstrap.inc \ + "base=https://github.com/$TINYGO_RELEASE_REPOSITORY" \ + "env $CURL_COMMAND_NAME \"\$base/releases/download/v0.42.0/file\"" + add_workflow_script_invocation "$fixture/.github/workflows/ci-core.yml" "$command" + expect_fail "unsupported repository helper indirection: $command" "$fixture" +done + +for command in \ + 'sudo python3 tools/bootstrap.resource' \ + 'arbitrary-wrapper node tools/bootstrap.resource'; do + fixture="$(make_fixture "unsupported-interpreter-indirection-$tests_run" "$FULL_ACTION_REF")" + write_repository_file "$fixture" tools/bootstrap.resource \ + "provenance=https://github.com/$TINYGO_RELEASE_REPOSITORY" + add_workflow_script_invocation "$fixture/.github/workflows/ci-core.yml" "$command" + expect_fail "unsupported non-shell helper indirection: $command" "$fixture" +done + +fixture="$(make_fixture nested-unsupported-helper-indirection "$FULL_ACTION_REF")" +write_repository_file "$fixture" scripts/outer.sh \ + '#!/usr/bin/env bash' \ + 'arbitrary-wrapper bash scripts/bootstrap.inc' +write_repository_file "$fixture" scripts/bootstrap.inc \ + "provenance=https://github.com/$TINYGO_RELEASE_REPOSITORY" +add_workflow_script_invocation "$fixture/.github/workflows/ci-core.yml" \ + 'bash scripts/outer.sh' +expect_fail "unsupported helper indirection inside a scanned shell source" "$fixture" + +for command in \ + 'sudo ./scripts/executable-helper' \ + 'timeout 10 scripts/executable-helper'; do + fixture="$(make_fixture "wrapped-executable-helper-$tests_run" "$FULL_ACTION_REF")" + write_repository_file "$fixture" scripts/executable-helper 'printf "benign\\n"' + chmod +x "$fixture/scripts/executable-helper" + add_workflow_script_invocation "$fixture/.github/workflows/ci-core.yml" "$command" + expect_fail "wrapped executable repository helper: $command" "$fixture" +done + +fixture="$(make_fixture wrapped-working-directory-helper "$FULL_ACTION_REF")" +write_repository_file "$fixture" tools/bootstrap.inc \ + "base=https://github.com/$TINYGO_RELEASE_REPOSITORY" +write_repository_file "$fixture" .github/workflows/wrapped-working-directory.yml \ + 'name: wrapped working directory' \ + 'jobs:' \ + ' helper:' \ + ' runs-on: ubuntu-latest' \ + ' steps:' \ + ' - working-directory: tools' \ + ' run: sudo bash bootstrap.inc' +expect_fail "working-directory resolves a helper behind unsupported indirection" "$fixture" + +fixture="$(make_fixture wrapped-symlink-helper "$FULL_ACTION_REF")" +write_repository_file "$fixture" real-tools/helper.inc 'printf "benign\\n"' +ln -s real-tools "$fixture/tools" +add_workflow_script_invocation "$fixture/.github/workflows/ci-core.yml" \ + 'sudo bash tools/helper.inc' +expect_fail "wrapped helper target with a symlink path component is rejected" "$fixture" + +fixture="$(make_fixture wrapped-traversed-helper "$FULL_ACTION_REF")" +write_repository_file "$fixture" tools/helper.inc 'printf "benign\\n"' +add_workflow_script_invocation "$fixture/.github/workflows/ci-core.yml" \ + 'sudo bash tools/../tools/helper.inc' +expect_fail "wrapped helper target traversal is rejected" "$fixture" + +fixture="$(make_fixture command-substitution-helper "$FULL_ACTION_REF")" +write_repository_file "$fixture" scripts/bootstrap.inc \ + "base=https://github.com/$TINYGO_RELEASE_REPOSITORY" +add_workflow_script_invocation "$fixture/.github/workflows/ci-core.yml" \ + 'result="$(bash scripts/bootstrap.inc)"' +expect_fail "command substitution cannot hide a repository helper" "$fixture" + +fixture="$(make_fixture backtick-substitution-helper "$FULL_ACTION_REF")" +write_repository_file "$fixture" scripts/bootstrap.inc \ + "base=https://github.com/$TINYGO_RELEASE_REPOSITORY" +add_workflow_script_invocation "$fixture/.github/workflows/ci-core.yml" \ + 'result=`bash scripts/bootstrap.inc`' +expect_fail "backtick substitution cannot hide a repository helper" "$fixture" + +fixture="$(make_fixture literal-substitution-data "$FULL_ACTION_REF")" +write_repository_file "$fixture" scripts/bootstrap.inc 'printf "benign\\n"' +add_workflow_script_invocation "$fixture/.github/workflows/ci-core.yml" \ + "echo '\$(bash scripts/bootstrap.inc)'" +expect_pass "single-quoted substitution-shaped text remains ordinary data" "$fixture" + +fixture="$(make_fixture subshell-helper "$FULL_ACTION_REF")" +write_repository_file "$fixture" scripts/bootstrap.inc \ + "base=https://github.com/$TINYGO_RELEASE_REPOSITORY" +add_workflow_script_invocation "$fixture/.github/workflows/ci-core.yml" \ + '( bash scripts/bootstrap.inc )' +expect_fail "subshell preserves repository helper discovery" "$fixture" + +fixture="$(make_fixture repository-data-arguments "$FULL_ACTION_REF")" +write_repository_file "$fixture" docs/file.txt 'harmless data' +write_repository_file "$fixture" scripts/data.txt 'pattern' +add_workflow_script_invocation "$fixture/.github/workflows/ci-core.yml" \ + 'cat docs/file.txt' +add_workflow_script_invocation "$fixture/.github/workflows/ci-core.yml" \ + 'grep pattern scripts/data.txt' +expect_pass "non-executable repository data arguments remain supported" "$fixture" + +fixture="$(make_fixture env-chdir-helper "$FULL_ACTION_REF")" +write_repository_file "$fixture" tools/bootstrap.inc \ + "base=https://github.com/$TINYGO_RELEASE_REPOSITORY" +add_workflow_script_invocation "$fixture/.github/workflows/ci-core.yml" \ + 'env -C tools bash bootstrap.inc' +expect_fail "env chdir option cannot hide a repository helper" "$fixture" + +fixture="$(make_fixture exec-argv0-helper "$FULL_ACTION_REF")" +write_repository_file "$fixture" scripts/bootstrap.inc \ + "base=https://github.com/$TINYGO_RELEASE_REPOSITORY" +add_workflow_script_invocation "$fixture/.github/workflows/ci-core.yml" \ + 'exec -a bootstrap bash scripts/bootstrap.inc' +expect_fail "exec argv0 option cannot hide a repository helper" "$fixture" + +fixture="$(make_fixture node-preload-helper "$FULL_ACTION_REF")" +write_repository_file "$fixture" tools/preload.resource \ + "provenance=https://github.com/$TINYGO_RELEASE_REPOSITORY" +add_workflow_script_invocation "$fixture/.github/workflows/ci-core.yml" \ + 'node --require ./tools/preload.resource -e "console.log(1)"' +expect_fail "Node preload force-scans a repository helper" "$fixture" + +fixture="$(make_fixture benign-node-preload "$FULL_ACTION_REF")" +write_repository_file "$fixture" tools/preload.resource \ + 'console.log("benign preload");' +write_repository_file "$fixture" scripts/example.mjs \ + 'console.log("benign helper");' +add_workflow_script_invocation "$fixture/.github/workflows/ci-core.yml" \ + 'node --require ./tools/preload.resource scripts/example.mjs' +expect_pass "Node preload and direct script targets remain statically resolved" "$fixture" + +for subcommand in validate-json pick-free-port manifest-wasm-path; do + fixture="$(make_fixture "direct-node-helper-$subcommand" "$FULL_ACTION_REF")" + write_repository_file "$fixture" scripts/ci-node-tools.mjs \ + 'console.log("benign helper");' + case "$subcommand" in + validate-json) arguments='package.json' ;; + pick-free-port) arguments='' ;; + manifest-wasm-path) arguments='examples/app' ;; + esac + add_workflow_script_invocation "$fixture/.github/workflows/ci-core.yml" \ + "$NODE_COMMAND_NAME scripts/ci-node-tools.mjs $subcommand${arguments:+ $arguments}" + expect_pass "direct tracked Node helper subcommand: $subcommand" "$fixture" +done + +fixture="$(make_fixture node-transparent-runtime-option "$FULL_ACTION_REF")" +write_repository_file "$fixture" scripts/example.mjs \ + 'console.log("benign helper");' +add_workflow_script_invocation "$fixture/.github/workflows/ci-core.yml" \ + "$NODE_COMMAND_NAME --experimental-websocket scripts/example.mjs" +expect_pass "Node experimental WebSocket option preserves tracked target resolution" "$fixture" + +fixture="$(make_fixture node-helper-provenance "$FULL_ACTION_REF")" +write_repository_file "$fixture" scripts/example.mjs \ + "const provenance = 'https://github.com/$TINYGO_RELEASE_REPOSITORY';" +add_workflow_script_invocation "$fixture/.github/workflows/ci-core.yml" \ + "$NODE_COMMAND_NAME scripts/example.mjs" +expect_fail "tracked Node helper remains under TinyGo provenance inspection" "$fixture" + +for shell_case in \ + "$BASH_COMMAND_NAME $SHELL_CODE_OPTION 'bash scripts/bootstrap.inc'" \ + "$SH_COMMAND_NAME $SHELL_CODE_OPTION 'source scripts/bootstrap.inc'" \ + "$BASH_COMMAND_NAME -l${SHELL_CODE_OPTION#-} 'bash scripts/bootstrap.inc'" \ + "$SH_COMMAND_NAME -x${SHELL_CODE_OPTION#-} 'source scripts/bootstrap.inc'" \ + "$BASH_COMMAND_NAME --command 'bash scripts/bootstrap.inc'" \ + "$BASH_COMMAND_NAME $SHELL_STDIN_OPTION" \ + "$SH_COMMAND_NAME $SHELL_STDIN_OPTION" \ + "$SH_COMMAND_NAME -x${SHELL_STDIN_OPTION#-}"; do + fixture="$(make_fixture "opaque-shell-$tests_run" "$FULL_ACTION_REF")" + add_workflow_script_invocation "$fixture/.github/workflows/ci-core.yml" "$shell_case" + expect_fail "opaque shell execution mode: $shell_case" "$fixture" +done + +for shell_name in "$BASH_COMMAND_NAME" "$SH_COMMAND_NAME"; do + fixture="$(make_fixture "pipeline-shell-$shell_name" "$FULL_ACTION_REF")" + add_workflow_script_invocation "$fixture/.github/workflows/ci-core.yml" \ + "printf 'echo ok\\n' | $shell_name" + expect_fail "pipeline-fed $shell_name stdin execution" "$fixture" +done + +fixture="$(make_fixture pipeline-stderr-shell "$FULL_ACTION_REF")" +add_workflow_script_invocation "$fixture/.github/workflows/ci-core.yml" \ + "printf 'echo ok\\n' |& $BASH_COMMAND_NAME" +expect_fail "stdout-and-stderr pipeline cannot feed opaque shell stdin" "$fixture" + +fixture="$(make_fixture opaque-eval "$FULL_ACTION_REF")" +add_workflow_script_invocation "$fixture/.github/workflows/ci-core.yml" \ + "$EVAL_COMMAND_NAME 'bash scripts/bootstrap.inc'" +expect_fail "shell eval is opaque execution" "$fixture" + +for python_case in \ + "python $SHELL_CODE_OPTION 'print(1)'" \ + "python $PYTHON_MODULE_OPTION fixture_module" \ + "$PYTHON_COMMAND_NAME $SHELL_CODE_OPTION 'print(1)'" \ + "$PYTHON_COMMAND_NAME $PYTHON_MODULE_OPTION fixture_module" \ + "$PYTHON_COMMAND_NAME -" \ + "printf 'print(1)\\n' | $PYTHON_COMMAND_NAME"; do + fixture="$(make_fixture "opaque-python-$tests_run" "$FULL_ACTION_REF")" + add_workflow_script_invocation "$fixture/.github/workflows/ci-core.yml" "$python_case" + expect_fail "opaque Python execution mode: $python_case" "$fixture" +done + +for node_case in \ + "$NODE_COMMAND_NAME $NODE_EVAL_OPTION 'console.log(1)'" \ + "$NODE_COMMAND_NAME --eval 'console.log(1)'" \ + "$NODE_COMMAND_NAME --eval='console.log(1)'" \ + "$NODE_COMMAND_NAME $NODE_PRINT_OPTION '1 + 1'" \ + "$NODE_COMMAND_NAME --print '1 + 1'" \ + "$NODE_COMMAND_NAME --print='1 + 1'" \ + "$NODE_COMMAND_NAME -pe '1 + 1'" \ + "$NODE_COMMAND_NAME --run fixture" \ + "$NODE_COMMAND_NAME --run=fixture" \ + "$NODE_COMMAND_NAME -" \ + "printf 'console.log(1)\\n' | $NODE_COMMAND_NAME"; do + fixture="$(make_fixture "opaque-node-$tests_run" "$FULL_ACTION_REF")" + add_workflow_script_invocation "$fixture/.github/workflows/ci-core.yml" "$node_case" + expect_fail "opaque Node execution mode: $node_case" "$fixture" +done + +for powershell_case in \ + "$PWSH_COMMAND_NAME -Command 'Write-Output ok'" \ + "$PWSH_COMMAND_NAME -CommandWithArgs 'Write-Output ok'" \ + "$PWSH_COMMAND_NAME -EncodedCommand ZgBvAG8A" \ + "$POWERSHELL_COMMAND_NAME -Command 'Write-Output ok'" \ + "$POWERSHELL_COMMAND_NAME -EncodedCommand ZgBvAG8A"; do + fixture="$(make_fixture "opaque-powershell-$tests_run" "$FULL_ACTION_REF")" + add_workflow_script_invocation "$fixture/.github/workflows/ci-core.yml" "$powershell_case" + expect_fail "opaque PowerShell execution mode: $powershell_case" "$fixture" +done + +for powershell_name in "$PWSH_COMMAND_NAME" "$POWERSHELL_COMMAND_NAME"; do + fixture="$(make_fixture "pipeline-powershell-$powershell_name" "$FULL_ACTION_REF")" + add_workflow_script_invocation "$fixture/.github/workflows/ci-core.yml" \ + "printf 'Write-Output ok\\n' | $powershell_name" + expect_fail "pipeline-fed $powershell_name stdin execution" "$fixture" +done + +for wrapper in command env exec sudo 'timeout 30' arbitrary-wrapper; do + fixture="$(make_fixture "wrapped-opaque-shell-$tests_run" "$FULL_ACTION_REF")" + add_workflow_script_invocation "$fixture/.github/workflows/ci-core.yml" \ + "$wrapper $BASH_COMMAND_NAME $SHELL_CODE_OPTION 'bash scripts/bootstrap.inc'" + expect_fail "$wrapper cannot restore opaque shell execution" "$fixture" +done + +fixture="$(make_fixture wrapped-opaque-sh "$FULL_ACTION_REF")" +add_workflow_script_invocation "$fixture/.github/workflows/ci-core.yml" \ + "sudo $SH_COMMAND_NAME $SHELL_CODE_OPTION 'source scripts/bootstrap.inc'" +expect_fail "sudo cannot restore opaque sh execution" "$fixture" + +for nesting in substitution backticks subshell; do + fixture="$(make_fixture "nested-opaque-shell-$nesting" "$FULL_ACTION_REF")" + case "$nesting" in + substitution) + command="result=\"\$($BASH_COMMAND_NAME $SHELL_CODE_OPTION 'printf ok')\"" + ;; + backticks) + command="result=\`$SH_COMMAND_NAME $SHELL_CODE_OPTION 'printf ok'\`" + ;; + subshell) + command="( $BASH_COMMAND_NAME $SHELL_CODE_OPTION 'printf ok' )" + ;; + esac + add_workflow_script_invocation "$fixture/.github/workflows/ci-core.yml" "$command" + expect_fail "$nesting cannot restore opaque shell execution" "$fixture" +done + +fixture="$(make_fixture nested-helper-opaque-shell "$FULL_ACTION_REF")" +write_shell_source "$fixture" scripts/outer.sh \ + "$BASH_COMMAND_NAME $SHELL_CODE_OPTION 'bash scripts/bootstrap.inc'" +add_workflow_script_invocation "$fixture/.github/workflows/ci-core.yml" \ + "$BASH_COMMAND_NAME scripts/outer.sh" +expect_fail "nested repository shell helper cannot restore opaque execution" "$fixture" + +fixture="$(make_fixture nested-helper-shell-stdin "$FULL_ACTION_REF")" +write_shell_source "$fixture" scripts/outer.sh \ + "$BASH_COMMAND_NAME <<< 'printf ok'" +add_workflow_script_invocation "$fixture/.github/workflows/ci-core.yml" \ + "$BASH_COMMAND_NAME scripts/outer.sh" +expect_fail "nested repository shell helper cannot consume opaque stdin" "$fixture" + +for context in simple sh conditional wrapper sudo timeout nice nohup stdbuf substitution backticks subshell pipeline heredoc; do + fixture="$(make_fixture "nested-targetless-$context" "$FULL_ACTION_REF")" + write_repository_file "$fixture" scripts/payload.txt 'printf "nested-stdin-executed\n"' + case "$context" in + simple) lines=("$BASH_COMMAND_NAME") ;; + sh) lines=("$SH_COMMAND_NAME") ;; + conditional) lines=("if $BASH_COMMAND_NAME; then" ':' 'fi') ;; + wrapper) lines=("env $BASH_COMMAND_NAME") ;; + sudo) lines=("sudo $BASH_COMMAND_NAME") ;; + timeout) lines=("timeout 30 $BASH_COMMAND_NAME") ;; + nice) lines=("nice $BASH_COMMAND_NAME") ;; + nohup) lines=("nohup $BASH_COMMAND_NAME") ;; + stdbuf) lines=("stdbuf -oL $BASH_COMMAND_NAME") ;; + substitution) lines=("result=\"\$($BASH_COMMAND_NAME)\"") ;; + backticks) lines=("result=\`$BASH_COMMAND_NAME\`") ;; + subshell) lines=("( $BASH_COMMAND_NAME )") ;; + pipeline) lines=('printf "printf ok\\n" |' "$BASH_COMMAND_NAME") ;; + heredoc) lines=('cat <&1)"; then + printf 'not ok %d - recursively scanned unknown-extension shell target unexpectedly passed\n%s\n' \ + "$tests_run" "$output" >&2 + exit 1 +fi +if [[ "$output" != *'scripts/middle.inc:'* || + "$output" != *'execution without a bounded script target'* ]]; then + printf 'not ok %d - unknown-extension shell target failed outside its nested boundary\n%s\n' \ + "$tests_run" "$output" >&2 + exit 1 +fi +printf 'ok %d - recursively scanned unknown-extension shell target\n' "$tests_run" + +fixture="$(make_fixture bounded-shell-with-stdin-data "$FULL_ACTION_REF")" +write_repository_file "$fixture" scripts/payload.txt 'ordinary fixture data' +write_shell_source "$fixture" scripts/reader.sh 'cat' +add_workflow_script_invocation "$fixture/.github/workflows/ci-core.yml" \ + "$BASH_COMMAND_NAME scripts/reader.sh < scripts/payload.txt" +expect_pass "explicit shell script target may receive ordinary stdin data" "$fixture" + +fixture="$(make_fixture inert-targetless-shell-data "$FULL_ACTION_REF")" +write_repository_file "$fixture" scripts/payload.txt 'ordinary fixture data' +write_shell_source "$fixture" scripts/outer.sh \ + "printf '%s\\n' '$BASH_COMMAND_NAME'" \ + "cat <<'EOF'" \ + "$BASH_COMMAND_NAME" \ + "$CURL_COMMAND_NAME" \ + 'EOF' \ + 'cat < scripts/payload.txt' +add_workflow_script_invocation "$fixture/.github/workflows/ci-core.yml" \ + "$BASH_COMMAND_NAME scripts/outer.sh" +expect_pass "quoted names, quoted heredocs, and benign stdin readers remain data" "$fixture" + +fixture="$(make_fixture malformed-repository-shell "$FULL_ACTION_REF")" +write_shell_source "$fixture" scripts/invalid.sh 'if then' +expect_fail "malformed tracked shell source blocks parser coverage" "$fixture" + +fixture="$(make_fixture malformed-workflow-shell "$FULL_ACTION_REF")" +add_workflow_script_invocation "$fixture/.github/workflows/ci-core.yml" \ + 'if then' +expect_fail "malformed executable run body blocks parser coverage" "$fixture" + +fixture="$(make_fixture inert-opaque-text "$FULL_ACTION_REF")" +add_workflow_script_invocation "$fixture/.github/workflows/ci-core.yml" \ + "echo '$BASH_COMMAND_NAME $SHELL_CODE_OPTION \"bash scripts/bootstrap.inc\"'" +add_workflow_script_invocation "$fixture/.github/workflows/ci-core.yml" \ + "printf '%s\\n' '$NODE_COMMAND_NAME $NODE_EVAL_OPTION \"console.log(1)\"'" +expect_pass "single-quoted opaque-interpreter text remains ordinary data" "$fixture" + +fixture="$(make_fixture supported-interpreter-file-modes "$FULL_ACTION_REF")" +write_repository_file "$fixture" scripts/example.sh 'printf "ok\\n"' +write_repository_file "$fixture" tools/example.resource 'print("ok")' +write_repository_file "$fixture" tools/example.ps1 'Write-Output ok' +add_workflow_script_invocation "$fixture/.github/workflows/ci-core.yml" \ + '/bin/bash --version' +add_workflow_script_invocation "$fixture/.github/workflows/ci-core.yml" \ + '/bin/bash -n scripts/example.sh' +add_workflow_script_invocation "$fixture/.github/workflows/ci-core.yml" \ + "$BASH_COMMAND_NAME -e scripts/example.sh" +add_workflow_script_invocation "$fixture/.github/workflows/ci-core.yml" \ + "$PYTHON_COMMAND_NAME tools/example.resource" +add_workflow_script_invocation "$fixture/.github/workflows/ci-core.yml" \ + "$PWSH_COMMAND_NAME -fIlE tools/example.ps1" +expect_pass "supported interpreter file and introspection modes remain accepted" "$fixture" + +for option in --rcfile --init-file; do + fixture="$(make_fixture "bash-option-arity-$option" "$FULL_ACTION_REF")" + write_repository_file "$fixture" scripts/benign.sh 'printf "benign\n"' + add_workflow_script_invocation "$fixture/.github/workflows/ci-core.yml" \ + "$BASH_COMMAND_NAME $option scripts/benign.sh $SHELL_CODE_OPTION 'printf ok'" + expect_fail "$option cannot hide later command-string execution" "$fixture" +done + +fixture="$(make_fixture wrapped-bash-option-arity "$FULL_ACTION_REF")" +write_repository_file "$fixture" scripts/benign.sh 'printf "benign\n"' +add_workflow_script_invocation "$fixture/.github/workflows/ci-core.yml" \ + "env $BASH_COMMAND_NAME --rcfile scripts/benign.sh $SHELL_CODE_OPTION 'printf ok'" +expect_fail "env wrapper cannot hide Bash command-string execution" "$fixture" + +fixture="$(make_fixture nested-bash-option-arity "$FULL_ACTION_REF")" +write_repository_file "$fixture" scripts/benign.sh 'printf "benign\n"' +write_shell_source "$fixture" scripts/outer.sh \ + "$BASH_COMMAND_NAME --init-file scripts/benign.sh $SHELL_CODE_OPTION 'printf ok'" +add_workflow_script_invocation "$fixture/.github/workflows/ci-core.yml" \ + "$BASH_COMMAND_NAME scripts/outer.sh" +expect_fail "nested shell helper cannot hide Bash command-string execution" "$fixture" + +for option in -i -l --login --debugger -eil; do + fixture="$(make_fixture "bash-startup-mode-$option" "$FULL_ACTION_REF")" + write_repository_file "$fixture" scripts/example.sh 'printf "benign\n"' + add_workflow_script_invocation "$fixture/.github/workflows/ci-core.yml" \ + "$BASH_COMMAND_NAME $option scripts/example.sh" + expect_fail "unsupported Bash startup mode $option" "$fixture" +done + +fixture="$(make_fixture unknown-bash-long-option "$FULL_ACTION_REF")" +write_repository_file "$fixture" scripts/example.sh 'printf "benign\n"' +add_workflow_script_invocation "$fixture/.github/workflows/ci-core.yml" \ + "$BASH_COMMAND_NAME --something-unknown scripts/example.sh $SHELL_CODE_OPTION 'printf ok'" +expect_fail "unknown Bash long option cannot hide later execution" "$fixture" + +for option in \ + '--rcfile=scripts/benign.sh' \ + '--init-file=scripts/benign.sh' \ + '-O extglob'; do + fixture="$(make_fixture "bash-option-variant-$tests_run" "$FULL_ACTION_REF")" + write_repository_file "$fixture" scripts/benign.sh 'printf "benign\n"' + add_workflow_script_invocation "$fixture/.github/workflows/ci-core.yml" \ + "$BASH_COMMAND_NAME $option $SHELL_CODE_OPTION 'printf ok'" + expect_fail "unsupported Bash option variant $option" "$fixture" +done + +fixture="$(make_fixture sh-rejects-bash-long-option "$FULL_ACTION_REF")" +write_repository_file "$fixture" scripts/example.sh 'printf "benign\n"' +add_workflow_script_invocation "$fixture/.github/workflows/ci-core.yml" \ + "$SH_COMMAND_NAME --noprofile scripts/example.sh" +expect_fail "sh does not accept Bash-only long options" "$fixture" + +for interpreter in "$BASH_COMMAND_NAME" "$SH_COMMAND_NAME"; do + fixture="$(make_fixture "bare-interpreter-$interpreter" "$FULL_ACTION_REF")" + add_workflow_script_invocation "$fixture/.github/workflows/ci-core.yml" \ + "$interpreter" + expect_fail "$interpreter without a bounded target is rejected" "$fixture" +done + +fixture="$(make_fixture supported-shell-option-forms "$FULL_ACTION_REF")" +write_repository_file "$fixture" scripts/example.sh 'printf "benign\n"' +add_workflow_script_invocation "$fixture/.github/workflows/ci-core.yml" \ + "$BASH_COMMAND_NAME -eu scripts/example.sh" +add_workflow_script_invocation "$fixture/.github/workflows/ci-core.yml" \ + "$BASH_COMMAND_NAME -- scripts/example.sh" +add_workflow_script_invocation "$fixture/.github/workflows/ci-core.yml" \ + "$SH_COMMAND_NAME -e scripts/example.sh" +expect_pass "bounded Bash and sh target options remain accepted" "$fixture" + +for shebang in \ + '#!/usr/bin/env sh' \ + '#!/usr/bin/env -S sh -e' \ + '#!/bin/sh' \ + '#!/usr/bin/sh' \ + '#!/bin/bash' \ + '#!/usr/bin/bash' \ + '#!/usr/bin/env bash' \ + '#!/usr/bin/env -S bash -e'; do + fixture="$(make_fixture "extensionless-shell-$tests_run" "$FULL_ACTION_REF")" + write_extensionless_downloader_helper "$fixture" "$shebang" + add_workflow_script_invocation "$fixture/.github/workflows/ci-core.yml" \ + './scripts/helper' + expect_fail "extensionless shell helper with $shebang is inspected" "$fixture" +done + +fixture="$(make_fixture extensionless-env-sh-wrapped "$FULL_ACTION_REF")" +write_extensionless_downloader_helper "$fixture" '#!/usr/bin/env sh' "$WGET_COMMAND_NAME" +add_workflow_script_invocation "$fixture/.github/workflows/ci-core.yml" \ + 'env ./scripts/helper' +expect_fail "env-wrapped extensionless sh helper is inspected" "$fixture" + +for shebang in \ + '#!/usr/bin/nobash' \ + '#!/usr/bin/env nobash' \ + '#!/usr/bin/env bashful' \ + '#!/usr/bin/env sh-wrapper'; do + fixture="$(make_fixture "extensionless-non-shell-$tests_run" "$FULL_ACTION_REF")" + write_extensionless_downloader_helper "$fixture" "$shebang" + add_workflow_script_invocation "$fixture/.github/workflows/ci-core.yml" \ + './scripts/helper' + expect_pass "near-match interpreter $shebang remains non-shell" "$fixture" +done + +for primitive in python python3 node pwsh powershell; do + fixture="$(make_fixture "interpreted-helper-$primitive" "$FULL_ACTION_REF")" + write_repository_file "$fixture" tools/bootstrap.resource \ + "provenance=https://github.com/$TINYGO_RELEASE_REPOSITORY" + case "$primitive" in + pwsh) command='pwsh -File tools/bootstrap.resource' ;; + powershell) command='powershell -File tools/bootstrap.resource' ;; + *) command="$primitive tools/bootstrap.resource" ;; + esac + add_workflow_script_invocation "$fixture/.github/workflows/ci-core.yml" "$command" + expect_fail "$primitive force-scans an unknown-extension target" "$fixture" +done + +fixture="$(make_fixture direct-unknown-extension "$FULL_ACTION_REF")" +write_repository_file "$fixture" tools/runner.data \ + "provenance=https://github.com/$TINYGO_RELEASE_REPOSITORY" +add_workflow_script_invocation "$fixture/.github/workflows/ci-core.yml" \ + './tools/runner.data' +expect_fail "direct repository path force-scans an unknown-extension target" "$fixture" + +fixture="$(make_fixture direct-unknown-extension-without-dot "$FULL_ACTION_REF")" +write_repository_file "$fixture" tools/runner.data \ + "provenance=https://github.com/$TINYGO_RELEASE_REPOSITORY" +add_workflow_script_invocation "$fixture/.github/workflows/ci-core.yml" \ + 'tools/runner.data' +expect_fail "direct repository path without dot prefix is force-scanned" "$fixture" + +fixture="$(make_fixture quoted-helper-path "$FULL_ACTION_REF")" +write_repository_file "$fixture" 'tools/path with spaces.inc' \ + "base=https://github.com/$TINYGO_RELEASE_REPOSITORY" \ + "env $CURL_COMMAND_NAME \"\$base/releases/download/v0.42.0/file\"" +add_workflow_script_invocation "$fixture/.github/workflows/ci-core.yml" \ + 'bash "tools/path with spaces.inc"' +expect_fail "quoted repository helper path with spaces is resolved" "$fixture" + +for scope in workflow job step; do + fixture="$(make_fixture "working-directory-$scope" "$FULL_ACTION_REF")" + write_repository_file "$fixture" tools/bootstrap.inc \ + "base=https://github.com/$TINYGO_RELEASE_REPOSITORY" \ + "env $CURL_COMMAND_NAME \"\$base/releases/download/v0.42.0/file\"" + case "$scope" in + workflow) + write_repository_file "$fixture" .github/workflows/working-directory.yml \ + 'name: working directory' \ + 'jobs:' \ + ' helper:' \ + ' runs-on: ubuntu-latest' \ + ' steps:' \ + ' - run: bash bootstrap.inc' \ + 'defaults:' \ + ' run:' \ + ' working-directory: tools' + ;; + job) + write_repository_file "$fixture" .github/workflows/working-directory.yml \ + 'name: working directory' \ + 'jobs:' \ + ' helper:' \ + ' runs-on: ubuntu-latest' \ + ' steps:' \ + ' - run: bash bootstrap.inc' \ + ' defaults:' \ + ' run:' \ + ' working-directory: tools' + ;; + step) + write_repository_file "$fixture" .github/workflows/working-directory.yml \ + 'name: working directory' \ + 'jobs:' \ + ' helper:' \ + ' runs-on: ubuntu-latest' \ + ' steps:' \ + ' - run: bash bootstrap.inc' \ + ' working-directory: tools' + ;; + esac + expect_fail "$scope working-directory resolves the executed helper" "$fixture" +done + +fixture="$(make_fixture working-directory-precedence "$FULL_ACTION_REF")" +write_repository_file "$fixture" tools/malicious/bootstrap.inc \ + "base=https://github.com/$TINYGO_RELEASE_REPOSITORY" +write_repository_file "$fixture" tools/benign/bootstrap.inc 'printf "benign\\n"' +write_repository_file "$fixture" .github/workflows/working-directory.yml \ + 'name: working directory precedence' \ + 'defaults:' \ + ' run:' \ + ' working-directory: tools/malicious' \ + 'jobs:' \ + ' helper:' \ + ' runs-on: ubuntu-latest' \ + ' defaults:' \ + ' run:' \ + ' working-directory: tools/benign' \ + ' steps:' \ + ' - run: bash bootstrap.inc' +expect_pass "job working-directory overrides workflow default" "$fixture" + +fixture="$(make_fixture step-working-directory-precedence "$FULL_ACTION_REF")" +write_repository_file "$fixture" tools/malicious/bootstrap.inc \ + "base=https://github.com/$TINYGO_RELEASE_REPOSITORY" +write_repository_file "$fixture" tools/benign/bootstrap.inc 'printf "benign\\n"' +write_repository_file "$fixture" .github/workflows/working-directory.yml \ + 'name: working directory precedence' \ + 'defaults:' \ + ' run:' \ + ' working-directory: tools/malicious' \ + 'jobs:' \ + ' helper:' \ + ' runs-on: ubuntu-latest' \ + ' steps:' \ + ' - run: bash bootstrap.inc' \ + ' working-directory: tools/benign' +expect_pass "step working-directory overrides inherited defaults" "$fixture" + +fixture="$(make_fixture dynamic-working-directory-no-helper "$FULL_ACTION_REF")" +write_repository_file "$fixture" .github/workflows/working-directory.yml \ + 'name: dynamic working directory' \ + 'jobs:' \ + ' harmless:' \ + ' runs-on: ubuntu-latest' \ + ' steps:' \ + ' - working-directory: ${{ matrix.directory }}' \ + ' run: echo harmless' +expect_pass "dynamic working-directory without local helper remains supported" "$fixture" + +fixture="$(make_fixture dynamic-step-shell "$FULL_ACTION_REF")" +write_repository_file "$fixture" .github/workflows/dynamic-shell.yml \ + 'name: dynamic shell' \ + 'jobs:' \ + ' dynamic:' \ + ' runs-on: ubuntu-latest' \ + ' steps:' \ + ' - shell: ${{ matrix.shell }}' \ + ' run: echo harmless' +expect_fail "dynamic executable step shell fails closed" "$fixture" + +for mode in traversal backslash expression; do + fixture="$(make_fixture "unsafe-working-directory-$mode" "$FULL_ACTION_REF")" + write_repository_file "$fixture" tools/bootstrap.inc 'printf "benign\\n"' + case "$mode" in + traversal) directory='tools/../tools' ;; + backslash) directory='tools\subdir' ;; + expression) directory='${{ matrix.directory }}' ;; + esac + write_repository_file "$fixture" .github/workflows/working-directory.yml \ + 'name: unsafe working directory' \ + 'jobs:' \ + ' helper:' \ + ' runs-on: ubuntu-latest' \ + ' steps:' \ + " - working-directory: $directory" \ + ' run: bash bootstrap.inc' + expect_fail "$mode working-directory fails closed for helper resolution" "$fixture" +done + +fixture="$(make_fixture traversed-helper-target "$FULL_ACTION_REF")" +write_repository_file "$fixture" tools/bootstrap.inc 'printf "benign\\n"' +add_workflow_script_invocation "$fixture/.github/workflows/ci-core.yml" \ + 'bash tools/../tools/bootstrap.inc' +expect_fail "helper target traversal is rejected" "$fixture" + +fixture="$(make_fixture backslash-helper-target "$FULL_ACTION_REF")" +write_repository_file "$fixture" tools/bootstrap.inc 'printf "benign\\n"' +add_workflow_script_invocation "$fixture/.github/workflows/ci-core.yml" \ + 'bash tools\bootstrap.inc' +expect_fail "helper target backslash spelling is rejected" "$fixture" + +outside_helper="$TMP_ROOT/outside-execution-target" +printf '%s\n' 'printf "outside\\n"' > "$outside_helper" +for mode in direct bash; do + fixture="$(make_fixture "symlink-helper-$mode" "$FULL_ACTION_REF")" + mkdir -p "$fixture/scripts" + ln -s "$outside_helper" "$fixture/scripts/helper" + if [[ "$mode" == direct ]]; then + command='scripts/helper' + else + command='bash scripts/helper' + fi + add_workflow_script_invocation "$fixture/.github/workflows/ci-core.yml" "$command" + expect_fail "$mode helper symlink escape is rejected" "$fixture" +done + +fixture="$(make_fixture symlink-helper-component "$FULL_ACTION_REF")" +mkdir -p "$fixture/real-tools" +write_repository_file "$fixture" real-tools/helper.inc 'printf "benign\\n"' +ln -s real-tools "$fixture/tools" +add_workflow_script_invocation "$fixture/.github/workflows/ci-core.yml" \ + 'bash tools/helper.inc' +expect_fail "helper target with a symlink path component is rejected" "$fixture" + +fixture="$(make_fixture workflow-standalone-mutable-step "$FULL_ACTION_REF")" +write_repository_file "$fixture" .github/workflows/standalone.yml \ + 'jobs:' \ + ' standalone:' \ + ' runs-on: ubuntu-latest' \ + ' steps:' \ + ' -' \ + ' uses: actions/checkout@v7' +expect_fail "workflow standalone step indicator with mutable Action" "$fixture" + +fixture="$(make_fixture workflow-standalone-pinned-step "$FULL_ACTION_REF")" +write_repository_file "$fixture" .github/workflows/standalone.yml \ + 'jobs:' \ + ' standalone:' \ + ' runs-on: ubuntu-latest' \ + ' steps:' \ + ' -' \ + " uses: actions/checkout@$FULL_ACTION_SHA # v7.0.1" +expect_fail "workflow standalone step indicator with pinned Action" "$fixture" + +fixture="$(make_fixture workflow-commented-standalone-step "$FULL_ACTION_REF")" +write_repository_file "$fixture" .github/workflows/standalone.yml \ + 'jobs:' \ + ' standalone:' \ + ' runs-on: ubuntu-latest' \ + ' steps:' \ + ' - # comment' \ + ' uses: actions/checkout@v7' +expect_fail "workflow commented standalone step indicator" "$fixture" + +fixture="$(make_fixture workflow-standalone-run-step "$FULL_ACTION_REF")" +write_repository_file "$fixture" .github/workflows/standalone.yml \ + 'jobs:' \ + ' standalone:' \ + ' runs-on: ubuntu-latest' \ + ' steps:' \ + ' -' \ + ' run: echo unsupported' +expect_fail "workflow standalone run step indicator" "$fixture" + +fixture="$(make_fixture composite-standalone-mutable-step "$FULL_ACTION_REF")" +write_repository_file "$fixture" .github/actions/standalone/action.yml \ + 'name: standalone' \ + 'runs:' \ + ' using: composite' \ + ' steps:' \ + ' -' \ + ' uses: actions/checkout@v7' +expect_fail "composite standalone step indicator with mutable Action" "$fixture" + +fixture="$(make_fixture composite-standalone-pinned-step "$FULL_ACTION_REF")" +write_repository_file "$fixture" .github/actions/standalone/action.yml \ + 'name: standalone' \ + 'runs:' \ + ' using: composite' \ + ' steps:' \ + ' -' \ + " uses: actions/checkout@$FULL_ACTION_SHA # v7.0.1" +expect_fail "composite standalone step indicator with pinned Action" "$fixture" + +fixture="$(make_fixture ordinary-standalone-sequence "$FULL_ACTION_REF")" +write_repository_file "$fixture" .github/workflows/data-sequence.yml \ + 'jobs:' \ + ' data:' \ + ' runs-on: ubuntu-latest' \ + ' strategy:' \ + ' matrix:' \ + ' include:' \ + ' -' \ + ' value: ordinary' \ + ' steps:' \ + ' - run: echo ordinary' +expect_pass "standalone sequence data outside steps" "$fixture" + +fixture="$(make_fixture protected-job-if-before "$FULL_ACTION_REF")" +add_job_property "$fixture/.github/workflows/ci-core.yml" before 'if: false' +expect_fail "protected TinyGo job if false before ordinary properties" "$fixture" + +fixture="$(make_fixture protected-job-if-after "$FULL_ACTION_REF")" +add_job_property "$fixture/.github/workflows/ci-core.yml" after 'if: false' +expect_fail "protected TinyGo job if false after steps" "$fixture" + +fixture="$(make_fixture protected-job-if-true "$FULL_ACTION_REF")" +add_job_property "$fixture/.github/workflows/ci-core.yml" before 'if: true' +expect_fail "protected TinyGo job if true" "$fixture" + +fixture="$(make_fixture protected-job-if-expression "$FULL_ACTION_REF")" +add_job_property "$fixture/.github/workflows/ci-core.yml" before 'if: ${{ always() }}' +expect_fail "protected TinyGo job if expression" "$fixture" + +fixture="$(make_fixture protected-job-continue-true "$FULL_ACTION_REF")" +add_job_property "$fixture/.github/workflows/ci-core.yml" before \ + 'continue-on-error: true' +expect_fail "protected TinyGo job continue-on-error true" "$fixture" + +fixture="$(make_fixture protected-job-continue-false "$FULL_ACTION_REF")" +add_job_property "$fixture/.github/workflows/ci-core.yml" after \ + 'continue-on-error: false' +expect_fail "protected TinyGo job continue-on-error false" "$fixture" + +fixture="$(make_fixture protected-job-continue-expression "$FULL_ACTION_REF")" +add_job_property "$fixture/.github/workflows/ci-core.yml" before \ + 'continue-on-error: ${{ matrix.experimental }}' +expect_fail "protected TinyGo job continue-on-error expression" "$fixture" + +fixture="$(make_fixture protected-job-needs-scalar "$FULL_ACTION_REF")" +add_job_property "$fixture/.github/workflows/ci-core.yml" before \ + 'needs: prerequisite' +expect_fail "protected TinyGo job scalar needs" "$fixture" + +fixture="$(make_fixture protected-job-needs-list "$FULL_ACTION_REF")" +add_job_property "$fixture/.github/workflows/ci-core.yml" after 'needs:' \ + '- prerequisite' +expect_fail "protected TinyGo job sequence needs" "$fixture" + +fixture="$(make_fixture protected-job-self-hosted "$FULL_ACTION_REF")" +replace_fixture_runs_on "$fixture/.github/workflows/ci-core.yml" self-hosted +expect_fail "protected TinyGo job self-hosted runner" "$fixture" + +fixture="$(make_fixture protected-job-runner-expression "$FULL_ACTION_REF")" +replace_fixture_runs_on "$fixture/.github/workflows/ci-core.yml" \ + '${{ matrix.runner }}' +expect_fail "protected TinyGo job runner expression" "$fixture" + +fixture="$(make_fixture protected-job-quoted-runner "$FULL_ACTION_REF")" +replace_fixture_runs_on "$fixture/.github/workflows/ci-core.yml" \ + "'ubuntu-latest'" +expect_fail "protected TinyGo job quoted runner" "$fixture" + +fixture="$(make_fixture protected-job-runner-flow-sequence "$FULL_ACTION_REF")" +replace_fixture_runs_on "$fixture/.github/workflows/ci-core.yml" \ + '[ubuntu-latest]' +expect_fail "protected TinyGo job flow-sequence runner" "$fixture" + +fixture="$(make_fixture protected-job-runner-block-sequence "$FULL_ACTION_REF")" +replace_fixture_runs_on "$fixture/.github/workflows/ci-core.yml" '' \ + '- ubuntu-latest' +expect_fail "protected TinyGo job block-sequence runner" "$fixture" + +fixture="$(make_fixture protected-job-missing-runner "$FULL_ACTION_REF")" +remove_fixture_runs_on "$fixture/.github/workflows/ci-core.yml" +expect_fail "protected TinyGo job missing runs-on" "$fixture" + +fixture="$(make_fixture protected-job-duplicate-runner "$FULL_ACTION_REF")" +add_job_property "$fixture/.github/workflows/ci-core.yml" before \ + 'runs-on: ubuntu-latest' +expect_fail "protected TinyGo job duplicate runs-on" "$fixture" + +fixture="$(make_fixture protected-job-container-scalar "$FULL_ACTION_REF")" +add_job_property "$fixture/.github/workflows/ci-core.yml" before \ + 'container: attacker/tinygo-tools:latest' +expect_fail "protected TinyGo job container scalar" "$fixture" + +fixture="$(make_fixture protected-job-container-mapping "$FULL_ACTION_REF")" +add_job_property "$fixture/.github/workflows/ci-core.yml" before 'container:' \ + 'image: attacker/tinygo-tools:latest' +expect_fail "protected TinyGo job container mapping" "$fixture" + +fixture="$(make_fixture protected-job-container-expression "$FULL_ACTION_REF")" +add_job_property "$fixture/.github/workflows/ci-core.yml" before \ + 'container: ${{ matrix.container }}' +expect_fail "protected TinyGo job container expression" "$fixture" + +fixture="$(make_fixture protected-job-container-null "$FULL_ACTION_REF")" +add_job_property "$fixture/.github/workflows/ci-core.yml" before \ + 'container: null' +expect_fail "protected TinyGo job container null" "$fixture" + +fixture="$(make_fixture protected-job-container-after-steps "$FULL_ACTION_REF")" +add_job_property "$fixture/.github/workflows/ci-core.yml" after \ + 'container: attacker/tinygo-tools:latest' +expect_fail "protected TinyGo job container after steps" "$fixture" + +fixture="$(make_fixture cross-job-tinygo-sequence "$FULL_ACTION_REF")" +split_verify_into_new_job "$fixture/.github/workflows/ci-core.yml" +expect_fail "Install and Verify TinyGo must share one containing job" "$fixture" + +fixture="$(make_fixture unrelated-job-controls "$FULL_ACTION_REF")" +add_unrelated_control_jobs "$fixture/.github/workflows/ci-core.yml" +expect_pass "unrelated jobs may use if continue-on-error and needs" "$fixture" + +fixture="$(make_fixture unrelated-job-runner-container "$FULL_ACTION_REF")" +add_unrelated_runner_jobs "$fixture/.github/workflows/ci-core.yml" +expect_pass "unrelated jobs may use self-hosted runners and containers" "$fixture" + +fixture="$(make_fixture protected-job-matrix "$FULL_ACTION_REF")" +add_job_strategy_matrix "$fixture/.github/workflows/ci-core.yml" +expect_pass "protected TinyGo job preserves strategy and matrix" "$fixture" + +fixture="$(make_fixture workflow-run-default "$FULL_ACTION_REF")" +add_workflow_run_default "$fixture/.github/workflows/ci-core.yml" +expect_pass "workflow run shell default does not invalidate protected steps" "$fixture" + +fixture="$(make_fixture job-run-default "$FULL_ACTION_REF")" +add_job_run_default "$fixture/.github/workflows/ci-core.yml" +expect_pass "job run shell default does not invalidate protected steps" "$fixture" + +fixture="$(make_fixture python-helper-tinygo-provenance "$FULL_ACTION_REF")" +write_repository_file "$fixture" scripts/install-tinygo.py \ + 'import subprocess' \ + "base = \"https://github.com/$TINYGO_RELEASE_REPOSITORY\"" \ + "subprocess.run([\"$CURL_COMMAND_NAME\", base + \"/releases/download/v0.42.0/tinygo_0.42.0_amd64.deb\"], check=True)" +add_workflow_script_invocation "$fixture/.github/workflows/ci-core.yml" \ + 'python3 scripts/install-tinygo.py' +expect_fail "invoked Python helper with TinyGo repository provenance" "$fixture" + +fixture="$(make_fixture python-helper-direct-tinygo-url "$FULL_ACTION_REF")" +write_repository_file "$fixture" scripts/install-tinygo.py \ + 'import subprocess' \ + "subprocess.run([\"$CURL_COMMAND_NAME\", \"https://github.com/${TINYGO_RELEASE_NAMESPACE}v0.42.0/tinygo_0.42.0_amd64.deb\"], check=True)" +expect_fail "Python helper with direct TinyGo release URL" "$fixture" + +fixture="$(make_fixture python-helper-case-variant-provenance "$FULL_ACTION_REF")" +write_repository_file "$fixture" scripts/install-tinygo.py \ + 'import subprocess' \ + "base = \"https://github.com/$TINYGO_RELEASE_REPOSITORY_CASE_VARIANT\"" \ + "subprocess.run([\"$CURL_COMMAND_NAME\", base + \"/releases/download/artifact\"], check=True)" +expect_fail "Python helper with case-variant TinyGo repository provenance" "$fixture" + +fixture="$(make_fixture javascript-helper-tinygo-provenance "$FULL_ACTION_REF")" +write_repository_file "$fixture" scripts/install-tinygo.mjs \ + 'import { spawnSync } from "node:child_process";' \ + "const base = \"https://github.com/$TINYGO_RELEASE_REPOSITORY\";" \ + "spawnSync(\"$CURL_COMMAND_NAME\", [base + \"/releases/download/v0.42.0/tinygo_0.42.0_amd64.deb\"]);" +add_workflow_script_invocation "$fixture/.github/workflows/ci-core.yml" \ + 'node scripts/install-tinygo.mjs' +expect_fail "invoked JavaScript helper with TinyGo repository provenance" "$fixture" + +fixture="$(make_fixture workflow-provided-tinygo-provenance "$FULL_ACTION_REF")" +write_repository_file "$fixture" scripts/install-tinygo.py \ + 'import os' \ + 'import subprocess' \ + "subprocess.run([\"$CURL_COMMAND_NAME\", os.environ[\"TINYGO_BASE\"] + \"/releases/download/artifact\"]);" +printf '%s\n' '' ' delegated-python:' ' runs-on: ubuntu-latest' \ + ' env:' \ + " TINYGO_BASE: https://github.com/$TINYGO_RELEASE_REPOSITORY" \ + ' steps:' \ + ' - run: python3 scripts/install-tinygo.py' \ + >> "$fixture/.github/workflows/ci-core.yml" +expect_fail "workflow cannot supply TinyGo provenance to a non-shell helper" "$fixture" + +fixture="$(make_fixture benign-python-helper "$FULL_ACTION_REF")" +write_repository_file "$fixture" scripts/benign.py \ + 'print("benign helper")' +add_workflow_script_invocation "$fixture/.github/workflows/ci-core.yml" \ + 'python3 scripts/benign.py' +expect_pass "invoked Python helper without TinyGo provenance" "$fixture" + +fixture="$(make_fixture loopback-javascript-helper "$FULL_ACTION_REF")" +write_repository_file "$fixture" scripts/loopback.mjs \ + 'const target = "http://127.0.0.1:8080/";' \ + 'console.log(target);' +expect_pass "JavaScript helper with loopback-only data" "$fixture" + +fixture="$(make_fixture documentation-tinygo-link "$FULL_ACTION_REF")" +write_repository_file "$fixture" docs/tinygo-reference.md \ + "Upstream: https://github.com/$TINYGO_RELEASE_REPOSITORY/releases/tag/v0.42.0" +expect_pass "documentation may reference the TinyGo repository" "$fixture" + +fixture="$(make_fixture uninvoked-python-provenance "$FULL_ACTION_REF")" +write_repository_file "$fixture" scripts/uninvoked.py \ + "base = \"https://github.com/$TINYGO_RELEASE_REPOSITORY\"" +expect_fail "uninvoked executable-source helper with TinyGo provenance" "$fixture" + +fixture="$(make_fixture outside-helper-provenance "$FULL_ACTION_REF")" +write_repository_file "$fixture" tools/install-tinygo.py \ + "base = \"https://github.com/$TINYGO_RELEASE_REPOSITORY\"" +add_workflow_script_invocation "$fixture/.github/workflows/ci-core.yml" \ + 'python3 tools/install-tinygo.py' +expect_fail "invoked helper outside standard CI source roots" "$fixture" + +fixture="$(make_fixture outside-go-helper-provenance "$FULL_ACTION_REF")" +write_repository_file "$fixture" tools/install-tinygo.go \ + 'package main' \ + 'import "fmt"' \ + "func main() { fmt.Println(\"https://github.com/$TINYGO_RELEASE_REPOSITORY\") }" +add_workflow_script_invocation "$fixture/.github/workflows/ci-core.yml" \ + 'go run tools/install-tinygo.go' +expect_fail "directly referenced Go helper with TinyGo provenance" "$fixture" + +fixture="$(make_fixture traversed-go-helper-provenance "$FULL_ACTION_REF")" +write_repository_file "$fixture" outside/install-tinygo.go \ + 'package main' \ + 'import "fmt"' \ + "func main() { fmt.Println(\"https://github.com/$TINYGO_RELEASE_REPOSITORY\") }" +parent_path_component=.. +add_workflow_script_invocation "$fixture/.github/workflows/ci-core.yml" \ + "go run tools/$parent_path_component/outside/install-tinygo.go" +expect_fail "traversed Go helper path cannot evade TinyGo provenance" "$fixture" + +fixture="$(make_fixture extensionless-helper-provenance "$FULL_ACTION_REF")" +write_repository_file "$fixture" scripts/install-tinygo \ + '#!/usr/bin/env python3' \ + "base = \"https://github.com/$TINYGO_RELEASE_REPOSITORY\"" +chmod +x "$fixture/scripts/install-tinygo" +expect_fail "extensionless executable helper with TinyGo provenance" "$fixture" + +fixture="$(make_fixture checker-short-provenance "$FULL_ACTION_REF")" +write_repository_file "$fixture" scripts/ci-supply-chain-check.sh \ + '#!/usr/bin/env bash' \ + "base=https://github.com/$TINYGO_RELEASE_REPOSITORY" +expect_fail "checker source is not exempt from TinyGo provenance policy" "$fixture" + +fixture="$(make_fixture harness-short-provenance "$FULL_ACTION_REF")" +write_repository_file "$fixture" .github/scripts/ci-supply-chain-check.tests.sh \ + '#!/usr/bin/env bash' \ + "base=https://github.com/$TINYGO_RELEASE_REPOSITORY" +expect_fail "test harness is not exempt from TinyGo provenance policy" "$fixture" + +fixture="$(make_fixture checker-active-downloader "$FULL_ACTION_REF")" +write_repository_file "$fixture" scripts/ci-supply-chain-check.sh \ + '#!/usr/bin/env bash' \ + "$CURL_COMMAND_NAME https://example.invalid/file" +expect_fail "checker source is not exempt from downloader policy" "$fixture" + +fixture="$(make_fixture harness-active-downloader "$FULL_ACTION_REF")" +write_repository_file "$fixture" .github/scripts/ci-supply-chain-check.tests.sh \ + '#!/usr/bin/env bash' \ + "$CURL_COMMAND_NAME https://example.invalid/file" +expect_fail "test harness is not exempt from downloader policy" "$fixture" + +expect_pass "protected install followed by separate TinyGo version smoke" \ + "$(make_fixture split-version-smoke "$FULL_ACTION_REF")" + +fixture="$(make_fixture missing-verify-shell "$FULL_ACTION_REF")" +remove_verify_shell "$fixture/.github/workflows/ci-core.yml" +expect_fail "Verify TinyGo without explicit shell" "$fixture" + +fixture="$(make_fixture ambient-verify-shell "$FULL_ACTION_REF")" +replace_verify_shell "$fixture/.github/workflows/ci-core.yml" bash +expect_fail "Verify TinyGo with PATH-resolved Bash shell" "$fixture" + +fixture="$(make_fixture incomplete-verify-shell "$FULL_ACTION_REF")" +replace_verify_shell "$fixture/.github/workflows/ci-core.yml" \ + '/bin/bash -e {0}' +expect_fail "Verify TinyGo with incomplete Bash shell" "$fixture" + +fixture="$(make_fixture sanitized-verify-shell "$FULL_ACTION_REF")" +replace_verify_shell "$fixture/.github/workflows/ci-core.yml" "$CLEAN_SHELL" +expect_fail "Verify TinyGo with install boundary shell" "$fixture" + +expect_fail "ambient PATH TinyGo version smoke" \ + "$(make_fixture ambient-version-smoke "$FULL_ACTION_REF" "$EXPECTED_SHA" before-install yes literal trusted protected ambient)" +fixture="$(make_fixture path-poisoned-version-smoke "$FULL_ACTION_REF")" +add_tinygo_path_poisoning "$fixture/.github/workflows/ci-core.yml" +expect_pass "PATH poisoning cannot redirect package-owned TinyGo smoke" "$fixture" +test_verify_shell_boundary +expect_fail "TinyGo version smoke inside sanitized install" \ + "$(make_fixture in-boundary-version-smoke "$FULL_ACTION_REF" "$EXPECTED_SHA" before-install yes literal trusted protected inside)" +expect_fail "protected install without TinyGo version smoke" \ + "$(make_fixture missing-version-smoke "$FULL_ACTION_REF" "$EXPECTED_SHA" before-install yes literal trusted protected missing)" +expect_fail "TinyGo version smoke before protected install" \ + "$(make_fixture early-version-smoke "$FULL_ACTION_REF" "$EXPECTED_SHA" before-install yes literal trusted protected before-install)" +expect_fail "malformed TinyGo version smoke" \ + "$(make_fixture malformed-version-smoke "$FULL_ACTION_REF" "$EXPECTED_SHA" before-install yes literal trusted protected malformed)" +expect_fail "sanitized TinyGo version smoke" \ + "$(make_fixture sanitized-version-smoke "$FULL_ACTION_REF" "$EXPECTED_SHA" before-install yes literal trusted protected custom-shell)" + +for position in before after; do + fixture="$(make_fixture "verify-continue-on-error-$position" "$FULL_ACTION_REF")" + add_verify_property "$fixture/.github/workflows/ci-core.yml" "$position" \ + 'continue-on-error: true' + expect_fail "Verify TinyGo continue-on-error $position run" "$fixture" + + fixture="$(make_fixture "verify-if-$position" "$FULL_ACTION_REF")" + add_verify_property "$fixture/.github/workflows/ci-core.yml" "$position" 'if: false' + expect_fail "Verify TinyGo if false $position run" "$fixture" + + fixture="$(make_fixture "verify-env-$position" "$FULL_ACTION_REF")" + add_verify_property "$fixture/.github/workflows/ci-core.yml" "$position" \ + 'env:' 'TINYGO_FAKE: attacker' + expect_fail "Verify TinyGo step env $position run" "$fixture" + + fixture="$(make_fixture "install-continue-on-error-$position" "$FULL_ACTION_REF")" + add_install_property "$fixture/.github/workflows/ci-core.yml" "$position" \ + 'continue-on-error: true' + expect_fail "Install TinyGo continue-on-error $position run" "$fixture" + + fixture="$(make_fixture "install-if-$position" "$FULL_ACTION_REF")" + add_install_property "$fixture/.github/workflows/ci-core.yml" "$position" 'if: false' + expect_fail "Install TinyGo if false $position run" "$fixture" + + fixture="$(make_fixture "install-env-$position" "$FULL_ACTION_REF")" + add_install_property "$fixture/.github/workflows/ci-core.yml" "$position" \ + 'env:' 'TINYGO_FAKE: attacker' + expect_fail "Install TinyGo step env $position run" "$fixture" +done + +fixture="$(make_fixture install-unexpected-property "$FULL_ACTION_REF")" +add_install_property "$fixture/.github/workflows/ci-core.yml" after \ + 'timeout-minutes: 1' +expect_fail "Install TinyGo unexpected step property" "$fixture" + +fixture="$(make_fixture verify-duplicate-run "$FULL_ACTION_REF")" +add_verify_property "$fixture/.github/workflows/ci-core.yml" after \ + 'run: /usr/local/bin/tinygo version' +expect_fail "Verify TinyGo duplicate run property" "$fixture" + +fixture="$(make_fixture install-duplicate-shell "$FULL_ACTION_REF")" +add_install_property "$fixture/.github/workflows/ci-core.yml" after \ + "shell: $CLEAN_SHELL" +expect_fail "Install TinyGo duplicate shell property" "$fixture" + +fixture="$(make_fixture reordered-verify-properties "$FULL_ACTION_REF")" +reorder_verify_properties "$fixture/.github/workflows/ci-core.yml" +expect_pass "Verify TinyGo valid properties in reordered mapping" "$fixture" + +fixture="$(make_fixture reordered-install-properties "$FULL_ACTION_REF")" +move_install_shell_after_run "$fixture/.github/workflows/ci-core.yml" +expect_pass "Install TinyGo valid properties in reordered mapping" "$fixture" + +expect_pass "full remote Action SHA with version annotation" \ + "$(make_fixture full-sha "$FULL_ACTION_REF")" +expect_fail "mutable major Action tag" \ + "$(make_fixture mutable-tag 'actions/checkout@v7')" +expect_fail "mutable branch Action ref" \ + "$(make_fixture mutable-branch 'actions/checkout@main')" +expect_fail "short Action SHA" \ + "$(make_fixture short-sha 'actions/checkout@3d3c42e5aac5')" +expect_pass "external reusable workflow with annotated full SHA" \ + "$(make_fixture reusable-workflow "$FULL_REUSABLE_REF")" +expect_fail "full remote Action SHA without version annotation" \ + "$(make_fixture missing-version-annotation "actions/checkout@$FULL_ACTION_SHA")" +expect_fail "remote Action SHA with prose annotation" \ + "$(make_fixture prose-version-annotation "actions/checkout@$FULL_ACTION_SHA # pinned")" +expect_fail "remote Action SHA with major-only annotation" \ + "$(make_fixture major-version-annotation "actions/checkout@$FULL_ACTION_SHA # v1")" +expect_fail "remote Action SHA with two-component annotation" \ + "$(make_fixture minor-version-annotation "actions/checkout@$FULL_ACTION_SHA # v1.2")" + +fixture="$(make_fixture job-env-uses-data "$FULL_ACTION_REF")" +write_repository_file "$fixture" .github/workflows/data.yml \ + 'jobs:' \ + ' data:' \ + ' runs-on: ubuntu-latest' \ + ' env:' \ + ' uses: harmless-data' \ + ' steps:' \ + ' - run: echo ok' +expect_pass "job env key named uses is ordinary data" "$fixture" + +fixture="$(make_fixture step-env-uses-data "$FULL_ACTION_REF")" +write_repository_file "$fixture" .github/workflows/data.yml \ + 'jobs:' \ + ' data:' \ + ' runs-on: ubuntu-latest' \ + ' steps:' \ + ' - run: echo ok' \ + ' env:' \ + ' uses: harmless-data' +expect_pass "step env key named uses is ordinary data" "$fixture" + +fixture="$(make_fixture with-uses-data "$FULL_ACTION_REF")" +write_repository_file "$fixture" .github/workflows/data.yml \ + 'jobs:' \ + ' data:' \ + ' runs-on: ubuntu-latest' \ + ' steps:' \ + " - uses: actions/checkout@$FULL_ACTION_SHA # v7.0.1" \ + ' with:' \ + ' uses: harmless-data' +expect_pass "with key named uses is ordinary data" "$fixture" + +fixture="$(make_fixture flow-env-uses-data "$FULL_ACTION_REF")" +write_repository_file "$fixture" .github/workflows/data.yml \ + 'jobs:' \ + ' data:' \ + ' runs-on: ubuntu-latest' \ + ' env: { uses: harmless-data }' \ + ' steps:' \ + ' - run: echo ok' +expect_pass "flow env data named uses is not an Action reference" "$fixture" + +fixture="$(make_fixture action-input-output-uses-data "$FULL_ACTION_REF")" +write_repository_file "$fixture" .github/actions/data/action.yml \ + 'name: data' \ + 'inputs:' \ + ' uses:' \ + ' default: harmless-data' \ + 'outputs:' \ + ' uses:' \ + ' value: harmless-data' \ + 'runs:' \ + ' using: composite' \ + ' steps:' \ + ' - run: echo ok' \ + ' shell: bash' +expect_pass "Action input and output keys named uses are ordinary data" "$fixture" + +fixture="$(make_fixture job-reusable-pinned "$FULL_ACTION_REF")" +write_repository_file "$fixture" .github/workflows/reusable-job.yml \ + 'jobs:' \ + ' call:' \ + " uses: $FULL_REUSABLE_REF" +expect_pass "job-level reusable workflow uses is validated" "$fixture" + +fixture="$(make_fixture job-reusable-mutable "$FULL_ACTION_REF")" +write_repository_file "$fixture" .github/workflows/reusable-job.yml \ + 'jobs:' \ + ' call:' \ + ' uses: owner/repo/.github/workflows/build.yml@main' +expect_fail "mutable job-level reusable workflow uses" "$fixture" + +fixture="$(make_fixture composite-action-pinned "$FULL_ACTION_REF")" +write_repository_file "$fixture" .github/actions/direct/action.yml \ + 'name: direct' \ + 'runs:' \ + ' using: composite' \ + ' steps:' \ + " - uses: owner/remote@$FULL_ACTION_SHA # v1.2.3" +expect_pass "composite Action step uses is validated" "$fixture" + +fixture="$(make_fixture composite-action-mutable "$FULL_ACTION_REF")" +write_repository_file "$fixture" .github/actions/direct/action.yml \ + 'name: direct' \ + 'runs:' \ + ' using: composite' \ + ' steps:' \ + ' - uses: owner/remote@main' +expect_fail "mutable composite Action step uses" "$fixture" + +expect_fail "old unqualified TinyGo checksum verifier" \ + "$(make_fixture old-unqualified-verifier "$FULL_ACTION_REF" "$EXPECTED_SHA" before-install yes literal unqualified)" + +fixture="$(make_fixture path-poisoned-unqualified-verifier "$FULL_ACTION_REF" "$EXPECTED_SHA" before-install yes literal unqualified)" +add_path_poisoning "$fixture/.github/workflows/ci-core.yml" +expect_fail "PATH-poisoned workflow with unqualified checksum verifier" "$fixture" + +fixture="$(make_fixture path-poisoned-trusted-verifier "$FULL_ACTION_REF" "$EXPECTED_SHA" before-install yes literal trusted)" +add_path_poisoning "$fixture/.github/workflows/ci-core.yml" +expect_pass "PATH-poisoned workflow with trusted checksum verifier" "$fixture" + +fixture="$(make_fixture bash-env-trusted-verifier "$FULL_ACTION_REF" "$EXPECTED_SHA" before-install yes literal trusted)" +add_bash_env_shadowing "$fixture/.github/workflows/ci-core.yml" +expect_pass "BASH_ENV-shadowed workflow with trusted checksum verifier" "$fixture" + +dynamic_base="https://github.com/${TINYGO_RELEASE_REPOSITORY}" +fixture="$(make_fixture dynamic-workflow-download "$FULL_ACTION_REF")" +printf '%s\n' '' ' dynamic-download:' ' steps:' ' - run: |' \ + " base=$dynamic_base" \ + " $CURL_COMMAND_NAME -fsSL -o /tmp/extra.deb \"\$base/releases/download/v0.42.0/tinygo_0.42.0_amd64.deb\"" \ + >> "$fixture/.github/workflows/ci-core.yml" +expect_fail "runtime-composed TinyGo download in workflow" "$fixture" + +fixture="$(make_fixture dynamic-helper-download "$FULL_ACTION_REF")" +dynamic_helper="$(printf '%s\n' "base=$dynamic_base" \ + "$CURL_COMMAND_NAME -fsSL -o /tmp/extra.deb \"\$base/releases/download/v0.42.0/tinygo_0.42.0_amd64.deb\"")" +write_repository_file "$fixture" scripts/install-tinygo.sh "$dynamic_helper" +add_workflow_script_invocation "$fixture/.github/workflows/ci-core.yml" \ + 'scripts/install-tinygo.sh' +expect_fail "runtime-composed TinyGo download in invoked helper" "$fixture" + +fixture="$(make_fixture continued-dynamic-download "$FULL_ACTION_REF")" +printf '%s\n' '' ' continued-download:' ' steps:' ' - run: |' \ + " base=$dynamic_base" \ + ' url="$base/releases/download/v0.42.0/tinygo_0.42.0_amd64.deb"' \ + " $CURL_COMMAND_NAME \\" ' "$url"' \ + >> "$fixture/.github/workflows/ci-core.yml" +expect_fail "continued runtime-composed TinyGo download" "$fixture" + +fixture="$(make_fixture dynamic-url-download "$FULL_ACTION_REF")" +write_shell_source "$fixture" scripts/dynamic-download.sh \ + 'url=https://example.com/file' "$CURL_COMMAND_NAME \"\$url\"" +expect_fail 'downloader with runtime URL' "$fixture" + +fixture="$(make_fixture absolute-dynamic-url-download "$FULL_ACTION_REF")" +write_shell_source "$fixture" scripts/dynamic-download.sh \ + 'url=https://example.com/file' "/usr/bin/$CURL_COMMAND_NAME \"\$url\"" +expect_fail 'absolute downloader with runtime URL' "$fixture" + +fixture="$(make_fixture alternate-absolute-downloader "$FULL_ACTION_REF")" +write_shell_source "$fixture" scripts/dynamic-download.sh \ + 'url=https://example.com/file' "/usr/local/bin/$CURL_COMMAND_NAME \"\$url\"" +expect_fail 'alternate absolute downloader path with runtime URL' "$fixture" + +fixture="$(make_fixture relative-path-downloader "$FULL_ACTION_REF")" +write_shell_source "$fixture" scripts/dynamic-download.sh \ + 'url=https://example.com/file' "./$CURL_COMMAND_NAME \"\$url\"" +expect_fail 'relative downloader path with runtime URL' "$fixture" + +fixture="$(make_fixture command-dynamic-url-download "$FULL_ACTION_REF")" +write_shell_source "$fixture" scripts/dynamic-download.sh \ + 'url=https://example.com/file' "command $CURL_COMMAND_NAME \"\$url\"" +expect_fail 'command-prefixed downloader with runtime URL' "$fixture" + +fixture="$(make_fixture conditional-dynamic-url-download "$FULL_ACTION_REF")" +write_shell_source "$fixture" scripts/dynamic-download.sh \ + 'url=https://example.com/file' "if $CURL_COMMAND_NAME \"\$url\"; then printf success; fi" +expect_fail 'conditional downloader with runtime URL' "$fixture" + +fixture="$(make_fixture substitution-dynamic-url-download "$FULL_ACTION_REF")" +write_shell_source "$fixture" scripts/dynamic-download.sh \ + 'url=https://example.com/file' "result=\"\$($CURL_COMMAND_NAME \"\$url\")\"" +expect_fail 'command-substitution downloader with runtime URL' "$fixture" + +fixture="$(make_fixture literal-external-download "$FULL_ACTION_REF")" +write_shell_source "$fixture" scripts/external-download.sh \ + "$CURL_COMMAND_NAME https://example.com/file" +expect_fail 'literal external downloader' "$fixture" + +fixture="$(make_fixture external-alternate-downloader "$FULL_ACTION_REF")" +write_shell_source "$fixture" scripts/external-download.sh \ + "$WGET_COMMAND_NAME https://example.com/file" +expect_fail 'literal external alternate downloader' "$fixture" + +fixture="$(make_fixture env-downloader "$FULL_ACTION_REF")" +write_shell_source "$fixture" scripts/wrapped-download.sh \ + 'url=https://example.com/file' "env $CURL_COMMAND_NAME \"\$url\"" +expect_fail 'env-wrapped downloader' "$fixture" + +fixture="$(make_fixture absolute-env-downloader "$FULL_ACTION_REF")" +write_shell_source "$fixture" scripts/wrapped-download.sh \ + 'url=https://example.com/file' "/usr/bin/env $CURL_COMMAND_NAME \"\$url\"" +expect_fail 'absolute env-wrapped downloader' "$fixture" + +fixture="$(make_fixture clean-env-downloader "$FULL_ACTION_REF")" +write_shell_source "$fixture" scripts/wrapped-download.sh \ + 'url=https://example.com/file' "env -i $CURL_COMMAND_NAME \"\$url\"" +expect_fail 'clean-env-wrapped downloader' "$fixture" + +fixture="$(make_fixture assigned-env-downloader "$FULL_ACTION_REF")" +write_shell_source "$fixture" scripts/wrapped-download.sh \ + 'url=https://example.com/file' "env FOO=bar $CURL_COMMAND_NAME \"\$url\"" +expect_fail 'assigned-env-wrapped downloader' "$fixture" + +for wrapper in exec sudo nohup 'timeout 10' 'nice -n 5' 'stdbuf -oL' xargs; do + fixture="$(make_fixture "${wrapper%% *}-wrapped-downloader" "$FULL_ACTION_REF")" + write_shell_source "$fixture" scripts/wrapped-download.sh \ + 'url=https://example.com/file' "$wrapper $CURL_COMMAND_NAME \"\$url\"" + expect_fail "$wrapper wrapped downloader" "$fixture" +done + +fixture="$(make_fixture bash-nested-downloader "$FULL_ACTION_REF")" +write_shell_source "$fixture" scripts/wrapped-download.sh \ + "bash -c '$CURL_COMMAND_NAME \"\$url\"'" +expect_fail 'Bash-nested downloader' "$fixture" + +fixture="$(make_fixture sh-nested-downloader "$FULL_ACTION_REF")" +write_shell_source "$fixture" scripts/wrapped-download.sh \ + "sh -c '$WGET_COMMAND_NAME \"\$url\"'" +expect_fail 'POSIX-shell-nested downloader' "$fixture" + +fixture="$(make_fixture variable-selected-downloader "$FULL_ACTION_REF")" +write_shell_source "$fixture" scripts/wrapped-download.sh \ + "downloader=$CURL_COMMAND_NAME" \ + 'url=https://example.com/file' \ + '"$downloader" "$url"' +expect_fail 'variable-selected downloader identity' "$fixture" + +fixture="$(make_fixture evaluated-downloader "$FULL_ACTION_REF")" +write_shell_source "$fixture" scripts/wrapped-download.sh \ + "cmd='$CURL_COMMAND_NAME https://example.com/file'" \ + 'eval "$cmd"' +expect_fail 'evaluated downloader string' "$fixture" + +fixture="$(make_fixture aliased-downloader "$FULL_ACTION_REF")" +write_shell_source "$fixture" scripts/wrapped-download.sh \ + "alias fetch='$CURL_COMMAND_NAME'" \ + 'fetch https://example.com/file' +expect_fail 'aliased downloader identity' "$fixture" + +fixture="$(make_fixture function-downloader "$FULL_ACTION_REF")" +write_shell_source "$fixture" scripts/wrapped-download.sh \ + "fetch() { $CURL_COMMAND_NAME \"\$url\"; }" \ + 'fetch' +expect_fail 'function-contained downloader identity' "$fixture" + +fixture="$(make_fixture simple-loopback-probe "$FULL_ACTION_REF")" +write_shell_source "$fixture" scripts/loopback-probe.sh \ + "$CURL_COMMAND_NAME -fsS \"http://127.0.0.1:\$port/\" >/dev/null 2>&1" +expect_pass 'literal-rooted loopback port probe' "$fixture" + +fixture="$(make_fixture path-loopback-probe "$FULL_ACTION_REF")" +write_shell_source "$fixture" scripts/loopback-probe.sh \ + "$CURL_COMMAND_NAME -fsS \"http://127.0.0.1:\$port/\$wasm_path\" >/dev/null 2>&1" +expect_pass 'literal-rooted loopback path probe' "$fixture" + +fixture="$(make_fixture substitution-loopback-probe "$FULL_ACTION_REF")" +write_shell_source "$fixture" scripts/loopback-probe.sh \ + "headers=\"\$($CURL_COMMAND_NAME -fsSI \"http://127.0.0.1:\$port/\$wasm_path?smoke=\$(date +%s%N)\" || true)\"" +expect_pass 'literal-rooted loopback command-substitution probe' "$fixture" + +fixture="$(make_fixture loopback-tool-requirement "$FULL_ACTION_REF")" +write_shell_source "$fixture" scripts/browser-smoke.sh \ + "require_command $CURL_COMMAND_NAME \"Install $CURL_COMMAND_NAME for smoke server readiness checks.\"" +expect_pass 'exact loopback tool requirement' "$fixture" + +fixture="$(make_fixture loopback-tool-package "$FULL_ACTION_REF")" +printf ' - run: sudo apt-get install -y brotli zstd %s\n' \ + "$CURL_COMMAND_NAME" >> "$fixture/.github/workflows/ci-browser-smoke.yml" +expect_pass 'exact loopback tool package install' "$fixture" + +fixture="$(make_fixture nested-external-loopback-download "$FULL_ACTION_REF")" +nested_loopback_command="$CURL_COMMAND_NAME -fsS \"http://127.0.0.1:\$port/\$($CURL_COMMAND_NAME https://example.com/file)\" >/dev/null 2>&1" +write_shell_source "$fixture" scripts/loopback-probe.sh "$nested_loopback_command" +expect_fail 'external downloader nested in loopback probe' "$fixture" + +fixture="$(make_fixture multiple-downloaders "$FULL_ACTION_REF")" +write_shell_source "$fixture" scripts/external-download.sh \ + "$CURL_COMMAND_NAME https://example.com/file || $WGET_COMMAND_NAME https://example.com/fallback" +expect_fail 'multiple external downloaders' "$fixture" + +fixture="$(make_fixture benign-downloader-string "$FULL_ACTION_REF")" +write_shell_source "$fixture" scripts/benign.sh \ + "message='$CURL_COMMAND_NAME \"\$url\" is not executed'" \ + 'printf "%s\\n" "$message"' +expect_fail 'downloader-shaped inert string is fail-closed' "$fixture" + +fixture="$(make_fixture benign-url-variable "$FULL_ACTION_REF")" +write_shell_source "$fixture" scripts/benign.sh \ + 'url=https://example.com/file' 'printf "%s\\n" "$url"' +expect_pass 'benign URL assignment without downloader' "$fixture" + +fixture="$(make_fixture downloader-comments "$FULL_ACTION_REF")" +write_shell_source "$fixture" scripts/benign.sh \ + "# $CURL_COMMAND_NAME and $WGET_COMMAND_NAME are comments only" \ + "printf 'inline comment ignored\\n' # $CURL_COMMAND_NAME and $WGET_COMMAND_NAME" \ + 'printf "comments ignored\\n"' +expect_pass 'comments may mention downloader tools' "$fixture" + +expect_fail "old TinyGo sequence without sanitized shell" \ + "$(make_fixture old-install-boundary "$FULL_ACTION_REF" "$EXPECTED_SHA" before-install yes literal trusted legacy)" +expect_fail "sanitized shell without protected staging" \ + "$(make_fixture no-protected-staging "$FULL_ACTION_REF" "$EXPECTED_SHA" before-install yes literal trusted no-staging)" +expect_fail "protected staging with checksum of original candidate" \ + "$(make_fixture checksum-original-candidate "$FULL_ACTION_REF" "$EXPECTED_SHA" before-install yes literal trusted checksum-original)" +expect_fail "protected checksum with install of original candidate" \ + "$(make_fixture install-original-candidate "$FULL_ACTION_REF" "$EXPECTED_SHA" before-install yes literal trusted install-original)" +expect_fail "protected boundary without cleanup" \ + "$(make_fixture missing-protected-cleanup "$FULL_ACTION_REF" "$EXPECTED_SHA" before-install yes literal trusted missing-cleanup)" +expect_fail "mutable TinyGo custom shell" \ + "$(make_fixture mutable-install-shell "$FULL_ACTION_REF" "$EXPECTED_SHA" before-install yes literal trusted mutable-shell)" + +fixture="$(make_fixture bash-env-sudo-interposition-old "$FULL_ACTION_REF" "$EXPECTED_SHA" before-install yes literal trusted legacy)" +add_bash_env_sudo_shadowing "$fixture/.github/workflows/ci-core.yml" +expect_fail "BASH_ENV sudo override with old install boundary" "$fixture" + +fixture="$(make_fixture bash-env-sudo-interposition-protected "$FULL_ACTION_REF")" +add_bash_env_sudo_shadowing "$fixture/.github/workflows/ci-core.yml" +expect_pass "BASH_ENV sudo override cannot enter protected install boundary" "$fixture" + +test_ambient_sudo_interposition +test_clean_shell_boundary + +for action_form in \ + '"uses": actions/checkout@v7' \ + "'uses': actions/checkout@v7" \ + '{ uses: actions/checkout@v7 }' \ + "\"uses\": actions/checkout@$FULL_ACTION_SHA # v7.0.1" \ + "{ uses: actions/checkout@$FULL_ACTION_SHA } # v7.0.1" \ + '"u\u0073es": actions/checkout@v7' \ + '? uses'; do + fixture="$(make_fixture alternate-action "$FULL_ACTION_REF")" + printf 'jobs:\n alternate:\n steps:\n - %s\n' "$action_form" \ + > "$fixture/.github/workflows/alternate.yml" + expect_fail "unsupported Action key: $action_form" "$fixture" +done + +fixture="$(make_fixture commented-action "$FULL_ACTION_REF")" +printf '# - "uses": actions/checkout@v7\n# - { uses: actions/checkout@v7 }\n' \ + > "$fixture/.github/workflows/comments.yml" +expect_pass "commented alternate Action keys" "$fixture" + +fixture="$(make_fixture scalar-content "$FULL_ACTION_REF")" +printf '%s\n' 'env:' ' VERSION: value' 'jobs:' ' quoted:' ' strategy:' ' matrix:' ' go:' " - '1.26.6'" \ + ' steps:' ' - run: |' ' echo "uses: actions/example@v7"' \ + > "$fixture/.github/workflows/scalars.yml" +expect_pass "quoted scalar list and literal script content" "$fixture" + +for scalar in '|' '|-' '|+' '>' '>-' '>+' '|2' '|2-' '|-2' '>2+' '>+2'; do + fixture="$(make_fixture "block-scalar-$tests_run" "$FULL_ACTION_REF")" + write_block_scalar_workflow "$fixture/.github/workflows/scalars.yml" "$scalar" + expect_pass "block scalar $scalar with mapping-like body content" "$fixture" +done + +for scalar in '|0' '|22' '|+-' '>-+'; do + fixture="$(make_fixture "invalid-block-scalar-$tests_run" "$FULL_ACTION_REF")" + write_block_scalar_workflow "$fixture/.github/workflows/scalars.yml" "$scalar" + expect_fail "invalid block scalar indicator $scalar" "$fixture" +done + +printf '%s\n' 'jobs:' ' named:' ' steps:' ' - name: |' ' multiline name' \ + ' uses: actions/checkout@v7' > "$fixture/.github/workflows/scalars.yml" +expect_fail "Action following a block-scalar step name" "$fixture" + +printf '%s\n' 'jobs:' ' named:' ' steps:' ' - name: |2' ' multiline name' \ + ' uses: actions/checkout@v7' > "$fixture/.github/workflows/scalars.yml" +expect_fail "Action following an explicit-indent block-scalar step name" "$fixture" + +printf '%s\n' 'jobs:' ' scalar:' ' steps:' ' - run: >+2' ' scalar body' \ + ' uses: actions/checkout@v7' > "$fixture/.github/workflows/scalars.yml" +expect_fail "Action following a folded explicit-indent block scalar" "$fixture" + +printf '%s\n' 'jobs:' ' named:' ' steps:' ' - name: |' ' multiline name' \ + ' uses: actions/checkout@v7' > "$fixture/.github/workflows/scalars.yml" +expect_fail "Action following a spaced block-scalar step name" "$fixture" + +fixture="$(make_fixture 'space in repository path' "$FULL_ACTION_REF")" +expect_pass "repository path containing spaces" "$fixture" +printf '%s\n' 'jobs:' ' newline:' ' steps:' \ + ' - uses: actions/checkout@v7' \ + > "$fixture/.github/workflows/"$'line\nbreak.yml' +expect_fail "workflow filename containing a newline" "$fixture" + +fixture="$(make_fixture local-action-yml './.github/actions/local')" +write_local_action "$fixture" './.github/actions/local' action.yml +expect_pass "local Action with action.yml" "$fixture" + +fixture="$(make_fixture local-action-yaml './.github/actions/local')" +write_local_action "$fixture" './.github/actions/local' action.yaml +expect_pass "local Action with action.yaml" "$fixture" + +fixture="$(make_fixture duplicate-local-metadata './.github/actions/local')" +write_local_action "$fixture" './.github/actions/local' action.yml +write_local_action "$fixture" './.github/actions/local' action.yaml +expect_fail "local Action with duplicate metadata entrypoints" "$fixture" + +fixture="$(make_fixture missing-local-metadata './.github/actions/local')" +mkdir -p "$fixture/.github/actions/local" +expect_fail "local Action missing metadata" "$fixture" + +fixture="$(make_fixture symlinked-local-action './.github/actions/local')" +write_local_action "$fixture" './outside-action' action.yml 'owner/remote@v1' +mkdir -p "$fixture/.github/actions" +ln -s ../../outside-action "$fixture/.github/actions/local" +expect_fail "symlinked local Action outside scanned tree" "$fixture" + +fixture="$(make_fixture symlinked-local-metadata './.github/actions/local')" +write_local_action "$fixture" './outside-action' action.yml 'owner/remote@v1' +mkdir -p "$fixture/.github/actions/local" +ln -s ../../../outside-action/action.yml "$fixture/.github/actions/local/action.yml" +expect_fail "symlinked local Action metadata outside scanned tree" "$fixture" + +fixture="$(make_fixture nested-mutable-action './.github/actions/local')" +write_local_action "$fixture" './.github/actions/local' action.yml 'owner/remote@v1' +expect_fail "local Action with mutable nested remote Action" "$fixture" + +fixture="$(make_fixture nested-pinned-action './.github/actions/local')" +write_local_action "$fixture" './.github/actions/local' action.yml \ + "owner/remote@$FULL_ACTION_SHA # v1.2.3" +expect_pass "local Action with pinned annotated nested Action" "$fixture" + +fixture="$(make_fixture local-reusable './.github/workflows/reusable.yml')" +write_local_workflow "$fixture" './.github/workflows/reusable.yml' +expect_pass "local reusable workflow" "$fixture" + +fixture="$(make_fixture local-reusable-mutable './.github/workflows/reusable.yml')" +write_local_workflow "$fixture" './.github/workflows/reusable.yml' 'owner/remote@main' +expect_fail "local reusable workflow with mutable nested Action" "$fixture" + +fixture="$(make_fixture local-reusable-pinned './.github/workflows/reusable.yml')" +write_local_workflow "$fixture" './.github/workflows/reusable.yml' \ + "owner/remote@$FULL_ACTION_SHA # v1.2.3" +expect_pass "local reusable workflow with pinned annotated nested Action" "$fixture" + +fixture="$(make_fixture outside-local-action './custom-action')" +write_local_action "$fixture" './custom-action' action.yml 'owner/remote@v1' +expect_fail "local Action outside scanned roots" "$fixture" + +expect_fail "local Action path traversal" \ + "$(make_fixture local-action-traversal './.github/actions/../../custom-action')" +expect_fail "backslash local Action path" \ + "$(make_fixture local-action-backslash '.\.github\actions\local')" + +extra_download="$CURL_COMMAND_NAME -fsSL -o /tmp/extra.deb \"https://github.com/${TINYGO_RELEASE_NAMESPACE}v0.42.0/tinygo_0.42.0_amd64.deb\"" +for placement in before after same-line canonical hash-prefix unexpected; do + fixture="$(make_fixture extra-download "$FULL_ACTION_REF")" + file="$fixture/.github/workflows/ci-core.yml" + case "$placement" in + before) + awk -v command="$extra_download" ' + / - name: Install TinyGo/ { print " - run: " command } + { print } + ' "$file" > "$TMP_ROOT/extra.yml" + mv "$TMP_ROOT/extra.yml" "$file" + ;; + after) printf ' - run: %s\n' "$extra_download" >> "$file" ;; + same-line) printf ' - run: %s; %s\n' "$extra_download" "$extra_download" >> "$file" ;; + canonical) + command="$(sed -n "/\\/usr\\/bin\\/$CURL_COMMAND_NAME -fsSL/p" "$file")" + printf '%s\n' "$command" >> "$file" + ;; + hash-prefix) printf ' - run: printf "# data"; %s\n' "$extra_download" >> "$file" ;; + unexpected) + printf 'jobs:\n extra:\n steps:\n - run: %s\n' "$extra_download" \ + > "$fixture/.github/workflows/extra.yml" + ;; + esac + expect_fail "additional TinyGo download: $placement" "$fixture" +done + +fixture="$(make_fixture delegated-tinygo-helper "$FULL_ACTION_REF")" +write_repository_file "$fixture" scripts/install-tinygo.sh "$extra_download" +add_workflow_script_invocation "$fixture/.github/workflows/ci-core.yml" \ + 'scripts/install-tinygo.sh' +expect_fail "invoked repository helper with direct TinyGo download" "$fixture" + +fixture="$(make_fixture uninvoked-tinygo-helper "$FULL_ACTION_REF")" +write_repository_file "$fixture" scripts/install-tinygo.sh "$extra_download" +expect_fail "uninvoked repository helper with direct TinyGo download" "$fixture" + +fixture="$(make_fixture non-shell-tinygo-source "$FULL_ACTION_REF")" +write_repository_file "$fixture" tools/install.py "$extra_download" +expect_fail "non-shell repository source with direct TinyGo download" "$fixture" + +fixture="$(make_fixture benign-invoked-helper "$FULL_ACTION_REF")" +write_repository_file "$fixture" scripts/helper.sh 'printf "benign helper\\n"' +add_workflow_script_invocation "$fixture/.github/workflows/ci-core.yml" \ + 'scripts/helper.sh' +expect_pass "invoked benign repository helper" "$fixture" + +fixture="$(make_fixture executable-checker-direct-download "$FULL_ACTION_REF")" +write_repository_file "$fixture" scripts/ci-supply-chain-check.sh "$extra_download" +expect_fail "executable checker script with direct TinyGo download" "$fixture" + +fixture="$(make_fixture executable-test-harness-direct-download "$FULL_ACTION_REF")" +write_repository_file "$fixture" .github/scripts/ci-supply-chain-check.tests.sh \ + "$extra_download" +expect_fail "executable test harness with direct TinyGo download" "$fixture" + +if grep -Fq -- "$TINYGO_RELEASE_NAMESPACE" "$CHECKER"; then + printf 'not ok - checker source contains raw TinyGo release namespace\n' >&2 + exit 1 +fi +printf 'ok - checker source contains no raw TinyGo release namespace\n' + +if grep -Fq -- "$TINYGO_RELEASE_NAMESPACE" "$BASH_SOURCE"; then + printf 'not ok - test harness source contains raw TinyGo release namespace\n' >&2 + exit 1 +fi +printf 'ok - test harness source contains no raw TinyGo release namespace\n' + +if grep -Fq -- "$TINYGO_RELEASE_REPOSITORY" "$CHECKER"; then + printf 'not ok - checker source contains raw TinyGo repository identity\n' >&2 + exit 1 +fi +printf 'ok - checker source contains no raw TinyGo repository identity\n' + +if grep -Fq -- "$TINYGO_RELEASE_REPOSITORY" "$BASH_SOURCE"; then + printf 'not ok - test harness source contains raw TinyGo repository identity\n' >&2 + exit 1 +fi +printf 'ok - test harness source contains no raw TinyGo repository identity\n' + +runtime_namespace="$TINYGO_RELEASE_NAMESPACE" +expected_runtime_namespace="${TINYGO_RELEASE_REPOSITORY}/releases/download/" +if [[ "$runtime_namespace" != "$expected_runtime_namespace" ]]; then + printf 'not ok - runtime TinyGo release namespace construction\n' >&2 + exit 1 +fi +printf 'ok - runtime TinyGo release namespace construction\n' + +expect_fail "missing TinyGo checksum" \ + "$(make_fixture missing-checksum "$FULL_ACTION_REF" "$EXPECTED_SHA" before-install no)" +expect_fail "wrong TinyGo checksum" \ + "$(make_fixture wrong-checksum "$FULL_ACTION_REF" "$(printf '0%.0s' {1..64})")" +expect_pass "verified TinyGo install contract" \ + "$(make_fixture verified-tinygo "$FULL_ACTION_REF")" +expect_fail "runtime-variable TinyGo download URL" \ + "$(make_fixture variable-tinygo-url "$FULL_ACTION_REF" "$EXPECTED_SHA" before-install yes variable-url)" +expect_fail "runtime-variable TinyGo checksum input" \ + "$(make_fixture variable-tinygo-digest "$FULL_ACTION_REF" "$EXPECTED_SHA" before-install yes variable-digest)" + +fixture="$(make_fixture runtime-trust-root-override "$FULL_ACTION_REF" "$EXPECTED_SHA" before-install yes variable-both)" +add_runtime_override "$fixture/.github/workflows/ci-core.yml" +expect_fail "runtime-variable TinyGo trust roots after GITHUB_ENV override" "$fixture" + +fixture="$(make_fixture literal-trust-root-override "$FULL_ACTION_REF")" +add_runtime_override "$fixture/.github/workflows/ci-core.yml" +expect_pass "GITHUB_ENV override cannot alter literal TinyGo trust roots" "$fixture" + +expect_fail "TinyGo install before verification" \ + "$(make_fixture install-first "$FULL_ACTION_REF" "$EXPECTED_SHA" after-install)" +expect_fail "commented TinyGo verification" \ + "$(make_fixture commented-verification "$FULL_ACTION_REF" "$EXPECTED_SHA" commented)" +expect_fail "TinyGo verification with ignored failure" \ + "$(make_fixture ignored-verification "$FULL_ACTION_REF" "$EXPECTED_SHA" ignored)" +expect_fail "TinyGo verification with warning-only failure" \ + "$(make_fixture warned-verification "$FULL_ACTION_REF" "$EXPECTED_SHA" warned)" +expect_fail "TinyGo verification relying on implicit errexit" \ + "$(make_fixture implicit-verification "$FULL_ACTION_REF" "$EXPECTED_SHA" implicit)" +expect_fail "TinyGo verification behind false condition" \ + "$(make_fixture skipped-verification "$FULL_ACTION_REF" "$EXPECTED_SHA" skipped)" +expect_fail "TinyGo artifact replaced after verification" \ + "$(make_fixture replaced-artifact "$FULL_ACTION_REF" "$EXPECTED_SHA" replaced)" + +synthetic_artifact="$TMP_ROOT/synthetic-tinygo.deb" +printf 'synthetic TinyGo archive\n' > "$synthetic_artifact" +sha256_file() { + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$1" + else + shasum -a 256 "$1" + fi | awk '{print $1}' +} + +verify_synthetic_checksum() { + local digest="$1" + local artifact="$2" + if command -v sha256sum >/dev/null 2>&1; then + printf '%s %s\n' "$digest" "$artifact" | sha256sum --check --strict - + else + printf '%s %s\n' "$digest" "$artifact" | shasum -a 256 --check - + fi +} + +synthetic_sha="$(sha256_file "$synthetic_artifact")" +tests_run=$((tests_run + 1)) +if ! verify_synthetic_checksum "$synthetic_sha" "$synthetic_artifact" >/dev/null; then + printf 'not ok %d - correct synthetic artifact checksum\n' "$tests_run" >&2 + exit 1 +fi +printf 'ok %d - correct synthetic artifact checksum\n' "$tests_run" + +tests_run=$((tests_run + 1)) +if verify_synthetic_checksum "$(printf '%064d' 0)" "$synthetic_artifact" >/dev/null 2>&1; then + printf 'not ok %d - incorrect synthetic artifact checksum unexpectedly passed\n' "$tests_run" >&2 + exit 1 +fi +printf 'ok %d - incorrect synthetic artifact checksum\n' "$tests_run" + +if [[ -x /usr/bin/env && -x /usr/bin/sha256sum ]]; then + # Execute the fixture's actual verification line without inherited errexit. + for digest in "$(printf '%064d' 0)" "$synthetic_sha"; do + if [[ "$digest" == "$synthetic_sha" ]]; then + case_name="correct checksum continues without inherited errexit" + else + case_name="wrong checksum stops without inherited errexit" + fi + fixture="$(make_fixture plain-bash "$FULL_ACTION_REF" "$digest")" + verification="$(sed -n '/sha256sum --check --strict/p' "$fixture/.github/workflows/ci-core.yml")" + verification="${verification//\/tmp\/goframe-tinygo-0.42.0-verified.deb/$synthetic_artifact}" + printf 'set +e\n%s\nprintf continued > "$continuation"\n' "$verification" > "$TMP_ROOT/verify.sh" + rm -f "$TMP_ROOT/continued" + tests_run=$((tests_run + 1)) + if synthetic_artifact="$synthetic_artifact" continuation="$TMP_ROOT/continued" \ + "$BASH" --noprofile --norc "$TMP_ROOT/verify.sh" > "$TMP_ROOT/checksum.log" 2>&1; then + if [[ "$digest" != "$synthetic_sha" || ! -f "$TMP_ROOT/continued" ]]; then + printf 'not ok %d - checksum continuation without errexit\n' "$tests_run" >&2 + exit 1 + fi + else + if [[ "$digest" == "$synthetic_sha" || -f "$TMP_ROOT/continued" ]]; then + printf 'not ok %d - checksum failure without errexit\n' "$tests_run" >&2 + exit 1 + fi + fi + printf 'ok %d - %s\n' "$tests_run" "$case_name" + done + + fake_bin="$TMP_ROOT/fake-bin" + fake_marker="$TMP_ROOT/fake-sha256sum-ran" + mkdir -p "$fake_bin" + { + printf '#!/usr/bin/env bash\n' + printf ': > "$FAKE_SHA256SUM_MARKER"\n' + printf 'exit 0\n' + } > "$fake_bin/sha256sum" + chmod +x "$fake_bin/sha256sum" + + for digest in "$(printf '%064d' 0)" "$synthetic_sha"; do + if [[ "$digest" == "$synthetic_sha" ]]; then + case_name="correct checksum passes under poisoned PATH" + else + case_name="wrong checksum fails under poisoned PATH" + fi + fixture="$(make_fixture poisoned-path-execution "$FULL_ACTION_REF" "$digest")" + verification="$(sed -n '/sha256sum --check --strict/p' "$fixture/.github/workflows/ci-core.yml")" + verification="${verification//\/tmp\/goframe-tinygo-0.42.0-verified.deb/$synthetic_artifact}" + printf 'set +e\n%s\n' "$verification" > "$TMP_ROOT/path-verify.sh" + rm -f "$fake_marker" + tests_run=$((tests_run + 1)) + if FAKE_SHA256SUM_MARKER="$fake_marker" PATH="$fake_bin:$PATH" \ + "$BASH" --noprofile --norc "$TMP_ROOT/path-verify.sh" > "$TMP_ROOT/path-checksum.log" 2>&1; then + if [[ "$digest" != "$synthetic_sha" ]]; then + printf 'not ok %d - wrong checksum passed under poisoned PATH\n' "$tests_run" >&2 + exit 1 + fi + else + if [[ "$digest" == "$synthetic_sha" ]]; then + printf 'not ok %d - correct checksum failed under poisoned PATH\n' "$tests_run" >&2 + exit 1 + fi + fi + if [[ -e "$fake_marker" ]]; then + printf 'not ok %d - fake PATH checksum verifier executed\n' "$tests_run" >&2 + exit 1 + fi + printf 'ok %d - %s\n' "$tests_run" "$case_name" + done + + checksum_function_marker="$TMP_ROOT/checksum-function-ran" + printf_function_marker="$TMP_ROOT/printf-function-ran" + { + printf 'sha256sum() {\n' + printf ' : > "$FAKE_SHA256SUM_FUNCTION_MARKER"\n' + printf ' return 0\n' + printf '}\n' + printf 'printf() {\n' + printf ' : > "$FAKE_PRINTF_FUNCTION_MARKER"\n' + printf ' builtin printf "attacker\\n"\n' + printf '}\n' + } > "$TMP_ROOT/bash-env" + + for digest in "$(printf '%064d' 0)" "$synthetic_sha"; do + if [[ "$digest" == "$synthetic_sha" ]]; then + case_name="correct checksum passes with BASH_ENV function overrides" + else + case_name="wrong checksum fails with BASH_ENV function overrides" + fi + fixture="$(make_fixture bash-env-execution "$FULL_ACTION_REF" "$digest")" + verification="$(sed -n '/sha256sum --check --strict/p' "$fixture/.github/workflows/ci-core.yml")" + verification="${verification//\/tmp\/goframe-tinygo-0.42.0-verified.deb/$synthetic_artifact}" + printf 'set +e\n%s\n' "$verification" > "$TMP_ROOT/bash-env-verify.sh" + rm -f "$checksum_function_marker" "$printf_function_marker" + tests_run=$((tests_run + 1)) + if BASH_ENV="$TMP_ROOT/bash-env" \ + FAKE_SHA256SUM_FUNCTION_MARKER="$checksum_function_marker" \ + FAKE_PRINTF_FUNCTION_MARKER="$printf_function_marker" \ + "$BASH" --noprofile --norc "$TMP_ROOT/bash-env-verify.sh" > "$TMP_ROOT/bash-env-checksum.log" 2>&1; then + if [[ "$digest" != "$synthetic_sha" ]]; then + printf 'not ok %d - wrong checksum passed with BASH_ENV function overrides\n' "$tests_run" >&2 + exit 1 + fi + else + if [[ "$digest" == "$synthetic_sha" ]]; then + printf 'not ok %d - correct checksum failed with BASH_ENV function overrides\n' "$tests_run" >&2 + exit 1 + fi + fi + if [[ -e "$checksum_function_marker" || -e "$printf_function_marker" ]]; then + printf 'not ok %d - BASH_ENV checksum or printf override executed\n' "$tests_run" >&2 + exit 1 + fi + printf 'ok %d - %s\n' "$tests_run" "$case_name" + done +else + printf 'skip - exact production verifier (/usr/bin/env -i /usr/bin/sha256sum) unavailable; portable SHA-256 controls passed\n' +fi + +printf 'ci supply-chain check tests: ok (%d cases)\n' "$tests_run" diff --git a/.github/tools/shell-context/go.mod b/.github/tools/shell-context/go.mod new file mode 100644 index 0000000..523d3b5 --- /dev/null +++ b/.github/tools/shell-context/go.mod @@ -0,0 +1,5 @@ +module github.com/graybuton/goframe/.github/tools/shell-context + +go 1.26.0 + +require mvdan.cc/sh/v3 v3.14.1 diff --git a/.github/tools/shell-context/go.sum b/.github/tools/shell-context/go.sum new file mode 100644 index 0000000..967dc91 --- /dev/null +++ b/.github/tools/shell-context/go.sum @@ -0,0 +1,2 @@ +mvdan.cc/sh/v3 v3.14.1 h1:bXkhQWNHCs0KZEChF8hYS6FC+T2N9mUZLbQv9blditI= +mvdan.cc/sh/v3 v3.14.1/go.mod h1:syYCoFET8w9tvevxiXUtY8/ICrU+l26jHmhJDra3Vwo= diff --git a/.github/tools/shell-context/main.go b/.github/tools/shell-context/main.go new file mode 100644 index 0000000..f2aa8ac --- /dev/null +++ b/.github/tools/shell-context/main.go @@ -0,0 +1,240 @@ +package main + +import ( + "bytes" + "fmt" + "io" + "os" + "sort" + "strconv" + "strings" + + "mvdan.cc/sh/v3/syntax" +) + +type commandEvent struct { + line uint + pipeInput bool + redirectInput bool + assigns int + words []string + command string +} + +func sourceText(src []byte, node syntax.Node) (string, error) { + start, end := int(node.Pos().Offset()), int(node.End().Offset()) + if start < 0 || end < start || end > len(src) { + return "", fmt.Errorf("invalid shell source span %d:%d", start, end) + } + return string(src[start:end]), nil +} + +func policyPartsText(src []byte, start, end int, parts []syntax.WordPart) (string, error) { + if start < 0 || end < start || end > len(src) { + return "", fmt.Errorf("invalid shell word span %d:%d", start, end) + } + cursor := start + var text strings.Builder + var literal strings.Builder + // Bash removes continued newlines from literal syntax, but not single-quoted data. + flushLiteral := func() { + text.WriteString(strings.ReplaceAll(literal.String(), "\\\n", "")) + literal.Reset() + } + for _, part := range parts { + partStart, partEnd := int(part.Pos().Offset()), int(part.End().Offset()) + if partStart < cursor || partEnd < partStart || partEnd > end { + return "", fmt.Errorf("invalid shell word part span %d:%d", partStart, partEnd) + } + literal.Write(src[cursor:partStart]) + raw := src[partStart:partEnd] + switch node := part.(type) { + case *syntax.Lit: + literal.Write(raw) + case *syntax.SglQuoted: + flushLiteral() + if node.Dollar && !strings.Contains(node.Value, "\\") { + if !bytes.HasPrefix(raw, []byte("$'")) { + return "", fmt.Errorf("invalid ANSI-C quoted word span %d:%d", partStart, partEnd) + } + text.Write(raw[1:]) + } else { + text.Write(raw) + } + case *syntax.DblQuoted: + flushLiteral() + if node.Dollar { + text.Write(raw) + break + } + if len(raw) < 2 || raw[0] != '"' || raw[len(raw)-1] != '"' { + return "", fmt.Errorf("invalid double-quoted word span %d:%d", partStart, partEnd) + } + inner, err := policyPartsText(src, partStart+1, partEnd-1, node.Parts) + if err != nil { + return "", err + } + text.WriteByte('"') + text.WriteString(inner) + text.WriteByte('"') + default: + flushLiteral() + text.Write(raw) + } + cursor = partEnd + } + literal.Write(src[cursor:end]) + flushLiteral() + return text.String(), nil +} + +func policyWordText(src []byte, word *syntax.Word) (string, error) { + return policyPartsText(src, int(word.Pos().Offset()), int(word.End().Offset()), word.Parts) +} + +func directCommandText(src []byte, call *syntax.CallExpr) (string, error) { + start, end := int(call.Pos().Offset()), int(call.End().Offset()) + if start < 0 || end < start || end > len(src) { + return "", fmt.Errorf("invalid shell command source span %d:%d", start, end) + } + type span struct{ start, end int } + var substitutions []span + syntax.Walk(call, func(node syntax.Node) bool { + switch node.(type) { + case *syntax.CmdSubst, *syntax.ProcSubst: + substitutions = append(substitutions, span{int(node.Pos().Offset()), int(node.End().Offset())}) + return false + } + return true + }) + sort.Slice(substitutions, func(i, j int) bool { return substitutions[i].start < substitutions[j].start }) + var text bytes.Buffer + for _, substitution := range substitutions { + if substitution.start < start || substitution.end < substitution.start || substitution.end > end { + return "", fmt.Errorf("invalid nested shell source span %d:%d", substitution.start, substitution.end) + } + text.Write(src[start:substitution.start]) + text.WriteString("$SUBSTITUTION") + start = substitution.end + } + text.Write(src[start:end]) + return text.String(), nil +} + +func analyze(src []byte, name string) ([]commandEvent, error) { + if bytes.IndexByte(src, 0) >= 0 { + return nil, fmt.Errorf("%s: NUL byte in shell source", name) + } + file, err := syntax.NewParser(syntax.Variant(syntax.LangBash)).Parse(bytes.NewReader(src), name) + if err != nil { + return nil, err + } + + piped := make(map[*syntax.CallExpr]bool) + redirected := make(map[*syntax.CallExpr]bool) + syntax.Walk(file, func(node syntax.Node) bool { + switch n := node.(type) { + case *syntax.BinaryCmd: + if n.Op == syntax.Pipe || n.Op == syntax.PipeAll { + syntax.Walk(n.Y, func(child syntax.Node) bool { + if call, ok := child.(*syntax.CallExpr); ok { + piped[call] = true + } + return true + }) + } + case *syntax.Stmt: + if call, ok := n.Cmd.(*syntax.CallExpr); ok { + for _, redir := range n.Redirs { + switch redir.Op { + case syntax.RdrIn, syntax.RdrInOut, syntax.DplIn, + syntax.Hdoc, syntax.DashHdoc, syntax.WordHdoc: + redirected[call] = true + } + } + } + } + return true + }) + + var events []commandEvent + var spanErr error + syntax.Walk(file, func(node syntax.Node) bool { + if spanErr != nil { + return false + } + call, ok := node.(*syntax.CallExpr) + if !ok || (len(call.Args) == 0 && len(call.Assigns) == 0) { + return true + } + event := commandEvent{ + line: call.Pos().Line(), + pipeInput: piped[call], + redirectInput: redirected[call], + assigns: len(call.Assigns), + } + for _, assign := range call.Assigns { + word, err := sourceText(src, assign) + if err != nil { + spanErr = err + return false + } + event.words = append(event.words, word) + } + for _, arg := range call.Args { + word, err := policyWordText(src, arg) + if err != nil { + spanErr = err + return false + } + event.words = append(event.words, word) + } + event.command, spanErr = directCommandText(src, call) + events = append(events, event) + return true + }) + return events, spanErr +} + +func writeEvents(w io.Writer, events []commandEvent) error { + field := func(value string) error { + _, err := io.WriteString(w, value+"\x00") + return err + } + for _, event := range events { + values := []string{"C", strconv.FormatUint(uint64(event.line), 10), + strconv.FormatBool(event.pipeInput), strconv.FormatBool(event.redirectInput), + strconv.Itoa(event.assigns), strconv.Itoa(len(event.words))} + values = append(values, event.words...) + values = append(values, event.command) + for _, value := range values { + if err := field(value); err != nil { + return err + } + } + } + if err := field("E"); err != nil { + return err + } + return field(strconv.Itoa(len(events))) +} + +func main() { + if len(os.Args) != 2 { + fmt.Fprintln(os.Stderr, "usage: shell-context ") + os.Exit(2) + } + src, err := io.ReadAll(os.Stdin) + if err != nil { + fmt.Fprintf(os.Stderr, "%s: read shell source: %v\n", os.Args[1], err) + os.Exit(1) + } + events, err := analyze(src, os.Args[1]) + if err == nil { + err = writeEvents(os.Stdout, events) + } + if err != nil { + fmt.Fprintf(os.Stderr, "%s: inspect shell source: %v\n", os.Args[1], err) + os.Exit(1) + } +} diff --git a/.github/tools/shell-context/main_test.go b/.github/tools/shell-context/main_test.go new file mode 100644 index 0000000..907c3f9 --- /dev/null +++ b/.github/tools/shell-context/main_test.go @@ -0,0 +1,88 @@ +package main + +import ( + "bytes" + "strings" + "testing" +) + +func TestExecutableContext(t *testing.T) { + src := []byte("printf '%s\\n' 'bash'\ncat <<'EOF'\nbash\nEOF\n" + + "printf x |\nbash\nif sh; then :; fi\n" + + "cat < { - console.error(error.message); - process.exit(1); -}); -server.listen(0, "127.0.0.1", () => { - const address = server.address(); - console.log(address.port); - server.close(); -});' + node scripts/ci-node-tools.mjs pick-free-port } resolve_port() { @@ -77,14 +67,7 @@ wait_for_server() { manifest_wasm_path() { local app="$1" - node -e 'const fs = require("node:fs"); -const manifestPath = process.argv[1] + "/.goframe/package/standalone/asset-manifest.json"; -const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")); -if (!manifest.entrypoints || !manifest.entrypoints.wasm) { - console.error(`missing entrypoints.wasm in ${manifestPath}`); - process.exit(1); -} -console.log(manifest.entrypoints.wasm);' "$app" + node scripts/ci-node-tools.mjs manifest-wasm-path "$app" } build_smoke_url() { diff --git a/scripts/check.sh b/scripts/check.sh index 8ab89ee..2b670fe 100755 --- a/scripts/check.sh +++ b/scripts/check.sh @@ -10,6 +10,10 @@ export GOCACHE="${GOCACHE:-/tmp/goframe-go-cache}" export XDG_CACHE_HOME="${XDG_CACHE_HOME:-/tmp/goframe-tinygo-cache}" mkdir -p "$GOCACHE" "$XDG_CACHE_HOME" +echo "== CI supply-chain gate ==" +scripts/ci-supply-chain-check.sh +.github/scripts/ci-supply-chain-check.tests.sh + echo "== Artifact gate ==" scripts/artifact-check.sh diff --git a/scripts/ci-node-tools.mjs b/scripts/ci-node-tools.mjs new file mode 100644 index 0000000..9f26066 --- /dev/null +++ b/scripts/ci-node-tools.mjs @@ -0,0 +1,88 @@ +import { readFile } from "node:fs/promises"; +import { createServer } from "node:net"; +import { join } from "node:path"; + +function requireArguments(command, args, minimum, maximum = minimum) { + if (args.length < minimum || args.length > maximum) { + const expected = minimum === maximum ? `${minimum}` : `${minimum} or more`; + const suffix = minimum === 1 && maximum === 1 ? "" : "s"; + throw new Error(`${command} requires ${expected} argument${suffix}`); + } +} + +function errorMessage(error) { + return error instanceof Error ? error.message : String(error); +} + +async function validateJSON(files) { + requireArguments("validate-json", files, 1, Number.POSITIVE_INFINITY); + for (const file of files) { + const contents = await readFile(file, "utf8"); + try { + JSON.parse(contents); + } catch (error) { + throw new Error(`${file}: ${errorMessage(error)}`); + } + } +} + +async function pickFreePort(args) { + requireArguments("pick-free-port", args, 0); + const port = await new Promise((resolve, reject) => { + const server = createServer(); + server.once("error", reject); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + if (!address || typeof address === "string") { + server.close(); + reject(new Error("could not determine the selected TCP port")); + return; + } + server.close((error) => { + if (error) { + reject(error); + return; + } + resolve(address.port); + }); + }); + }); + console.log(port); +} + +async function manifestWASMPath(args) { + requireArguments("manifest-wasm-path", args, 1); + const manifestPath = join( + args[0], + ".goframe", + "package", + "standalone", + "asset-manifest.json", + ); + const manifest = JSON.parse(await readFile(manifestPath, "utf8")); + if (!manifest.entrypoints || !manifest.entrypoints.wasm) { + throw new Error(`missing entrypoints.wasm in ${manifestPath}`); + } + console.log(manifest.entrypoints.wasm); +} + +const [command, ...args] = process.argv.slice(2); + +try { + switch (command) { + case "validate-json": + await validateJSON(args); + break; + case "pick-free-port": + await pickFreePort(args); + break; + case "manifest-wasm-path": + await manifestWASMPath(args); + break; + default: + throw new Error(`unknown command: ${command || ""}`); + } +} catch (error) { + console.error(`ci-node-tools: ${errorMessage(error)}`); + process.exitCode = 1; +} diff --git a/scripts/ci-supply-chain-check.sh b/scripts/ci-supply-chain-check.sh new file mode 100755 index 0000000..9a04c02 --- /dev/null +++ b/scripts/ci-supply-chain-check.sh @@ -0,0 +1,2496 @@ +#!/usr/bin/env bash +set -euo pipefail + +EXPECTED_TINYGO_VERSION="0.42.0" +EXPECTED_TINYGO_SHA256="2082c4762fea6d5cc4cd1f4a243eaacf07b12f576717d4c6b74828bd163cb563" +TINYGO_RELEASE_REPOSITORY="tinygo-org/"'tinygo' +TINYGO_RELEASE_NAMESPACE="${TINYGO_RELEASE_REPOSITORY}/releases/download/" +CURL_COMMAND_NAME='cur''l' +WGET_COMMAND_NAME='wg''et' +EVAL_COMMAND_NAME='ev''al' +EXPECTED_TINYGO_WORKFLOWS=( + ".github/workflows/ci-browser-smoke.yml" + ".github/workflows/ci-core.yml" + ".github/workflows/ci-wasm-size.yml" +) +TINYGO_VERIFIED_DEB="/tmp/goframe-tinygo-${EXPECTED_TINYGO_VERSION}-verified.deb" +tinygo_shell_template='/usr/bin/env -i PATH=/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin /bin/bash --noprofile --norc -e -u -o pipefail {0}' +tinygo_cleanup_command="trap '/usr/bin/sudo /usr/bin/rm -f $TINYGO_VERIFIED_DEB' EXIT" +tinygo_download_command="/usr/bin/$CURL_COMMAND_NAME -fsSL -o /tmp/tinygo.deb \"https://github.com/${TINYGO_RELEASE_NAMESPACE}v${EXPECTED_TINYGO_VERSION}/tinygo_${EXPECTED_TINYGO_VERSION}_amd64.deb\"" +tinygo_stage_command="/usr/bin/sudo /usr/bin/install -o root -g root -m 0644 /tmp/tinygo.deb $TINYGO_VERIFIED_DEB" +tinygo_verify_command="/usr/bin/env -i /usr/bin/sha256sum --check --strict - <<< '$EXPECTED_TINYGO_SHA256 $TINYGO_VERIFIED_DEB' || exit 1" +tinygo_install_command="/usr/bin/sudo /usr/bin/apt-get install -y $TINYGO_VERIFIED_DEB" +tinygo_verify_shell_template='/bin/bash --noprofile --norc -p -e -o pipefail {0}' +tinygo_version_command='/usr/local/bin/tinygo version' +yaml_block_scalar_re='^[|>]([1-9][+-]?|[+-][1-9]?)?$' + +usage() { + printf 'usage: %s [repository-root]\n' "${0##*/}" >&2 +} + +if (( $# > 1 )); then + usage + exit 2 +fi + +if (( $# == 1 )); then + ROOT_DIR="$(cd "$1" && pwd)" +else + ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +fi + +failures=0 +remote_action_count=0 +loopback_download_count=0 + +fail() { + printf 'ci supply-chain check: %s\n' "$*" >&2 + failures=1 +} + +shell_context_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.github/tools/shell-context" && pwd)" +shell_context_tmp="$(mktemp -d)" +trap 'rm -rf "$shell_context_tmp"' EXIT +if ! (cd "$shell_context_dir" && GOTOOLCHAIN=local go mod verify && + GOTOOLCHAIN=local go build -mod=readonly -o "$shell_context_tmp/shell-context" .); then + fail "could not verify and build the pinned shell execution-context parser" + exit 1 +fi + +trim_whitespace() { + local value="$1" + value="${value#"${value%%[![:space:]]*}"}" + value="${value%"${value##*[![:space:]]}"}" + printf '%s' "$value" +} + +path_has_symlink_component() { + local current="$1" + local remainder="$2" + local component + + while [[ -n "$remainder" ]]; do + component="${remainder%%/*}" + current="$current/$component" + if [[ -L "$current" ]]; then + return 0 + fi + if [[ "$remainder" == */* ]]; then + remainder="${remainder#*/}" + else + remainder="" + fi + done + return 1 +} + +validate_local_uses_ref() { + local file="$1" + local line_number="$2" + local value="$3" + local kind tail padded target scan_root metadata_count=0 metadata="" + + case "$value" in + ./.github/actions/*) + kind=action + scan_root="$ROOT_DIR/.github/actions" + tail="${value#./.github/actions/}" + ;; + ./.github/workflows/*) + kind=workflow + scan_root="$ROOT_DIR/.github/workflows" + tail="${value#./.github/workflows/}" + ;; + *) + fail "${file#"$ROOT_DIR/"}:$line_number local uses reference is outside the scanned .github/actions and .github/workflows roots: $value" + return + ;; + esac + + padded="/$tail/" + if [[ -z "$tail" || "$tail" == *\\* || "$padded" == *'//'* || + "$padded" == *'/./'* || "$padded" == *'/../'* ]]; then + fail "${file#"$ROOT_DIR/"}:$line_number local uses reference is not a canonical in-root path: $value" + return + fi + if [[ -L "$scan_root" ]] || path_has_symlink_component "$scan_root" "$tail"; then + fail "${file#"$ROOT_DIR/"}:$line_number local uses reference traverses a symbolic link outside the file scan: $value" + return + fi + + target="$ROOT_DIR/${value#./}" + if [[ "$kind" == action ]]; then + if [[ ! -d "$target" ]]; then + fail "${file#"$ROOT_DIR/"}:$line_number local Action directory does not exist: $value" + return + fi + if [[ -L "$target/action.yml" || -L "$target/action.yaml" ]]; then + fail "${file#"$ROOT_DIR/"}:$line_number local Action metadata must be a regular file inside the scanned tree: $value" + return + fi + if [[ -f "$target/action.yml" ]]; then + metadata_count=$((metadata_count + 1)) + metadata="$target/action.yml" + fi + if [[ -f "$target/action.yaml" ]]; then + metadata_count=$((metadata_count + 1)) + metadata="$target/action.yaml" + fi + if (( metadata_count != 1 )); then + fail "${file#"$ROOT_DIR/"}:$line_number local Action must contain exactly one action.yml or action.yaml: $value" + return + fi + validate_local_action_runtime "$file" "$line_number" "$metadata" "$value" + return + fi + + if [[ ! "$tail" =~ \.ya?ml$ || ! -f "$target" ]]; then + fail "${file#"$ROOT_DIR/"}:$line_number local reusable workflow must name an existing .yml or .yaml file: $value" + fi +} + +count_active_occurrences() { + local needle="$1" + local file="$2" + local line remainder count=0 + while IFS= read -r line || [[ -n "$line" ]]; do + if [[ "$line" =~ ^[[:space:]]*# ]]; then + continue + fi + # A # inside a shell string is data, not necessarily a comment. + remainder="$line" + while [[ "$remainder" == *"$needle"* ]]; do + count=$((count + 1)) + remainder="${remainder#*"$needle"}" + done + done < "$file" + printf '%d' "$count" +} + +tinygo_release_url_owner_allowed() { + local path="$1" + local candidate + + for candidate in "${EXPECTED_TINYGO_WORKFLOWS[@]}"; do + if [[ "$path" == "$candidate" ]]; then + return 0 + fi + done + return 1 +} + +shell_downloader_token_re="(^|[^[:alnum:]_.-])(${CURL_COMMAND_NAME}|${WGET_COMMAND_NAME})([^[:alnum:]_./-]|$)" + +shell_command_has_downloader_token() { + local command="$1" + # This is a bounded authored-shell policy, not a shell interpreter. Any + # textual downloader identity is security-relevant regardless of wrappers. + [[ "$command" =~ $shell_downloader_token_re ]] +} + +shell_command_downloader_token_count() { + local remainder="$1" + local match count=0 + + while [[ "$remainder" =~ $shell_downloader_token_re ]]; do + match="${BASH_REMATCH[0]}" + count=$((count + 1)) + remainder="${remainder#*"$match"}" + done + printf '%d' "$count" +} + +shell_command_is_loopback_probe() { + local command="$1" + local direct_re='^(if[[:space:]]+)?(command[[:space:]]+)?(/usr/bin/)?'"$CURL_COMMAND_NAME"'([[:space:]]+-[A-Za-z]+)*[[:space:]]+"http://127[.]0[.]0[.]1:[^"]*"[[:space:]]*(>/dev/null[[:space:]]+2>&1)?[[:space:]]*(;[[:space:]]*then)?$' + local substitution_re='^[A-Za-z_][A-Za-z0-9_]*="\$\([[:space:]]*(command[[:space:]]+)?(/usr/bin/)?'"$CURL_COMMAND_NAME"'([[:space:]]+-[A-Za-z]+)*[[:space:]]+"http://127[.]0[.]0[.]1:[^"]*"[[:space:]]*(\|\|[[:space:]]+true)?[[:space:]]*\)"$' + if (( $(shell_command_downloader_token_count "$command") != 1 )); then + return 1 + fi + [[ "$command" =~ $direct_re || "$command" =~ $substitution_re ]] +} + +shell_command_is_loopback_support_command() { + local relative="$1" + local command="$2" + + if [[ "$relative" == scripts/browser-smoke.sh ]] && + [[ "$command" == "require_command $CURL_COMMAND_NAME \"Install $CURL_COMMAND_NAME for smoke server readiness checks.\"" ]]; then + return 0 + fi + [[ "$relative" == .github/workflows/ci-browser-smoke.yml ]] && + [[ "$command" == "sudo apt-get install -y brotli zstd $CURL_COMMAND_NAME" ]] +} + +inspect_shell_logical_command() { + local relative="$1" + local line_number="$2" + local command="$3" + local working_directory="${4:-}" + local strict_helpers="${5:-false}" + + command="$(trim_whitespace "$command")" + inspect_referenced_ci_helpers \ + "$relative" "$line_number" "$command" "$working_directory" \ + "$strict_helpers" + inspect_shell_downloader "$relative" "$line_number" "$command" +} + +inspect_shell_downloader() { + local relative="$1" + local line_number="$2" + local command="$3" + + if [[ -z "$command" ]] || ! shell_command_has_downloader_token "$command"; then + return + fi + if tinygo_release_url_owner_allowed "$relative" && + [[ "$command" == "$tinygo_download_command" ]]; then + return + fi + if shell_command_is_loopback_probe "$command"; then + loopback_download_count=$((loopback_download_count + 1)) + return + fi + if shell_command_is_loopback_support_command "$relative" "$command"; then + return + fi + fail "$relative:$line_number downloader token is outside the accepted TinyGo fetch and literal-rooted loopback contract" +} + +pending_shell_command="" +pending_shell_line=0 + +reset_shell_logical_command() { + pending_shell_command="" + pending_shell_line=0 +} + +strip_shell_comment() { + local line="$1" + local index character previous quote="" escaped=false + + for ((index = 0; index < ${#line}; index++)); do + character="${line:index:1}" + if [[ "$escaped" == true ]]; then + escaped=false + continue + fi + if [[ "$quote" == "'" ]]; then + if [[ "$character" == "'" ]]; then + quote="" + fi + continue + fi + if [[ "$quote" == '"' ]]; then + if [[ "$character" == \\ ]]; then + escaped=true + elif [[ "$character" == '"' ]]; then + quote="" + fi + continue + fi + if [[ "$character" == \\ ]]; then + escaped=true + continue + fi + if [[ "$character" == "'" || "$character" == '"' ]]; then + quote="$character" + continue + fi + if [[ "$character" != \# ]]; then + continue + fi + if (( index == 0 )); then + printf '%s' "${line:0:index}" + return + fi + previous="${line:index-1:1}" + case "$previous" in + [[:space:]]|';'|'|'|'&') + printf '%s' "${line:0:index}" + return + ;; + esac + done + printf '%s' "$line" +} + +consume_shell_physical_line() { + local relative="$1" + local line_number="$2" + local line="$3" + local working_directory="${4:-}" + local strict_helpers="${5:-false}" + local command + + line="$(strip_shell_comment "$line")" + command="$(trim_whitespace "$line")" + if [[ -z "$pending_shell_command" && ( -z "$command" || "$command" == \#* ) ]]; then + return + fi + if [[ -z "$pending_shell_command" ]]; then + pending_shell_line="$line_number" + fi + if [[ "$command" == *\\ ]]; then + command="${command%\\}" + pending_shell_command="${pending_shell_command}${pending_shell_command:+ }$command" + return + fi + pending_shell_command="${pending_shell_command}${pending_shell_command:+ }$command" + inspect_shell_logical_command \ + "$relative" "$pending_shell_line" "$pending_shell_command" \ + "$working_directory" "$strict_helpers" + reset_shell_logical_command +} + +flush_shell_logical_command() { + local relative="$1" + local working_directory="${2:-}" + local strict_helpers="${3:-false}" + if [[ -n "$pending_shell_command" ]]; then + inspect_shell_logical_command \ + "$relative" "$pending_shell_line" "$pending_shell_command" \ + "$working_directory" "$strict_helpers" + fi + reset_shell_logical_command +} + +scan_shell_downloaders() { + local file="$1" + local relative="$2" + local working_directory="${3:-}" + local strict_helpers="${4:-false}" + inspect_syntax_aware_shell_source "$relative" 1 \ + "$working_directory" "$strict_helpers" < "$file" +} + +repository_file_is_shell_source() { + local relative="$1" + local file="$2" + local first_line name + local -a words=() + + case "$relative" in + *.sh|*.bash) return 0 ;; + esac + if [[ "${relative##*/}" == *.* ]]; then + return 1 + fi + IFS= read -r first_line < "$file" || true + [[ "$first_line" == '#!'* ]] || return 1 + IFS=$' \t' read -r -a words <<< "${first_line#\#!}" + case "${words[0]:-}" in + /bin/sh) return 0 ;; + /usr/bin/sh) return 0 ;; + /bin/bash) return 0 ;; + /usr/bin/bash) return 0 ;; + /usr/bin/env) ;; + *) return 1 ;; + esac + if [[ "${words[1]:-}" == -S ]]; then + name="${words[2]:-}" + else + name="${words[1]:-}" + fi + [[ "$name" == sh || "$name" == bash ]] +} + +repository_file_is_helper_source() { + local relative="$1" + local file="$2" + local first_line + + case "$relative" in + *.sh|*.bash|*.py|*.js|*.mjs|*.cjs|*.ps1|*.rb|*.go|*.ts|*.tsx) + return 0 + ;; + esac + if [[ -x "$file" ]]; then + return 0 + fi + IFS= read -r first_line < "$file" || true + [[ "$first_line" == '#!'* ]] +} + +repository_file_is_ci_executable_source() { + local relative="$1" + local file="$2" + + # This is a bounded authored-CI provenance inventory, not language parsing. + # Common helper-language files and executable files are inspected regardless + # of invocation; additional source types are included under CI helper roots. + case "$relative" in + *.sh|*.bash|*.py|*.js|*.mjs|*.cjs|*.ps1|*.rb) return 0 ;; + esac + case "$relative" in + .github/scripts/*|.github/actions/*|scripts/*) + repository_file_is_helper_source "$relative" "$file" + return + ;; + esac + [[ -x "$file" ]] +} + +repository_file_is_authored_ci_yaml() { + local relative="$1" + + case "$relative" in + .github/workflows/*.yml|.github/workflows/*.yaml|\ + .github/actions/*.yml|.github/actions/*.yaml|\ + .github/actions/*/*.yml|.github/actions/*/*.yaml) + return 0 + ;; + esac + return 1 +} + +scan_ci_executable_tinygo_provenance() { + local relative="$1" + local file="$2" + local forced="${3:-false}" + local grep_status line syntax + + # A literal repository identity is the bounded, language-agnostic marker. + # Deliberately encoded or character-by-character construction is outside this + # authored-CI policy; arbitrary source-language data flow is not interpreted. + if [[ "$forced" != true ]] && + ! repository_file_is_ci_executable_source "$relative" "$file" && + ! repository_file_is_authored_ci_yaml "$relative"; then + return + fi + if grep -Fiq -- "$TINYGO_RELEASE_REPOSITORY" "$file"; then + if tinygo_release_url_owner_allowed "$relative"; then + while IFS= read -r line || [[ -n "$line" ]]; do + if ! grep -Fiq -- "$TINYGO_RELEASE_REPOSITORY" <<< "$line"; then + continue + fi + syntax="$(trim_whitespace "$line")" + if [[ "$syntax" != "$tinygo_download_command" ]]; then + fail "TinyGo repository provenance in $relative is outside the accepted fetch command" + fi + done < "$file" + else + fail "TinyGo repository provenance is not allowed in executable CI source: $relative" + fi + else + grep_status=$? + if (( grep_status > 1 )); then + fail "could not inspect executable CI source for TinyGo provenance: $relative" + fi + fi +} + +shell_tokens=() + +tokenize_shell_command() { + local command="$1" + local token="" quote="" character next escaped=false index + + shell_tokens=() + for ((index = 0; index < ${#command}; index++)); do + character="${command:index:1}" + if [[ "$escaped" == true ]]; then + token+="$character" + escaped=false + continue + fi + if [[ "$quote" == "'" ]]; then + if [[ "$character" == "'" ]]; then + quote="" + else + token+="$character" + fi + continue + fi + if [[ "$quote" == '"' ]]; then + if [[ "$character" == \\ ]]; then + escaped=true + elif [[ "$character" == '"' ]]; then + quote="" + else + token+="$character" + fi + continue + fi + case "$character" in + \\) + escaped=true + ;; + "'"|'"') + quote="$character" + ;; + [[:space:]]) + if [[ -n "$token" ]]; then + shell_tokens[${#shell_tokens[@]}]="$token" + token="" + fi + ;; + ';'|'|'|'&'|'('|')') + if [[ -n "$token" ]]; then + shell_tokens[${#shell_tokens[@]}]="$token" + token="" + fi + next="" + if (( index + 1 < ${#command} )); then + next="${command:index+1:1}" + fi + if [[ ( "$character" == '|' || "$character" == '&' ) && + "$next" == "$character" ]] || + [[ "$character" == '|' && "$next" == '&' ]]; then + shell_tokens[${#shell_tokens[@]}]="$character$next" + index=$((index + 1)) + else + shell_tokens[${#shell_tokens[@]}]="$character" + fi + ;; + *) + token+="$character" + ;; + esac + done + if [[ "$escaped" == true || -n "$quote" ]]; then + return 1 + fi + if [[ -n "$token" ]]; then + shell_tokens[${#shell_tokens[@]}]="$token" + fi +} + +shell_token_is_operator() { + case "$1" in + ';'|'|'|'|&'|'||'|'&'|'&&'|'('|')') return 0 ;; + esac + return 1 +} + +execution_target_is_dynamic() { + case "$1" in + *'$'*|*'`'*|*'${{'*) return 0 ;; + esac + return 1 +} + +resolve_execution_working_directory() { + local source_relative="$1" + local line_number="$2" + local working_directory="$3" + local strict="$4" + local padded + + resolved_execution_directory="" + if [[ -z "$working_directory" || "$working_directory" == . ]]; then + return 0 + fi + if execution_target_is_dynamic "$working_directory"; then + if [[ "$strict" == true ]]; then + fail "$source_relative:$line_number cannot statically resolve a repository helper under dynamic working-directory: $working_directory" + fi + return 1 + fi + working_directory="${working_directory#./}" + padded="/$working_directory/" + if [[ "$working_directory" == /* || "$working_directory" == *\\* || + "$padded" == *'//'* || "$padded" == *'/./'* || + "$padded" == *'/../'* ]]; then + fail "$source_relative:$line_number execution working-directory is not a canonical repository-relative path: $working_directory" + return 1 + fi + if path_has_symlink_component "$ROOT_DIR" "$working_directory"; then + fail "$source_relative:$line_number execution working-directory traverses a symbolic link: $working_directory" + return 1 + fi + if [[ ! -d "$ROOT_DIR/$working_directory" ]]; then + fail "$source_relative:$line_number execution working-directory does not name a repository directory: $working_directory" + return 1 + fi + resolved_execution_directory="$working_directory" +} + +scan_execution_target() { + local source_relative="$1" + local line_number="$2" + local working_directory="$3" + local target="$4" + local language="$5" + local strict="$6" + local mode="${7:-scan}" + local relative padded file shell_source=false + + if [[ -z "$target" || "$target" == '{0}' ]]; then + return + fi + if execution_target_is_dynamic "$target"; then + if [[ "$strict" == true && + "$mode" != reject-indirect-executable ]]; then + fail "$source_relative:$line_number cannot statically resolve repository execution target: $target" + fi + return + fi + if [[ "$target" == /* ]]; then + if [[ "$mode" == scan || "$strict" == true ]]; then + fail "$source_relative:$line_number repository execution target must be repository-relative: $target" + fi + return + fi + if ! resolve_execution_working_directory \ + "$source_relative" "$line_number" "$working_directory" "$strict"; then + return + fi + target="${target#./}" + if [[ -n "$resolved_execution_directory" ]]; then + relative="$resolved_execution_directory/$target" + else + relative="$target" + fi + padded="/$relative/" + if [[ -z "$relative" || "$relative" == *\\* || + "$padded" == *'//'* || "$padded" == *'/./'* || + "$padded" == *'/../'* ]]; then + if [[ "$mode" == scan || "$strict" == true ]]; then + fail "$source_relative:$line_number repository execution target is not canonical: $target" + fi + return + fi + if path_has_symlink_component "$ROOT_DIR" "$relative"; then + fail "$source_relative:$line_number repository execution target traverses a symbolic link: $target" + return + fi + file="$ROOT_DIR/$relative" + if [[ ! -f "$file" ]]; then + if [[ "$strict" == true && + "$mode" != reject-indirect-executable ]]; then + fail "$source_relative:$line_number repository execution target does not name an existing regular file: $target" + fi + return + fi + if [[ "$mode" == reject-indirect-executable && ! -x "$file" ]]; then + return + fi + if [[ "$mode" == reject-indirect || + "$mode" == reject-indirect-executable ]]; then + if [[ "$strict" != true ]] && + repository_file_is_ci_executable_source "$relative" "$file"; then + return + fi + fail "$source_relative:$line_number repository execution target appears behind unsupported execution indirection: $target" + return + fi + + scan_ci_executable_tinygo_provenance "$relative" "$file" true + if [[ "$language" == shell ]]; then + shell_source=true + elif [[ "$language" == direct ]] && + repository_file_is_shell_source "$relative" "$file"; then + shell_source=true + fi + if [[ "$shell_source" == true ]] && + ! repository_file_is_shell_source "$relative" "$file"; then + scan_shell_downloaders "$file" "$relative" + fi +} + +inspect_interpreter_target() { + local source_relative="$1" + local line_number="$2" + local working_directory="$3" + local strict="$4" + local interpreter="$5" + local start="$6" + local end="$7" + local mode="${8:-scan}" + local stdin_from_pipe="${9:-false}" + local known_wrapper="${10:-false}" + local index=$start token target="" language=non-shell option_flags + + if [[ "$interpreter" == bash || "$interpreter" == sh ]]; then + language=shell + while (( index < end )); do + token="${shell_tokens[index]}" + if [[ "$token" == \<* ]]; then + fail "$source_relative:$line_number $interpreter redirected stdin execution is outside the bounded authored-CI contract" + return + fi + if [[ "$token" == -- ]]; then + index=$((index + 1)) + if (( index < end )); then + target="${shell_tokens[index]}" + fi + break + fi + if [[ "$token" == - ]]; then + fail "$source_relative:$line_number $interpreter stdin execution is outside the bounded authored-CI contract" + return + fi + case "$token" in + --command) + fail "$source_relative:$line_number $interpreter command-string execution is outside the bounded authored-CI contract" + return + ;; + --rcfile|--rcfile=*|--init-file|--init-file=*|--debugger|--login) + fail "$source_relative:$line_number $interpreter startup mode is outside the bounded authored-CI contract: $token" + return + ;; + --noprofile|--norc) + if [[ "$interpreter" != bash ]]; then + fail "$source_relative:$line_number unsupported $interpreter option prevents bounded execution-target resolution: $token" + return + fi + index=$((index + 1)) + continue + ;; + --version|--help) + if [[ "$interpreter" != bash ]] || (( index + 1 < end )); then + fail "$source_relative:$line_number unsupported $interpreter introspection form: $token" + fi + return + ;; + -o) + if [[ "$interpreter" != bash ]] || (( index + 1 >= end )) || + [[ "${shell_tokens[index+1]}" != pipefail ]]; then + fail "$source_relative:$line_number unsupported $interpreter option or missing pipefail argument: $token" + return + fi + index=$((index + 2)) + continue + ;; + -O) + fail "$source_relative:$line_number unsupported $interpreter startup option: $token" + return + ;; + -?*) + option_flags="${token#-}" + if [[ "$option_flags" == *c* ]]; then + fail "$source_relative:$line_number $interpreter command-string execution is outside the bounded authored-CI contract: $token" + return + fi + if [[ "$option_flags" == *s* ]]; then + fail "$source_relative:$line_number $interpreter stdin execution is outside the bounded authored-CI contract: $token" + return + fi + if [[ "$option_flags" == *i* || "$option_flags" == *l* ]]; then + fail "$source_relative:$line_number $interpreter startup mode is outside the bounded authored-CI contract: $token" + return + fi + if [[ "$interpreter" == bash && "$option_flags" =~ ^[eunpxv]+$ ]] || + [[ "$interpreter" == sh && "$option_flags" =~ ^[eunxv]+$ ]]; then + index=$((index + 1)) + continue + fi + fail "$source_relative:$line_number unsupported $interpreter option prevents bounded execution-target resolution: $token" + return + ;; + *) target="$token"; break ;; + esac + done + if [[ -z "$target" ]]; then + if [[ "$mode" == scan || "$known_wrapper" == true || + "$stdin_from_pipe" == true ]]; then + fail "$source_relative:$line_number $interpreter execution without a bounded script target is unsupported" + fi + return + fi + if [[ "$target" == - ]]; then + fail "$source_relative:$line_number $interpreter stdin execution is outside the bounded authored-CI contract" + return + fi + elif [[ "$interpreter" == python || "$interpreter" == python3 ]]; then + while (( index < end )); do + token="${shell_tokens[index]}" + if [[ "$token" == \<* ]]; then + fail "$source_relative:$line_number $interpreter redirected stdin execution is outside the bounded authored-CI contract" + return + fi + if [[ "$token" == -- ]]; then + index=$((index + 1)) + if (( index < end )); then + target="${shell_tokens[index]}" + fi + break + fi + case "$token" in + -c|-m) + fail "$source_relative:$line_number $interpreter opaque code or module execution is outside the bounded authored-CI contract: $token" + return + ;; + -) + fail "$source_relative:$line_number $interpreter stdin execution is outside the bounded authored-CI contract" + return + ;; + -W|-X) + if (( index + 1 >= end )); then + fail "$source_relative:$line_number $interpreter option is missing its argument: $token" + return + fi + index=$((index + 2)) + continue + ;; + -W?*|-X?*|-B|-E|-I|-O|-OO|-q|-s|-S|-u|-v|-x) + index=$((index + 1)) + continue + ;; + -V|--version|-h|--help) + return + ;; + -*) + fail "$source_relative:$line_number unsupported $interpreter option prevents bounded execution-target resolution: $token" + return + ;; + *) target="$token"; break ;; + esac + done + elif [[ "$interpreter" == node ]]; then + while (( index < end )); do + token="${shell_tokens[index]}" + if [[ "$token" == \<* ]]; then + fail "$source_relative:$line_number Node redirected stdin execution is outside the bounded authored-CI contract" + return + fi + if [[ "$token" == -- ]]; then + index=$((index + 1)) + if (( index < end )); then + target="${shell_tokens[index]}" + fi + break + fi + case "$token" in + -|--eval|--eval=*|--print|--print=*|--run|--run=*) + fail "$source_relative:$line_number Node opaque code, script-graph, or stdin execution is outside the bounded authored-CI contract: $token" + return + ;; + -e|-p) + fail "$source_relative:$line_number Node inline code execution is outside the bounded authored-CI contract: $token" + return + ;; + -r|--require|--import|--loader|--experimental-loader) + index=$((index + 1)) + if (( index >= end )); then + fail "$source_relative:$line_number Node preload option is missing its repository execution target" + return + fi + scan_execution_target "$source_relative" "$line_number" \ + "$working_directory" "${shell_tokens[index]}" non-shell "$strict" "$mode" + index=$((index + 1)) + continue + ;; + --require=*|--import=*|--loader=*|--experimental-loader=*) + scan_execution_target "$source_relative" "$line_number" \ + "$working_directory" "${token#*=}" non-shell "$strict" "$mode" + index=$((index + 1)) + continue + ;; + --experimental-websocket) + index=$((index + 1)) + continue + ;; + -v|--version|-h|--help) + return + ;; + -?*) + if [[ "$token" != --* ]]; then + option_flags="${token#-}" + if [[ "$option_flags" == *e* || "$option_flags" == *p* ]]; then + fail "$source_relative:$line_number Node inline code execution is outside the bounded authored-CI contract: $token" + return + fi + fi + fail "$source_relative:$line_number unsupported Node option prevents bounded execution-target resolution: $token" + return + ;; + *) target="$token"; break ;; + esac + done + else + while (( index < end )); do + token="${shell_tokens[index]}" + if [[ "$token" == \<* ]]; then + fail "$source_relative:$line_number $interpreter redirected stdin execution is outside the bounded authored-CI contract" + return + fi + case "$token" in + -[Ff][Ii][Ll][Ee]) + index=$((index + 1)) + if (( index >= end )); then + fail "$source_relative:$line_number $interpreter -File is missing its repository execution target" + return + fi + target="${shell_tokens[index]}" + if [[ "$target" == - ]]; then + fail "$source_relative:$line_number $interpreter stdin execution is outside the bounded authored-CI contract" + return + fi + break + ;; + -*) + fail "$source_relative:$line_number $interpreter command or encoded execution mode is outside the bounded authored-CI contract: $token" + return + ;; + *) target="$token"; break ;; + esac + done + fi + if [[ -n "$target" ]]; then + scan_execution_target "$source_relative" "$line_number" \ + "$working_directory" "$target" "$language" "$strict" "$mode" + elif [[ "$stdin_from_pipe" == true ]]; then + fail "$source_relative:$line_number $interpreter cannot consume executable stdin from a pipeline in the bounded authored-CI contract" + fi +} + +supported_interpreter_name() { + local candidate="$1" + + [[ "$candidate" == bash ]] || + [[ "$candidate" == sh ]] || + [[ "$candidate" == python ]] || + [[ "$candidate" == python3 ]] || + [[ "$candidate" == node ]] || + [[ "$candidate" == pwsh ]] || + [[ "$candidate" == powershell ]] +} + +inspect_unsupported_execution_indirection() { + local source_relative="$1" + local line_number="$2" + local working_directory="$3" + local strict="$4" + local start="$5" + local end="$6" + local stdin_from_pipe="${7:-false}" + local index=$((start + 1)) token command_name known_wrapper=false + + case "${shell_tokens[start]##*/}" in + printf|echo|grep) + # These data consumers do not execute their arguments as shell targets. + return + ;; + sudo|timeout|nice|nohup|stdbuf|xargs) + known_wrapper=true + ;; + esac + + # The command head is unsupported, so inspect later tokens only for the + # repository execution shapes that the canonical grammar already owns. + while (( index < end )); do + token="${shell_tokens[index]}" + command_name="${token##*/}" + if supported_interpreter_name "$command_name"; then + inspect_interpreter_target "$source_relative" "$line_number" \ + "$working_directory" "$strict" "$command_name" \ + "$((index + 1))" "$end" reject-indirect "$stdin_from_pipe" \ + "$known_wrapper" + return + fi + case "$command_name" in + source|.) + index=$((index + 1)) + if (( index < end )); then + scan_execution_target "$source_relative" "$line_number" \ + "$working_directory" "${shell_tokens[index]}" shell "$strict" \ + reject-indirect + fi + return + ;; + $EVAL_COMMAND_NAME) + fail "$source_relative:$line_number shell eval is outside the bounded authored-CI execution contract" + return + ;; + esac + if [[ "$token" != *://* && "$token" != *[[:space:]]* ]] && + [[ "$token" == ./* || + ( "$token" != /* && "$token" == */* ) ]]; then + scan_execution_target "$source_relative" "$line_number" \ + "$working_directory" "$token" direct "$strict" \ + reject-indirect-executable + fi + index=$((index + 1)) + done +} + +inspect_shell_command_segment() { + local source_relative="$1" + local line_number="$2" + local working_directory="$3" + local strict="$4" + local start="$5" + local end="$6" + local stdin_from_pipe="${7:-false}" + local index=$start token command_name + + while (( index < end )) && + [[ "${shell_tokens[index]}" =~ ^[A-Za-z_][A-Za-z0-9_]*= ]]; do + index=$((index + 1)) + done + while (( index < end )); do + token="${shell_tokens[index]}" + command_name="${token##*/}" + case "$command_name" in + command) + index=$((index + 1)) + while (( index < end )); do + token="${shell_tokens[index]}" + case "$token" in + --) index=$((index + 1)); break ;; + -p) index=$((index + 1)) ;; + -v|-V) return ;; + -*) + if [[ "$strict" == true ]]; then + fail "$source_relative:$line_number unsupported command wrapper option prevents bounded execution-target resolution: $token" + fi + return + ;; + *) break ;; + esac + done + ;; + exec) + index=$((index + 1)) + while (( index < end )); do + token="${shell_tokens[index]}" + case "$token" in + --) index=$((index + 1)); break ;; + -c|-l) index=$((index + 1)) ;; + -a) + if (( index + 1 >= end )); then + if [[ "$strict" == true ]]; then + fail "$source_relative:$line_number exec -a is missing its argv0 argument" + fi + return + fi + index=$((index + 2)) + ;; + -*) + if [[ "$strict" == true ]]; then + fail "$source_relative:$line_number unsupported exec wrapper option prevents bounded execution-target resolution: $token" + fi + return + ;; + *) break ;; + esac + done + ;; + env) + index=$((index + 1)) + while (( index < end )); do + token="${shell_tokens[index]}" + case "$token" in + --) index=$((index + 1)); break ;; + -i|--ignore-environment) index=$((index + 1)) ;; + -u|--unset) + if (( index + 1 >= end )); then + if [[ "$strict" == true ]]; then + fail "$source_relative:$line_number env unset option is missing its variable name" + fi + return + fi + index=$((index + 2)) + ;; + --unset=*) index=$((index + 1)) ;; + -C|--chdir|-S|--split-string|--chdir=*|--split-string=*) + if [[ "$strict" == true ]]; then + fail "$source_relative:$line_number env option changes bounded execution-target semantics: $token" + fi + return + ;; + -*) + if [[ "$strict" == true ]]; then + fail "$source_relative:$line_number unsupported env wrapper option prevents bounded execution-target resolution: $token" + fi + return + ;; + *) + if [[ "$token" =~ ^[A-Za-z_][A-Za-z0-9_]*= ]]; then + index=$((index + 1)) + else + break + fi + ;; + esac + done + ;; + *) break ;; + esac + done + if (( index >= end )); then + return + fi + token="${shell_tokens[index]}" + command_name="${token##*/}" + if supported_interpreter_name "$command_name"; then + inspect_interpreter_target "$source_relative" "$line_number" \ + "$working_directory" "$strict" "$command_name" \ + "$((index + 1))" "$end" scan "$stdin_from_pipe" + return + fi + case "$command_name" in + source|.) + index=$((index + 1)) + if (( index < end )); then + scan_execution_target "$source_relative" "$line_number" \ + "$working_directory" "${shell_tokens[index]}" shell "$strict" + fi + ;; + $EVAL_COMMAND_NAME) + fail "$source_relative:$line_number shell eval is outside the bounded authored-CI execution contract" + ;; + go) + index=$((index + 1)) + if (( index < end )) && [[ "${shell_tokens[index]}" == run ]]; then + index=$((index + 1)) + while (( index < end )); do + token="${shell_tokens[index]}" + if [[ "$token" == -* ]]; then + index=$((index + 1)) + continue + fi + case "$token" in + *.go|./*.go|*/*.go) + scan_execution_target "$source_relative" "$line_number" \ + "$working_directory" "$token" non-shell "$strict" + ;; + esac + index=$((index + 1)) + done + fi + ;; + *) + if [[ "$token" == ./* || + ( "$token" != /* && "$token" == */* ) ]]; then + scan_execution_target "$source_relative" "$line_number" \ + "$working_directory" "$token" direct "$strict" + fi + inspect_unsupported_execution_indirection "$source_relative" \ + "$line_number" "$working_directory" "$strict" "$index" "$end" \ + "$stdin_from_pipe" + ;; + esac +} + +inspect_static_command_substitutions() { + local source_relative="$1" + local line_number="$2" + local command="$3" + local working_directory="$4" + local strict="$5" + local index=0 length=${#command} character next quote="" escaped=false + local inner_start inner_end depth inner_quote inner_escaped + + # Substitutions are nested executable command segments hidden from the flat + # token stream. Inspect their static bodies through the same bounded grammar. + while (( index < length )); do + character="${command:index:1}" + if [[ "$escaped" == true ]]; then + escaped=false + index=$((index + 1)) + continue + fi + if [[ "$quote" == "'" ]]; then + if [[ "$character" == "'" ]]; then + quote="" + fi + index=$((index + 1)) + continue + fi + if [[ "$character" == \\ ]]; then + escaped=true + index=$((index + 1)) + continue + fi + if [[ "$character" == "'" && "$quote" != '"' ]]; then + quote="'" + index=$((index + 1)) + continue + fi + if [[ "$character" == '"' ]]; then + if [[ "$quote" == '"' ]]; then + quote="" + else + quote='"' + fi + index=$((index + 1)) + continue + fi + next="" + if (( index + 1 < length )); then + next="${command:index+1:1}" + fi + if [[ "$character" == '$' && "$next" == '(' ]]; then + inner_start=$((index + 2)) + inner_end=$inner_start + depth=1 + inner_quote="" + inner_escaped=false + while (( inner_end < length )); do + character="${command:inner_end:1}" + if [[ "$inner_escaped" == true ]]; then + inner_escaped=false + inner_end=$((inner_end + 1)) + continue + fi + if [[ "$inner_quote" == "'" ]]; then + if [[ "$character" == "'" ]]; then + inner_quote="" + fi + inner_end=$((inner_end + 1)) + continue + fi + if [[ "$character" == \\ ]]; then + inner_escaped=true + inner_end=$((inner_end + 1)) + continue + fi + if [[ "$character" == "'" && "$inner_quote" != '"' ]]; then + inner_quote="'" + inner_end=$((inner_end + 1)) + continue + fi + if [[ "$character" == '"' ]]; then + if [[ "$inner_quote" == '"' ]]; then + inner_quote="" + else + inner_quote='"' + fi + inner_end=$((inner_end + 1)) + continue + fi + if [[ "$inner_quote" != '"' && "$character" == '$' ]] && + (( inner_end + 1 < length )) && + [[ "${command:inner_end+1:1}" == '(' ]]; then + depth=$((depth + 1)) + inner_end=$((inner_end + 2)) + continue + fi + if [[ -z "$inner_quote" && "$character" == ')' ]]; then + depth=$((depth - 1)) + if (( depth == 0 )); then + break + fi + fi + inner_end=$((inner_end + 1)) + done + if (( depth != 0 )); then + if [[ "$strict" == true ]]; then + fail "$source_relative:$line_number cannot safely inspect command substitution for repository helper resolution" + fi + return + fi + inspect_referenced_ci_helpers "$source_relative" "$line_number" \ + "${command:inner_start:inner_end-inner_start}" \ + "$working_directory" "$strict" + index=$((inner_end + 1)) + continue + fi + if [[ "$character" == '`' ]]; then + inner_start=$((index + 1)) + inner_end=$inner_start + inner_escaped=false + while (( inner_end < length )); do + character="${command:inner_end:1}" + if [[ "$inner_escaped" == true ]]; then + inner_escaped=false + elif [[ "$character" == \\ ]]; then + inner_escaped=true + elif [[ "$character" == '`' ]]; then + break + fi + inner_end=$((inner_end + 1)) + done + if (( inner_end < length )); then + inspect_referenced_ci_helpers "$source_relative" "$line_number" \ + "${command:inner_start:inner_end-inner_start}" \ + "$working_directory" "$strict" + index=$((inner_end + 1)) + continue + fi + fi + index=$((index + 1)) + done +} + +inspect_referenced_ci_helpers() { + local source_relative="$1" + local line_number="$2" + local command="$3" + local working_directory="${4:-}" + local strict="${5:-false}" + local start=0 index stdin_from_pipe=false + + inspect_static_command_substitutions "$source_relative" "$line_number" \ + "$command" "$working_directory" "$strict" + if ! tokenize_shell_command "$command"; then + if [[ "$strict" == true ]]; then + fail "$source_relative:$line_number cannot safely tokenize executable command for repository helper resolution" + fi + return + fi + for ((index = 0; index <= ${#shell_tokens[@]}; index++)); do + if (( index == ${#shell_tokens[@]} )) || + shell_token_is_operator "${shell_tokens[index]}"; then + if (( start < index )); then + inspect_shell_command_segment "$source_relative" "$line_number" \ + "$working_directory" "$strict" "$start" "$index" \ + "$stdin_from_pipe" + fi + stdin_from_pipe=false + if (( index < ${#shell_tokens[@]} )) && + [[ "${shell_tokens[index]}" == '|' || + "${shell_tokens[index]}" == '|&' ]]; then + stdin_from_pipe=true + fi + start=$((index + 1)) + fi + done +} + +inspect_syntax_aware_shell_source() { + local relative="$1" + local base_line="$2" + local working_directory="$3" + local strict="$4" + local events record line pipe_input redirect_input assign_count word_count word command + local expected count=0 completed=false index + local -a raw_words=() parsed_words=() shell_tokens=() + + events="$(mktemp "$shell_context_tmp/events.XXXXXX")" + if ! "$shell_context_tmp/shell-context" "$relative" > "$events"; then + fail "$relative:$base_line shell execution-context parsing failed" + rm -f "$events" + return + fi + while IFS= read -r -d '' record; do + case "$record" in + C) + if ! IFS= read -r -d '' line || + ! IFS= read -r -d '' pipe_input || + ! IFS= read -r -d '' redirect_input || + ! IFS= read -r -d '' assign_count || + ! IFS= read -r -d '' word_count || + [[ ! "$line" =~ ^[1-9][0-9]*$ || + ! "$word_count" =~ ^[1-9][0-9]*$ || + ! "$assign_count" =~ ^[0-9]+$ || + ( "$pipe_input" != true && "$pipe_input" != false ) || + ( "$redirect_input" != true && "$redirect_input" != false ) ]] || + (( assign_count > word_count )); then + fail "$relative:$base_line malformed shell parser event" + break + fi + raw_words=() + for ((index = 0; index < word_count; index++)); do + if ! IFS= read -r -d '' word; then + fail "$relative:$base_line incomplete shell parser word record" + break + fi + raw_words[${#raw_words[@]}]="$word" + done + if (( ${#raw_words[@]} != word_count )) || + ! IFS= read -r -d '' command; then + fail "$relative:$base_line incomplete shell parser command record" + break + fi + parsed_words=() + for word in "${raw_words[@]}"; do + if (( ${#parsed_words[@]} < assign_count )); then + parsed_words[${#parsed_words[@]}]="$word" + continue + fi + if ! tokenize_shell_command "$word"; then + fail "$relative:$base_line cannot decode shell parser word" + break + fi + if (( ${#shell_tokens[@]} == 1 )); then + parsed_words[${#parsed_words[@]}]="${shell_tokens[0]}" + elif (( ${#shell_tokens[@]} == 0 )) && + [[ "$word" == "''" || "$word" == '""' ]]; then + parsed_words[${#parsed_words[@]}]="" + elif [[ "$word" == *'$('* || "$word" == *'`'* || + "$word" == *'<('* || "$word" == *'>('* ]]; then + # Nested executable calls are emitted separately by the AST walk. + parsed_words[${#parsed_words[@]}]="$word" + else + fail "$relative:$base_line cannot decode shell parser word boundary" + break + fi + done + if (( ${#parsed_words[@]} != word_count )); then + break + fi + shell_tokens=("${parsed_words[@]}") + if [[ "$redirect_input" == true ]]; then + shell_tokens[${#shell_tokens[@]}]='/dev/null 2>&1 && + [[ "$(git -C "$ROOT_DIR" rev-parse --is-inside-work-tree 2>/dev/null || true)" == true ]]; then + repository_uses_git_inventory=true +fi + +repository_inventory_complete=false +while IFS= read -r -d '' repository_path; do + if [[ -z "$repository_path" ]]; then + repository_inventory_complete=true + continue + fi + if [[ "$repository_path" == "$ROOT_DIR/"* ]]; then + relative="${repository_path#"$ROOT_DIR/"}" + file="$repository_path" + else + relative="$repository_path" + file="$ROOT_DIR/$repository_path" + fi + if [[ ! -f "$file" ]]; then + continue + fi + if grep -Fq -- "$TINYGO_RELEASE_NAMESPACE" "$file"; then + if ! tinygo_release_url_owner_allowed "$relative"; then + fail "TinyGo release-download namespace is not owned by $relative" + fi + else + grep_status=$? + if (( grep_status > 1 )); then + fail "could not inspect repository file for TinyGo release downloads: $relative" + fi + fi + if repository_file_is_shell_source "$relative" "$file"; then + scan_shell_downloaders "$file" "$relative" + fi + scan_ci_executable_tinygo_provenance "$relative" "$file" +done < <(enumerate_repository_files) +if [[ "$repository_inventory_complete" != true ]]; then + fail "could not enumerate repository-owned files for TinyGo release downloads" +fi + +scan_roots=() +for candidate in "$ROOT_DIR/.github/workflows" "$ROOT_DIR/.github/actions"; do + if [[ -d "$candidate" ]]; then + if [[ -L "$candidate" ]]; then + fail "authored GitHub workflow and Action scan root must not be a symbolic link: ${candidate#"$ROOT_DIR/"}" + else + scan_roots+=("$candidate") + fi + fi +done +if (( ${#scan_roots[@]} == 0 )); then + fail "no authored GitHub workflow or Action directories found under $ROOT_DIR/.github" +fi + +ci_files=() +ci_file_types=() +ci_file_default_shell_count=() +ci_file_default_shell_value=() +ci_file_default_shell_line=() +ci_file_default_directory_count=() +ci_file_default_directory_value=() +ci_file_default_directory_line=() +ci_file_action_using_count=() +ci_file_action_using_value=() +ci_file_action_using_line=() + +ci_job_file=() +ci_job_id=() +ci_job_line=() +ci_job_if_line=() +ci_job_continue_line=() +ci_job_needs_line=() +ci_job_runs_on_count=() +ci_job_runs_on_value=() +ci_job_runs_on_line=() +ci_job_container_line=() +ci_job_uses_count=() +ci_job_uses_value=() +ci_job_uses_line=() +ci_job_uses_comment=() +ci_job_uses_comment_is_yaml=() +ci_job_default_shell_count=() +ci_job_default_shell_value=() +ci_job_default_shell_line=() +ci_job_default_directory_count=() +ci_job_default_directory_value=() +ci_job_default_directory_line=() + +ci_step_file=() +ci_step_kind=() +ci_step_job=() +ci_step_line=() +ci_step_property_count=() +ci_step_name_count=() +ci_step_name_value=() +ci_step_install_name=() +ci_step_verify_name=() +ci_step_shell_count=() +ci_step_shell_value=() +ci_step_shell_line=() +ci_step_run_count=() +ci_step_run_value=() +ci_step_run_line=() +ci_step_run_indent=() +ci_step_directory_count=() +ci_step_directory_value=() +ci_step_directory_line=() +ci_step_uses_count=() +ci_step_uses_value=() +ci_step_uses_line=() +ci_step_uses_comment=() +ci_step_uses_comment_is_yaml=() +ci_run_step=() +ci_run_line=() +ci_run_text=() +ci_run_raw=() + +register_ci_file() { + local relative="$1" + ci_current_file=${#ci_files[@]} + ci_files[ci_current_file]="$relative" + case "$relative" in + .github/workflows/*) ci_file_types[ci_current_file]=workflow ;; + *) ci_file_types[ci_current_file]=action ;; + esac + ci_file_default_shell_count[ci_current_file]=0 + ci_file_default_shell_value[ci_current_file]="" + ci_file_default_shell_line[ci_current_file]=0 + ci_file_default_directory_count[ci_current_file]=0 + ci_file_default_directory_value[ci_current_file]="" + ci_file_default_directory_line[ci_current_file]=0 + ci_file_action_using_count[ci_current_file]=0 + ci_file_action_using_value[ci_current_file]="" + ci_file_action_using_line[ci_current_file]=0 +} + +register_ci_job() { + local file_index="$1" + local id="$2" + local line_number="$3" + ci_current_job=${#ci_job_id[@]} + ci_job_file[ci_current_job]="$file_index" + ci_job_id[ci_current_job]="$id" + ci_job_line[ci_current_job]="$line_number" + ci_job_if_line[ci_current_job]=0 + ci_job_continue_line[ci_current_job]=0 + ci_job_needs_line[ci_current_job]=0 + ci_job_runs_on_count[ci_current_job]=0 + ci_job_runs_on_value[ci_current_job]="" + ci_job_runs_on_line[ci_current_job]=0 + ci_job_container_line[ci_current_job]=0 + ci_job_uses_count[ci_current_job]=0 + ci_job_uses_value[ci_current_job]="" + ci_job_uses_line[ci_current_job]=0 + ci_job_uses_comment[ci_current_job]="" + ci_job_uses_comment_is_yaml[ci_current_job]=false + ci_job_default_shell_count[ci_current_job]=0 + ci_job_default_shell_value[ci_current_job]="" + ci_job_default_shell_line[ci_current_job]=0 + ci_job_default_directory_count[ci_current_job]=0 + ci_job_default_directory_value[ci_current_job]="" + ci_job_default_directory_line[ci_current_job]=0 +} + +find_ci_job() { + local file_index="$1" + local id="$2" + local index + ci_found_job=-1 + for ((index = 0; index < ${#ci_job_id[@]}; index++)); do + if [[ "${ci_job_file[index]}" == "$file_index" && + "${ci_job_id[index]}" == "$id" ]]; then + ci_found_job=$index + return + fi + done +} + +register_ci_step() { + local file_index="$1" + local kind="$2" + local job_index="$3" + local line_number="$4" + ci_current_step=${#ci_step_file[@]} + ci_step_file[ci_current_step]="$file_index" + ci_step_kind[ci_current_step]="$kind" + ci_step_job[ci_current_step]="$job_index" + ci_step_line[ci_current_step]="$line_number" + ci_step_property_count[ci_current_step]=0 + ci_step_name_count[ci_current_step]=0 + ci_step_name_value[ci_current_step]="" + ci_step_install_name[ci_current_step]=false + ci_step_verify_name[ci_current_step]=false + ci_step_shell_count[ci_current_step]=0 + ci_step_shell_value[ci_current_step]="" + ci_step_shell_line[ci_current_step]=0 + ci_step_run_count[ci_current_step]=0 + ci_step_run_value[ci_current_step]="" + ci_step_run_line[ci_current_step]=0 + ci_step_run_indent[ci_current_step]=0 + ci_step_directory_count[ci_current_step]=0 + ci_step_directory_value[ci_current_step]="" + ci_step_directory_line[ci_current_step]=0 + ci_step_uses_count[ci_current_step]=0 + ci_step_uses_value[ci_current_step]="" + ci_step_uses_line[ci_current_step]=0 + ci_step_uses_comment[ci_current_step]="" + ci_step_uses_comment_is_yaml[ci_current_step]=false +} + +record_ci_job_property() { + local job_index="$1" + local key="$2" + local value="$3" + local line_number="$4" + local comment="$5" + local comment_is_yaml="$6" + + case "$key" in + if) ci_job_if_line[job_index]="$line_number" ;; + continue-on-error) ci_job_continue_line[job_index]="$line_number" ;; + needs) ci_job_needs_line[job_index]="$line_number" ;; + runs-on) + ci_job_runs_on_count[job_index]=$((ci_job_runs_on_count[job_index] + 1)) + if (( ci_job_runs_on_count[job_index] == 1 )); then + ci_job_runs_on_value[job_index]="$value" + ci_job_runs_on_line[job_index]="$line_number" + fi + ;; + container) ci_job_container_line[job_index]="$line_number" ;; + uses) + ci_job_uses_count[job_index]=$((ci_job_uses_count[job_index] + 1)) + if (( ci_job_uses_count[job_index] == 1 )); then + ci_job_uses_value[job_index]="$value" + ci_job_uses_line[job_index]="$line_number" + ci_job_uses_comment[job_index]="$comment" + ci_job_uses_comment_is_yaml[job_index]="$comment_is_yaml" + fi + ;; + esac +} + +record_ci_step_property() { + local step_index="$1" + local key="$2" + local value="$3" + local line_number="$4" + local comment="$5" + local comment_is_yaml="$6" + + ci_step_property_count[step_index]=$((ci_step_property_count[step_index] + 1)) + case "$key" in + name) + ci_step_name_count[step_index]=$((ci_step_name_count[step_index] + 1)) + ci_step_name_value[step_index]="$value" + if [[ "$value" == 'Install TinyGo' ]]; then + ci_step_install_name[step_index]=true + elif [[ "$value" == 'Verify TinyGo' ]]; then + ci_step_verify_name[step_index]=true + fi + ;; + shell) + ci_step_shell_count[step_index]=$((ci_step_shell_count[step_index] + 1)) + if (( ci_step_shell_count[step_index] == 1 )); then + ci_step_shell_value[step_index]="$value" + ci_step_shell_line[step_index]="$line_number" + fi + ;; + run) + ci_step_run_count[step_index]=$((ci_step_run_count[step_index] + 1)) + if (( ci_step_run_count[step_index] == 1 )); then + ci_step_run_value[step_index]="$value" + ci_step_run_line[step_index]="$line_number" + fi + ;; + working-directory) + ci_step_directory_count[step_index]=$((ci_step_directory_count[step_index] + 1)) + if (( ci_step_directory_count[step_index] == 1 )); then + ci_step_directory_value[step_index]="$value" + ci_step_directory_line[step_index]="$line_number" + fi + ;; + uses) + ci_step_uses_count[step_index]=$((ci_step_uses_count[step_index] + 1)) + if (( ci_step_uses_count[step_index] == 1 )); then + ci_step_uses_value[step_index]="$value" + ci_step_uses_line[step_index]="$line_number" + ci_step_uses_comment[step_index]="$comment" + ci_step_uses_comment_is_yaml[step_index]="$comment_is_yaml" + fi + ;; + esac +} + +parse_authored_ci_file() { + local file="$1" + local relative="${file#"$ROOT_DIR/"}" + local line line_number=0 content syntax indent key value sequence_prefix + local comment comment_is_yaml key_indent stack_count stack_index parent_kind + local active_step=-1 active_step_indent=-1 job_index=-1 + local scalar_indent=-1 scalar_step=-1 scalar_key="" + local mapping_re='^(-[[:space:]]+)?([A-Za-z_][A-Za-z0-9_-]*):([[:space:]]+(.*))?$' + local inline_comment_re='^(.*[^[:space:]])[[:space:]]+#(.*)$' + local full_comment_re='^[[:space:]]*#(.*)$' + local path_keys=() + local path_indents=() + + register_ci_file "$relative" + local file_index=$ci_current_file + while IFS= read -r line || [[ -n "$line" ]]; do + line_number=$((line_number + 1)) + indent="${line%%[![:space:]]*}" + syntax="$(trim_whitespace "$line")" + if (( scalar_indent >= 0 )) && + [[ -z "$syntax" || ${#indent} -gt scalar_indent ]]; then + if (( scalar_step >= 0 )) && [[ "$scalar_key" == run ]]; then + ci_run_step[${#ci_run_step[@]}]="$scalar_step" + ci_run_line[${#ci_run_line[@]}]="$line_number" + ci_run_text[${#ci_run_text[@]}]="$syntax" + ci_run_raw[${#ci_run_raw[@]}]="$line" + fi + continue + fi + scalar_indent=-1 + scalar_step=-1 + scalar_key="" + + content="$line" + comment="" + comment_is_yaml=false + if [[ "$line" =~ $inline_comment_re ]]; then + content="${BASH_REMATCH[1]}" + comment="${BASH_REMATCH[2]}" + comment_is_yaml=true + elif [[ "$line" =~ $full_comment_re ]]; then + content="" + comment="${BASH_REMATCH[1]}" + comment_is_yaml=true + fi + syntax="$(trim_whitespace "$content")" + if [[ -z "$syntax" ]]; then + continue + fi + if [[ "$syntax" =~ $mapping_re ]]; then + sequence_prefix="${BASH_REMATCH[1]}" + key="${BASH_REMATCH[2]}" + value="${BASH_REMATCH[4]}" + key_indent=$((${#indent} + ${#sequence_prefix})) + else + sequence_prefix="" + key_indent=${#indent} + if [[ "$syntax" == '- '* ]]; then + key_indent=$((key_indent + 2)) + fi + fi + + while (( ${#path_indents[@]} > 0 )); do + stack_index=$((${#path_indents[@]} - 1)) + if (( path_indents[stack_index] < key_indent )); then + break + fi + unset 'path_indents[stack_index]' 'path_keys[stack_index]' + done + stack_count=${#path_keys[@]} + parent_kind="" + if [[ "${ci_file_types[file_index]}" == workflow ]] && + (( stack_count == 3 )) && + [[ "${path_keys[0]}" == jobs && "${path_keys[2]}" == steps ]]; then + parent_kind=workflow-step + elif [[ "${ci_file_types[file_index]}" == action ]] && + (( stack_count == 2 )) && + [[ "${path_keys[0]}" == runs && "${path_keys[1]}" == steps ]]; then + parent_kind=composite-step + fi + if (( active_step >= 0 && key_indent < active_step_indent )); then + active_step=-1 + active_step_indent=-1 + fi + + if [[ ! "$syntax" =~ $mapping_re ]]; then + if [[ "$syntax" == - && -n "$parent_kind" ]]; then + fail "$relative:$line_number unsupported standalone step sequence indicator; use canonical '- key:' step mappings" + active_step=-1 + active_step_indent=-1 + continue + fi + case "${syntax#- }" in + *:*|\{*|\[*|\?*|\&*|\**|\!*) + fail "$relative:$line_number unsupported workflow mapping syntax; use canonical block keys" + ;; + esac + continue + fi + + if [[ -n "$sequence_prefix" && -n "$parent_kind" ]]; then + job_index=-1 + if [[ "$parent_kind" == workflow-step ]]; then + find_ci_job "$file_index" "${path_keys[1]}" + job_index=$ci_found_job + fi + register_ci_step "$file_index" "$parent_kind" "$job_index" "$line_number" + active_step=$ci_current_step + active_step_indent=$key_indent + elif [[ -n "$sequence_prefix" && active_step -ge 0 ]] && + (( key_indent <= active_step_indent )); then + active_step=-1 + active_step_indent=-1 + fi + + case "$value" in + \{*|\[*|\&*|\**|\!*) + case "$key" in + env|with|inputs|outputs) ;; + *) + fail "$relative:$line_number unsupported workflow mapping value; use canonical block mappings" + ;; + esac + continue + ;; + esac + + if [[ "${ci_file_types[file_index]}" == workflow && + -z "$sequence_prefix" ]] && + (( stack_count == 1 )) && [[ "${path_keys[0]}" == jobs ]] && + [[ -z "$value" ]]; then + register_ci_job "$file_index" "$key" "$line_number" + fi + + if [[ "${ci_file_types[file_index]}" == workflow ]] && + (( stack_count >= 2 )) && [[ "${path_keys[0]}" == jobs ]]; then + find_ci_job "$file_index" "${path_keys[1]}" + job_index=$ci_found_job + if (( job_index >= 0 && stack_count == 2 )); then + record_ci_job_property "$job_index" "$key" "$value" \ + "$line_number" "$comment" "$comment_is_yaml" + fi + if (( job_index >= 0 && stack_count == 4 )) && + [[ "${path_keys[2]}" == defaults && "${path_keys[3]}" == run ]]; then + case "$key" in + shell) + ci_job_default_shell_count[job_index]=$((ci_job_default_shell_count[job_index] + 1)) + ci_job_default_shell_value[job_index]="$value" + ci_job_default_shell_line[job_index]="$line_number" + ;; + working-directory) + ci_job_default_directory_count[job_index]=$((ci_job_default_directory_count[job_index] + 1)) + ci_job_default_directory_value[job_index]="$value" + ci_job_default_directory_line[job_index]="$line_number" + ;; + esac + fi + fi + if [[ "${ci_file_types[file_index]}" == workflow ]] && + (( stack_count == 2 )) && + [[ "${path_keys[0]}" == defaults && "${path_keys[1]}" == run ]]; then + case "$key" in + shell) + ci_file_default_shell_count[file_index]=$((ci_file_default_shell_count[file_index] + 1)) + ci_file_default_shell_value[file_index]="$value" + ci_file_default_shell_line[file_index]="$line_number" + ;; + working-directory) + ci_file_default_directory_count[file_index]=$((ci_file_default_directory_count[file_index] + 1)) + ci_file_default_directory_value[file_index]="$value" + ci_file_default_directory_line[file_index]="$line_number" + ;; + esac + fi + if [[ "${ci_file_types[file_index]}" == action ]] && + (( stack_count == 1 )) && [[ "${path_keys[0]}" == runs ]] && + [[ "$key" == using ]]; then + ci_file_action_using_count[file_index]=$((ci_file_action_using_count[file_index] + 1)) + ci_file_action_using_value[file_index]="$value" + ci_file_action_using_line[file_index]="$line_number" + fi + if (( active_step >= 0 && key_indent == active_step_indent )); then + record_ci_step_property "$active_step" "$key" "$value" \ + "$line_number" "$comment" "$comment_is_yaml" + if [[ "$key" == run ]]; then + ci_step_run_indent[active_step]=$key_indent + fi + fi + + if [[ "$value" =~ $yaml_block_scalar_re ]]; then + scalar_indent=$key_indent + if (( active_step >= 0 && key_indent == active_step_indent )); then + scalar_step=$active_step + scalar_key="$key" + fi + continue + fi + if [[ -z "$value" ]]; then + path_indents[stack_count]=$key_indent + path_keys[stack_count]="$key" + fi + done < "$file" +} + +unquote_yaml_scalar() { + local value="$1" + if (( ${#value} >= 2 )); then + if [[ "${value:0:1}" == '"' && "${value: -1}" == '"' ]]; then + value="${value:1:${#value}-2}" + elif [[ "${value:0:1}" == "'" && "${value: -1}" == "'" ]]; then + value="${value:1:${#value}-2}" + fi + fi + printf '%s' "$value" +} + +validate_local_action_runtime() { + local source_file="$1" + local line_number="$2" + local metadata="$3" + local value="$4" + local relative="${metadata#"$ROOT_DIR/"}" + local index found=-1 count runtime runtime_line + + for ((index = 0; index < ${#ci_files[@]}; index++)); do + if [[ "${ci_files[index]}" == "$relative" ]]; then + found=$index + break + fi + done + if (( found < 0 )); then + fail "${source_file#"$ROOT_DIR/"}:$line_number could not structurally inspect local Action metadata: $value" + return + fi + count=${ci_file_action_using_count[found]} + runtime=${ci_file_action_using_value[found]} + runtime_line=${ci_file_action_using_line[found]} + if (( count != 1 )); then + fail "$relative:$runtime_line local Action must contain exactly one canonical runs.using: composite" + elif [[ "$runtime" != composite ]]; then + fail "$relative:$runtime_line unsupported local Action runtime '$runtime'; A3 permits composite Actions only" + fi +} + +validate_action_uses_ref() { + local file="$1" + local line_number="$2" + local value="$3" + local comment="$4" + local comment_is_yaml="$5" + + value="$(unquote_yaml_scalar "$value")" + if [[ -z "$value" ]]; then + fail "${file#"$ROOT_DIR/"}:$line_number has an empty uses reference" + return + fi + if [[ "$value" == docker://* ]]; then + fail "${file#"$ROOT_DIR/"}:$line_number direct Docker Actions are unsupported in the A3 authored-CI contract: $value" + return + fi + if [[ "$value" == ./* ]]; then + validate_local_uses_ref "$file" "$line_number" "$value" + return + fi + + remote_action_count=$((remote_action_count + 1)) + if [[ ! "$value" =~ ^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+(/[^@[:space:]]+)?@[0-9a-f]{40}$ ]]; then + fail "${file#"$ROOT_DIR/"}:$line_number remote uses reference is not pinned to a lowercase 40-character commit SHA: $value" + fi + comment="$(trim_whitespace "$comment")" + if [[ "$comment_is_yaml" != true || ! "$comment" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + fail "${file#"$ROOT_DIR/"}:$line_number remote uses reference must have a trailing # vMAJOR.MINOR.PATCH annotation: $value" + fi +} + +validate_authored_action_refs() { + local index file + for ((index = 0; index < ${#ci_job_id[@]}; index++)); do + if (( ci_job_uses_count[index] > 1 )); then + file="$ROOT_DIR/${ci_files[${ci_job_file[index]}]}" + fail "${ci_files[${ci_job_file[index]}]}:${ci_job_line[index]} reusable-workflow job contains duplicate uses properties" + elif (( ci_job_uses_count[index] == 1 )); then + file="$ROOT_DIR/${ci_files[${ci_job_file[index]}]}" + validate_action_uses_ref "$file" "${ci_job_uses_line[index]}" \ + "${ci_job_uses_value[index]}" "${ci_job_uses_comment[index]}" \ + "${ci_job_uses_comment_is_yaml[index]}" + fi + done + for ((index = 0; index < ${#ci_step_file[@]}; index++)); do + if (( ci_step_uses_count[index] > 1 )); then + fail "${ci_files[${ci_step_file[index]}]}:${ci_step_line[index]} Action step contains duplicate uses properties" + elif (( ci_step_uses_count[index] == 1 )); then + file="$ROOT_DIR/${ci_files[${ci_step_file[index]}]}" + validate_action_uses_ref "$file" "${ci_step_uses_line[index]}" \ + "${ci_step_uses_value[index]}" "${ci_step_uses_comment[index]}" \ + "${ci_step_uses_comment_is_yaml[index]}" + fi + done +} + +effective_step_directory() { + local step_index="$1" + local file_index=${ci_step_file[step_index]} + local job_index=${ci_step_job[step_index]} + + effective_directory="" + if (( ci_file_default_directory_count[file_index] > 1 )); then + fail "${ci_files[file_index]}:${ci_file_default_directory_line[file_index]} workflow defaults.run contains duplicate working-directory properties" + fi + if (( ci_file_default_directory_count[file_index] == 1 )); then + effective_directory=${ci_file_default_directory_value[file_index]} + fi + if (( job_index >= 0 )); then + if (( ci_job_default_directory_count[job_index] > 1 )); then + fail "${ci_files[file_index]}:${ci_job_default_directory_line[job_index]} job defaults.run contains duplicate working-directory properties" + fi + if (( ci_job_default_directory_count[job_index] == 1 )); then + effective_directory=${ci_job_default_directory_value[job_index]} + fi + fi + if (( ci_step_directory_count[step_index] > 1 )); then + fail "${ci_files[file_index]}:${ci_step_directory_line[step_index]} step contains duplicate working-directory properties" + fi + if (( ci_step_directory_count[step_index] == 1 )); then + effective_directory=${ci_step_directory_value[step_index]} + fi + effective_directory="$(unquote_yaml_scalar "$effective_directory")" +} + +scan_ci_step_run() { + local step_index="$1" + local relative="${ci_files[${ci_step_file[step_index]}]}" + local directory="$2" + local value index line raw indent body_indent=-1 base_line=0 script="" shell="" + local previous="" blanks=0 have_previous=false + local indent_indicator_re='^[|>][+-]?([1-9])' + local -a body_lines=() + local file_index=${ci_step_file[step_index]} + local job_index=${ci_step_job[step_index]} + + if (( ci_step_run_count[step_index] != 1 )); then + return + fi + value=${ci_step_run_value[step_index]} + if (( ci_file_default_shell_count[file_index] == 1 )); then + shell=${ci_file_default_shell_value[file_index]} + fi + if (( job_index >= 0 )) && + (( ci_job_default_shell_count[job_index] == 1 )); then + shell=${ci_job_default_shell_value[job_index]} + fi + if (( ci_step_shell_count[step_index] == 1 )); then + shell=${ci_step_shell_value[step_index]} + fi + shell="$(unquote_yaml_scalar "$shell")" + if [[ "$shell" == pwsh || "$shell" == powershell || + "$shell" == pwsh\ * || "$shell" == powershell\ * ]]; then + # PowerShell policy remains on its existing, separate path. + if [[ "$value" =~ $yaml_block_scalar_re ]]; then + for ((index = 0; index < ${#ci_run_step[@]}; index++)); do + if [[ "${ci_run_step[index]}" == "$step_index" ]]; then + consume_shell_physical_line "$relative" "${ci_run_line[index]}" \ + "${ci_run_text[index]}" "$directory" true + fi + done + else + consume_shell_physical_line "$relative" "${ci_step_run_line[step_index]}" \ + "$(unquote_yaml_scalar "$value")" "$directory" true + fi + flush_shell_logical_command "$relative" "$directory" true + return + fi + reset_shell_logical_command + if [[ "$value" =~ $yaml_block_scalar_re ]]; then + if [[ "$value" =~ $indent_indicator_re ]]; then + body_indent=$((ci_step_run_indent[step_index] + BASH_REMATCH[1])) + fi + for ((index = 0; index < ${#ci_run_step[@]}; index++)); do + if [[ "${ci_run_step[index]}" != "$step_index" ]]; then + continue + fi + raw=${ci_run_raw[index]} + if (( base_line == 0 )); then + base_line=${ci_run_line[index]} + fi + if [[ -n "$(trim_whitespace "$raw")" && body_indent -lt 0 ]]; then + indent=${raw%%[![:space:]]*} + body_indent=${#indent} + fi + if (( body_indent >= 0 )); then + if [[ -n "$(trim_whitespace "$raw")" ]]; then + indent=${raw%%[![:space:]]*} + if (( ${#indent} < body_indent )); then + fail "$relative:${ci_run_line[index]} malformed run block indentation" + return + fi + fi + if [[ -n "$(trim_whitespace "$raw")" ]]; then + line=${raw:body_indent} + else + line="" + fi + else + line="" + fi + body_lines[${#body_lines[@]}]="$line" + done + if [[ "$value" == '>'* ]]; then + for line in "${body_lines[@]}"; do + if [[ -z "$line" ]]; then + blanks=$((blanks + 1)) + continue + fi + if [[ "$have_previous" == true ]]; then + if (( blanks > 0 )); then + while (( blanks > 0 )); do + script+=$'\n' + blanks=$((blanks - 1)) + done + elif [[ "$previous" == [[:space:]]* || + "$line" == [[:space:]]* ]]; then + script+=$'\n' + else + script+=" " + fi + else + while (( blanks > 0 )); do + script+=$'\n' + blanks=$((blanks - 1)) + done + fi + script+="$line" + previous="$line" + have_previous=true + done + script+=$'\n' + else + for line in "${body_lines[@]}"; do + script+="$line"$'\n' + done + fi + else + base_line=${ci_step_run_line[step_index]} + script="$(unquote_yaml_scalar "$value")"$'\n' + fi + if (( base_line == 0 )); then + base_line=${ci_step_run_line[step_index]} + fi + inspect_syntax_aware_shell_source "$relative" "$base_line" \ + "$directory" true <<< "$script" +} + +inspect_ci_shell_template() { + local relative="$1" + local line_number="$2" + local shell_value="$3" + local directory="$4" + local selector + + selector="$(unquote_yaml_scalar "$shell_value")" + if [[ "$selector" == bash || "$selector" == sh ]]; then + shell_value="$selector {0}" + fi + inspect_shell_logical_command "$relative" "$line_number" \ + "$shell_value" "$directory" true +} + +apply_authored_execution_policy() { + local index file_index job_index relative directory shell + + for ((file_index = 0; file_index < ${#ci_files[@]}; file_index++)); do + if (( ci_file_default_shell_count[file_index] > 1 )); then + fail "${ci_files[file_index]}:${ci_file_default_shell_line[file_index]} workflow defaults.run contains duplicate shell properties" + elif (( ci_file_default_shell_count[file_index] == 1 )); then + shell=${ci_file_default_shell_value[file_index]} + directory="" + if (( ci_file_default_directory_count[file_index] == 1 )); then + directory=${ci_file_default_directory_value[file_index]} + fi + if [[ "$shell" =~ $yaml_block_scalar_re ]]; then + fail "${ci_files[file_index]}:${ci_file_default_shell_line[file_index]} executable shell must use a canonical inline scalar; block/folded shell scalars are outside the bounded authored-CI execution contract" + elif [[ "$shell" == *'${{'* ]]; then + fail "${ci_files[file_index]}:${ci_file_default_shell_line[file_index]} dynamic workflow defaults.run.shell is outside the bounded execution contract" + else + inspect_ci_shell_template "${ci_files[file_index]}" \ + "${ci_file_default_shell_line[file_index]}" "$shell" "$directory" true + fi + fi + done + for ((job_index = 0; job_index < ${#ci_job_id[@]}; job_index++)); do + file_index=${ci_job_file[job_index]} + if (( ci_job_default_shell_count[job_index] > 1 )); then + fail "${ci_files[file_index]}:${ci_job_default_shell_line[job_index]} job defaults.run contains duplicate shell properties" + elif (( ci_job_default_shell_count[job_index] == 1 )); then + shell=${ci_job_default_shell_value[job_index]} + directory="" + if (( ci_file_default_directory_count[file_index] == 1 )); then + directory=${ci_file_default_directory_value[file_index]} + fi + if (( ci_job_default_directory_count[job_index] == 1 )); then + directory=${ci_job_default_directory_value[job_index]} + fi + if [[ "$shell" =~ $yaml_block_scalar_re ]]; then + fail "${ci_files[file_index]}:${ci_job_default_shell_line[job_index]} executable shell must use a canonical inline scalar; block/folded shell scalars are outside the bounded authored-CI execution contract" + elif [[ "$shell" == *'${{'* ]]; then + fail "${ci_files[file_index]}:${ci_job_default_shell_line[job_index]} dynamic job defaults.run.shell is outside the bounded execution contract" + else + inspect_ci_shell_template "${ci_files[file_index]}" \ + "${ci_job_default_shell_line[job_index]}" "$shell" "$directory" true + fi + fi + done + for ((index = 0; index < ${#ci_step_file[@]}; index++)); do + file_index=${ci_step_file[index]} + relative=${ci_files[file_index]} + effective_step_directory "$index" + directory=$effective_directory + if (( ci_step_shell_count[index] > 1 )); then + fail "$relative:${ci_step_shell_line[index]} step contains duplicate shell properties" + elif (( ci_step_shell_count[index] == 1 )); then + shell=${ci_step_shell_value[index]} + if [[ "$shell" =~ $yaml_block_scalar_re ]]; then + fail "$relative:${ci_step_shell_line[index]} executable shell must use a canonical inline scalar; block/folded shell scalars are outside the bounded authored-CI execution contract" + elif [[ "$shell" == *'${{'* ]]; then + fail "$relative:${ci_step_shell_line[index]} dynamic step shell is outside the bounded execution contract" + else + inspect_ci_shell_template "$relative" \ + "${ci_step_shell_line[index]}" "$shell" "$directory" true + fi + fi + if (( ci_step_run_count[index] > 1 )); then + fail "$relative:${ci_step_run_line[index]} step contains duplicate run properties" + else + scan_ci_step_run "$index" "$directory" + fi + done +} + +tinygo_step_body_is_exact() { + local step_index="$1" + local index syntax body=() + for ((index = 0; index < ${#ci_run_step[@]}; index++)); do + if [[ "${ci_run_step[index]}" != "$step_index" ]]; then + continue + fi + syntax="$(trim_whitespace "${ci_run_text[index]}")" + if [[ -n "$syntax" && "$syntax" != \#* ]]; then + body[${#body[@]}]="$syntax" + fi + done + (( ${#body[@]} == 5 )) || return 1 + [[ "${body[0]}" == "$tinygo_cleanup_command" ]] || return 1 + [[ "${body[1]}" == "$tinygo_download_command" ]] || return 1 + [[ "${body[2]}" == "$tinygo_stage_command" ]] || return 1 + [[ "${body[3]}" == "$tinygo_verify_command" ]] || return 1 + [[ "${body[4]}" == "$tinygo_install_command" ]] +} + +validate_tinygo_job_controls() { + local job_index="$1" + local relative="${ci_files[${ci_job_file[job_index]}]}" + local id="${ci_job_id[job_index]}" + if (( ci_job_if_line[job_index] > 0 )); then + fail "$relative:${ci_job_if_line[job_index]} protected TinyGo job $id must be unconditional and blocking: contains job-level if" + fi + if (( ci_job_continue_line[job_index] > 0 )); then + fail "$relative:${ci_job_continue_line[job_index]} protected TinyGo job $id must be unconditional and blocking: contains job-level continue-on-error" + fi + if (( ci_job_needs_line[job_index] > 0 )); then + fail "$relative:${ci_job_needs_line[job_index]} protected TinyGo job $id must be unconditional and blocking: contains job-level needs" + fi + if (( ci_job_runs_on_count[job_index] != 1 )); then + fail "$relative:${ci_job_line[job_index]} protected TinyGo job $id must contain exactly one canonical runs-on: ubuntu-latest" + elif [[ "${ci_job_runs_on_value[job_index]}" != ubuntu-latest ]]; then + fail "$relative:${ci_job_runs_on_line[job_index]} protected TinyGo job $id must use canonical runs-on: ubuntu-latest" + fi + if (( ci_job_container_line[job_index] > 0 )); then + fail "$relative:${ci_job_container_line[job_index]} protected TinyGo job $id must run directly on the trusted hosted runner without a job container" + fi +} + +evaluate_tinygo_workflow() { + local file_index="$1" + local relative="${ci_files[file_index]}" + local job_index step_index pending=false job_sequences valid + tinygo_sequence_count=0 + for ((job_index = 0; job_index < ${#ci_job_id[@]}; job_index++)); do + if [[ "${ci_job_file[job_index]}" != "$file_index" ]]; then + continue + fi + pending=false + job_sequences=0 + for ((step_index = 0; step_index < ${#ci_step_file[@]}; step_index++)); do + if [[ "${ci_step_job[step_index]}" != "$job_index" ]]; then + continue + fi + if [[ "${ci_step_install_name[step_index]}" == true && + "${ci_step_verify_name[step_index]}" == true ]]; then + fail "$relative:${ci_step_line[step_index]} TinyGo step has conflicting duplicate names" + pending=false + continue + fi + if [[ "${ci_step_install_name[step_index]}" == true ]]; then + if [[ "$pending" == true ]]; then + fail "$relative:${ci_step_line[step_index]} Verify TinyGo must immediately follow Install TinyGo" + fi + valid=true + if (( ci_step_property_count[step_index] != 3 || + ci_step_name_count[step_index] != 1 || + ci_step_shell_count[step_index] != 1 || + ci_step_run_count[step_index] != 1 )) || + [[ "${ci_step_shell_value[step_index]}" != "$tinygo_shell_template" || + "${ci_step_run_value[step_index]}" != '|' ]] || + ! tinygo_step_body_is_exact "$step_index"; then + valid=false + fi + if [[ "$valid" != true ]]; then + fail "$relative:${ci_step_line[step_index]} Install TinyGo must contain only the exact sanitized shell and protected run transaction" + pending=false + else + pending=true + fi + elif [[ "${ci_step_verify_name[step_index]}" == true ]]; then + valid=true + if (( ci_step_property_count[step_index] != 3 || + ci_step_name_count[step_index] != 1 || + ci_step_shell_count[step_index] != 1 || + ci_step_run_count[step_index] != 1 )) || + [[ "${ci_step_shell_value[step_index]}" != "$tinygo_verify_shell_template" || + "${ci_step_run_value[step_index]}" != "$tinygo_version_command" ]]; then + valid=false + fi + if [[ "$valid" != true ]]; then + fail "$relative:${ci_step_line[step_index]} Verify TinyGo must contain only the exact privileged shell and package-owned version command" + pending=false + elif [[ "$pending" != true ]]; then + fail "$relative:${ci_step_line[step_index]} Verify TinyGo must immediately follow Install TinyGo" + else + job_sequences=$((job_sequences + 1)) + tinygo_sequence_count=$((tinygo_sequence_count + 1)) + pending=false + fi + elif [[ "$pending" == true ]]; then + fail "$relative:${ci_step_line[step_index]} Verify TinyGo must immediately follow Install TinyGo" + pending=false + fi + done + if [[ "$pending" == true ]]; then + fail "$relative:${ci_job_line[job_index]} protected TinyGo job ${ci_job_id[job_index]} must keep Verify TinyGo immediately after Install TinyGo" + fi + if (( job_sequences > 0 )); then + validate_tinygo_job_controls "$job_index" + fi + done +} + +authored_ci_files=() +while IFS= read -r -d '' file; do + authored_ci_files[${#authored_ci_files[@]}]="$file" + parse_authored_ci_file "$file" +done < <(find "${scan_roots[@]}" -type f \( -name '*.yml' -o -name '*.yaml' \) -print0) + +validate_authored_action_refs +apply_authored_execution_policy + +direct_tinygo_downloads=0 +for file in "${authored_ci_files[@]}"; do + download_count="$(count_active_occurrences "$TINYGO_RELEASE_NAMESPACE" "$file")" + direct_tinygo_downloads=$((direct_tinygo_downloads + download_count)) + if (( download_count > 0 )); then + relative="${file#"$ROOT_DIR/"}" + expected=false + for candidate in "${EXPECTED_TINYGO_WORKFLOWS[@]}"; do + if [[ "$relative" == "$candidate" ]]; then + expected=true + fi + done + if [[ "$expected" == false ]]; then + fail "unexpected direct TinyGo download in $relative" + elif (( download_count != 1 )); then + fail "$relative must contain exactly one direct TinyGo release download, found $download_count" + fi + fi +done +if (( direct_tinygo_downloads != ${#EXPECTED_TINYGO_WORKFLOWS[@]} )); then + fail "expected ${#EXPECTED_TINYGO_WORKFLOWS[@]} direct TinyGo release downloads, found $direct_tinygo_downloads" +fi + +accepted_tinygo_install_sequences=0 +for relative in "${EXPECTED_TINYGO_WORKFLOWS[@]}"; do + file="$ROOT_DIR/$relative" + if [[ ! -f "$file" ]]; then + fail "required TinyGo workflow is missing: $relative" + continue + fi + if [[ "$(count_active_occurrences "$tinygo_download_command" "$file")" != 1 ]]; then + fail "$relative must contain exactly one accepted TinyGo download command" + fi + if [[ "$(count_active_occurrences "$tinygo_cleanup_command" "$file")" != 1 ]]; then + fail "$relative must contain exactly one accepted TinyGo cleanup command" + fi + if [[ "$(count_active_occurrences "$tinygo_stage_command" "$file")" != 1 ]]; then + fail "$relative must contain exactly one accepted TinyGo staging command" + fi + if [[ "$(count_active_occurrences "$tinygo_verify_command" "$file")" != 1 ]]; then + fail "$relative must contain exactly one accepted TinyGo verification command" + fi + if [[ "$(count_active_occurrences "$tinygo_install_command" "$file")" != 1 ]]; then + fail "$relative must contain exactly one accepted TinyGo installation command" + fi + if [[ "$(count_active_occurrences "$tinygo_version_command" "$file")" != 1 ]]; then + fail "$relative must contain exactly one accepted TinyGo version-report command" + fi + file_index=-1 + for ((index = 0; index < ${#ci_files[@]}; index++)); do + if [[ "${ci_files[index]}" == "$relative" ]]; then + file_index=$index + break + fi + done + if (( file_index < 0 )); then + fail "$relative was not structurally inspected" + continue + fi + evaluate_tinygo_workflow "$file_index" + if (( tinygo_sequence_count != 1 )); then + fail "$relative must contain exactly one sanitized TinyGo staging, verification, installation, and cleanup sequence immediately followed by an ordinary TinyGo version smoke" + else + accepted_tinygo_install_sequences=$((accepted_tinygo_install_sequences + 1)) + fi +done + +if (( failures != 0 )); then + exit 1 +fi + +printf 'ci supply-chain check: ok (%d remote Action refs, %d verified TinyGo downloads, %d protected install sequences, %d loopback probes)\n' \ + "$remote_action_count" "$direct_tinygo_downloads" \ + "$accepted_tinygo_install_sequences" "$loopback_download_count" diff --git a/scripts/custom-index-rewrite-browser-smoke.mjs b/scripts/custom-index-rewrite-browser-smoke.mjs index eefb6c4..6e2e635 100644 --- a/scripts/custom-index-rewrite-browser-smoke.mjs +++ b/scripts/custom-index-rewrite-browser-smoke.mjs @@ -25,6 +25,15 @@ if (compiler !== "go" && compiler !== "tinygo") { throw new Error(`HARNESS FAILURE: unsupported compiler ${JSON.stringify(compiler)}`); } +// Upper bounds, not expectations: a stuck operation must fail with its stage +// name instead of staying silent until the CI job limit. +const commandTimeoutMS = 5 * 60 * 1000; +const commandKillGraceMS = 5 * 1000; +const cdpCallTimeoutMS = 30 * 1000; +const fetchTimeoutMS = 10 * 1000; +const startedAt = Date.now(); +let currentStage = "startup"; + const authoredSentinels = [ "", '', @@ -109,16 +118,23 @@ try { profile = await mkdtemp(join(tmpdir(), "goframe-custom-index-chrome-")); const scenarios = []; for (const mode of ["marker", "legacy"]) { + stage(`prepare scenario ${mode}`); scenarios.push(await prepareScenario(mode)); } + stage("prepare scenario generated-url"); const generatedURLScenario = await prepareGeneratedURLScenario(); + stage("prepare scenario base-target"); const targetOnlyFixture = await prepareVariantFixture("base-target", "base-target.html"); const targetOnlyScenario = await prepareScenario("base-target", targetOnlyFixture, "marker"); + stage("negative active-base managed package"); const negativeManaged = await verifyActiveBaseManagedFailure(targetOnlyScenario); + stage("negative managed-structure packages"); const negativeManagedStructure = await verifyManagedStructureFailures(targetOnlyScenario); scenarios.push(targetOnlyScenario); + stage("prepare scenario base-authored"); const authoredBase = await prepareAuthoredBaseScenario(); + stage("start Chrome"); browser = await startBrowser(chrome, [ "--headless", "--no-sandbox", @@ -149,17 +165,26 @@ try { await client.call("Runtime.enable"); await client.call("Page.enable"); await client.call("Network.enable"); + stage("oracle attribute"); const attributeOracle = await runAttributeOracle(); + stage("oracle managed span tree"); const managedSpanOracle = await runManagedSpanTreeOracle(); + stage("oracle managed-first semantic"); const semanticOracle = await runManagedFirstSemanticOracle(); + stage("oracle base resolution"); const baseOracle = await runBaseResolutionOracle(); + stage("oracle generated JavaScript source"); const javascriptSourceOracle = runGeneratedJavaScriptSourceOracle(generatedURLScenario); for (const scenario of scenarios) { + stage(`browser scenario ${scenario.mode}`); scenario.browser = await runBrowserScenario(scenario); } + stage("browser scenario generated-url"); generatedURLScenario.browser = await runGeneratedURLBrowserScenario(generatedURLScenario); + stage("browser scenario base-authored"); authoredBase.browser = await runAuthoredBaseBrowserScenario(authoredBase); + stage("report"); const stableScenarios = scenarios.map((scenario) => ({ mode: scenario.mode, @@ -1401,7 +1426,7 @@ async function runBaseResolutionOracle() { }); } } finally { - await new Promise((resolveClose) => oracleServer.close(resolveClose)); + await closeOracleServer(oracleServer); } return results; @@ -1978,6 +2003,7 @@ async function runManagedFirstSemanticOracle() { const schedulesBeforeUnknown = orderedRuntimeSchedules.length; const unknownResponse = await fetch( `${origin}/ordered-runtime/unknown.js?delay=999999`, + { signal: AbortSignal.timeout(fetchTimeoutMS) }, ); assert(unknownResponse.status === 404, "unknown ordered runtime resource was not rejected"); await unknownResponse.text(); @@ -2034,7 +2060,7 @@ async function runManagedFirstSemanticOracle() { }, }); } finally { - await new Promise((resolveClose) => oracleServer.close(resolveClose)); + await closeOracleServer(oracleServer); } return results; @@ -2069,6 +2095,15 @@ async function waitForOracleDocument(expectedURL) { throw new Error(`HARNESS FAILURE: semantic oracle did not load ${expectedURL}`); } +// Chrome can hold speculative connections that never carry a request. +// server.close() alone waits for Chrome to drop them, which may never happen. +function closeOracleServer(oracleServer) { + return new Promise((resolveClose) => { + oracleServer.close(resolveClose); + oracleServer.closeAllConnections(); + }); +} + function encodeDoubleQuotedAttribute(value) { return value.replaceAll("&", "&").replaceAll('"', """); } @@ -2168,7 +2203,10 @@ async function waitForServer(port, child) { throw new Error(`HARNESS FAILURE: goxc serve exited before readiness\n${serverError}`); } try { - const response = await fetch(`http://127.0.0.1:${port}/`, { cache: "no-store" }); + const response = await fetch(`http://127.0.0.1:${port}/`, { + cache: "no-store", + signal: AbortSignal.timeout(fetchTimeoutMS), + }); if (response.ok) return; } catch (error) { lastError = error; @@ -2196,7 +2234,9 @@ async function waitForPage(port) { throw new Error(`HARNESS FAILURE: Chrome exited before CDP was ready\n${browserError}`); } try { - const response = await fetch(`http://127.0.0.1:${port}/json`); + const response = await fetch(`http://127.0.0.1:${port}/json`, { + signal: AbortSignal.timeout(fetchTimeoutMS), + }); const targets = await response.json(); const page = targets.find((entry) => entry.type === "page" && entry.webSocketDebuggerUrl); if (page) return page; @@ -2211,8 +2251,18 @@ async function waitForPage(port) { async function connect(url) { const socket = new WebSocket(url); await new Promise((resolveOpen, reject) => { - socket.addEventListener("open", resolveOpen, { once: true }); - socket.addEventListener("error", reject, { once: true }); + const timer = setTimeout(() => { + reject(new Error(`HARNESS FAILURE: CDP socket did not open within ${cdpCallTimeoutMS}ms`)); + socket.close(); + }, cdpCallTimeoutMS); + socket.addEventListener("open", () => { + clearTimeout(timer); + resolveOpen(); + }, { once: true }); + socket.addEventListener("error", (event) => { + clearTimeout(timer); + reject(event); + }, { once: true }); }); let nextID = 1; let closedError = null; @@ -2243,12 +2293,28 @@ async function connect(url) { if (closedError) return Promise.reject(closedError); return new Promise((resolveCall, reject) => { const id = nextID++; - pending.set(id, { resolve: resolveCall, reject }); + const stageName = currentStage; + const timer = setTimeout(() => { + pending.delete(id); + reject(new Error( + `HARNESS FAILURE: CDP ${method} timed out after ${cdpCallTimeoutMS}ms during stage ${JSON.stringify(stageName)}`, + )); + }, cdpCallTimeoutMS); + pending.set(id, { + resolve(result) { + clearTimeout(timer); + resolveCall(result); + }, + reject(error) { + clearTimeout(timer); + reject(error); + }, + }); try { socket.send(JSON.stringify({ id, method, params })); } catch (error) { + pending.get(id)?.reject(error); pending.delete(id); - reject(error); terminate({ type: "send" }); } }); @@ -2285,25 +2351,67 @@ async function runCommand(command, args, environment) { function runCommandResult(command, args, environment) { return new Promise((resolveCommand, reject) => { + // The child leads its own process group so a timeout also stops the + // compiler processes it started. const child = spawn(command, args, { cwd: rootDir, stdio: ["ignore", "pipe", "pipe"], env: environment, + detached: true, }); + const stageName = currentStage; let output = ""; + let timedOut = false; + let killTimer = null; + const timer = setTimeout(() => { + timedOut = true; + signalProcessGroup(child, "SIGTERM"); + killTimer = setTimeout(() => signalProcessGroup(child, "SIGKILL"), commandKillGraceMS); + }, commandTimeoutMS); child.stdout.on("data", (chunk) => { output += chunk; }); child.stderr.on("data", (chunk) => { output += chunk; }); - child.once("error", reject); + child.once("error", (error) => { + clearTimeout(timer); + clearTimeout(killTimer); + reject(error); + }); child.once("exit", (code, signal) => { + clearTimeout(timer); + clearTimeout(killTimer); + if (timedOut) { + // The leader is gone; make sure no group member outlives it. + signalProcessGroup(child, "SIGKILL"); + reject(new Error( + `HARNESS FAILURE: ${command} ${args.join(" ")} timed out after ${commandTimeoutMS}ms during stage ${JSON.stringify(stageName)} (${signal ?? code})\n${output}`, + )); + return; + } resolveCommand({ code, signal, output }); }); }); } +function signalProcessGroup(child, signal) { + if (child.pid === undefined) return; + try { + process.kill(-child.pid, signal); + } catch (error) { + // ESRCH: every member of the group has already exited. + if (error.code === "ESRCH") return; + child.kill(signal); + } +} + +function stage(name) { + currentStage = name; + const elapsed = ((Date.now() - startedAt) / 1000).toFixed(1); + console.error(`custom index rewrite (${compiler}) [+${elapsed}s] stage: ${name}`); +} + async function stopProcess(child) { if (!child || child.exitCode !== null || child.signalCode !== null) return; const exited = new Promise((resolveExit) => child.once("exit", resolveExit));