diff --git a/.github/workflows/reusable-homebrew-bottle-maintenance.yml b/.github/workflows/reusable-homebrew-bottle-maintenance.yml index d43560536b..527010308c 100644 --- a/.github/workflows/reusable-homebrew-bottle-maintenance.yml +++ b/.github/workflows/reusable-homebrew-bottle-maintenance.yml @@ -82,6 +82,7 @@ jobs: kandelo-repository: Automattic/kandelo kandelo-ref: main tap-repository: Automattic/kandelo-homebrew + tap-name: Automattic/kandelo-homebrew tap-ref: main formulae: ${{ inputs.formulae }} arches: ${{ inputs.arches }} diff --git a/.github/workflows/reusable-homebrew-bottle-publish.yml b/.github/workflows/reusable-homebrew-bottle-publish.yml index 380fb475b9..79c1e501b0 100644 --- a/.github/workflows/reusable-homebrew-bottle-publish.yml +++ b/.github/workflows/reusable-homebrew-bottle-publish.yml @@ -12,6 +12,9 @@ on: tap-repository: type: string default: Automattic/kandelo-homebrew + tap-name: + type: string + default: Automattic/kandelo-homebrew tap-ref: type: string default: main @@ -61,13 +64,14 @@ jobs: DRY_RUN: ${{ inputs.dry-run }} KANDELO_REPOSITORY: ${{ inputs.kandelo-repository }} KANDELO_REF: ${{ inputs.kandelo-ref }} + TAP_NAME: ${{ inputs.tap-name }} TAP_REPOSITORY: ${{ inputs.tap-repository }} TAP_REF: ${{ inputs.tap-ref }} BOTTLE_ROOT_URL: ${{ inputs.bottle-root-url }} run: | set -euo pipefail - [ "$CALLER_REPOSITORY" = "Automattic/kandelo-homebrew" ] || { - echo "::error::publication requires the protected first-party tap caller"; exit 2; + [ "$CALLER_REPOSITORY" = "$TAP_REPOSITORY" ] || { + echo "::error::the caller repository must own the target tap repository"; exit 2; } [ "$CALLER_REF" = "refs/heads/main" ] || { echo "::error::publication requires the tap main branch"; exit 2; @@ -78,29 +82,48 @@ jobs: [ -z "$BOTTLE_ROOT_URL" ] || { echo "::error::the publisher derives the bottle root from the tap repository"; exit 2; } - if [ "$DRY_RUN" = "true" ]; then - [ "$CALLER_WORKFLOW_REF" = "Automattic/kandelo-homebrew/.github/workflows/dry-run-bottles.yml@refs/heads/main" ] || { - echo "::error::dry-run publication requires the reviewed tap dry-run workflow"; exit 2; - } - exit 0 - fi - case "$CALLER_WORKFLOW_REF" in - Automattic/kandelo-homebrew/.github/workflows/publish-bottles.yml@refs/heads/main|\ - Automattic/kandelo-homebrew/.github/workflows/maintain-bottles.yml@refs/heads/main) ;; - *) echo "::error::publication requires a reviewed tap write workflow"; exit 2 ;; - esac [ "$KANDELO_REPOSITORY" = "Automattic/kandelo" ] || { echo "::error::publication requires Automattic/kandelo"; exit 2; } [ "$KANDELO_REF" = "main" ] || { echo "::error::publication requires Kandelo main"; exit 2; } - [ "$TAP_REPOSITORY" = "Automattic/kandelo-homebrew" ] || { - echo "::error::publication requires Automattic/kandelo-homebrew"; exit 2; - } [ "$TAP_REF" = "main" ] || { echo "::error::publication requires tap main"; exit 2; } + case "$TAP_REPOSITORY" in + Automattic/kandelo-homebrew) + [ "$TAP_NAME" = "Automattic/kandelo-homebrew" ] || { + echo "::error::the first-party tap name must remain Automattic/kandelo-homebrew"; exit 2; + } + ;; + *) + [[ "$TAP_REPOSITORY" =~ ^[A-Za-z0-9_.-]+/homebrew-[A-Za-z0-9_.-]+$ ]] || { + echo "::error::third-party tap repositories must use owner/homebrew-name"; exit 2; + } + tap_owner="${TAP_REPOSITORY%%/*}" + tap_short_name="${TAP_REPOSITORY#*/homebrew-}" + derived_tap_name="${tap_owner}/${tap_short_name}" + normalized_derived_tap_name="$(printf '%s' "$derived_tap_name" | tr '[:upper:]' '[:lower:]')" + [ "$normalized_derived_tap_name" != "automattic/kandelo-homebrew" ] || { + echo "::error::the protected first-party tap name cannot be derived from another repository"; exit 2; + } + [ "$TAP_NAME" = "${tap_owner}/${tap_short_name}" ] || { + echo "::error::tap-name must be owner/name derived from the conventional repository"; exit 2; + } + ;; + esac + if [ "$DRY_RUN" = "true" ]; then + [ "$CALLER_WORKFLOW_REF" = "$CALLER_REPOSITORY/.github/workflows/dry-run-bottles.yml@refs/heads/main" ] || { + echo "::error::dry-run publication requires the reviewed tap dry-run workflow"; exit 2; + } + exit 0 + fi + case "$CALLER_WORKFLOW_REF" in + "$CALLER_REPOSITORY/.github/workflows/publish-bottles.yml@refs/heads/main"|\ + "$CALLER_REPOSITORY/.github/workflows/maintain-bottles.yml@refs/heads/main") ;; + *) echo "::error::publication requires a reviewed tap write workflow"; exit 2 ;; + esac - name: Checkout Kandelo workflow source uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 @@ -432,6 +455,7 @@ jobs: systemd_run_bin="/usr/bin/systemd-run" systemctl_bin="/usr/bin/systemctl" getent_bin="/usr/bin/getent" + findmnt_bin="/usr/bin/findmnt" pgrep_bin="/usr/bin/pgrep" pkill_bin="/usr/bin/pkill" useradd_bin="/usr/sbin/useradd" @@ -443,7 +467,7 @@ jobs: [ $((8#$sudo_mode & 0022)) -ne 0 ]; then echo "::error::runner sudo boundary is unavailable"; exit 2 fi - for protected_bin in "$systemd_run_bin" "$systemctl_bin" "$getent_bin" "$pgrep_bin" \ + for protected_bin in "$systemd_run_bin" "$systemctl_bin" "$getent_bin" "$findmnt_bin" "$pgrep_bin" \ "$useradd_bin" "$userdel_bin"; do protected_mode="$(stat -c '%a' "$protected_bin" 2>/dev/null || true)" if [ ! -f "$protected_bin" ] || [ -L "$protected_bin" ] || \ @@ -549,6 +573,7 @@ jobs: KANDELO_HOMEBREW_ARCH: ${{ matrix.arch }} KANDELO_HOMEBREW_BOTTLE_ROOT_URL: ${{ needs.plan.outputs.bottle-root-prefix }} KANDELO_HOMEBREW_TAP_REPOSITORY: ${{ inputs.tap-repository }} + KANDELO_HOMEBREW_TAP_NAME: ${{ inputs.tap-name }} run: | set -euo pipefail for secret_name in GH_TOKEN GITHUB_TOKEN HOMEBREW_GITHUB_API_TOKEN \ @@ -593,6 +618,7 @@ jobs: bash scripts/dev-shell.sh bash scripts/homebrew-bottle-build.sh \ --tap-root "$GITHUB_WORKSPACE/tap" \ --tap-repository "$KANDELO_HOMEBREW_TAP_REPOSITORY" \ + --tap-name "$KANDELO_HOMEBREW_TAP_NAME" \ --formula "$KANDELO_HOMEBREW_FORMULA" \ --arch "$KANDELO_HOMEBREW_ARCH" \ --out "$bottle_out" \ @@ -681,6 +707,7 @@ jobs: FORMULA: ${{ matrix.formula }} HOMEBREW_BREW_COMMIT: 34c40c18ffa2029b611b61c73273e32c003d0842 KANDELO_SHA: ${{ needs.plan.outputs.kandelo-sha }} + TAP_NAME: ${{ inputs.tap-name }} TAP_REPOSITORY: ${{ inputs.tap-repository }} TAP_SHA: ${{ needs.plan.outputs.tap-sha }} run: | @@ -702,6 +729,7 @@ jobs: bash scripts/dev-shell.sh bash scripts/homebrew-validate-formula-source-closure.sh \ --tap-root "$GITHUB_WORKSPACE/tap" \ --tap-repository "$TAP_REPOSITORY" \ + --tap-name "$TAP_NAME" \ --formula "$FORMULA" \ --base-ref "$TAP_SHA" \ --reviewed-tap-root "$GITHUB_WORKSPACE/tap-reviewed" @@ -716,6 +744,7 @@ jobs: KANDELO_HOMEBREW_KANDELO_COMMIT: ${{ needs.plan.outputs.kandelo-sha }} KANDELO_HOMEBREW_TAP_COMMIT: ${{ needs.plan.outputs.tap-sha }} KANDELO_HOMEBREW_TAP_REPOSITORY: ${{ inputs.tap-repository }} + KANDELO_HOMEBREW_TAP_NAME: ${{ inputs.tap-name }} run: | set -euo pipefail cd "$GITHUB_WORKSPACE/kandelo-postbuild" @@ -726,6 +755,7 @@ jobs: --arch "$KANDELO_HOMEBREW_ARCH" \ --release-tag "$KANDELO_HOMEBREW_RELEASE_TAG" \ --tap-repository "$KANDELO_HOMEBREW_TAP_REPOSITORY" \ + --tap-name "$KANDELO_HOMEBREW_TAP_NAME" \ --tap-commit "$KANDELO_HOMEBREW_TAP_COMMIT" \ --kandelo-commit "$KANDELO_HOMEBREW_KANDELO_COMMIT" \ --bottle-root-url "$KANDELO_HOMEBREW_BOTTLE_ROOT_URL" \ @@ -760,6 +790,7 @@ jobs: KANDELO_HOMEBREW_RELEASE_TAG: ${{ needs.plan.outputs.release-tag }} KANDELO_HOMEBREW_TAP_COMMIT: ${{ needs.plan.outputs.tap-sha }} KANDELO_HOMEBREW_TAP_REPOSITORY: ${{ inputs.tap-repository }} + KANDELO_HOMEBREW_TAP_NAME: ${{ inputs.tap-name }} run: | set -euo pipefail for secret_name in GH_TOKEN GITHUB_TOKEN HOMEBREW_GITHUB_API_TOKEN \ @@ -778,6 +809,7 @@ jobs: --arch "$KANDELO_HOMEBREW_ARCH" --release-tag "$KANDELO_HOMEBREW_RELEASE_TAG" --tap-repository "$KANDELO_HOMEBREW_TAP_REPOSITORY" + --tap-name "$KANDELO_HOMEBREW_TAP_NAME" --tap-commit "$KANDELO_HOMEBREW_TAP_COMMIT" --kandelo-commit "$KANDELO_HOMEBREW_KANDELO_COMMIT" --bottle-root-url "$KANDELO_HOMEBREW_BOTTLE_ROOT_URL" @@ -798,6 +830,7 @@ jobs: --arch "$ARCH" \ --abi "$KANDELO_HOMEBREW_ABI" \ --tap-repository "$TAP_REPOSITORY" \ + --tap-name "$TAP_NAME" \ --tap-commit "$TAP_COMMIT" \ --kandelo-commit "$KANDELO_COMMIT" \ --bottle-root-url "$BOTTLE_ROOT_URL" \ @@ -901,6 +934,7 @@ jobs: KANDELO_HOMEBREW_RELEASE_TAG: ${{ needs.plan.outputs.release-tag }} KANDELO_HOMEBREW_TAP_COMMIT: ${{ needs.plan.outputs.tap-sha }} KANDELO_HOMEBREW_TAP_REPOSITORY: ${{ inputs.tap-repository }} + KANDELO_HOMEBREW_TAP_NAME: ${{ inputs.tap-name }} run: | set -euo pipefail cd kandelo @@ -910,6 +944,7 @@ jobs: --arch "$KANDELO_HOMEBREW_ARCH" --release-tag "$KANDELO_HOMEBREW_RELEASE_TAG" --tap-repository "$KANDELO_HOMEBREW_TAP_REPOSITORY" + --tap-name "$KANDELO_HOMEBREW_TAP_NAME" --tap-commit "$KANDELO_HOMEBREW_TAP_COMMIT" --kandelo-commit "$KANDELO_HOMEBREW_KANDELO_COMMIT" --bottle-root-url "$KANDELO_HOMEBREW_BOTTLE_ROOT_URL" @@ -930,6 +965,7 @@ jobs: --arg arch "$ARCH" \ --arg abi "${RELEASE_TAG#bottles-abi-v}" \ --arg tap_repository "$TAP_REPOSITORY" \ + --arg tap_name "$TAP_NAME" \ --arg tap_commit "$TAP_COMMIT" \ --arg kandelo_commit "$KANDELO_COMMIT" \ --arg sha256 "$BOTTLE_SHA256" \ @@ -937,6 +973,7 @@ jobs: .kind == "child" and .formula == $formula and .arch == $arch and .abi == ($abi | tonumber) and (.tap_repository | ascii_downcase) == ($tap_repository | ascii_downcase) and + .tap_name == $tap_name and .tap_commit == $tap_commit and .kandelo_commit == $kandelo_commit and .bottle.sha256 == $sha256 and .bottle.bytes == ($bytes | tonumber) ' "$RUNNER_TEMP/homebrew-oci-child/receipt.json" >/dev/null @@ -949,6 +986,7 @@ jobs: GH_TOKEN: ${{ github.token }} KANDELO_HOMEBREW_FORMULA: ${{ matrix.formula }} KANDELO_HOMEBREW_TAP_REPOSITORY: ${{ inputs.tap-repository }} + KANDELO_HOMEBREW_TAP_NAME: ${{ inputs.tap-name }} run: | set -euo pipefail cd kandelo @@ -956,6 +994,7 @@ jobs: --layout "$RUNNER_TEMP/homebrew-oci-child/layout" --layout-receipt "$RUNNER_TEMP/homebrew-oci-child/receipt.json" --tap-repository "$KANDELO_HOMEBREW_TAP_REPOSITORY" + --tap-name "$KANDELO_HOMEBREW_TAP_NAME" --formula "$KANDELO_HOMEBREW_FORMULA" --out-json "$RUNNER_TEMP/homebrew-upload-receipt/receipt.json" ) @@ -971,6 +1010,7 @@ jobs: KANDELO_HOMEBREW_RELEASE_TAG: ${{ needs.plan.outputs.release-tag }} KANDELO_HOMEBREW_TAP_COMMIT: ${{ needs.plan.outputs.tap-sha }} KANDELO_HOMEBREW_TAP_REPOSITORY: ${{ inputs.tap-repository }} + KANDELO_HOMEBREW_TAP_NAME: ${{ inputs.tap-name }} run: | set -euo pipefail cd kandelo @@ -981,6 +1021,7 @@ jobs: --arch "$KANDELO_HOMEBREW_ARCH" --release-tag "$KANDELO_HOMEBREW_RELEASE_TAG" --tap-repository "$KANDELO_HOMEBREW_TAP_REPOSITORY" + --tap-name "$KANDELO_HOMEBREW_TAP_NAME" --tap-commit "$KANDELO_HOMEBREW_TAP_COMMIT" --kandelo-commit "$KANDELO_HOMEBREW_KANDELO_COMMIT" --bottle-root-url "$KANDELO_HOMEBREW_BOTTLE_ROOT_URL" @@ -1060,6 +1101,7 @@ jobs: env: KANDELO_HOMEBREW_FORMULA: ${{ matrix.formula }} KANDELO_HOMEBREW_TAP_REPOSITORY: ${{ inputs.tap-repository }} + KANDELO_HOMEBREW_TAP_NAME: ${{ inputs.tap-name }} run: | set -euo pipefail for secret_name in GH_TOKEN GITHUB_TOKEN HOMEBREW_GITHUB_API_TOKEN \ @@ -1090,7 +1132,8 @@ jobs: --layout-receipt "$receipt" \ --kind child \ --formula "$KANDELO_HOMEBREW_FORMULA" \ - --tap-repository "$KANDELO_HOMEBREW_TAP_REPOSITORY" + --tap-repository "$KANDELO_HOMEBREW_TAP_REPOSITORY" \ + --tap-name "$KANDELO_HOMEBREW_TAP_NAME" printf '%s\0%s\0' "$child_root/layout" "$receipt" \ >>"$RUNNER_TEMP/homebrew-index-child-args.txt" done @@ -1101,6 +1144,7 @@ jobs: env: KANDELO_HOMEBREW_FORMULA: ${{ matrix.formula }} KANDELO_HOMEBREW_TAP_REPOSITORY: ${{ inputs.tap-repository }} + KANDELO_HOMEBREW_TAP_NAME: ${{ inputs.tap-name }} run: | set -euo pipefail for secret_name in GH_TOKEN GITHUB_TOKEN HOMEBREW_GITHUB_API_TOKEN \ @@ -1175,6 +1219,7 @@ jobs: GH_TOKEN: ${{ github.token }} KANDELO_HOMEBREW_FORMULA: ${{ matrix.formula }} KANDELO_HOMEBREW_TAP_REPOSITORY: ${{ inputs.tap-repository }} + KANDELO_HOMEBREW_TAP_NAME: ${{ inputs.tap-name }} run: | set -euo pipefail cd kandelo @@ -1182,6 +1227,7 @@ jobs: --layout "$RUNNER_TEMP/homebrew-complete-index/layout" \ --layout-receipt "$RUNNER_TEMP/homebrew-complete-index/layout-receipt.json" \ --tap-repository "$KANDELO_HOMEBREW_TAP_REPOSITORY" \ + --tap-name "$KANDELO_HOMEBREW_TAP_NAME" \ --formula "$KANDELO_HOMEBREW_FORMULA" \ --out-json "$RUNNER_TEMP/homebrew-complete-index/transport-receipt.json" @@ -1369,6 +1415,7 @@ jobs: KANDELO_HOMEBREW_RELEASE_TAG: ${{ needs.plan.outputs.release-tag }} KANDELO_HOMEBREW_TAP_COMMIT: ${{ needs.plan.outputs.tap-sha }} KANDELO_HOMEBREW_TAP_REPOSITORY: ${{ inputs.tap-repository }} + KANDELO_HOMEBREW_TAP_NAME: ${{ inputs.tap-name }} run: | set -euo pipefail mkdir -p "$RUNNER_TEMP/homebrew-verified-input" @@ -1379,6 +1426,7 @@ jobs: --arch "$KANDELO_HOMEBREW_ARCH" --release-tag "$KANDELO_HOMEBREW_RELEASE_TAG" --tap-repository "$KANDELO_HOMEBREW_TAP_REPOSITORY" + --tap-name "$KANDELO_HOMEBREW_TAP_NAME" --tap-commit "$KANDELO_HOMEBREW_TAP_COMMIT" --kandelo-commit "$KANDELO_HOMEBREW_KANDELO_COMMIT" --bottle-root-url "$KANDELO_HOMEBREW_BOTTLE_ROOT_URL" @@ -1398,6 +1446,7 @@ jobs: env: KANDELO_HOMEBREW_FORMULA: ${{ matrix.formula }} KANDELO_HOMEBREW_TAP_REPOSITORY: ${{ inputs.tap-repository }} + KANDELO_HOMEBREW_TAP_NAME: ${{ inputs.tap-name }} run: | set -euo pipefail cd kandelo @@ -1405,6 +1454,7 @@ jobs: --layout "$RUNNER_TEMP/homebrew-oci-child/layout" --layout-receipt "$RUNNER_TEMP/homebrew-oci-child/receipt.json" --tap-repository "$KANDELO_HOMEBREW_TAP_REPOSITORY" + --tap-name "$KANDELO_HOMEBREW_TAP_NAME" --formula "$KANDELO_HOMEBREW_FORMULA" --out-json "$RUNNER_TEMP/homebrew-upload-receipt/receipt.json" --dry-run @@ -1423,6 +1473,7 @@ jobs: KANDELO_HOMEBREW_RELEASE_TAG: ${{ needs.plan.outputs.release-tag }} KANDELO_HOMEBREW_TAP_COMMIT: ${{ needs.plan.outputs.tap-sha }} KANDELO_HOMEBREW_TAP_REPOSITORY: ${{ inputs.tap-repository }} + KANDELO_HOMEBREW_TAP_NAME: ${{ inputs.tap-name }} run: | set -euo pipefail cd kandelo @@ -1433,6 +1484,7 @@ jobs: --arch "$KANDELO_HOMEBREW_ARCH" --release-tag "$KANDELO_HOMEBREW_RELEASE_TAG" --tap-repository "$KANDELO_HOMEBREW_TAP_REPOSITORY" + --tap-name "$KANDELO_HOMEBREW_TAP_NAME" --tap-commit "$KANDELO_HOMEBREW_TAP_COMMIT" --kandelo-commit "$KANDELO_HOMEBREW_KANDELO_COMMIT" --bottle-root-url "$KANDELO_HOMEBREW_BOTTLE_ROOT_URL" @@ -1455,6 +1507,7 @@ jobs: KANDELO_HOMEBREW_ARCH: ${{ matrix.arch }} KANDELO_HOMEBREW_FORMULA: ${{ matrix.formula }} KANDELO_HOMEBREW_TAP_REPOSITORY: ${{ inputs.tap-repository }} + KANDELO_HOMEBREW_TAP_NAME: ${{ inputs.tap-name }} run: | set -euo pipefail for secret_name in GH_TOKEN GITHUB_TOKEN HOMEBREW_GITHUB_API_TOKEN \ @@ -1474,7 +1527,8 @@ jobs: --layout-receipt "$layout_receipt" \ --kind index \ --formula "$KANDELO_HOMEBREW_FORMULA" \ - --tap-repository "$KANDELO_HOMEBREW_TAP_REPOSITORY" + --tap-repository "$KANDELO_HOMEBREW_TAP_REPOSITORY" \ + --tap-name "$KANDELO_HOMEBREW_TAP_NAME" owner_repo="$(printf '%s' "$KANDELO_HOMEBREW_TAP_REPOSITORY" | tr '[:upper:]' '[:lower:]')" remote="ghcr.io/${owner_repo}/${KANDELO_HOMEBREW_FORMULA}" jq -e \ @@ -1586,6 +1640,7 @@ jobs: merge_args=( --tap-root "$merged_tap" --tap-repository "$TAP_REPOSITORY" + --tap-name "$TAP_NAME" --formula "$FORMULA" --arch "$ARCH" --release-tag "$RELEASE_TAG" @@ -1662,6 +1717,7 @@ jobs: systemd_run_bin="/usr/bin/systemd-run" systemctl_bin="/usr/bin/systemctl" getent_bin="/usr/bin/getent" + findmnt_bin="/usr/bin/findmnt" pgrep_bin="/usr/bin/pgrep" pkill_bin="/usr/bin/pkill" useradd_bin="/usr/sbin/useradd" @@ -1672,7 +1728,7 @@ jobs: [ $((8#$sudo_mode & 0022)) -ne 0 ]; then echo "::error::runner sudo boundary is unavailable"; exit 2 fi - for protected_bin in "$systemd_run_bin" "$systemctl_bin" "$getent_bin" "$pgrep_bin" \ + for protected_bin in "$systemd_run_bin" "$systemctl_bin" "$getent_bin" "$findmnt_bin" "$pgrep_bin" \ "$useradd_bin"; do protected_mode="$(stat -c '%a' "$protected_bin" 2>/dev/null || true)" if [ ! -f "$protected_bin" ] || [ -L "$protected_bin" ] || \ @@ -1779,6 +1835,7 @@ jobs: KANDELO_HOMEBREW_RELEASE_ABI: ${{ needs.plan.outputs.abi }} KANDELO_HOMEBREW_TAP_COMMIT: ${{ needs.plan.outputs.tap-sha }} KANDELO_HOMEBREW_TAP_REPOSITORY: ${{ inputs.tap-repository }} + KANDELO_HOMEBREW_TAP_NAME: ${{ inputs.tap-name }} run: | set -euo pipefail for secret_name in GH_TOKEN GITHUB_TOKEN HOMEBREW_GITHUB_API_TOKEN \ @@ -1816,6 +1873,7 @@ jobs: bash scripts/dev-shell.sh bash scripts/homebrew-verify-poured-bottle.sh \ --tap-root "$RUNNER_TEMP/homebrew-merged-tap" \ --tap-repository "$KANDELO_HOMEBREW_TAP_REPOSITORY" \ + --tap-name "$KANDELO_HOMEBREW_TAP_NAME" \ --tap-commit "$KANDELO_HOMEBREW_TAP_COMMIT" \ --formula "$KANDELO_HOMEBREW_FORMULA" \ --arch "$KANDELO_HOMEBREW_ARCH" \ @@ -1932,6 +1990,7 @@ jobs: bash scripts/dev-shell.sh bash scripts/homebrew-merge-bottle-json.sh \ --tap-root "$merged_tap" \ --tap-repository "$TAP_REPOSITORY" \ + --tap-name "$TAP_NAME" \ --formula "$FORMULA" \ --arch "$ARCH" \ --release-tag "$RELEASE_TAG" \ @@ -1963,6 +2022,7 @@ jobs: export KANDELO_HOMEBREW_ARCH="$ARCH" export KANDELO_HOMEBREW_RELEASE_TAG="$RELEASE_TAG" export KANDELO_HOMEBREW_TAP_REPOSITORY="$TAP_REPOSITORY" + export KANDELO_HOMEBREW_TAP_NAME="$TAP_NAME" export KANDELO_HOMEBREW_BOTTLE_ARCHIVE="$RUNTIME_BOTTLE" export KANDELO_HOMEBREW_BOTTLE_JSON="$RUNNER_TEMP/homebrew-verified-input/bottle.json" export KANDELO_HOMEBREW_BOTTLE_ROOT_URL="$BOTTLE_ROOT_URL" @@ -1978,6 +2038,7 @@ jobs: --arg build_home "/home/kandelo-homebrew-build" \ '[$github_workspace, $runner_workspace, $runner_temp, $build_home]')" bash scripts/dev-shell.sh env \ + KANDELO_HOMEBREW_TAP_NAME="$KANDELO_HOMEBREW_TAP_NAME" \ KANDELO_HOMEBREW_FORBIDDEN_ROOTS_JSON="$KANDELO_HOMEBREW_FORBIDDEN_ROOTS_JSON" \ bash scripts/homebrew-generate-sidecars-from-env.sh @@ -2001,6 +2062,7 @@ jobs: export KANDELO_HOMEBREW_ARCH="$ARCH" export KANDELO_HOMEBREW_RELEASE_TAG="$RELEASE_TAG" export KANDELO_HOMEBREW_TAP_REPOSITORY="$TAP_REPOSITORY" + export KANDELO_HOMEBREW_TAP_NAME="$TAP_NAME" export KANDELO_HOMEBREW_BOTTLE_ARCHIVE="$RUNTIME_BOTTLE" export KANDELO_HOMEBREW_BOTTLE_JSON="$RUNNER_TEMP/homebrew-verified-input/bottle.json" export KANDELO_HOMEBREW_BOTTLE_ROOT_URL="$BOTTLE_ROOT_URL" @@ -2016,6 +2078,7 @@ jobs: --arg build_home "/home/kandelo-homebrew-build" \ '[$github_workspace, $runner_workspace, $runner_temp, $build_home]')" bash scripts/dev-shell.sh env \ + KANDELO_HOMEBREW_TAP_NAME="$KANDELO_HOMEBREW_TAP_NAME" \ KANDELO_HOMEBREW_FORBIDDEN_ROOTS_JSON="$KANDELO_HOMEBREW_FORBIDDEN_ROOTS_JSON" \ bash -c ' set -euo pipefail @@ -2092,7 +2155,7 @@ jobs: --out "$browser_gallery_root" --formula hello --title "GNU hello Homebrew VFS" - --description "GNU hello poured from the published Automattic/kandelo-homebrew bottle into a browser-smoked Kandelo VFS image." + --description "GNU hello poured from the published $KANDELO_HOMEBREW_TAP_NAME bottle into a browser-smoked Kandelo VFS image." ) bash scripts/homebrew-create-browser-gallery.sh "${gallery_args[@]}" gallery_validation_args=( @@ -2168,6 +2231,7 @@ jobs: KANDELO_HOMEBREW_RELEASE_TAG: ${{ needs.plan.outputs.release-tag }} KANDELO_HOMEBREW_TAP_COMMIT: ${{ needs.plan.outputs.tap-sha }} KANDELO_HOMEBREW_TAP_REPOSITORY: ${{ inputs.tap-repository }} + KANDELO_HOMEBREW_TAP_NAME: ${{ inputs.tap-name }} run: | set -euo pipefail publish_handoff="$RUNNER_TEMP/homebrew-publish-handoff" @@ -2198,6 +2262,7 @@ jobs: --arch "$KANDELO_HOMEBREW_ARCH" --release-tag "$KANDELO_HOMEBREW_RELEASE_TAG" --tap-repository "$KANDELO_HOMEBREW_TAP_REPOSITORY" + --tap-name "$KANDELO_HOMEBREW_TAP_NAME" --tap-commit "$KANDELO_HOMEBREW_TAP_COMMIT" --kandelo-commit "$KANDELO_HOMEBREW_KANDELO_COMMIT" --bottle-root-url "$KANDELO_HOMEBREW_BOTTLE_ROOT_URL" @@ -2305,6 +2370,7 @@ jobs: KANDELO_HOMEBREW_RELEASE_TAG: ${{ needs.plan.outputs.release-tag }} KANDELO_HOMEBREW_TAP_COMMIT: ${{ needs.plan.outputs.tap-sha }} KANDELO_HOMEBREW_TAP_REPOSITORY: ${{ inputs.tap-repository }} + KANDELO_HOMEBREW_TAP_NAME: ${{ inputs.tap-name }} run: | set -euo pipefail error_file="$RUNNER_TEMP/homebrew-finalize-error.txt" @@ -2318,6 +2384,7 @@ jobs: --arch "$KANDELO_HOMEBREW_ARCH" --release-tag "$KANDELO_HOMEBREW_RELEASE_TAG" --tap-repository "$KANDELO_HOMEBREW_TAP_REPOSITORY" + --tap-name "$KANDELO_HOMEBREW_TAP_NAME" --tap-commit "$KANDELO_HOMEBREW_TAP_COMMIT" --kandelo-commit "$KANDELO_HOMEBREW_KANDELO_COMMIT" --bottle-root-url "$KANDELO_HOMEBREW_BOTTLE_ROOT_URL" @@ -2351,6 +2418,8 @@ jobs: KANDELO_HOMEBREW_KANDELO_COMMIT: ${{ needs.plan.outputs.kandelo-sha }} KANDELO_HOMEBREW_RELEASE_TAG: ${{ needs.plan.outputs.release-tag }} KANDELO_HOMEBREW_TAP_COMMIT: ${{ needs.plan.outputs.tap-sha }} + KANDELO_HOMEBREW_TAP_REPOSITORY: ${{ inputs.tap-repository }} + KANDELO_HOMEBREW_TAP_NAME: ${{ inputs.tap-name }} STATE_LOCK_OWNER_DETAIL: ${{ matrix.formula }}, ${{ matrix.arch }} run: | set -euo pipefail @@ -2363,6 +2432,8 @@ jobs: --formula "$KANDELO_HOMEBREW_FORMULA" --arch "$KANDELO_HOMEBREW_ARCH" --release-tag "$KANDELO_HOMEBREW_RELEASE_TAG" + --tap-repository "$KANDELO_HOMEBREW_TAP_REPOSITORY" + --tap-name "$KANDELO_HOMEBREW_TAP_NAME" --status success --kandelo-commit "$KANDELO_HOMEBREW_KANDELO_COMMIT" --tap-commit "$KANDELO_HOMEBREW_TAP_COMMIT" @@ -2389,6 +2460,8 @@ jobs: KANDELO_HOMEBREW_KANDELO_COMMIT: ${{ needs.plan.outputs.kandelo-sha }} KANDELO_HOMEBREW_RELEASE_TAG: ${{ needs.plan.outputs.release-tag }} KANDELO_HOMEBREW_TAP_COMMIT: ${{ needs.plan.outputs.tap-sha }} + KANDELO_HOMEBREW_TAP_REPOSITORY: ${{ inputs.tap-repository }} + KANDELO_HOMEBREW_TAP_NAME: ${{ inputs.tap-name }} PUBLISH_HANDOFF_OUTCOME: ${{ steps.publish-handoff.outcome }} PUBLISH_OUTCOME: ${{ steps.publish.outcome }} STATE_LOCK_OWNER_DETAIL: ${{ matrix.formula }}, ${{ matrix.arch }} @@ -2402,6 +2475,8 @@ jobs: --formula "$KANDELO_HOMEBREW_FORMULA" --arch "$KANDELO_HOMEBREW_ARCH" --release-tag "$KANDELO_HOMEBREW_RELEASE_TAG" + --tap-repository "$KANDELO_HOMEBREW_TAP_REPOSITORY" + --tap-name "$KANDELO_HOMEBREW_TAP_NAME" --status failed --error "$error_text" --kandelo-commit "$KANDELO_HOMEBREW_KANDELO_COMMIT" diff --git a/docs/homebrew-publishing.md b/docs/homebrew-publishing.md index 60aa02c2ad..4cd9d6ec0b 100644 --- a/docs/homebrew-publishing.md +++ b/docs/homebrew-publishing.md @@ -1,9 +1,9 @@ # Homebrew Publishing -Kandelo's Homebrew publishing path is a first-party bottle publication and -validation pipeline. The implementation lives in the main -`Automattic/kandelo` repository; the live tap repository is -`Automattic/kandelo-homebrew`. +Kandelo's Homebrew publishing path is a bottle publication and validation +pipeline shared by the first-party tap and conventional third-party taps. The +implementation lives in the main `Automattic/kandelo` repository; the +first-party live tap repository is `Automattic/kandelo-homebrew`. This is not a general user-facing Homebrew install guide yet. Do not document `brew tap` or guest `brew install` commands until guest Homebrew install has @@ -26,14 +26,21 @@ Homebrew's `bottle do` block. |---|---| | `Automattic/kandelo` | Schemas, validators, reusable workflows, package build scripts, VFS planner/builder, Node/browser smoke tests, and this documentation. | | `Automattic/kandelo-homebrew` | Tap state: `Formula/`, generated `Kandelo/` sidecars, bottle blocks, and provenance reports. | +| `/homebrew-` | A third-party tap's Formulae, generated state, GHCR bottle packages, and caller-scoped publication authority. | The checked-in `homebrew/kandelo-homebrew/` directory is a reviewable template and test fixture for the tap shape. Live generated tap state belongs in `Automattic/kandelo-homebrew`, not in the main repository template. -Use the full repository name in automation and documentation. The chosen tap -name intentionally differs from Homebrew's common `homebrew-` repository -convention, so do not infer a short tap alias without verifying it. +Repository identity and Homebrew tap identity are separate inputs. A +conventional repository `/homebrew-` has canonical Homebrew tap +name `/`. Repository identity owns GitHub checkout, GHCR paths, +and the caller token; tap identity owns `brew` references, installed Formula +paths, receipts, OCI titles, and Kandelo sidecars. The first-party repository +is an explicit exception: both its repository identity and tap name are +`Automattic/kandelo-homebrew`. No conventional repository may derive that +protected first-party tap name, so repository and tap identities remain a +one-to-one publication boundary. ## Artifact Model @@ -153,7 +160,7 @@ Ruby. Publish same-tap runtime dependencies before their consumers. The bottle builder resolves the selected Formula's recursive runtime closure in -topological order, filters it to `Automattic/kandelo-homebrew`, and installs +topological order, filters it to the selected canonical tap name, and installs each dependency separately with `--force-bottle --as-dependency --ignore-dependencies`. A missing Kandelo bottle therefore fails before the consumer source build; Homebrew is not allowed to silently replace a prior @@ -239,7 +246,7 @@ The reusable publisher is: .github/workflows/reusable-homebrew-bottle-publish.yml ``` -The tap may call it with: +The first-party tap may call it with: ```yaml jobs: @@ -251,10 +258,16 @@ jobs: uses: Automattic/kandelo/.github/workflows/reusable-homebrew-bottle-publish.yml@ with: tap-repository: Automattic/kandelo-homebrew + tap-name: Automattic/kandelo-homebrew formulae: hello arches: wasm32 ``` +A conventional third-party tap repository such as `Example/homebrew-tools` +uses the same caller shape with `tap-repository: Example/homebrew-tools` and +`tap-name: Example/tools`. The repository and tap name pair is validated before +any checkout or dry-run exit. + The caller grants the maximum permission ceiling. A write-capable publication caller must grant `contents: write`, `packages: write`, and `actions: read`, but the reusable workflow explicitly downgrades each job to its required subset. @@ -268,24 +281,29 @@ schedule explicitly narrows itself to read scopes. PRs from untrusted forks must not receive this caller ceiling; they can run schema and local build checks but cannot invoke the trusted publisher. -Every call is fixed to a reviewed `repository_dispatch` workflow in -`Automattic/kandelo-homebrew@main`. Non-dry calls may come from -`publish-bottles.yml` or `maintain-bottles.yml`; dry calls must come from -`dry-run-bottles.yml`. The normal caller is displayed as +Every call is fixed to a reviewed `repository_dispatch` workflow on the target +tap repository's `main` branch, and the caller repository must exactly equal +the target tap repository. Non-dry calls may come from `publish-bottles.yml` or +`maintain-bottles.yml`; dry calls must come from `dry-run-bottles.yml`. The +first-party normal caller is displayed as **Publish Kandelo bottles**; do not restore the narrower legacy **Publish hello bottle** name. The three dispatch events are `publish-kandelo-bottles`, `dry-run-kandelo-bottles`, and `maintain-kandelo-bottles`. Publish and dry-run payloads must select at least one Formula and architecture; an absent or empty selection is an error, not a successful no-op. -Write-capable publication is additionally fixed to `Automattic/kandelo@main` -and `Automattic/kandelo-homebrew@main`. The bottle root is never caller-selected: +All publication, including dry runs, is additionally fixed to +`Automattic/kandelo@main` and the caller tap's `main` branch. The bottle root is +never caller-selected: the workflow rejects a non-empty `bottle-root-url` and derives `https://ghcr.io/v2//` from the tap -repository. Arbitrary Kandelo or tap refs are accepted only by the reviewed -dry-run caller. The maintenance workflow is callable but is not directly -branch-dispatchable; its operator-facing caller must live on the protected -default branch and grant write scopes explicitly. Third-party actions in the -privileged path are pinned by commit. +repository. The separate reusable maintenance workflow remains first-party +specific because its rollback and deletion paths own default-tap state. A +third-party `maintain-bottles.yml` on the protected default branch may call the +generic publisher for rebuilds, but generic rollback and deletion orchestration +are not provided by this change. Third-party actions in the privileged path are +pinned by commit. The reusable workflow uses the caller's scoped +`github.token`; it cannot publish another repository's tap state or GHCR +packages because caller and target repository identities must match. After a read-only planning job resolves the immutable Kandelo commit, tap commit, ABI namespace, derived bottle root, and formula matrix, each @@ -299,12 +317,15 @@ only per `(tap, formula)`, so unrelated Formulae retain parallel throughput: `/home/linuxbrew/.linuxbrew` prefix. This preserves the selected prefix and Cellar so ordinary host build-dependency bottles remain usable. Within that read-only build, all Formula-evaluating Homebrew commands run as a distinct - unprivileged user. Kandelo, tap, and patched Homebrew source are recursively - non-writable and non-replaceable by that identity; only a root-provisioned - shared temporary root, Homebrew cache/temp, prefix, and build home are - writable. Dependency lists and install logs used by the workflow identity - live in a separate mode-0700 control directory under the protected output - root; Formula processes cannot preplant or replace those paths. The wrapper + unprivileged user. The original Kandelo and tap checkouts remain hidden from + that identity. Each transient service receives root-created, read-only bind + aliases for those exact trees, and the Kandelo SDK environment points only + at the alias. The patched Homebrew source is recursively non-writable and + non-replaceable; only a root-provisioned shared temporary root, Homebrew + cache/temp, prefix, and build home are writable. Dependency lists and install + logs used by the workflow identity live in a separate mode-0700 control + directory under the protected output root; Formula processes cannot preplant + or replace those paths. The wrapper uses an explicit host `sudo` boundary, a fixed environment allowlist, and a transient systemd service with control-group kill semantics and `NoNewPrivileges=yes` for every Brew invocation. A final diff --git a/homebrew/kandelo-homebrew/.github/workflows/dry-run-bottles.yml b/homebrew/kandelo-homebrew/.github/workflows/dry-run-bottles.yml index 4810cf29a9..78ba266651 100644 --- a/homebrew/kandelo-homebrew/.github/workflows/dry-run-bottles.yml +++ b/homebrew/kandelo-homebrew/.github/workflows/dry-run-bottles.yml @@ -15,6 +15,7 @@ jobs: kandelo-repository: ${{ github.event.client_payload.kandelo_repository || 'Automattic/kandelo' }} kandelo-ref: ${{ github.event.client_payload.kandelo_ref || 'main' }} tap-repository: ${{ github.event.client_payload.tap_repository || 'Automattic/kandelo-homebrew' }} + tap-name: ${{ github.event.client_payload.tap_name || 'Automattic/kandelo-homebrew' }} tap-ref: ${{ github.event.client_payload.tap_ref || 'main' }} formulae: ${{ github.event.client_payload.formulae }} arches: ${{ github.event.client_payload.arches || 'wasm32' }} diff --git a/homebrew/kandelo-homebrew/.github/workflows/publish-bottles.yml b/homebrew/kandelo-homebrew/.github/workflows/publish-bottles.yml index a1a26db9d6..da47744eae 100644 --- a/homebrew/kandelo-homebrew/.github/workflows/publish-bottles.yml +++ b/homebrew/kandelo-homebrew/.github/workflows/publish-bottles.yml @@ -15,6 +15,7 @@ jobs: kandelo-repository: Automattic/kandelo kandelo-ref: main tap-repository: Automattic/kandelo-homebrew + tap-name: Automattic/kandelo-homebrew tap-ref: main formulae: ${{ github.event.client_payload.formulae }} arches: ${{ github.event.client_payload.arches || 'wasm32' }} diff --git a/homebrew/kandelo-homebrew/README.md b/homebrew/kandelo-homebrew/README.md index 5b52291949..299f2eac6a 100644 --- a/homebrew/kandelo-homebrew/README.md +++ b/homebrew/kandelo-homebrew/README.md @@ -48,6 +48,13 @@ The protected dispatch events are `publish-kandelo-bottles`, `dry-run-kandelo-bottles`, and `maintain-kandelo-bottles`; publish and dry-run requests must include nonempty Formula and architecture selections. +The publisher keeps GitHub repository identity separate from canonical +Homebrew tap identity. Conventional third-party repository +`/homebrew-` must pass tap name `/` and must call from +that same repository's reviewed `main` workflow. This first-party repository is +an explicit naming exception: both values are +`Automattic/kandelo-homebrew`. + The caller grants the maximum permission ceiling. Four fresh runner roles then downgrade it: a read-only build/test job, a `packages: write` uploader without tap write access, a read-only anonymous/runtime verifier, and a diff --git a/scripts/check-homebrew-publish-workflow-trust.rb b/scripts/check-homebrew-publish-workflow-trust.rb index 3068b0c730..fac0fb448d 100644 --- a/scripts/check-homebrew-publish-workflow-trust.rb +++ b/scripts/check-homebrew-publish-workflow-trust.rb @@ -15,12 +15,12 @@ UPLOAD_ACTION = "actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a" DOWNLOAD_ACTION = "actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c" BREW_COMMIT = "34c40c18ffa2029b611b61c73273e32c003d0842" -PUBLISHER_PLAN_DIGEST = "75468bd935146ca1c78e1a757a314bb6355bce63f01707c3345936d3f9ccaf07" -PUBLISHER_BUILD_DIGEST = "682e73ef771a9beebb0bbfaea2770d6f1cd7062882b4bb3145958d204e1436b5" -PUBLISHER_UPLOAD_DIGEST = "329ff7dfafc8f3c262ae8c817187d875db451da05d7d98a12eafda45cdbf2213" -PUBLISHER_INDEX_DIGEST = "2c5988c9b840dcc1e845f40b58e47ed8263a9babccdae08140bea145a4199de3" -PUBLISHER_VERIFY_DIGEST = "73cc22e389c4fb449f32976860178dc40bce686739c6662cb769ca2ad93b7003" -PUBLISHER_FINALIZE_DIGEST = "2e7a24cdcaa63e631cee1a967bff1c41bb0f8f8e57e2039929efda5d069cf9f9" +PUBLISHER_PLAN_DIGEST = "a4e98950e1360850c15cf4ebe091ffb2676fb6b7fbdfcda7e07d71b203e3a51b" +PUBLISHER_BUILD_DIGEST = "f873732408b33ab5c412a0a297e543021eb8bb1c2ab6a5c97644fc1fd320c78c" +PUBLISHER_UPLOAD_DIGEST = "016a5f370cb08dd615455348f3420a0d5fbda444fa13f4248eac5cdab0d7f3c9" +PUBLISHER_INDEX_DIGEST = "143ba3916705d3c76ef337ddf89def07ff3515400a95827eb14042a12ab31cd8" +PUBLISHER_VERIFY_DIGEST = "62abd1d17ffebe2a8088baa5fb124cd3cdeeae2f8308a37efc393e28d1b4655c" +PUBLISHER_FINALIZE_DIGEST = "46241674d594effc2102058fa95f63f659b1fb73540cb8cd421eb15b84adece7" MAINTENANCE_VALIDATE_DIGEST = "9ab856fe40640172500d82b5179a096aa028763bf696aeac865d732298617a22" MAINTENANCE_ROLLBACK_DIGEST = "45ff220697da9604dbe69c82761f285ba2e3e5182ef0819360128b82dd169efc" @@ -196,6 +196,7 @@ def check_tap_callers "kandelo-repository" => "Automattic/kandelo", "kandelo-ref" => "main", "tap-repository" => "Automattic/kandelo-homebrew", + "tap-name" => "Automattic/kandelo-homebrew", "tap-ref" => "main", "formulae" => "${{ github.event.client_payload.formulae }}", "arches" => "${{ github.event.client_payload.arches || 'wasm32' }}", @@ -223,6 +224,7 @@ def check_tap_callers "kandelo-repository" => "${{ github.event.client_payload.kandelo_repository || 'Automattic/kandelo' }}", "kandelo-ref" => "${{ github.event.client_payload.kandelo_ref || 'main' }}", "tap-repository" => "${{ github.event.client_payload.tap_repository || 'Automattic/kandelo-homebrew' }}", + "tap-name" => "${{ github.event.client_payload.tap_name || 'Automattic/kandelo-homebrew' }}", "tap-ref" => "${{ github.event.client_payload.tap_ref || 'main' }}", "dry-run" => true, }), @@ -264,6 +266,7 @@ def check_publisher(workflow) "kandelo-repository" => { "type" => "string", "default" => "Automattic/kandelo" }, "kandelo-ref" => { "type" => "string", "default" => "main" }, "tap-repository" => { "type" => "string", "default" => "Automattic/kandelo-homebrew" }, + "tap-name" => { "type" => "string", "default" => "Automattic/kandelo-homebrew" }, "tap-ref" => { "type" => "string", "default" => "main" }, "formulae" => { "type" => "string", "required" => true }, "arches" => { "type" => "string", "default" => "wasm32" }, @@ -372,21 +375,27 @@ def check_publisher(workflow) "DRY_RUN" => "${{ inputs.dry-run }}", "KANDELO_REPOSITORY" => "${{ inputs.kandelo-repository }}", "KANDELO_REF" => "${{ inputs.kandelo-ref }}", + "TAP_NAME" => "${{ inputs.tap-name }}", "TAP_REPOSITORY" => "${{ inputs.tap-repository }}", "TAP_REF" => "${{ inputs.tap-ref }}", "BOTTLE_ROOT_URL" => "${{ inputs.bottle-root-url }}", }, "publisher caller validation mapping changed") validation_run = validation.fetch("run") [ - '[ "$CALLER_REPOSITORY" = "Automattic/kandelo-homebrew" ]', + '[ "$CALLER_REPOSITORY" = "$TAP_REPOSITORY" ]', '[ "$CALLER_REF" = "refs/heads/main" ]', '[ "$CALLER_EVENT_NAME" = "repository_dispatch" ]', - "dry-run-bottles.yml@refs/heads/main", - "publish-bottles.yml@refs/heads/main", - "maintain-bottles.yml@refs/heads/main", + '"$CALLER_REPOSITORY/.github/workflows/dry-run-bottles.yml@refs/heads/main"', + '"$CALLER_REPOSITORY/.github/workflows/publish-bottles.yml@refs/heads/main"', + '"$CALLER_REPOSITORY/.github/workflows/maintain-bottles.yml@refs/heads/main"', '[ "$KANDELO_REPOSITORY" = "Automattic/kandelo" ]', '[ "$KANDELO_REF" = "main" ]', - '[ "$TAP_REPOSITORY" = "Automattic/kandelo-homebrew" ]', + 'Automattic/kandelo-homebrew)', + '[ "$TAP_NAME" = "Automattic/kandelo-homebrew" ]', + '[[ "$TAP_REPOSITORY" =~ ^[A-Za-z0-9_.-]+/homebrew-[A-Za-z0-9_.-]+$ ]]', + 'tap_short_name="${TAP_REPOSITORY#*/homebrew-}"', + '[ "$normalized_derived_tap_name" != "automattic/kandelo-homebrew" ]', + '[ "$TAP_NAME" = "${tap_owner}/${tap_short_name}" ]', '[ "$TAP_REF" = "main" ]', '[ -z "$BOTTLE_ROOT_URL" ]', ].each do |predicate| @@ -394,7 +403,10 @@ def check_publisher(workflow) end dry_index = validation_run.index('if [ "$DRY_RUN" = "true" ]') caller_index = validation_run.index('[ "$CALLER_REF" = "refs/heads/main" ]') - check(dry_index && caller_index && caller_index < dry_index, + kandelo_index = validation_run.index('[ "$KANDELO_REPOSITORY" = "Automattic/kandelo" ]') + tap_name_index = validation_run.index('case "$TAP_REPOSITORY" in') + check(dry_index && caller_index && kandelo_index && tap_name_index && + caller_index < dry_index && kandelo_index < dry_index && tap_name_index < dry_index, "publisher dry-run can bypass caller authority validation") release_run = named_step(plan_steps, "Resolve release and bottle root").fetch("run") @@ -614,6 +626,7 @@ def check_publisher(workflow) "GH_TOKEN" => "${{ github.token }}", "KANDELO_HOMEBREW_FORMULA" => "${{ matrix.formula }}", "KANDELO_HOMEBREW_TAP_REPOSITORY" => "${{ inputs.tap-repository }}", + "KANDELO_HOMEBREW_TAP_NAME" => "${{ inputs.tap-name }}", }, "publisher uploader credentials escape the isolated upload step") index_credential_steps = index_steps.select do |step| @@ -625,6 +638,7 @@ def check_publisher(workflow) "GH_TOKEN" => "${{ github.token }}", "KANDELO_HOMEBREW_FORMULA" => "${{ matrix.formula }}", "KANDELO_HOMEBREW_TAP_REPOSITORY" => "${{ inputs.tap-repository }}", + "KANDELO_HOMEBREW_TAP_NAME" => "${{ inputs.tap-name }}", }, "publisher version-index credentials escape the isolated transport step") finalizer_credential_steps = finalize_steps.select do |step| @@ -771,6 +785,7 @@ def check_publisher(workflow) 'systemd_run_bin="/usr/bin/systemd-run"', 'systemctl_bin="/usr/bin/systemctl"', 'getent_bin="/usr/bin/getent"', + 'findmnt_bin="/usr/bin/findmnt"', 'pgrep_bin="/usr/bin/pgrep"', 'pkill_bin="/usr/bin/pkill"', 'useradd_bin="/usr/sbin/useradd"', @@ -814,13 +829,14 @@ def check_publisher(workflow) KANDELO_HOMEBREW_BROWSER_EVIDENCE ].all? { |name| dev_shell.include?("--keep #{name}") }, "dev shell drops exact Homebrew runtime evidence inputs") + check(!dev_shell.include?("--keep KANDELO_HOMEBREW_TAP_NAME"), + "dev shell globally preserves caller-selected Homebrew tap identity") bottle_builder = File.read(File.join(REPO_ROOT, "scripts/homebrew-bottle-build.sh")) [ 'homebrew_patched_launcher_isolate "$BUILD_USER"', 'homebrew_patched_launcher_teardown "$BUILD_USER"', "homebrew_patched_launcher_verify_isolation", - 'homebrew_assert_tree_not_writable_by_user "$BUILD_USER" "$OUT_DIR"', - 'homebrew_assert_tree_not_replaceable_by_user "$BUILD_USER" "$OUT_DIR"', + '"$WORK_DIR" "$KANDELO_ROOT" "$TAP_ROOT" "$OUT_DIR"', "CI Formula execution requires KANDELO_HOMEBREW_BUILD_USER", 'mktemp -d "$SHARED_TEMP/homebrew-build.XXXXXX"', 'CONTROL_DIR="$(mktemp -d "$OUT_DIR/.control.XXXXXX")"', @@ -932,13 +948,21 @@ def check_publisher(workflow) "systemd-run", "--wait", "--collect", "--pipe", "--property=KillMode=control-group", "--property=SendSIGKILL=yes", "--property=NoNewPrivileges=yes", "--expand-environment=no", + '"--property=BindReadOnlyPaths=$kandelo_root:$source_alias_dir/kandelo"', + '"--property=BindReadOnlyPaths=$tap_root:$source_alias_dir/tap"', + '"--property=InaccessiblePaths=$kandelo_root"', + '"--property=InaccessiblePaths=$tap_root"', + '"--property=InaccessiblePaths=$output_root"', '"--uid=$build_user"', '"--gid=$build_group"', 'env_bin="$(command -v env)"', 'printf \' --working-directory="$working_directory" -- %q -i\'', 'printf \'bottle_tag_env=()\\n\'', 'for variable in KANDELO_HOMEBREW_BOTTLE_TAG HOMEBREW_KANDELO_BOTTLE_TAG', 'bottle_tag_env+=("%s=${%s}")', - 'printf \' "${bottle_tag_env[@]}" %q "$@"\\n\' "$protected_brew"', + 'HOMEBREW_KANDELO_ROOT=$source_alias_dir/kandelo', + 'KANDELO_HOMEBREW_KANDELO_ROOT=$source_alias_dir/kandelo', + 'printf \' "${bottle_tag_env[@]}" "$command_path" "$@"\\n\'', + "__kandelo_verify_source_aliases", "/usr/bin/findmnt", '"$sudo_bin" install -o root -g root -m 0555 "$wrapper_source" "$wrapper_path"', "-writable -print -quit", "! -readable -o ! -executable", "-prune", "homebrew_patched_launcher_uid_has_processes", "homebrew_patched_launcher_teardown", @@ -1079,6 +1103,7 @@ def check_publisher(workflow) 'git -C "$GITHUB_WORKSPACE/kandelo-postbuild" rev-parse HEAD', 'git -C "$GITHUB_WORKSPACE/tap-reviewed" rev-parse HEAD', '--tap-root "$GITHUB_WORKSPACE/tap"', '--tap-repository "$TAP_REPOSITORY"', + '--tap-name "$TAP_NAME"', '--formula "$FORMULA"', '--base-ref "$TAP_SHA"', '--reviewed-tap-root "$GITHUB_WORKSPACE/tap-reviewed"', ].each do |fragment| @@ -1106,6 +1131,7 @@ def check_publisher(workflow) [ 'cd "$GITHUB_WORKSPACE/kandelo-postbuild"', "scripts/homebrew-create-build-handoff.sh", '--tap-repository "$KANDELO_HOMEBREW_TAP_REPOSITORY"', + '--tap-name "$KANDELO_HOMEBREW_TAP_NAME"', '--bottle "$BOTTLE_ARCHIVE"', '--bottle-json "$BOTTLE_JSON"', '--dependency-provenance "$DEPENDENCY_PROVENANCE"', '--out "$RUNNER_TEMP/homebrew-build-handoff"', @@ -1157,7 +1183,9 @@ def check_publisher(workflow) "publisher exposes upload credentials before validating the handoff") check(upload_validate.fetch("run").include?("scripts/homebrew-validate-build-handoff.sh") && upload_validate.fetch("run").include?('--tap-repository "$KANDELO_HOMEBREW_TAP_REPOSITORY"') && + upload_validate.fetch("run").include?('--tap-name "$KANDELO_HOMEBREW_TAP_NAME"') && upload_attempt.fetch("run").include?("scripts/homebrew-ghcr-upload.sh") && + upload_attempt.fetch("run").include?('--tap-name "$KANDELO_HOMEBREW_TAP_NAME"') && upload_attempt.fetch("run").include?('--out-json "$RUNNER_TEMP/homebrew-upload-receipt/receipt.json"'), "publisher isolated upload path changed") trusted_runner_roots = [ @@ -1307,6 +1335,7 @@ def check_publisher(workflow) "Validate receipt against exact bottle bytes").fetch("run") [canonical_build, canonical_receipt].each do |run| check(run.include?('--tap-repository "$KANDELO_HOMEBREW_TAP_REPOSITORY"') && + run.include?('--tap-name "$KANDELO_HOMEBREW_TAP_NAME"') && run.include?('--out-bottle-json "$RUNNER_TEMP/homebrew-verified-input/bottle.json"'), "publisher does not reconstruct canonical bottle JSON") end @@ -1320,6 +1349,7 @@ def check_publisher(workflow) "Compose only reconstructed bottle metadata into the fresh tap").fetch("run") [ "scripts/homebrew-merge-bottle-json.sh", '--bottle-json "$BOTTLE_JSON"', + '--tap-repository "$TAP_REPOSITORY"', '--tap-name "$TAP_NAME"', '--release-tag "$RELEASE_TAG"', '--expected-sha256 "$BOTTLE_SHA256"', '--expected-root-url "$BOTTLE_ROOT_URL"', 'merged_tap="$RUNNER_TEMP/homebrew-merged-tap"', @@ -1380,13 +1410,14 @@ def check_publisher(workflow) check(sidecar_run.include?(fragment), "publisher sidecar inspection lacks trusted forbidden-root source #{fragment}") end - forbidden_root_forwarding = [ + sidecar_env_forwarding = [ 'bash scripts/dev-shell.sh env \\', + 'KANDELO_HOMEBREW_TAP_NAME="$KANDELO_HOMEBREW_TAP_NAME" \\', 'KANDELO_HOMEBREW_FORBIDDEN_ROOTS_JSON="$KANDELO_HOMEBREW_FORBIDDEN_ROOTS_JSON" \\', ] - forbidden_root_forwarding.each do |fragment| + sidecar_env_forwarding.each do |fragment| check(sidecar_run.include?(fragment), - "publisher sidecar inspection drops trusted forbidden roots at the dev-shell boundary") + "publisher sidecar inspection drops explicit identity or root data at the dev-shell boundary") end verifier_source = File.read(File.join(REPO_ROOT, "scripts/homebrew-generate-sidecars-from-env.sh")) fingerprint_source = File.read(File.join(REPO_ROOT, "scripts/homebrew-sysroot-fingerprint.sh")) @@ -1416,9 +1447,9 @@ def check_publisher(workflow) check(browser_run.include?(fragment), "publisher browser sidecar regeneration lacks trusted forbidden-root source #{fragment}") end - forbidden_root_forwarding.each do |fragment| + sidecar_env_forwarding.each do |fragment| check(browser_run.include?(fragment), - "publisher browser sidecar regeneration drops trusted forbidden roots at the dev-shell boundary") + "publisher browser sidecar regeneration drops explicit identity or root data at the dev-shell boundary") end package_handoff_run = named_step(verify_steps, @@ -1581,6 +1612,7 @@ def check_maintenance(workflow) "kandelo-repository" => "Automattic/kandelo", "kandelo-ref" => "main", "tap-repository" => "Automattic/kandelo-homebrew", + "tap-name" => "Automattic/kandelo-homebrew", "tap-ref" => "main", "formulae" => "${{ inputs.formulae }}", "arches" => "${{ inputs.arches }}", @@ -1702,6 +1734,38 @@ def self_test(publisher, maintenance) step = mutate_named_step(w, "plan", "Validate caller trust boundary") step["run"] = step.fetch("run").sub("refs/heads/main", "refs/heads/feature") }, + "caller publishes another repository" => lambda { |w| + step = mutate_named_step(w, "plan", "Validate caller trust boundary") + step["run"] = step.fetch("run").sub( + '[ "$CALLER_REPOSITORY" = "$TAP_REPOSITORY" ]', "true" + ) + }, + "nonconventional third-party tap repository" => lambda { |w| + step = mutate_named_step(w, "plan", "Validate caller trust boundary") + step["run"] = step.fetch("run").sub( + '^[A-Za-z0-9_.-]+/homebrew-[A-Za-z0-9_.-]+$', + '^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$' + ) + }, + "repository and Homebrew tap identity mismatch" => lambda { |w| + step = mutate_named_step(w, "plan", "Validate caller trust boundary") + step["run"] = step.fetch("run").sub( + '[ "$TAP_NAME" = "${tap_owner}/${tap_short_name}" ]', "true" + ) + }, + "conventional repository aliases the protected first-party tap" => lambda { |w| + step = mutate_named_step(w, "plan", "Validate caller trust boundary") + step["run"] = step.fetch("run").sub( + '[ "$normalized_derived_tap_name" != "automattic/kandelo-homebrew" ]', "true" + ) + }, + "caller workflow rebound to first-party repository" => lambda { |w| + step = mutate_named_step(w, "plan", "Validate caller trust boundary") + step["run"] = step.fetch("run").gsub( + '$CALLER_REPOSITORY/.github/workflows/', + 'Automattic/kandelo-homebrew/.github/workflows/' + ) + }, "dry-run feature workflow" => lambda { |w| step = mutate_named_step(w, "plan", "Validate caller trust boundary") step["run"] = step.fetch("run").sub("dry-run-bottles.yml@refs/heads/main", diff --git a/scripts/homebrew-bottle-build.sh b/scripts/homebrew-bottle-build.sh index cdb4a22f32..417fb66481 100755 --- a/scripts/homebrew-bottle-build.sh +++ b/scripts/homebrew-bottle-build.sh @@ -4,6 +4,7 @@ set -euo pipefail TAP_ROOT="" TAP_REPOSITORY="${KANDELO_HOMEBREW_TAP_REPOSITORY:-Automattic/kandelo-homebrew}" +TAP_NAME_INPUT="${KANDELO_HOMEBREW_TAP_NAME:-}" FORMULA="" ARCH="" OUT_DIR="" @@ -13,7 +14,7 @@ SHARED_TEMP="${KANDELO_HOMEBREW_SHARED_TEMP:-}" usage() { cat >&2 <<'EOF' -usage: scripts/homebrew-bottle-build.sh --tap-root [--tap-repository ] --formula --arch --out --bottle-root-url +usage: scripts/homebrew-bottle-build.sh --tap-root [--tap-repository ] [--tap-name ] --formula --arch --out --bottle-root-url This script is intended to run inside scripts/dev-shell.sh. It invokes the absolute Homebrew executable named by HOMEBREW_BREW_FILE, avoiding host PATH @@ -31,6 +32,7 @@ while [ "$#" -gt 0 ]; do case "$1" in --tap-root) TAP_ROOT="${2:-}"; shift 2 ;; --tap-repository) TAP_REPOSITORY="${2:-}"; shift 2 ;; + --tap-name) TAP_NAME_INPUT="${2:-}"; shift 2 ;; --formula) FORMULA="${2:-}"; shift 2 ;; --arch) ARCH="${2:-}"; shift 2 ;; --out) OUT_DIR="${2:-}"; shift 2 ;; @@ -89,6 +91,9 @@ if [ -z "$BREW_BIN" ] || [ ! -x "$BREW_BIN" ]; then fi KANDELO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" +# shellcheck source=/dev/null +. "$KANDELO_ROOT/scripts/homebrew-tap-identity.sh" +TAP_NAME="$(homebrew_resolve_tap_name "$TAP_REPOSITORY" "$TAP_NAME_INPUT")" PATCH_FILE="$KANDELO_ROOT/homebrew/patches/0001-add-kandelo-wasm-bottle-tags.patch" . "$KANDELO_ROOT/scripts/homebrew-patched-launcher.sh" mkdir -p "$OUT_DIR/bottles" @@ -128,7 +133,6 @@ chmod 0700 "$XDG_CONFIG_HOME" "$XDG_CONFIG_HOME/homebrew" homebrew_patched_launcher_prepare "$BREW_BIN" "$PATCH_FILE" "$WORK_DIR" BREW_BIN="$HOMEBREW_PATCHED_BREW_BIN" -TAP_NAME="$(printf '%s' "$TAP_REPOSITORY" | tr '[:upper:]' '[:lower:]')" BOTTLE_TAG="${ARCH}_kandelo" export HOMEBREW_NO_AUTO_UPDATE="${HOMEBREW_NO_AUTO_UPDATE:-1}" @@ -186,9 +190,8 @@ if [ -n "$BUILD_USER" ]; then # loading TypeScript sources. Do that while the workflow identity still owns # the checkout; the isolated build identity receives no source write access. rm -rf "$KANDELO_ROOT/host/dist" - homebrew_patched_launcher_isolate "$BUILD_USER" "$WORK_DIR" "$KANDELO_ROOT" "$TAP_ROOT" - homebrew_assert_tree_not_writable_by_user "$BUILD_USER" "$OUT_DIR" - homebrew_assert_tree_not_replaceable_by_user "$BUILD_USER" "$OUT_DIR" + homebrew_patched_launcher_isolate "$BUILD_USER" \ + "$WORK_DIR" "$KANDELO_ROOT" "$TAP_ROOT" "$OUT_DIR" BREW_BIN="$HOMEBREW_PATCHED_BREW_BIN" elif [ "${GITHUB_ACTIONS:-}" = "true" ]; then echo "homebrew-bottle-build.sh: CI Formula execution requires KANDELO_HOMEBREW_BUILD_USER" >&2 @@ -301,6 +304,7 @@ python3 "$KANDELO_ROOT/scripts/homebrew-dependency-provenance.py" capture \ --brew-bin "$BREW_BIN" \ --tap-root "$TAP_ROOT" \ --tap-repository "$TAP_REPOSITORY" \ + --tap-name "$TAP_NAME" \ --tap-commit "$TAP_COMMIT" \ --formula "$FORMULA" \ --arch "$ARCH" \ diff --git a/scripts/homebrew-bottle-runtime-evidence.py b/scripts/homebrew-bottle-runtime-evidence.py index 88f35795fd..147e7218d0 100755 --- a/scripts/homebrew-bottle-runtime-evidence.py +++ b/scripts/homebrew-bottle-runtime-evidence.py @@ -84,12 +84,38 @@ def sha256_file(path: pathlib.Path) -> str: return digest.hexdigest() -def normalized_tap(repository: str) -> str: - require_string(repository, "tap repository", TAP_REPOSITORY) - owner, name = repository.lower().split("/", 1) +def normalized_identity(value: str, label: str) -> str: + require_string(value, label, TAP_REPOSITORY) + owner, name = value.lower().split("/", 1) return f"{owner}/{name}" +def normalized_tap_repository(args: argparse.Namespace) -> str: + return normalized_identity(args.tap_repository, "tap repository") + + +def normalized_tap_name(args: argparse.Namespace) -> str: + repository = normalized_tap_repository(args) + owner, repository_name = repository.split("/", 1) + if repository == "automattic/kandelo-homebrew": + expected = repository + else: + if not repository_name.startswith("homebrew-") or repository_name == "homebrew-": + fail("third-party tap repositories must use owner/homebrew-name") + expected = f"{owner}/{repository_name.removeprefix('homebrew-')}" + if expected == "automattic/kandelo-homebrew": + fail("the protected first-party tap name cannot be derived from another repository") + requested = args.tap_name + if requested is None: + if repository != "automattic/kandelo-homebrew": + fail("tap name is required when repository and Homebrew identities may differ") + requested = args.tap_repository + selected = normalized_identity(requested, "tap name") + if selected != expected: + fail("tap name does not match the tap repository") + return selected + + def validate_arguments(args: argparse.Namespace) -> None: require_string(args.formula, "formula", FORMULA_NAME) if args.arch not in ("wasm32", "wasm64"): @@ -97,8 +123,9 @@ def validate_arguments(args: argparse.Namespace) -> None: if isinstance(args.abi, bool) or not isinstance(args.abi, int) or args.abi <= 0: fail("ABI must be a positive integer") require_string(args.tap_commit, "tap commit", COMMIT) - tap = normalized_tap(args.tap_repository) - expected_root = f"https://ghcr.io/v2/{tap}" + repository = normalized_tap_repository(args) + normalized_tap_name(args) + expected_root = f"https://ghcr.io/v2/{repository}" if args.bottle_root_url != expected_root: fail(f"bottle root URL does not match {expected_root}") require_string(args.bottle_sha256, "bottle sha256", SHA256) @@ -123,6 +150,8 @@ def validate_dependency_provenance(args: argparse.Namespace) -> dict[str, Any]: str(provenance_path), "--tap-repository", args.tap_repository, + "--tap-name", + normalized_tap_name(args), "--tap-commit", args.tap_commit, "--formula", @@ -207,7 +236,7 @@ def validate_formula_info( formula = formulae[0] if not isinstance(formula, dict): fail("Homebrew Formula info record must be an object") - expected_full_name = f"{normalized_tap(args.tap_repository)}/{args.formula}" + expected_full_name = f"{normalized_tap_name(args)}/{args.formula}" if formula.get("name") != args.formula or str(formula.get("full_name", "")).lower() != expected_full_name: fail("Homebrew Formula info identity does not match the exact tap Formula") versions = formula.get("versions") @@ -319,7 +348,7 @@ def target_receipt(args: argparse.Namespace, version: str) -> dict[str, Any]: source = receipt.get("source") if not isinstance(source, dict): fail("target receipt source must be an object") - tap = normalized_tap(args.tap_repository) + tap = normalized_tap_name(args) if str(source.get("tap", "")).lower() != tap or source.get("tap_git_head") != args.tap_commit: fail("target receipt is not bound to the exact tap commit") if source.get("spec") not in (None, "stable"): @@ -417,9 +446,13 @@ def build_document(args: argparse.Namespace) -> dict[str, Any]: "dependencies": dependencies, "formula": args.formula, "node": node_evidence(args), - "schema": 1, + "schema": 2, "selection": selection, - "tap": {"commit": args.tap_commit, "repository": args.tap_repository}, + "tap": { + "commit": args.tap_commit, + "name": normalized_tap_name(args), + "repository": args.tap_repository, + }, "target": { "install_log": target_install_evidence(args, tag_name, selection), "receipt": target_receipt(args, version), @@ -434,12 +467,16 @@ def validate_document(document: Any, args: argparse.Namespace) -> None: {"abi", "arch", "bottle", "dependencies", "formula", "node", "schema", "selection", "tap", "target"}, "runtime evidence", ) - if root["schema"] != 1 or root["formula"] != args.formula or root["arch"] != args.arch: + if root["schema"] != 2 or root["formula"] != args.formula or root["arch"] != args.arch: fail("runtime evidence Formula identity does not match") if root["abi"] != args.abi: fail("runtime evidence ABI does not match") - tap = exact_keys(root["tap"], {"commit", "repository"}, "runtime evidence tap") - if tap != {"commit": args.tap_commit, "repository": args.tap_repository}: + tap = exact_keys(root["tap"], {"commit", "name", "repository"}, "runtime evidence tap") + if tap != { + "commit": args.tap_commit, + "name": normalized_tap_name(args), + "repository": args.tap_repository, + }: fail("runtime evidence tap identity does not match") version, tag_name = canonical_bottle(args) bottle = exact_keys( @@ -553,7 +590,7 @@ def validate_document(document: Any, args: argparse.Namespace) -> None: fail("runtime target receipt is not an explicit target install") if receipt["path"] != f"Cellar/{args.formula}/{version}/INSTALL_RECEIPT.json": fail("runtime target receipt path does not match") - if receipt["source_tap"] != normalized_tap(args.tap_repository) or receipt["source_tap_git_head"] != args.tap_commit: + if receipt["source_tap"] != normalized_tap_name(args) or receipt["source_tap_git_head"] != args.tap_commit: fail("runtime target receipt source does not match") require_string(receipt["sha256"], "runtime target receipt sha256", SHA256) homebrew_version = require_string( @@ -591,6 +628,7 @@ def add_common(parser: argparse.ArgumentParser) -> None: parser.add_argument("--arch", required=True) parser.add_argument("--abi", type=int, required=True) parser.add_argument("--tap-repository", required=True) + parser.add_argument("--tap-name") parser.add_argument("--tap-commit", required=True) parser.add_argument("--tap-root", required=True) parser.add_argument("--bottle-root-url", required=True) diff --git a/scripts/homebrew-create-build-handoff.sh b/scripts/homebrew-create-build-handoff.sh index e135a41da4..3789661578 100755 --- a/scripts/homebrew-create-build-handoff.sh +++ b/scripts/homebrew-create-build-handoff.sh @@ -10,6 +10,7 @@ FORMULA="" ARCH="" RELEASE_TAG="" TAP_REPOSITORY="" +TAP_NAME_INPUT="" TAP_COMMIT="" KANDELO_COMMIT="" BOTTLE_ROOT_URL="" @@ -21,7 +22,7 @@ FORBIDDEN_ROOTS=() usage() { cat >&2 <<'EOF' -usage: scripts/homebrew-create-build-handoff.sh --formula --arch --release-tag --tap-repository --tap-commit --kandelo-commit --bottle-root-url --bottle --bottle-json --dependency-provenance --out --forbidden-root [--forbidden-root ...] +usage: scripts/homebrew-create-build-handoff.sh --formula --arch --release-tag --tap-repository [--tap-name ] --tap-commit --kandelo-commit --bottle-root-url --bottle --bottle-json --dependency-provenance --out --forbidden-root [--forbidden-root ...] Creates a handoff containing only manifest.json, bottle.json, one bottle archive, and bounded dependency-pour provenance. Formula sources, environment @@ -35,6 +36,7 @@ while [ "$#" -gt 0 ]; do --arch) ARCH="${2:-}"; shift 2 ;; --release-tag) RELEASE_TAG="${2:-}"; shift 2 ;; --tap-repository) TAP_REPOSITORY="${2:-}"; shift 2 ;; + --tap-name) TAP_NAME_INPUT="${2:-}"; shift 2 ;; --tap-commit) TAP_COMMIT="${2:-}"; shift 2 ;; --kandelo-commit) KANDELO_COMMIT="${2:-}"; shift 2 ;; --bottle-root-url) BOTTLE_ROOT_URL="${2:-}"; shift 2 ;; @@ -91,6 +93,9 @@ if ! [[ "$TAP_REPOSITORY" =~ ^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$ ]]; then echo "homebrew-create-build-handoff.sh: invalid tap repository: $TAP_REPOSITORY" >&2 exit 2 fi +# shellcheck source=/dev/null +. "$SCRIPT_ROOT/homebrew-tap-identity.sh" +TAP_NAME="$(homebrew_resolve_tap_name "$TAP_REPOSITORY" "$TAP_NAME_INPUT")" case "$ARCH" in wasm32|wasm64) ;; *) echo "homebrew-create-build-handoff.sh: invalid arch: $ARCH" >&2; exit 2 ;; @@ -146,6 +151,7 @@ python3 "$SCRIPT_ROOT/homebrew-dependency-provenance.py" validate \ --formula "$FORMULA" \ --arch "$ARCH" \ --tap-repository "$TAP_REPOSITORY" \ + --tap-name "$TAP_NAME" \ --tap-commit "$TAP_COMMIT" \ --bottle-root-url "$BOTTLE_ROOT_URL" @@ -172,8 +178,8 @@ sha256_file() { BOTTLE_SHA256="$(sha256_file "$BOTTLE")" BOTTLE_BYTES="$(wc -c <"$BOTTLE" | tr -d '[:space:]')" BOTTLE_TAG="${ARCH}_kandelo" -OWNER_LOWER="$(printf '%s' "${TAP_REPOSITORY%%/*}" | tr '[:upper:]' '[:lower:]')" -REPO_LOWER="$(printf '%s' "${TAP_REPOSITORY#*/}" | tr '[:upper:]' '[:lower:]')" +OWNER_LOWER="${TAP_NAME%%/*}" +REPO_LOWER="${TAP_NAME#*/}" FORMULA_KEY="${OWNER_LOWER}/${REPO_LOWER}/${FORMULA}" FORMULA_PATH="Library/Taps/${OWNER_LOWER}/homebrew-${REPO_LOWER}/Formula/${FORMULA}.rb" BOTTLE_INSTALL_CELLAR="/home/linuxbrew/.linuxbrew/Cellar" @@ -234,6 +240,7 @@ jq -nS \ --arg arch "$ARCH" \ --arg release_tag "$RELEASE_TAG" \ --arg tap_repository "$TAP_REPOSITORY" \ + --arg tap_name "$TAP_NAME" \ --arg tap_commit "$TAP_COMMIT" \ --arg kandelo_commit "$KANDELO_COMMIT" \ --arg bottle_root_url "$BOTTLE_ROOT_URL" \ @@ -247,11 +254,12 @@ jq -nS \ --arg dependency_sha256 "$DEPENDENCY_PROVENANCE_SHA256" \ --arg dependency_bytes "$DEPENDENCY_PROVENANCE_BYTES" ' { - schema: 2, + schema: 3, formula: $formula, arch: $arch, release_tag: $release_tag, tap_repository: $tap_repository, + tap_name: $tap_name, tap_commit: $tap_commit, kandelo_commit: $kandelo_commit, bottle_root_url: $bottle_root_url, @@ -281,6 +289,7 @@ bash "$SCRIPT_ROOT/homebrew-validate-build-handoff.sh" \ --arch "$ARCH" \ --release-tag "$RELEASE_TAG" \ --tap-repository "$TAP_REPOSITORY" \ + --tap-name "$TAP_NAME" \ --tap-commit "$TAP_COMMIT" \ --kandelo-commit "$KANDELO_COMMIT" \ --bottle-root-url "$BOTTLE_ROOT_URL" \ diff --git a/scripts/homebrew-dependency-provenance.py b/scripts/homebrew-dependency-provenance.py index 503f01f660..b96c8ece73 100755 --- a/scripts/homebrew-dependency-provenance.py +++ b/scripts/homebrew-dependency-provenance.py @@ -102,12 +102,34 @@ def run_brew(brew_bin: pathlib.Path, *arguments: str) -> str: fail(f"brew {' '.join(arguments)} output is not UTF-8: {error}") -def tap_name(repository: str) -> str: - require_string(repository, "tap repository", TAP_REPOSITORY) - owner, name = repository.lower().split("/", 1) +def normalized_tap_name(name: str) -> str: + require_string(name, "tap name", TAP_REPOSITORY) + owner, name = name.lower().split("/", 1) return f"{owner}/{name}" +def selected_tap_name(args: argparse.Namespace) -> str: + repository = normalized_tap_name(args.tap_repository) + owner, repository_name = repository.split("/", 1) + if repository == "automattic/kandelo-homebrew": + expected = repository + else: + if not repository_name.startswith("homebrew-") or repository_name == "homebrew-": + fail("third-party tap repositories must use owner/homebrew-name") + expected = f"{owner}/{repository_name.removeprefix('homebrew-')}" + if expected == "automattic/kandelo-homebrew": + fail("the protected first-party tap name cannot be derived from another repository") + requested = args.tap_name + if requested is None: + if repository != "automattic/kandelo-homebrew": + fail("tap name is required when repository and Homebrew identities may differ") + requested = args.tap_repository + selected = normalized_tap_name(requested) + if selected != expected: + fail("tap name does not match the tap repository") + return selected + + def parse_expected_dependencies(contents: str, label: str, normalized_tap: str) -> set[str]: expected: set[str] = set() prefix = f"{normalized_tap}/" @@ -137,13 +159,13 @@ def expected_dependencies(path: pathlib.Path, normalized_tap: str) -> set[str]: def exact_tap_dependencies( - tap_root: pathlib.Path, repository: str, formula: str, arch: str, bottle_root_url: str + tap_root: pathlib.Path, tap_name: str, formula: str, arch: str, bottle_root_url: str ) -> dict[str, dict[str, Any]]: resolver = pathlib.Path(__file__).with_name("homebrew-formula-runtime-closure.rb") regular_file(resolver, "static Formula dependency resolver", MAX_JSON_BYTES) try: result = subprocess.run( - ["ruby", str(resolver), str(tap_root), repository, formula, arch], + ["ruby", str(resolver), str(tap_root), tap_name, formula, arch], check=False, stdout=subprocess.PIPE, stderr=subprocess.PIPE, @@ -163,7 +185,7 @@ def exact_tap_dependencies( if not isinstance(document, dict) or len(document) > MAX_DEPENDENCIES: fail(f"static Formula dependency metadata must contain at most {MAX_DEPENDENCIES} entries") - normalized_tap = tap_name(repository) + normalized_tap = normalized_tap_name(tap_name) expected_tag = f"{arch}_kandelo" dependencies: dict[str, dict[str, Any]] = {} prior_full_name = "" @@ -203,13 +225,13 @@ def exact_tap_dependencies( def exact_direct_dependencies( - tap_root: pathlib.Path, repository: str, formula: str + tap_root: pathlib.Path, tap_name: str, formula: str ) -> set[str]: resolver = pathlib.Path(__file__).with_name("homebrew-formula-runtime-closure.rb") regular_file(resolver, "static Formula dependency resolver", MAX_JSON_BYTES) try: result = subprocess.run( - ["ruby", str(resolver), str(tap_root), repository, formula, "--direct"], + ["ruby", str(resolver), str(tap_root), tap_name, formula, "--direct"], check=False, stdout=subprocess.PIPE, stderr=subprocess.PIPE, @@ -230,7 +252,7 @@ def exact_direct_dependencies( except UnicodeDecodeError as error: fail(f"static direct Formula dependency list is not UTF-8: {error}") return parse_expected_dependencies( - contents, "static direct dependency", tap_name(repository) + contents, "static direct dependency", normalized_tap_name(tap_name) ) @@ -306,7 +328,8 @@ def formula_record(info: Any, expected_full_name: str, expected_name: str) -> di def capture(args: argparse.Namespace) -> None: repository = args.tap_repository - normalized_tap = tap_name(repository) + require_string(repository, "tap repository", TAP_REPOSITORY) + normalized_tap = selected_tap_name(args) require_string(args.tap_commit, "tap commit", COMMIT) require_string(args.formula, "formula", FORMULA_NAME) if args.arch not in ("wasm32", "wasm64"): @@ -464,8 +487,9 @@ def capture(args: argparse.Namespace) -> None: "bottle_tag": bottle_tag, "dependencies": [selected[name] for name in sorted(selected)], "formula": args.formula, - "schema": 1, + "schema": 2, "tap_commit": args.tap_commit, + "tap_name": normalized_tap, "tap_repository": repository, } validate_document(output, args) @@ -511,15 +535,21 @@ def validate_document(document: Any, args: argparse.Namespace) -> None: "formula", "schema", "tap_commit", + "tap_name", "tap_repository", }, "dependency provenance", ) - if root["schema"] != 1: - fail("dependency provenance schema must be 1") + if root["schema"] != 2: + fail("dependency provenance schema must be 2") if root["formula"] != args.formula or root["arch"] != args.arch: fail("dependency provenance formula or architecture does not match the build") - if root["tap_repository"] != args.tap_repository or root["tap_commit"] != args.tap_commit: + normalized_tap = selected_tap_name(args) + if ( + root["tap_repository"] != args.tap_repository + or root["tap_name"] != normalized_tap + or root["tap_commit"] != args.tap_commit + ): fail("dependency provenance tap identity does not match the build") if root["bottle_root_url"] != args.bottle_root_url: fail("dependency provenance bottle root does not match the build") @@ -529,7 +559,6 @@ def validate_document(document: Any, args: argparse.Namespace) -> None: dependencies = root["dependencies"] if not isinstance(dependencies, list) or len(dependencies) > MAX_DEPENDENCIES: fail(f"dependency provenance must contain at most {MAX_DEPENDENCIES} dependencies") - normalized_tap = tap_name(args.tap_repository) seen: set[str] = set() prior_full_name = "" validation_tap_root = getattr(args, "tap_root", None) @@ -538,14 +567,14 @@ def validate_document(document: Any, args: argparse.Namespace) -> None: if validation_tap_root: static_dependencies = exact_tap_dependencies( pathlib.Path(validation_tap_root), - args.tap_repository, + normalized_tap, args.formula, args.arch, args.bottle_root_url, ) static_direct_dependencies = exact_direct_dependencies( pathlib.Path(validation_tap_root), - args.tap_repository, + normalized_tap, args.formula, ) for index, dependency in enumerate(dependencies): @@ -675,6 +704,7 @@ def parser() -> argparse.ArgumentParser: common.add_argument("--formula", required=True) common.add_argument("--arch", required=True) common.add_argument("--tap-repository", required=True) + common.add_argument("--tap-name") common.add_argument("--tap-commit", required=True) common.add_argument("--bottle-root-url", required=True) diff --git a/scripts/homebrew-generate-sidecars-from-env.sh b/scripts/homebrew-generate-sidecars-from-env.sh index 4fc50a18f0..2a7e15848e 100755 --- a/scripts/homebrew-generate-sidecars-from-env.sh +++ b/scripts/homebrew-generate-sidecars-from-env.sh @@ -35,6 +35,7 @@ for name in \ KANDELO_HOMEBREW_ARCH \ KANDELO_HOMEBREW_RELEASE_TAG \ KANDELO_HOMEBREW_TAP_REPOSITORY \ + KANDELO_HOMEBREW_TAP_NAME \ KANDELO_HOMEBREW_BOTTLE_ARCHIVE \ KANDELO_HOMEBREW_BOTTLE_JSON \ KANDELO_HOMEBREW_BOTTLE_ROOT_URL \ @@ -110,7 +111,10 @@ fi CACHE_KEY_SHA="$ACTUAL_BOTTLE_SHA256" FORMULA_SHA256="$(shasum -a 256 "$FORMULA_PATH" | awk '{print $1}')" -TAP_NAME="$(printf '%s' "$KANDELO_HOMEBREW_TAP_REPOSITORY" | tr '[:upper:]' '[:lower:]')" +# shellcheck source=/dev/null +. "$KANDELO_ROOT/scripts/homebrew-tap-identity.sh" +TAP_NAME="$(homebrew_resolve_tap_name \ + "$KANDELO_HOMEBREW_TAP_REPOSITORY" "$KANDELO_HOMEBREW_TAP_NAME")" SDK_FINGERPRINT="$(shasum -a 256 "$KANDELO_ROOT/sdk/activate.sh" | awk '{print $1}')" SYSROOT_FINGERPRINT="$(bash "$KANDELO_ROOT/scripts/homebrew-sysroot-fingerprint.sh" \ --kandelo-root "$BUILD_ROOT" --arch "$KANDELO_HOMEBREW_ARCH")" @@ -132,6 +136,7 @@ python3 "$KANDELO_ROOT/scripts/homebrew-dependency-provenance.py" validate \ --formula "$KANDELO_HOMEBREW_FORMULA" \ --arch "$KANDELO_HOMEBREW_ARCH" \ --tap-repository "$KANDELO_HOMEBREW_TAP_REPOSITORY" \ + --tap-name "$TAP_NAME" \ --tap-commit "$TAP_COMMIT" \ --bottle-root-url "$KANDELO_HOMEBREW_BOTTLE_ROOT_URL" \ --tap-root "$FORMULA_SOURCE_ROOT" @@ -141,6 +146,7 @@ python3 "$KANDELO_ROOT/scripts/homebrew-bottle-runtime-evidence.py" validate \ --arch "$KANDELO_HOMEBREW_ARCH" \ --abi "$ABI_VERSION" \ --tap-repository "$KANDELO_HOMEBREW_TAP_REPOSITORY" \ + --tap-name "$TAP_NAME" \ --tap-commit "$TAP_COMMIT" \ --tap-root "$KANDELO_HOMEBREW_TAP_ROOT" \ --bottle-root-url "$KANDELO_HOMEBREW_BOTTLE_ROOT_URL" \ @@ -371,7 +377,7 @@ declarations = run_json_command( / "scripts/homebrew-formula-runtime-closure.rb" ), os.environ["FORMULA_SOURCE_ROOT"], - os.environ["KANDELO_HOMEBREW_TAP_REPOSITORY"], + os.environ["TAP_NAME"], formula, "--declarations-json", ], diff --git a/scripts/homebrew-ghcr-upload.sh b/scripts/homebrew-ghcr-upload.sh index 8b8b2b57a6..32fc89fc90 100755 --- a/scripts/homebrew-ghcr-upload.sh +++ b/scripts/homebrew-ghcr-upload.sh @@ -5,6 +5,7 @@ set -euo pipefail LAYOUT="" LAYOUT_RECEIPT="" TAP_REPOSITORY="" +TAP_NAME_INPUT="" FORMULA="" OUT_JSON="" DRY_RUN=0 @@ -19,7 +20,7 @@ trap cleanup EXIT usage() { cat >&2 <<'EOF' -usage: scripts/homebrew-ghcr-upload.sh --layout --layout-receipt --tap-repository --formula --out-json [--dry-run] +usage: scripts/homebrew-ghcr-upload.sh --layout --layout-receipt --tap-repository [--tap-name ] --formula --out-json [--dry-run] Validates an explicit local OCI layout, preflights the destination reference, and uses ORAS only to copy that immutable layout to GHCR. It never evaluates @@ -32,6 +33,7 @@ while [ "$#" -gt 0 ]; do --layout) LAYOUT="${2:-}"; shift 2 ;; --layout-receipt) LAYOUT_RECEIPT="${2:-}"; shift 2 ;; --tap-repository) TAP_REPOSITORY="${2:-}"; shift 2 ;; + --tap-name) TAP_NAME_INPUT="${2:-}"; shift 2 ;; --formula) FORMULA="${2:-}"; shift 2 ;; --out-json) OUT_JSON="${2:-}"; shift 2 ;; --dry-run) DRY_RUN=1; shift ;; @@ -77,6 +79,9 @@ if ! command -v oras >/dev/null 2>&1; then fi SCRIPT_ROOT="$(cd "$(dirname "$0")" && pwd -P)" +# shellcheck source=/dev/null +. "$SCRIPT_ROOT/homebrew-tap-identity.sh" +TAP_NAME="$(homebrew_resolve_tap_name "$TAP_REPOSITORY" "$TAP_NAME_INPUT")" KIND="$(jq -er '.kind' "$LAYOUT_RECEIPT")" case "$KIND" in child) @@ -99,7 +104,8 @@ case "$KIND" in esac if [ "$(jq -er '.formula' "$LAYOUT_RECEIPT")" != "$FORMULA" ] || [ "$(jq -er '.tap_repository | ascii_downcase' "$LAYOUT_RECEIPT")" != \ - "$(printf '%s' "$TAP_REPOSITORY" | tr '[:upper:]' '[:lower:]')" ]; then + "$(printf '%s' "$TAP_REPOSITORY" | tr '[:upper:]' '[:lower:]')" ] || + [ "$(jq -er '.tap_name | ascii_downcase' "$LAYOUT_RECEIPT")" != "$TAP_NAME" ]; then echo "homebrew-ghcr-upload.sh: layout receipt publication identity mismatch" >&2 exit 2 fi @@ -250,6 +256,7 @@ jq -nS \ --arg kind "$KIND" \ --arg formula "$FORMULA" \ --arg tap_repository "$TAP_REPOSITORY" \ + --arg tap_name "$TAP_NAME" \ --arg remote "$REMOTE" \ --arg reference "$DESTINATION_REF" \ --arg digest "$EXPECTED_DIGEST" \ @@ -258,10 +265,11 @@ jq -nS \ --arg public_readback_digest "$public_readback_digest" \ --arg status "$status" \ --slurpfile layout "$LAYOUT_RECEIPT" '{ - schema: 2, + schema: 3, kind: $kind, formula: $formula, tap_repository: $tap_repository, + tap_name: $tap_name, layout: $layout[0], layout_receipt_sha256: $layout_receipt_sha256, publication: { diff --git a/scripts/homebrew-merge-bottle-json.sh b/scripts/homebrew-merge-bottle-json.sh index da95cae278..c9903d1d74 100755 --- a/scripts/homebrew-merge-bottle-json.sh +++ b/scripts/homebrew-merge-bottle-json.sh @@ -5,6 +5,7 @@ set -euo pipefail TAP_ROOT="" TAP_REPOSITORY="" +TAP_NAME_INPUT="" FORMULA="" ARCH="" BOTTLE_JSON="" @@ -15,7 +16,7 @@ RELEASE_TAG="" usage() { cat >&2 <<'EOF' -usage: scripts/homebrew-merge-bottle-json.sh --tap-root --tap-repository --formula --arch --release-tag --bottle-json --expected-sha256 --expected-root-url --expected-cellar +usage: scripts/homebrew-merge-bottle-json.sh --tap-root --tap-repository [--tap-name ] --formula --arch --release-tag --bottle-json --expected-sha256 --expected-root-url --expected-cellar EOF } @@ -23,6 +24,7 @@ while [ "$#" -gt 0 ]; do case "$1" in --tap-root) TAP_ROOT="${2:-}"; shift 2 ;; --tap-repository) TAP_REPOSITORY="${2:-}"; shift 2 ;; + --tap-name) TAP_NAME_INPUT="${2:-}"; shift 2 ;; --formula) FORMULA="${2:-}"; shift 2 ;; --arch) ARCH="${2:-}"; shift 2 ;; --release-tag) RELEASE_TAG="${2:-}"; shift 2 ;; @@ -45,6 +47,15 @@ if ! [[ "$TAP_REPOSITORY" =~ ^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$ ]]; then echo "homebrew-merge-bottle-json.sh: invalid tap repository" >&2 exit 2 fi +KANDELO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" +# shellcheck source=/dev/null +. "$KANDELO_ROOT/scripts/homebrew-tap-identity.sh" +TAP_NAME="$(homebrew_resolve_tap_name "$TAP_REPOSITORY" "$TAP_NAME_INPUT")" +EXPECTED_REPOSITORY_ROOT="https://ghcr.io/v2/$(printf '%s' "$TAP_REPOSITORY" | tr '[:upper:]' '[:lower:]')" +if [ "$EXPECTED_ROOT_URL" != "$EXPECTED_REPOSITORY_ROOT" ]; then + echo "homebrew-merge-bottle-json.sh: expected bottle root does not match tap repository" >&2 + exit 2 +fi case "$EXPECTED_CELLAR" in any) EXPECTED_CELLAR_DSL=":any" ;; any_skip_relocation) EXPECTED_CELLAR_DSL=":any_skip_relocation" ;; @@ -84,8 +95,10 @@ if [ ! -f "$FORMULA_PATH" ] || [ -L "$FORMULA_PATH" ] || \ fi TAG="${ARCH}_kandelo" +FORMULA_JSON_PATH="Library/Taps/${TAP_NAME%%/*}/homebrew-${TAP_NAME#*/}/Formula/${FORMULA}.rb" jq -e \ --arg formula "$FORMULA" \ + --arg formula_path "$FORMULA_JSON_PATH" \ --arg tag "$TAG" \ --arg sha "$EXPECTED_SHA256" \ --arg root "$EXPECTED_ROOT_URL" \ @@ -93,6 +106,7 @@ jq -e \ (keys | length) == 1 and (to_entries[0].key == $formula) and (to_entries[0].value.formula.name == $formula) and + (to_entries[0].value.formula.path == $formula_path) and (to_entries[0].value.bottle.root_url == $root) and (to_entries[0].value.bottle.cellar == $cellar) and (to_entries[0].value.bottle.rebuild | type == "number" and . >= 0 and floor == .) and @@ -104,7 +118,6 @@ jq -e \ exit 1 } -KANDELO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" # shellcheck source=/dev/null . "$KANDELO_ROOT/scripts/homebrew-sibling-bottle-policy.sh" COMPOSER="$KANDELO_ROOT/scripts/homebrew-compose-formula-bottle.rb" diff --git a/scripts/homebrew-oci-layout.py b/scripts/homebrew-oci-layout.py index 58daf172f5..629efde1e6 100755 --- a/scripts/homebrew-oci-layout.py +++ b/scripts/homebrew-oci-layout.py @@ -224,6 +224,7 @@ def validate_arguments(args: argparse.Namespace) -> None: fail(f"unsupported architecture: {args.arch}") require_int(args.abi, "ABI", 1) require_string(args.tap_repository, "tap repository", TAP_REPOSITORY) + selected_tap_name(args) require_string(args.tap_commit, "tap commit", COMMIT) require_string(args.kandelo_commit, "Kandelo commit", COMMIT) expected_root = f"https://ghcr.io/v2/{args.tap_repository.lower()}" @@ -231,8 +232,37 @@ def validate_arguments(args: argparse.Namespace) -> None: fail(f"bottle root URL must be {expected_root}") -def normalized_tap(repository: str) -> str: - return repository.lower() +def normalized_identity(value: str) -> str: + return value.lower() + + +def tap_name_for_repository(repository_value: str) -> str: + repository = normalized_identity(repository_value) + require_string(repository, "tap repository", TAP_REPOSITORY) + owner, repository_name = repository.split("/", 1) + if repository == "automattic/kandelo-homebrew": + return repository + if not repository_name.startswith("homebrew-") or repository_name == "homebrew-": + fail("third-party tap repositories must use owner/homebrew-name") + tap_name = f"{owner}/{repository_name.removeprefix('homebrew-')}" + if tap_name == "automattic/kandelo-homebrew": + fail("the protected first-party tap name cannot be derived from another repository") + return tap_name + + +def selected_tap_name(args: argparse.Namespace) -> str: + repository = normalized_identity(args.tap_repository) + expected = tap_name_for_repository(repository) + requested = getattr(args, "tap_name", None) + if requested is None: + if repository != "automattic/kandelo-homebrew": + fail("tap name is required when repository and Homebrew identities may differ") + requested = args.tap_repository + require_string(requested, "tap name", TAP_REPOSITORY) + selected = normalized_identity(requested) + if selected != expected: + fail("tap name does not match the tap repository") + return selected def formula_revision(pkg_version: str) -> int: @@ -329,14 +359,14 @@ def source_closure( *, tap_root: pathlib.Path, kandelo_root: pathlib.Path, - tap_repository: str, + tap_name: str, formula: str, expected_formula_identity: str | None = None, expected_formula_mode: str | None = None, ) -> dict[str, str]: tap_root = real_directory(tap_root, "tap source root") kandelo_root = real_directory(kandelo_root, "Kandelo source root") - require_string(tap_repository, "tap repository", TAP_REPOSITORY) + require_string(tap_name, "tap name", TAP_REPOSITORY) require_string(formula, "formula", FORMULA_NAME) formula_path = tap_root / "Formula" / f"{formula}.rb" tap_identity = formula_identity_for_path(formula_path, kandelo_root) @@ -346,7 +376,7 @@ def source_closure( formula_source = formula_path.read_text(encoding="utf-8") except UnicodeDecodeError as error: fail(f"tap Formula source is not UTF-8: {error}") - owner, repository = normalized_tap(tap_repository).split("/", 1) + owner, repository = normalized_identity(tap_name).split("/", 1) require_line = ( f'require (Tap.fetch("{owner}", "{repository}").path/' '"Kandelo/formula_support/kandelo_formula_support").to_s' @@ -720,7 +750,7 @@ def semantic_annotations(args: argparse.Namespace, bottle: dict[str, Any], metad ], "dev.kandelo.homebrew.source_closure_sha256": metadata["source_closure_sha256"], "dev.kandelo.homebrew.pkg_version": bottle["pkg_version"], - "dev.kandelo.homebrew.tap_repository": normalized_tap(args.tap_repository), + "dev.kandelo.homebrew.tap_repository": normalized_identity(args.tap_repository), } @@ -756,9 +786,9 @@ def formula_annotations( bottle["pkg_version"], bottle["rebuild"] ), "org.opencontainers.image.source": ( - f"https://github.com/{normalized_tap(args.tap_repository)}" + f"https://github.com/{normalized_identity(args.tap_repository)}" ), - "org.opencontainers.image.title": f"{normalized_tap(args.tap_repository)}/{args.formula}", + "org.opencontainers.image.title": f"{selected_tap_name(args)}/{args.formula}", "org.opencontainers.image.version": bottle["pkg_version"], } ) @@ -812,7 +842,7 @@ def build_child(args: argparse.Namespace) -> None: closure = source_closure( tap_root=pathlib.Path(args.tap_root), kandelo_root=pathlib.Path(args.kandelo_root), - tap_repository=args.tap_repository, + tap_name=selected_tap_name(args), formula=args.formula, expected_formula_identity=metadata["formula_source_identity_sha256"], expected_formula_mode=metadata["formula_source_mode"], @@ -898,8 +928,9 @@ def build_child(args: argparse.Namespace) -> None: "transport_tag": transport_tag, }, "pkg_version": bottle["pkg_version"], - "schema": 1, + "schema": 2, "tap_commit": args.tap_commit, + "tap_name": selected_tap_name(args), "tap_repository": args.tap_repository, "top_ref": top_reference(bottle["pkg_version"], bottle["rebuild"]), } @@ -1008,11 +1039,11 @@ def load_receipt(path: pathlib.Path) -> dict[str, Any]: "abi", "arch", "bottle", "bottle_rebuild", "formula", "formula_revision", "formula_source_identity_sha256", "formula_source_sha256", "kandelo_commit", "kind", "oci", "pkg_version", "schema", "source_closure_sha256", "tap_commit", - "tap_repository", "top_ref", + "tap_name", "tap_repository", "top_ref", }, "OCI child receipt", ) - if root["schema"] != 1 or root["kind"] != "child": + if root["schema"] != 2 or root["kind"] != "child": fail("OCI child receipt has an invalid schema") require_string(root["formula"], "receipt formula", FORMULA_NAME) if root["arch"] not in ("wasm32", "wasm64"): @@ -1028,7 +1059,12 @@ def load_receipt(path: pathlib.Path) -> dict[str, Any]: root["formula_source_identity_sha256"], "receipt Formula identity sha256", SHA256 ) require_string(root["source_closure_sha256"], "receipt source closure sha256", SHA256) - require_string(root["tap_repository"], "receipt tap repository", TAP_REPOSITORY) + receipt_repository = require_string( + root["tap_repository"], "receipt tap repository", TAP_REPOSITORY + ) + receipt_tap_name = require_string(root["tap_name"], "receipt tap name", TAP_REPOSITORY) + if normalized_identity(receipt_tap_name) != tap_name_for_repository(receipt_repository): + fail("OCI child receipt tap name does not match its repository") require_string(root["tap_commit"], "receipt tap commit", COMMIT) require_string(root["kandelo_commit"], "receipt Kandelo commit", COMMIT) bottle = exact_keys(root["bottle"], {"bytes", "sha256", "url"}, "receipt bottle") @@ -1098,7 +1134,7 @@ def expected_semantics(receipt: dict[str, Any]) -> dict[str, str]: ], "dev.kandelo.homebrew.source_closure_sha256": receipt["source_closure_sha256"], "dev.kandelo.homebrew.pkg_version": receipt["pkg_version"], - "dev.kandelo.homebrew.tap_repository": normalized_tap(receipt["tap_repository"]), + "dev.kandelo.homebrew.tap_repository": normalized_identity(receipt["tap_repository"]), } @@ -1111,7 +1147,7 @@ def expected_top_annotations(semantics: dict[str, str], top_ref: str) -> dict[st f"https://github.com/{semantics['dev.kandelo.homebrew.tap_repository']}" ), "org.opencontainers.image.title": ( - f"{semantics['dev.kandelo.homebrew.tap_repository']}/" + f"{tap_name_for_repository(semantics['dev.kandelo.homebrew.tap_repository'])}/" f"{semantics['dev.kandelo.homebrew.formula']}" ), "org.opencontainers.image.version": semantics["dev.kandelo.homebrew.pkg_version"], @@ -1215,7 +1251,7 @@ def validate_manifest_descriptor( f"{semantics['dev.kandelo.homebrew.tap_repository']}" ), "org.opencontainers.image.title": ( - f"{semantics['dev.kandelo.homebrew.tap_repository']}/" + f"{tap_name_for_repository(semantics['dev.kandelo.homebrew.tap_repository'])}/" f"{semantics['dev.kandelo.homebrew.formula']}" ), "org.opencontainers.image.version": semantics["dev.kandelo.homebrew.pkg_version"], @@ -1448,7 +1484,8 @@ def merge_index(args: argparse.Namespace) -> None: "kind": "index", "pkg_version": first["pkg_version"], "bottle_rebuild": first["bottle_rebuild"], - "schema": 1, + "schema": 2, + "tap_name": first["tap_name"], "tap_repository": first["tap_repository"], "top": { "digest": top_descriptor["digest"], @@ -1467,11 +1504,11 @@ def validate_index_receipt(receipt: Any) -> dict[str, Any]: { "abi", "bottle_rebuild", "children", "formula", "formula_revision", "formula_source_identity_sha256", "kind", "pkg_version", "schema", - "source_closure_sha256", "tap_repository", "top", + "source_closure_sha256", "tap_name", "tap_repository", "top", }, "OCI index receipt", ) - if root["schema"] != 1 or root["kind"] != "index": + if root["schema"] != 2 or root["kind"] != "index": fail("OCI index receipt has an invalid schema") require_int(root["abi"], "OCI index receipt ABI", 1) formula = require_string(root["formula"], "OCI index receipt formula", FORMULA_NAME) @@ -1487,7 +1524,14 @@ def validate_index_receipt(receipt: Any) -> dict[str, Any]: require_string( root["source_closure_sha256"], "OCI index receipt source closure sha256", SHA256 ) - require_string(root["tap_repository"], "OCI index receipt tap repository", TAP_REPOSITORY) + receipt_repository = require_string( + root["tap_repository"], "OCI index receipt tap repository", TAP_REPOSITORY + ) + receipt_tap_name = require_string( + root["tap_name"], "OCI index receipt tap name", TAP_REPOSITORY + ) + if normalized_identity(receipt_tap_name) != tap_name_for_repository(receipt_repository): + fail("OCI index receipt tap name does not match its repository") children = root["children"] if not isinstance(children, list) or not 1 <= len(children) <= 2: fail("OCI index receipt must contain one or two children") @@ -1558,7 +1602,7 @@ def validate_index_layout(layout: pathlib.Path, receipt: dict[str, Any]) -> None ], "dev.kandelo.homebrew.source_closure_sha256": receipt["source_closure_sha256"], "dev.kandelo.homebrew.pkg_version": receipt["pkg_version"], - "dev.kandelo.homebrew.tap_repository": normalized_tap(receipt["tap_repository"]), + "dev.kandelo.homebrew.tap_repository": normalized_identity(receipt["tap_repository"]), } if top["annotations"] != expected_top_annotations(semantics, receipt["top"]["ref"]): fail("Homebrew top index semantic identity does not match receipt") @@ -1590,7 +1634,7 @@ def source_closure_command(args: argparse.Namespace) -> None: closure = source_closure( tap_root=pathlib.Path(args.tap_root), kandelo_root=pathlib.Path(args.kandelo_root), - tap_repository=args.tap_repository, + tap_name=selected_tap_name(args), formula=args.formula, ) write_json( @@ -1598,8 +1642,9 @@ def source_closure_command(args: argparse.Namespace) -> None: { "formula": args.formula, **closure, - "schema": 1, - "tap_repository": normalized_tap(args.tap_repository), + "schema": 2, + "tap_name": selected_tap_name(args), + "tap_repository": normalized_identity(args.tap_repository), }, ) @@ -2052,6 +2097,7 @@ def validate_publication_receipt_command(args: argparse.Namespace) -> None: tap_repository = require_string( args.tap_repository, "publication tap repository", TAP_REPOSITORY ) + tap_name = selected_tap_name(args) layout_path = pathlib.Path(args.layout_receipt) if args.kind == "child": layout = load_receipt(layout_path) @@ -2067,7 +2113,8 @@ def validate_publication_receipt_command(args: argparse.Namespace) -> None: expected_previous = layout["top"]["previous_digest"] if ( layout["formula"] != formula - or normalized_tap(layout["tap_repository"]) != normalized_tap(tap_repository) + or normalized_identity(layout["tap_repository"]) != normalized_identity(tap_repository) + or normalized_identity(layout["tap_name"]) != tap_name ): fail("publication layout identity does not match the requested Formula and tap") @@ -2075,11 +2122,11 @@ def validate_publication_receipt_command(args: argparse.Namespace) -> None: load_json(pathlib.Path(args.receipt), "OCI publication receipt", MAX_RECEIPT_BYTES), { "formula", "kind", "layout", "layout_receipt_sha256", "publication", - "schema", "tap_repository", + "schema", "tap_name", "tap_repository", }, "OCI publication receipt", ) - if receipt["schema"] != 2 or receipt["kind"] != args.kind: + if receipt["schema"] != 3 or receipt["kind"] != args.kind: fail("OCI publication receipt has an invalid schema or kind") receipt_formula = require_string( receipt["formula"], "OCI publication receipt Formula", FORMULA_NAME @@ -2087,7 +2134,14 @@ def validate_publication_receipt_command(args: argparse.Namespace) -> None: receipt_tap = require_string( receipt["tap_repository"], "OCI publication receipt tap repository", TAP_REPOSITORY ) - if receipt_formula != formula or normalized_tap(receipt_tap) != normalized_tap(tap_repository): + receipt_tap_name = require_string( + receipt["tap_name"], "OCI publication receipt tap name", TAP_REPOSITORY + ) + if ( + receipt_formula != formula + or normalized_identity(receipt_tap) != normalized_identity(tap_repository) + or normalized_identity(receipt_tap_name) != tap_name + ): fail("OCI publication receipt identity does not match the requested Formula and tap") if receipt["layout"] != layout: fail("OCI publication receipt does not embed the exact layout receipt") @@ -2104,7 +2158,7 @@ def validate_publication_receipt_command(args: argparse.Namespace) -> None: }, "OCI publication result", ) - expected_remote = f"ghcr.io/{normalized_tap(tap_repository)}/{formula}" + expected_remote = f"ghcr.io/{normalized_identity(tap_repository)}/{formula}" if publication["remote"] != expected_remote: fail("OCI publication receipt remote is invalid") if publication["reference"] != expected_reference: @@ -2135,6 +2189,7 @@ def parser() -> argparse.ArgumentParser: "out-layout", "out-receipt", ): child.add_argument(f"--{flag}", required=True) + child.add_argument("--tap-name") child.add_argument("--abi", type=int, required=True) child.set_defaults(handler=build_child) merge = commands.add_parser("merge-index") @@ -2154,6 +2209,7 @@ def parser() -> argparse.ArgumentParser: closure = commands.add_parser("source-closure") for flag in ("tap-root", "kandelo-root", "tap-repository", "formula", "out"): closure.add_argument(f"--{flag}", required=True) + closure.add_argument("--tap-name") closure.set_defaults(handler=source_closure_command) validate_index = commands.add_parser("validate-index") validate_index.add_argument("--layout", required=True) @@ -2174,6 +2230,7 @@ def parser() -> argparse.ArgumentParser: validate_publication_receipt.add_argument("--kind", choices=("child", "index"), required=True) validate_publication_receipt.add_argument("--formula", required=True) validate_publication_receipt.add_argument("--tap-repository", required=True) + validate_publication_receipt.add_argument("--tap-name") validate_publication_receipt.add_argument("--allow-dry-run", action="store_true") validate_publication_receipt.set_defaults(handler=validate_publication_receipt_command) return root diff --git a/scripts/homebrew-patched-launcher.sh b/scripts/homebrew-patched-launcher.sh index 0423786569..4e15ddbaf1 100644 --- a/scripts/homebrew-patched-launcher.sh +++ b/scripts/homebrew-patched-launcher.sh @@ -9,6 +9,7 @@ HOMEBREW_PATCHED_OVERLAY="" HOMEBREW_PATCHED_LAUNCHER="" HOMEBREW_PATCHED_BREW_BIN="" HOMEBREW_PATCHED_PROTECTED_DIR="" +HOMEBREW_PATCHED_SOURCE_ALIAS_DIR="" HOMEBREW_PATCHED_INTEGRITY_SHA256="" HOMEBREW_PATCHED_SUDO_BIN="" HOMEBREW_PATCHED_SYSTEMD_RUN_BIN="" @@ -50,11 +51,13 @@ homebrew_assert_tree_not_writable_by_user() { echo "homebrew-patched-launcher: privileged host boundary is not initialized" >&2 return 2 } - writable="$("$HOMEBREW_PATCHED_SUDO_BIN" -H -u "$user" -- \ + if ! writable="$("$HOMEBREW_PATCHED_SUDO_BIN" -H -u "$user" -- \ find "$tree" -xdev \ \( -writable -print -quit \) -o \ - \( -type d \( ! -readable -o ! -executable \) -prune \) \ - 2>/dev/null)" + \( -type d \( ! -readable -o ! -executable \) -prune \))"; then + echo "homebrew-patched-launcher: could not inspect protected source as $user: $tree" >&2 + return 2 + fi if [ -n "$writable" ]; then echo "homebrew-patched-launcher: build user can write protected source: $writable" >&2 return 1 @@ -137,6 +140,11 @@ homebrew_patched_launcher_cleanup() { >/dev/null 2>&1 || true HOMEBREW_PATCHED_PROTECTED_DIR="" fi + if [ -n "$HOMEBREW_PATCHED_SOURCE_ALIAS_DIR" ]; then + "$HOMEBREW_PATCHED_SUDO_BIN" rm -rf "$HOMEBREW_PATCHED_SOURCE_ALIAS_DIR" \ + >/dev/null 2>&1 || true + HOMEBREW_PATCHED_SOURCE_ALIAS_DIR="" + fi if [ -n "$HOMEBREW_PATCHED_LAUNCHER" ] && [ -L "$HOMEBREW_PATCHED_LAUNCHER" ]; then rm -f "$HOMEBREW_PATCHED_LAUNCHER" 2>/dev/null || \ "$HOMEBREW_PATCHED_SUDO_BIN" rm -f "$HOMEBREW_PATCHED_LAUNCHER" \ @@ -164,16 +172,17 @@ homebrew_patched_launcher_cleanup() { # to a dedicated user inside a transient systemd service. KillMode=control-group # makes double-forked or session-detached descendants part of the call lifecycle. homebrew_patched_launcher_isolate() { - if [ "$#" -ne 4 ]; then - echo "homebrew_patched_launcher_isolate: expected BUILD_USER WORK_DIR KANDELO_ROOT TAP_ROOT" >&2 + if [ "$#" -ne 5 ]; then + echo "homebrew_patched_launcher_isolate: expected BUILD_USER WORK_DIR KANDELO_ROOT TAP_ROOT OUTPUT_ROOT" >&2 return 2 fi - local build_user="$1" work_dir="$2" kandelo_root="$3" tap_root="$4" - local build_group build_home protected_brew wrapper_source wrapper_path + local build_user="$1" work_dir="$2" kandelo_root="$3" tap_root="$4" output_root="$5" + local build_group build_home protected_brew protected_audit + local wrapper_source wrapper_path audit_source local mutable_root protected_root local sudo_bin sudo_mode env_bin variable value patched_prefix patched_repo local systemd_run_bin systemctl_bin getent_bin pgrep_bin pkill_bin - local build_uid systemd_slice unit_prefix + local build_uid systemd_slice unit_prefix source_alias_dir local -a preserved_variables [ "$(uname -s)" = "Linux" ] || { @@ -218,6 +227,8 @@ homebrew_patched_launcher_isolate() { "$build_user" "$pgrep_bin" /usr/bin/pgrep pgrep homebrew_assert_protected_host_executable \ "$build_user" "$pkill_bin" /usr/bin/pkill pkill /usr/bin/pgrep + homebrew_assert_protected_host_executable \ + "$build_user" /usr/bin/findmnt /usr/bin/findmnt findmnt [ -d /run/systemd/system ] || { echo "homebrew-patched-launcher: systemd is not the active service manager" >&2 return 2 @@ -235,6 +246,19 @@ homebrew_patched_launcher_isolate() { return 2 } + for protected_root in "$kandelo_root" "$tap_root" "$output_root"; do + if [ ! -d "$protected_root" ] || [ -L "$protected_root" ]; then + echo "homebrew-patched-launcher: protected root is not a real directory: $protected_root" >&2 + return 2 + fi + case "$protected_root" in + *:*) + echo "homebrew-patched-launcher: protected root cannot contain ':' for a systemd bind: $protected_root" >&2 + return 2 + ;; + esac + done + for mutable_root in "$work_dir" "$HOMEBREW_CACHE" "$HOMEBREW_TEMP"; do if [ ! -d "$mutable_root" ] || [ -L "$mutable_root" ]; then echo "homebrew-patched-launcher: mutable build root is not a real directory: $mutable_root" >&2 @@ -262,9 +286,43 @@ homebrew_patched_launcher_isolate() { HOMEBREW_PATCHED_PROTECTED_DIR="$HOMEBREW_PATCHED_PREFIX/.kandelo-homebrew-$$-${RANDOM}" "$sudo_bin" install -d -o root -g root -m 0755 "$HOMEBREW_PATCHED_PROTECTED_DIR" + source_alias_dir="$work_dir/source-aliases" + "$sudo_bin" install -d -o root -g root -m 0555 \ + "$source_alias_dir" "$source_alias_dir/kandelo" "$source_alias_dir/tap" + HOMEBREW_PATCHED_SOURCE_ALIAS_DIR="$source_alias_dir" protected_brew="$HOMEBREW_PATCHED_PROTECTED_DIR/brew" "$sudo_bin" ln -s "$HOMEBREW_PATCHED_OVERLAY/bin/brew" "$protected_brew" + audit_source="$work_dir/audit-source-aliases" + protected_audit="$HOMEBREW_PATCHED_PROTECTED_DIR/audit-source-aliases" + { + printf '#!/usr/bin/env bash\nset -euo pipefail\n' + printf 'expected_kandelo=%q\n' "$source_alias_dir/kandelo" + printf 'expected_tap=%q\n' "$source_alias_dir/tap" + printf 'if [ "${HOMEBREW_KANDELO_ROOT:-}" != "$expected_kandelo" ] || ' + printf '[ "${KANDELO_HOMEBREW_KANDELO_ROOT:-}" != "$expected_kandelo" ]; then\n' + printf ' echo "homebrew-patched-launcher: isolated Kandelo root does not use the protected alias" >&2\n' + printf ' exit 2\nfi\n' + printf 'for source_alias in "$expected_kandelo" "$expected_tap"; do\n' + printf ' if [ ! -d "$source_alias" ] || [ ! -r "$source_alias" ] || [ ! -x "$source_alias" ]; then\n' + printf ' echo "homebrew-patched-launcher: protected source alias is inaccessible: $source_alias" >&2\n' + printf ' exit 2\n fi\n' + printf ' mount_options="$(/usr/bin/findmnt --noheadings --output VFS-OPTIONS --target "$source_alias")" || {\n' + printf ' echo "homebrew-patched-launcher: could not inspect protected source mount: $source_alias" >&2\n' + printf ' exit 2\n }\n' + printf ' case ",${mount_options// /}," in\n' + printf ' *,ro,*) ;;\n' + printf ' *) echo "homebrew-patched-launcher: protected source mount is writable: $source_alias" >&2; exit 1 ;;\n' + printf ' esac\ndone\n' + printf 'for hidden_root in %q %q %q; do\n' \ + "$kandelo_root" "$tap_root" "$output_root" + printf ' if [ -e "$hidden_root" ] || [ -r "$hidden_root" ] || [ -x "$hidden_root" ]; then\n' + printf ' echo "homebrew-patched-launcher: original protected root is visible to Formula execution: $hidden_root" >&2\n' + printf ' exit 1\n fi\ndone\n' + } >"$audit_source" + "$sudo_bin" install -o root -g root -m 0555 "$audit_source" "$protected_audit" + rm -f "$audit_source" + wrapper_source="$work_dir/run-isolated-brew" wrapper_path="$HOMEBREW_PATCHED_PROTECTED_DIR/run-brew" systemd_slice="kandelo-homebrew-build-${build_uid}.slice" @@ -274,8 +332,8 @@ homebrew_patched_launcher_isolate() { PATH XDG_CONFIG_HOME HOMEBREW_CACHE HOMEBREW_TEMP HOMEBREW_NO_AUTO_UPDATE HOMEBREW_NO_INSTALL_CLEANUP HOMEBREW_NO_ANALYTICS HOMEBREW_DEVELOPER - KANDELO_HOMEBREW_ARCH KANDELO_HOMEBREW_KANDELO_ROOT - HOMEBREW_KANDELO_ARCH HOMEBREW_KANDELO_ROOT HOMEBREW_KANDELO_NODE + KANDELO_HOMEBREW_ARCH + HOMEBREW_KANDELO_ARCH HOMEBREW_KANDELO_NODE HOMEBREW_KANDELO_LLVM_BIN HOMEBREW_KANDELO_ABI HOMEBREW_KANDELO_NODE_RECEIPT_PATH LLVM_BIN WASM_POSIX_LLVM_DIR @@ -288,9 +346,11 @@ homebrew_patched_launcher_isolate() { printf 'if [ -n "${%s+x}" ]; then bottle_tag_env+=("%s=${%s}"); fi\n' \ "$variable" "$variable" "$variable" done - # The workflow checkout may live below a home directory that the isolated - # build identity cannot traverse. The mutable work root was already - # verified for that identity, so use it as the service working directory. + printf 'command_path=%q\n' "$protected_brew" + printf 'if [ "${1:-}" = __kandelo_verify_source_aliases ]; then\n' + printf ' [ "$#" -eq 1 ] || { echo "homebrew-patched-launcher: source audit accepts no arguments" >&2; exit 2; }\n' + printf ' command_path=%q\n' "$protected_audit" + printf ' shift\nfi\n' printf 'working_directory=%q\n' "$work_dir" printf 'unit=%q-$$-${RANDOM}.service\n' "$unit_prefix" printf 'exec %q -n -- %q --quiet --wait --collect --pipe' \ @@ -300,6 +360,11 @@ homebrew_patched_launcher_isolate() { "--uid=$build_user" "--gid=$build_group" \ "--property=KillMode=control-group" "--property=SendSIGKILL=yes" \ "--property=TimeoutStopSec=10s" "--property=NoNewPrivileges=yes" \ + "--property=BindReadOnlyPaths=$kandelo_root:$source_alias_dir/kandelo" \ + "--property=BindReadOnlyPaths=$tap_root:$source_alias_dir/tap" \ + "--property=InaccessiblePaths=$kandelo_root" \ + "--property=InaccessiblePaths=$tap_root" \ + "--property=InaccessiblePaths=$output_root" \ "--service-type=exec" \ "--expand-environment=no" printf ' --working-directory="$working_directory" -- %q -i' "$env_bin" @@ -311,14 +376,17 @@ homebrew_patched_launcher_isolate() { printf ' %q' "$variable=$value" fi done - printf ' "${bottle_tag_env[@]}" %q "$@"\n' "$protected_brew" + printf ' %q %q' "HOMEBREW_KANDELO_ROOT=$source_alias_dir/kandelo" \ + "KANDELO_HOMEBREW_KANDELO_ROOT=$source_alias_dir/kandelo" + printf ' "${bottle_tag_env[@]}" "$command_path" "$@"\n' } >"$wrapper_source" "$sudo_bin" install -o root -g root -m 0555 "$wrapper_source" "$wrapper_path" rm -f "$wrapper_source" "$sudo_bin" chmod 0555 "$HOMEBREW_PATCHED_PROTECTED_DIR" - for protected_root in \ - "$kandelo_root" "$tap_root" "$HOMEBREW_PATCHED_REPO" "$HOMEBREW_PATCHED_OVERLAY"; do + # The overlay is a Git worktree, so its backing repository must remain + # traversable. Protect the Formula-executing overlay itself instead. + for protected_root in "$source_alias_dir" "$HOMEBREW_PATCHED_OVERLAY"; do homebrew_assert_tree_not_writable_by_user "$build_user" "$protected_root" homebrew_assert_tree_not_replaceable_by_user "$build_user" "$protected_root" done @@ -338,8 +406,18 @@ homebrew_patched_launcher_isolate() { "$sudo_bin" rm -f "$HOMEBREW_PATCHED_LAUNCHER" HOMEBREW_PATCHED_LAUNCHER="$protected_brew" HOMEBREW_PATCHED_BREW_BIN="$wrapper_path" - patched_prefix="$("$HOMEBREW_PATCHED_BREW_BIN" --prefix)" || return - patched_repo="$("$HOMEBREW_PATCHED_BREW_BIN" --repository)" || return + "$HOMEBREW_PATCHED_BREW_BIN" __kandelo_verify_source_aliases || { + echo "homebrew-patched-launcher: isolated source aliases failed verification" >&2 + return 1 + } + if ! patched_prefix="$("$HOMEBREW_PATCHED_BREW_BIN" --prefix)"; then + echo "homebrew-patched-launcher: isolated wrapper could not report the Homebrew prefix" >&2 + return 1 + fi + if ! patched_repo="$("$HOMEBREW_PATCHED_BREW_BIN" --repository)"; then + echo "homebrew-patched-launcher: isolated wrapper could not report the Homebrew repository" >&2 + return 1 + fi [ "$patched_prefix" = "$HOMEBREW_PATCHED_PREFIX" ] || { echo "homebrew-patched-launcher: isolated wrapper changed Homebrew prefix" >&2 return 1 diff --git a/scripts/homebrew-publish-sidecars.sh b/scripts/homebrew-publish-sidecars.sh index 080e2d4a00..94422a9211 100755 --- a/scripts/homebrew-publish-sidecars.sh +++ b/scripts/homebrew-publish-sidecars.sh @@ -9,6 +9,8 @@ KANDELO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" # shellcheck source=/dev/null . "$KANDELO_ROOT/scripts/homebrew-sibling-bottle-policy.sh" TAP_ROOT="" +TAP_REPOSITORY="Automattic/kandelo-homebrew" +TAP_NAME_INPUT="" SIDECAR_ROOT="" PUBLICATION_HANDOFF="" FORMULA="" @@ -31,7 +33,7 @@ COMPOSE_ROOT="" usage() { cat >&2 <<'EOF' -usage: scripts/homebrew-publish-sidecars.sh --tap-root --formula --arch --release-tag --status [--kandelo-commit ] [--tap-commit ] [--publication-handoff ] [--sidecar-root ] [--error ] [--reason ] [--rollback-ref ] [--deleted-package-url --deletion-reason ] [--repair-only] [--dry-run] [--no-lock] +usage: scripts/homebrew-publish-sidecars.sh --tap-root [--tap-repository ] [--tap-name ] --formula --arch --release-tag --status [--kandelo-commit ] [--tap-commit ] [--publication-handoff ] [--sidecar-root ] [--error ] [--reason ] [--rollback-ref ] [--deleted-package-url --deletion-reason ] [--repair-only] [--dry-run] [--no-lock] Success either composes a validated package-scoped --publication-handoff against refreshed tap state or publishes a generated --sidecar-root payload, @@ -47,6 +49,8 @@ while [ "$#" -gt 0 ]; do case "$1" in --kandelo-root) KANDELO_ROOT="${2:-}"; shift 2 ;; --tap-root) TAP_ROOT="${2:-}"; shift 2 ;; + --tap-repository) TAP_REPOSITORY="${2:-}"; shift 2 ;; + --tap-name) TAP_NAME_INPUT="${2:-}"; shift 2 ;; --sidecar-root) SIDECAR_ROOT="${2:-}"; shift 2 ;; --publication-handoff) PUBLICATION_HANDOFF="${2:-}"; shift 2 ;; --formula) FORMULA="${2:-}"; shift 2 ;; @@ -82,6 +86,10 @@ require arch "$ARCH" require release-tag "$RELEASE_TAG" require status "$STATUS" +# shellcheck source=/dev/null +. "$KANDELO_ROOT/scripts/homebrew-tap-identity.sh" +TAP_NAME="$(homebrew_resolve_tap_name "$TAP_REPOSITORY" "$TAP_NAME_INPUT")" + if ! [[ "$FORMULA" =~ ^[a-z0-9][a-z0-9._-]*$ ]]; then echo "homebrew-publish-sidecars.sh: invalid formula name: $FORMULA" >&2 exit 2 @@ -320,10 +328,11 @@ compose_publication_handoff() { jq -e \ --arg formula "$FORMULA" --arg arch "$ARCH" --arg tag "$tag" \ --arg release_tag "$RELEASE_TAG" --arg tap_commit "$TAP_COMMIT" \ + --arg tap_repository "$TAP_REPOSITORY" --arg tap_name "$TAP_NAME" \ --arg kandelo_commit "$KANDELO_COMMIT" --arg sha "$bottle_sha" \ --arg url "$bottle_url" ' .schema == 1 and .release_tag == $release_tag and - .tap_repository == "Automattic/kandelo-homebrew" and + .tap_repository == $tap_repository and .tap_name == $tap_name and .tap_commit == $tap_commit and .kandelo_commit == $kandelo_commit and (.packages | length) == 1 and .packages[0].name == $formula and @@ -365,7 +374,8 @@ compose_publication_handoff() { bash "$KANDELO_ROOT/scripts/homebrew-validate-formula-source-closure.sh" \ --tap-root "$COMPOSE_ROOT" \ - --tap-repository "Automattic/kandelo-homebrew" \ + --tap-repository "$TAP_REPOSITORY" \ + --tap-name "$TAP_NAME" \ --formula "$FORMULA" \ --base-ref "$input_tap_commit" >/dev/null @@ -376,7 +386,8 @@ compose_publication_handoff() { --input "$handoff/build/dependency-provenance.json" \ --formula "$FORMULA" \ --arch "$ARCH" \ - --tap-repository "Automattic/kandelo-homebrew" \ + --tap-repository "$TAP_REPOSITORY" \ + --tap-name "$TAP_NAME" \ --tap-commit "$input_tap_commit" \ --bottle-root-url "$bottle_root" \ --tap-root "$COMPOSE_ROOT" diff --git a/scripts/homebrew-tap-identity.sh b/scripts/homebrew-tap-identity.sh new file mode 100755 index 0000000000..d34a8a7250 --- /dev/null +++ b/scripts/homebrew-tap-identity.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env bash +# Shared validation for GitHub tap repositories and canonical Homebrew tap names. + +homebrew_resolve_tap_name() { + local repository="${1:-}" requested_name="${2:-}" normalized_repository + local normalized_name owner repository_name expected_name + + if ! [[ "$repository" =~ ^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$ ]]; then + echo "homebrew-tap-identity.sh: invalid tap repository: $repository" >&2 + return 2 + fi + normalized_repository="$(printf '%s' "$repository" | tr '[:upper:]' '[:lower:]')" + if [ -z "$requested_name" ]; then + if [ "$normalized_repository" != "automattic/kandelo-homebrew" ]; then + echo "homebrew-tap-identity.sh: tap name is required when repository and Homebrew identities may differ" >&2 + return 2 + fi + requested_name="$repository" + fi + if ! [[ "$requested_name" =~ ^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$ ]]; then + echo "homebrew-tap-identity.sh: invalid tap name: $requested_name" >&2 + return 2 + fi + normalized_name="$(printf '%s' "$requested_name" | tr '[:upper:]' '[:lower:]')" + if [ "$normalized_repository" = "automattic/kandelo-homebrew" ]; then + expected_name="automattic/kandelo-homebrew" + else + owner="${normalized_repository%%/*}" + repository_name="${normalized_repository#*/}" + case "$repository_name" in + homebrew-?*) expected_name="$owner/${repository_name#homebrew-}" ;; + *) + echo "homebrew-tap-identity.sh: third-party tap repositories must use owner/homebrew-name" >&2 + return 2 + ;; + esac + if [ "$expected_name" = "automattic/kandelo-homebrew" ]; then + echo "homebrew-tap-identity.sh: the protected first-party tap name cannot be derived from another repository" >&2 + return 2 + fi + fi + if [ "$normalized_name" != "$expected_name" ]; then + echo "homebrew-tap-identity.sh: tap name $requested_name does not match repository $repository" >&2 + return 2 + fi + printf '%s\n' "$normalized_name" +} diff --git a/scripts/homebrew-validate-build-handoff.sh b/scripts/homebrew-validate-build-handoff.sh index 650a4ab416..747a7ca4e6 100755 --- a/scripts/homebrew-validate-build-handoff.sh +++ b/scripts/homebrew-validate-build-handoff.sh @@ -11,6 +11,7 @@ FORMULA="" ARCH="" RELEASE_TAG="" TAP_REPOSITORY="" +TAP_NAME_INPUT="" TAP_COMMIT="" KANDELO_COMMIT="" BOTTLE_ROOT_URL="" @@ -21,7 +22,7 @@ FORBIDDEN_ROOTS=() usage() { cat >&2 <<'EOF' -usage: scripts/homebrew-validate-build-handoff.sh --handoff --formula --arch --release-tag --tap-repository --tap-commit --kandelo-commit --bottle-root-url --forbidden-root [--forbidden-root ...] [--tap-root ] [--out-env ] [--out-bottle-json ] +usage: scripts/homebrew-validate-build-handoff.sh --handoff --formula --arch --release-tag --tap-repository [--tap-name ] --tap-commit --kandelo-commit --bottle-root-url --forbidden-root [--forbidden-root ...] [--tap-root ] [--out-env ] [--out-bottle-json ] Validates an untrusted build handoff against values from the publisher plan. The handoff must contain exactly manifest.json, bottle.json, @@ -41,6 +42,7 @@ while [ "$#" -gt 0 ]; do --arch) ARCH="${2:-}"; shift 2 ;; --release-tag) RELEASE_TAG="${2:-}"; shift 2 ;; --tap-repository) TAP_REPOSITORY="${2:-}"; shift 2 ;; + --tap-name) TAP_NAME_INPUT="${2:-}"; shift 2 ;; --tap-commit) TAP_COMMIT="${2:-}"; shift 2 ;; --kandelo-commit) KANDELO_COMMIT="${2:-}"; shift 2 ;; --bottle-root-url) BOTTLE_ROOT_URL="${2:-}"; shift 2 ;; @@ -93,6 +95,9 @@ if ! [[ "$TAP_REPOSITORY" =~ ^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$ ]]; then echo "homebrew-validate-build-handoff.sh: invalid tap repository: $TAP_REPOSITORY" >&2 exit 2 fi +# shellcheck source=/dev/null +. "$SCRIPT_ROOT/homebrew-tap-identity.sh" +TAP_NAME="$(homebrew_resolve_tap_name "$TAP_REPOSITORY" "$TAP_NAME_INPUT")" case "$ARCH" in wasm32|wasm64) ;; *) echo "homebrew-validate-build-handoff.sh: invalid arch: $ARCH" >&2; exit 2 ;; @@ -176,6 +181,7 @@ if ! jq -e \ --arg arch "$ARCH" \ --arg release_tag "$RELEASE_TAG" \ --arg tap_repository "$TAP_REPOSITORY" \ + --arg tap_name "$TAP_NAME" \ --arg tap_commit "$TAP_COMMIT" \ --arg kandelo_commit "$KANDELO_COMMIT" \ --arg bottle_root_url "$BOTTLE_ROOT_URL" ' @@ -183,13 +189,14 @@ if ! jq -e \ type == "object" and keys == ($expected | sort); exact_keys([ "arch", "bottle", "bottle_root_url", "dependency_provenance", "formula", - "kandelo_commit", "release_tag", "schema", "tap_commit", "tap_repository" + "kandelo_commit", "release_tag", "schema", "tap_commit", "tap_name", "tap_repository" ]) and - .schema == 2 and + .schema == 3 and .formula == $formula and .arch == $arch and .release_tag == $release_tag and .tap_repository == $tap_repository and + .tap_name == $tap_name and .tap_commit == $tap_commit and .kandelo_commit == $kandelo_commit and .bottle_root_url == $bottle_root_url and @@ -222,8 +229,8 @@ EXPECTED_DEPENDENCY_BYTES="$(jq -r '.dependency_provenance.bytes' "$MANIFEST")" BOTTLE_RELOCATION_CELLAR="$(jq -r '.bottle.cellar' "$MANIFEST")" BOTTLE_TAG="${ARCH}_kandelo" BOTTLE_ARCHIVE="$HANDOFF/$ARCHIVE_NAME" -OWNER_LOWER="$(printf '%s' "${TAP_REPOSITORY%%/*}" | tr '[:upper:]' '[:lower:]')" -REPO_LOWER="$(printf '%s' "${TAP_REPOSITORY#*/}" | tr '[:upper:]' '[:lower:]')" +OWNER_LOWER="${TAP_NAME%%/*}" +REPO_LOWER="${TAP_NAME#*/}" FORMULA_KEY="${OWNER_LOWER}/${REPO_LOWER}/${FORMULA}" FORMULA_PATH="Library/Taps/${OWNER_LOWER}/homebrew-${REPO_LOWER}/Formula/${FORMULA}.rb" BOTTLE_INSTALL_CELLAR="/home/linuxbrew/.linuxbrew/Cellar" @@ -278,6 +285,7 @@ dependency_validation_args=( --formula "$FORMULA" --arch "$ARCH" --tap-repository "$TAP_REPOSITORY" + --tap-name "$TAP_NAME" --tap-commit "$TAP_COMMIT" --bottle-root-url "$BOTTLE_ROOT_URL" ) @@ -408,6 +416,7 @@ if [ -n "$OUT_ENV" ]; then printf 'ARCH=%q\n' "$ARCH" printf 'RELEASE_TAG=%q\n' "$RELEASE_TAG" printf 'TAP_REPOSITORY=%q\n' "$TAP_REPOSITORY" + printf 'TAP_NAME=%q\n' "$TAP_NAME" printf 'TAP_COMMIT=%q\n' "$TAP_COMMIT" printf 'KANDELO_COMMIT=%q\n' "$KANDELO_COMMIT" printf 'BOTTLE_ROOT_URL=%q\n' "$BOTTLE_ROOT_URL" diff --git a/scripts/homebrew-validate-formula-source-closure.sh b/scripts/homebrew-validate-formula-source-closure.sh index 75886b7064..2e11a8c29e 100755 --- a/scripts/homebrew-validate-formula-source-closure.sh +++ b/scripts/homebrew-validate-formula-source-closure.sh @@ -6,13 +6,14 @@ SCRIPT_ROOT="$(cd "$(dirname "$0")" && pwd -P)" TAP_ROOT="" TAP_REPOSITORY="" +TAP_NAME_INPUT="" FORMULA="" BASE_REF="" REVIEWED_TAP_ROOT="" usage() { cat >&2 <<'EOF' -usage: scripts/homebrew-validate-formula-source-closure.sh --tap-root --tap-repository --formula --base-ref [--reviewed-tap-root ] +usage: scripts/homebrew-validate-formula-source-closure.sh --tap-root --tap-repository [--tap-name ] --formula --base-ref [--reviewed-tap-root ] Compares the working tap against the reviewed Formula source at base-ref. Canonical bottle metadata may differ. Formula code and every file in the @@ -26,6 +27,7 @@ while [ "$#" -gt 0 ]; do case "$1" in --tap-root) TAP_ROOT="${2:-}"; shift 2 ;; --tap-repository) TAP_REPOSITORY="${2:-}"; shift 2 ;; + --tap-name) TAP_NAME_INPUT="${2:-}"; shift 2 ;; --formula) FORMULA="${2:-}"; shift 2 ;; --base-ref) BASE_REF="${2:-}"; shift 2 ;; --reviewed-tap-root) REVIEWED_TAP_ROOT="${2:-}"; shift 2 ;; @@ -55,6 +57,9 @@ if ! [[ "$TAP_REPOSITORY" =~ ^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$ ]]; then echo "homebrew-validate-formula-source-closure.sh: invalid tap repository: $TAP_REPOSITORY" >&2 exit 2 fi +# shellcheck source=/dev/null +. "$SCRIPT_ROOT/homebrew-tap-identity.sh" +TAP_NAME="$(homebrew_resolve_tap_name "$TAP_REPOSITORY" "$TAP_NAME_INPUT")" if ! [[ "$FORMULA" =~ ^[a-z0-9][a-z0-9._-]*$ ]]; then echo "homebrew-validate-formula-source-closure.sh: invalid formula: $FORMULA" >&2 exit 2 @@ -131,8 +136,8 @@ if ! ruby "$SCRIPT_ROOT/homebrew-formula-source-digest.rb" \ exit 1 fi -owner="$(printf '%s' "${TAP_REPOSITORY%%/*}" | tr '[:upper:]' '[:lower:]')" -repository="$(printf '%s' "${TAP_REPOSITORY#*/}" | tr '[:upper:]' '[:lower:]')" +owner="${TAP_NAME%%/*}" +repository="${TAP_NAME#*/}" support_require="require (Tap.fetch(\"$owner\", \"$repository\").path/\"Kandelo/formula_support/kandelo_formula_support\").to_s" support_marker="Kandelo/formula_support/kandelo_formula_support" @@ -232,6 +237,7 @@ python3 "$SCRIPT_ROOT/homebrew-oci-layout.py" source-closure \ --tap-root "$TAP_ROOT" \ --kandelo-root "$(dirname "$SCRIPT_ROOT")" \ --tap-repository "$TAP_REPOSITORY" \ + --tap-name "$TAP_NAME" \ --formula "$FORMULA" \ --out "$CURRENT_CLOSURE" if [ -n "$REVIEWED_TAP_ROOT" ]; then @@ -240,6 +246,7 @@ if [ -n "$REVIEWED_TAP_ROOT" ]; then --tap-root "$REVIEWED_TAP_ROOT" \ --kandelo-root "$(dirname "$SCRIPT_ROOT")" \ --tap-repository "$TAP_REPOSITORY" \ + --tap-name "$TAP_NAME" \ --formula "$FORMULA" \ --out "$REVIEWED_CLOSURE" if ! cmp -s "$CURRENT_CLOSURE" "$REVIEWED_CLOSURE"; then @@ -249,7 +256,7 @@ if [ -n "$REVIEWED_TAP_ROOT" ]; then fi ruby "$SCRIPT_ROOT/homebrew-formula-runtime-closure.rb" \ - "$TAP_ROOT" "$TAP_REPOSITORY" "$FORMULA" --declarations-json \ + "$TAP_ROOT" "$TAP_NAME" "$FORMULA" --declarations-json \ >/dev/null echo "homebrew-validate-formula-source-closure.sh: validated $FORMULA at $BASE_COMMIT" diff --git a/scripts/homebrew-validate-publish-handoff.sh b/scripts/homebrew-validate-publish-handoff.sh index c624acab7e..a6b85aff34 100755 --- a/scripts/homebrew-validate-publish-handoff.sh +++ b/scripts/homebrew-validate-publish-handoff.sh @@ -7,6 +7,7 @@ FORMULA="" ARCH="" RELEASE_TAG="" TAP_REPOSITORY="" +TAP_NAME_INPUT="" TAP_COMMIT="" KANDELO_COMMIT="" BOTTLE_ROOT_URL="" @@ -15,7 +16,7 @@ FORBIDDEN_ROOTS=() usage() { cat >&2 <<'EOF' -usage: scripts/homebrew-validate-publish-handoff.sh --handoff --formula --arch --release-tag --tap-repository --tap-commit --kandelo-commit --bottle-root-url --tap-root --forbidden-root [--forbidden-root ...] +usage: scripts/homebrew-validate-publish-handoff.sh --handoff --formula --arch --release-tag --tap-repository [--tap-name ] --tap-commit --kandelo-commit --bottle-root-url --tap-root --forbidden-root [--forbidden-root ...] Checks the exact build/receipt/composition artifact grammar and cross-validates all publication data without loading Formula Ruby or executing package code. @@ -29,6 +30,7 @@ while [ "$#" -gt 0 ]; do --arch) ARCH="${2:-}"; shift 2 ;; --release-tag) RELEASE_TAG="${2:-}"; shift 2 ;; --tap-repository) TAP_REPOSITORY="${2:-}"; shift 2 ;; + --tap-name) TAP_NAME_INPUT="${2:-}"; shift 2 ;; --tap-commit) TAP_COMMIT="${2:-}"; shift 2 ;; --kandelo-commit) KANDELO_COMMIT="${2:-}"; shift 2 ;; --bottle-root-url) BOTTLE_ROOT_URL="${2:-}"; shift 2 ;; @@ -177,6 +179,9 @@ done < <(find "$HANDOFF" -mindepth 1 -print0) SCRIPT_ROOT="$(cd "$(dirname "$0")" && pwd -P)" # shellcheck source=/dev/null +. "$SCRIPT_ROOT/homebrew-tap-identity.sh" +TAP_NAME="$(homebrew_resolve_tap_name "$TAP_REPOSITORY" "$TAP_NAME_INPUT")" +# shellcheck source=/dev/null . "$SCRIPT_ROOT/homebrew-sibling-bottle-policy.sh" # shellcheck source=/dev/null . "$SCRIPT_ROOT/homebrew-publication-limits.sh" @@ -191,6 +196,7 @@ bash "$SCRIPT_ROOT/homebrew-validate-upload-receipt.sh" \ --arch "$ARCH" \ --release-tag "$RELEASE_TAG" \ --tap-repository "$TAP_REPOSITORY" \ + --tap-name "$TAP_NAME" \ --tap-commit "$TAP_COMMIT" \ --kandelo-commit "$KANDELO_COMMIT" \ --bottle-root-url "$BOTTLE_ROOT_URL" \ @@ -201,6 +207,7 @@ python3 "$SCRIPT_ROOT/homebrew-dependency-provenance.py" validate \ --formula "$FORMULA" \ --arch "$ARCH" \ --tap-repository "$TAP_REPOSITORY" \ + --tap-name "$TAP_NAME" \ --tap-commit "$TAP_COMMIT" \ --bottle-root-url "$BOTTLE_ROOT_URL" \ --tap-root "$TAP_ROOT" @@ -233,11 +240,13 @@ BOTTLE_TAG="${ARCH}_kandelo" if ! jq -e \ --arg formula "$FORMULA" --arg arch "$ARCH" --arg tag "$BOTTLE_TAG" \ --arg release_tag "$RELEASE_TAG" --arg tap_repository "$TAP_REPOSITORY" \ + --arg tap_name "$TAP_NAME" \ --arg tap_commit "$TAP_COMMIT" --arg kandelo_commit "$KANDELO_COMMIT" \ --arg abi "$ABI_VERSION" --arg url "$BOTTLE_URL" --arg sha "$BOTTLE_SHA256" ' keys == ["generated_at", "generator", "kandelo_abi", "kandelo_commit", "kandelo_repository", "packages", "release_tag", "schema", "tap_commit", "tap_name", "tap_repository"] and .schema == 1 and .release_tag == $release_tag and - .tap_repository == $tap_repository and .tap_commit == $tap_commit and + .tap_repository == $tap_repository and .tap_name == $tap_name and + .tap_commit == $tap_commit and .kandelo_commit == $kandelo_commit and .kandelo_abi == ($abi | tonumber) and (.packages | length) == 1 and .packages[0].name == $formula and .packages[0].formula_path == ("Formula/" + $formula + ".rb") and @@ -393,9 +402,6 @@ require_max_size "formula JSON" "$FORMULA_JSON" "$HOMEBREW_MAX_SIDECAR_JSON_BYTE require_max_size "link JSON" "$LINK_JSON" "$HOMEBREW_MAX_SIDECAR_JSON_BYTES" require_max_size "provenance JSON" "$PROVENANCE_JSON" "$HOMEBREW_MAX_PROVENANCE_BYTES" -OWNER_LOWER="$(printf '%s' "${TAP_REPOSITORY%%/*}" | tr '[:upper:]' '[:lower:]')" -REPO_LOWER="$(printf '%s' "${TAP_REPOSITORY#*/}" | tr '[:upper:]' '[:lower:]')" -TAP_NAME="${OWNER_LOWER}/${REPO_LOWER}" FULL_NAME="${TAP_NAME}/${FORMULA}" BOTTLE_CELLAR="/home/linuxbrew/.linuxbrew/Cellar" BOTTLE_RELOCATION_CELLAR="$(jq -r '.bottle.cellar' "$BUILD_ROOT/manifest.json")" diff --git a/scripts/homebrew-validate-upload-receipt.sh b/scripts/homebrew-validate-upload-receipt.sh index 1d60da81b1..f7cc352fd7 100755 --- a/scripts/homebrew-validate-upload-receipt.sh +++ b/scripts/homebrew-validate-upload-receipt.sh @@ -8,6 +8,7 @@ FORMULA="" ARCH="" RELEASE_TAG="" TAP_REPOSITORY="" +TAP_NAME_INPUT="" TAP_COMMIT="" KANDELO_COMMIT="" BOTTLE_ROOT_URL="" @@ -18,7 +19,7 @@ FORBIDDEN_ROOTS=() usage() { cat >&2 <<'EOF' -usage: scripts/homebrew-validate-upload-receipt.sh --receipt --handoff --formula --arch --release-tag --tap-repository --tap-commit --kandelo-commit --bottle-root-url --forbidden-root [--forbidden-root ...] [--out-env ] [--out-bottle-json ] [--allow-dry-run] +usage: scripts/homebrew-validate-upload-receipt.sh --receipt --handoff --formula --arch --release-tag --tap-repository [--tap-name ] --tap-commit --kandelo-commit --bottle-root-url --forbidden-root [--forbidden-root ...] [--out-env ] [--out-bottle-json ] [--allow-dry-run] Revalidates the build handoff, then checks the strict upload receipt against the plan identity and the handoff's recomputed bottle digest and byte count. @@ -33,6 +34,7 @@ while [ "$#" -gt 0 ]; do --arch) ARCH="${2:-}"; shift 2 ;; --release-tag) RELEASE_TAG="${2:-}"; shift 2 ;; --tap-repository) TAP_REPOSITORY="${2:-}"; shift 2 ;; + --tap-name) TAP_NAME_INPUT="${2:-}"; shift 2 ;; --tap-commit) TAP_COMMIT="${2:-}"; shift 2 ;; --kandelo-commit) KANDELO_COMMIT="${2:-}"; shift 2 ;; --bottle-root-url) BOTTLE_ROOT_URL="${2:-}"; shift 2 ;; @@ -89,6 +91,9 @@ if ! [[ "$receipt_bytes" =~ ^[0-9]+$ ]] || [ "$receipt_bytes" -gt 65536 ]; then fi SCRIPT_ROOT="$(cd "$(dirname "$0")" && pwd -P)" +# shellcheck source=/dev/null +. "$SCRIPT_ROOT/homebrew-tap-identity.sh" +TAP_NAME="$(homebrew_resolve_tap_name "$TAP_REPOSITORY" "$TAP_NAME_INPUT")" validation_tmp="$(mktemp -d)" trap 'rm -rf "$validation_tmp"' EXIT build_env="$validation_tmp/build.env" @@ -98,6 +103,7 @@ build_validation_args=( --arch "$ARCH" --release-tag "$RELEASE_TAG" --tap-repository "$TAP_REPOSITORY" + --tap-name "$TAP_NAME" --tap-commit "$TAP_COMMIT" --kandelo-commit "$KANDELO_COMMIT" --bottle-root-url "$BOTTLE_ROOT_URL" @@ -137,6 +143,7 @@ if ! jq -e \ --arg arch "$ARCH" \ --arg abi "$EXPECTED_ABI" \ --arg tap_repository "$TAP_REPOSITORY" \ + --arg tap_name "$TAP_NAME" \ --arg tap_commit "$TAP_COMMIT" \ --arg kandelo_commit "$KANDELO_COMMIT" \ --arg url "$EXPECTED_URL" \ @@ -149,12 +156,13 @@ if ! jq -e \ type == "object" and keys == ($expected | sort); exact_keys([ "formula", "kind", "layout", "layout_receipt_sha256", "publication", "schema", - "tap_repository" + "tap_name", "tap_repository" ]) and - .schema == 2 and + .schema == 3 and .kind == "child" and .formula == $formula and (.tap_repository | ascii_downcase) == ($tap_repository | ascii_downcase) and + .tap_name == $tap_name and .layout_receipt_sha256 == $layout_receipt_sha256 and .layout.kind == "child" and .layout.formula == $formula and @@ -163,6 +171,7 @@ if ! jq -e \ .layout.tap_commit == $tap_commit and .layout.kandelo_commit == $kandelo_commit and (.layout.tap_repository | ascii_downcase) == ($tap_repository | ascii_downcase) and + .layout.tap_name == $tap_name and .layout.bottle.url == $url and .layout.bottle.sha256 == $sha256 and .layout.bottle.bytes == ($bytes | tonumber) and diff --git a/scripts/homebrew-verify-poured-bottle.sh b/scripts/homebrew-verify-poured-bottle.sh index c347124a92..6cfbe9367f 100755 --- a/scripts/homebrew-verify-poured-bottle.sh +++ b/scripts/homebrew-verify-poured-bottle.sh @@ -4,6 +4,7 @@ set -euo pipefail TAP_ROOT="" TAP_REPOSITORY="" +TAP_NAME_INPUT="" TAP_COMMIT="" FORMULA="" ARCH="" @@ -22,7 +23,7 @@ SHARED_TEMP="${KANDELO_HOMEBREW_SHARED_TEMP:-}" usage() { cat >&2 <<'EOF' -usage: scripts/homebrew-verify-poured-bottle.sh --tap-root --tap-repository --tap-commit --formula --arch --abi --bottle --bottle-json --bottle-url --bottle-sha256 --bottle-bytes --bottle-root-url --dependency-provenance --selection-receipt --out +usage: scripts/homebrew-verify-poured-bottle.sh --tap-root --tap-repository [--tap-name ] --tap-commit --formula --arch --abi --bottle --bottle-json --bottle-url --bottle-sha256 --bottle-bytes --bottle-root-url --dependency-provenance --selection-receipt --out The tap must already contain the reconstructed target bottle block. In CI all Homebrew and Formula execution runs as the dedicated isolated workflow user. @@ -37,6 +38,7 @@ while [ "$#" -gt 0 ]; do case "$1" in --tap-root) TAP_ROOT="${2:-}"; shift 2 ;; --tap-repository) TAP_REPOSITORY="${2:-}"; shift 2 ;; + --tap-name) TAP_NAME_INPUT="${2:-}"; shift 2 ;; --tap-commit) TAP_COMMIT="${2:-}"; shift 2 ;; --formula) FORMULA="${2:-}"; shift 2 ;; --arch) ARCH="${2:-}"; shift 2 ;; @@ -97,6 +99,9 @@ case "$ARCH" in wasm32|wasm64) ;; *) echo "homebrew-verify-poured-bottle.sh: inv TAP_ROOT="$(cd "$TAP_ROOT" && pwd -P)" KANDELO_ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" +# shellcheck source=/dev/null +. "$KANDELO_ROOT/scripts/homebrew-tap-identity.sh" +TAP_NAME="$(homebrew_resolve_tap_name "$TAP_REPOSITORY" "$TAP_NAME_INPUT")" for file in "$BOTTLE" "$BOTTLE_JSON" "$DEPENDENCY_PROVENANCE" "$SELECTION_RECEIPT"; do [ -f "$file" ] && [ ! -L "$file" ] || { echo "homebrew-verify-poured-bottle.sh: required input is not a regular file: $file" >&2 @@ -158,7 +163,6 @@ chmod 0700 "$XDG_CONFIG_HOME" "$XDG_CONFIG_HOME/homebrew" homebrew_patched_launcher_prepare "$BREW_BIN" "$PATCH_FILE" "$WORK_DIR" BREW_BIN="$HOMEBREW_PATCHED_BREW_BIN" -TAP_NAME="$(printf '%s' "$TAP_REPOSITORY" | tr '[:upper:]' '[:lower:]')" FORMULA_REF="$TAP_NAME/$FORMULA" BOTTLE_TAG="${ARCH}_kandelo" export HOMEBREW_NO_AUTO_UPDATE="${HOMEBREW_NO_AUTO_UPDATE:-1}" @@ -185,9 +189,8 @@ TAPPED_TAP_ROOT="$("$BREW_BIN" --repository "$TAP_NAME")" if [ -n "$BUILD_USER" ]; then rm -rf "$KANDELO_ROOT/host/dist" - homebrew_patched_launcher_isolate "$BUILD_USER" "$WORK_DIR" "$KANDELO_ROOT" "$TAP_ROOT" - homebrew_assert_tree_not_writable_by_user "$BUILD_USER" "$OUT_PARENT" - homebrew_assert_tree_not_replaceable_by_user "$BUILD_USER" "$OUT_PARENT" + homebrew_patched_launcher_isolate "$BUILD_USER" \ + "$WORK_DIR" "$KANDELO_ROOT" "$TAP_ROOT" "$OUT_PARENT" BREW_BIN="$HOMEBREW_PATCHED_BREW_BIN" elif [ "${GITHUB_ACTIONS:-}" = "true" ]; then echo "homebrew-verify-poured-bottle.sh: CI Formula execution requires KANDELO_HOMEBREW_BUILD_USER" >&2 @@ -340,6 +343,7 @@ python3 "$KANDELO_ROOT/scripts/homebrew-dependency-provenance.py" capture \ --brew-bin "$BREW_BIN" \ --tap-root "$TAP_ROOT" \ --tap-repository "$TAP_REPOSITORY" \ + --tap-name "$TAP_NAME" \ --tap-commit "$TAP_COMMIT" \ --formula "$FORMULA" \ --arch "$ARCH" \ @@ -365,6 +369,7 @@ python3 "$KANDELO_ROOT/scripts/homebrew-bottle-runtime-evidence.py" capture \ --arch "$ARCH" \ --abi "$ABI" \ --tap-repository "$TAP_REPOSITORY" \ + --tap-name "$TAP_NAME" \ --tap-commit "$TAP_COMMIT" \ --tap-root "$TAP_ROOT" \ --bottle-root-url "$BOTTLE_ROOT_URL" \ diff --git a/scripts/test-homebrew-bottle-runtime-evidence.sh b/scripts/test-homebrew-bottle-runtime-evidence.sh index 5290daa31d..ecd76e1dc4 100755 --- a/scripts/test-homebrew-bottle-runtime-evidence.sh +++ b/scripts/test-homebrew-bottle-runtime-evidence.sh @@ -25,6 +25,7 @@ version="1.0" arch="wasm32" abi=39 tap_repository="Automattic/kandelo-homebrew" +tap_name="automattic/kandelo-homebrew" tap_commit="" bottle_root="https://ghcr.io/v2/automattic/kandelo-homebrew" bottle="$TMPDIR/hello--1.0.wasm32_kandelo.bottle.tar.gz" @@ -92,8 +93,9 @@ jq -nS --argjson abi "$abi" '{schema: 1, formula: "hello", arch: "wasm32", argv: ["/tmp/hello.wasm", "--version"], status: "success" }' >"$node_receipt" jq -nS --arg tap_commit "$tap_commit" '{ - schema: 1, formula: "hello", arch: "wasm32", - tap_repository: "Automattic/kandelo-homebrew", tap_commit: $tap_commit, + schema: 2, formula: "hello", arch: "wasm32", + tap_repository: "Automattic/kandelo-homebrew", tap_name: "automattic/kandelo-homebrew", + tap_commit: $tap_commit, bottle_root_url: "https://ghcr.io/v2/automattic/kandelo-homebrew", bottle_tag: "wasm32_kandelo", dependencies: [] }' >"$dependency_provenance" @@ -108,6 +110,7 @@ capture_args=( --arch "$arch" --abi "$abi" --tap-repository "$tap_repository" + --tap-name "$tap_name" --tap-commit "$tap_commit" --tap-root "$tap" --bottle-root-url "$bottle_root" @@ -130,12 +133,14 @@ python3 "$REPO_ROOT/scripts/homebrew-bottle-runtime-evidence.py" capture \ python3 "$REPO_ROOT/scripts/homebrew-bottle-runtime-evidence.py" validate \ --input "$evidence" \ --formula "$formula" --arch "$arch" --abi "$abi" \ - --tap-repository "$tap_repository" --tap-commit "$tap_commit" --tap-root "$tap" \ + --tap-repository "$tap_repository" --tap-name "$tap_name" \ + --tap-commit "$tap_commit" --tap-root "$tap" \ --bottle-root-url "$bottle_root" --bottle-json "$bottle_json" \ --bottle-url "$bottle_url" --bottle-sha256 "$bottle_sha" --bottle-bytes "$bottle_bytes" \ --dependency-provenance "$dependency_provenance" -jq -e --arg sha "$bottle_sha" --arg url "$bottle_url" ' +jq -e --arg sha "$bottle_sha" --arg url "$bottle_url" --arg tap_name "$tap_name" ' + .schema == 2 and .tap.name == $tap_name and .bottle.sha256 == $sha and .bottle.url == $url and .selection.bottle.mode == "anonymous-public-readback" and .target.receipt.built_as_bottle == true and @@ -201,7 +206,8 @@ jq '.unexpected = true' "$evidence" >"$TMPDIR/extra.json" if python3 "$REPO_ROOT/scripts/homebrew-bottle-runtime-evidence.py" validate \ --input "$TMPDIR/extra.json" \ --formula "$formula" --arch "$arch" --abi "$abi" \ - --tap-repository "$tap_repository" --tap-commit "$tap_commit" --tap-root "$tap" \ + --tap-repository "$tap_repository" --tap-name "$tap_name" \ + --tap-commit "$tap_commit" --tap-root "$tap" \ --bottle-root-url "$bottle_root" --bottle-json "$bottle_json" \ --bottle-url "$bottle_url" --bottle-sha256 "$bottle_sha" --bottle-bytes "$bottle_bytes" \ --dependency-provenance "$dependency_provenance" >/dev/null 2>&1; then diff --git a/scripts/test-homebrew-oci-layout.sh b/scripts/test-homebrew-oci-layout.sh index 3bbaaf9a5f..7c802d7215 100755 --- a/scripts/test-homebrew-oci-layout.sh +++ b/scripts/test-homebrew-oci-layout.sh @@ -10,7 +10,6 @@ ABI="$(sed -nE 's/^pub const ABI_VERSION: u32 = ([0-9]+);$/\1/p' \ TOOL="$REPO_ROOT/scripts/homebrew-oci-layout.py" TAP_COMMIT=1111111111111111111111111111111111111111 KANDELO_COMMIT=2222222222222222222222222222222222222222 -ROOT_URL=https://ghcr.io/v2/automattic/kandelo-homebrew sha256_file() { if command -v sha256sum >/dev/null 2>&1; then @@ -25,14 +24,18 @@ make_fixture() { local support_payload="${4:-fixture support v1}" local archived_formula_mode="${5:-0644}" local formula_extra="${6:-}" + local tap_repository="${7:-Automattic/kandelo-homebrew}" + local tap_name="${8:-Automattic/kandelo-homebrew}" + local tap_owner="${tap_name%%/*}" tap_short_name="${tap_name#*/}" + local root_url="https://ghcr.io/v2/$(printf '%s' "$tap_repository" | tr '[:upper:]' '[:lower:]')" local root="$TMP_ROOT/$label" local stage="$root/stage/hello/1.0" local bottle="$root/hello--1.0.${arch}_kandelo.bottle.tar.gz" local bottle_json="$root/bottle.json" sha mkdir -p "$stage/.brew" "$stage/bin" \ "$root/tap/Formula" "$root/tap/Kandelo/formula_support" - cat >"$root/tap/Formula/hello.rb" <<'RUBY' -require (Tap.fetch("automattic", "kandelo-homebrew").path/"Kandelo/formula_support/kandelo_formula_support").to_s + cat >"$root/tap/Formula/hello.rb" <"$stage/INSTALL_RECEIPT.json" tar -czf "$bottle" -C "$root/stage" hello sha="$(sha256_file "$bottle")" - jq -nS --arg arch "$arch" --arg sha "$sha" '{ + jq -nS --arg arch "$arch" --arg sha "$sha" \ + --arg formula_path "Library/Taps/$(printf '%s' "$tap_owner" | tr '[:upper:]' '[:lower:]')/homebrew-$(printf '%s' "$tap_short_name" | tr '[:upper:]' '[:lower:]')/Formula/hello.rb" \ + --arg root_url "$root_url" '{ hello: { formula: { name: "hello", - path: "Library/Taps/automattic/homebrew-kandelo-homebrew/Formula/hello.rb", + path: $formula_path, pkg_version: "1.0" }, bottle: { - root_url: "https://ghcr.io/v2/automattic/kandelo-homebrew", + root_url: $root_url, cellar: "any_skip_relocation", rebuild: 0, tags: {($arch + "_kandelo"): {sha256: $sha}} @@ -86,10 +91,13 @@ build_child() { local support_payload="${4:-fixture support v1}" local archived_formula_mode="${5:-0644}" local formula_extra="${6:-}" + local tap_repository="${7:-Automattic/kandelo-homebrew}" + local tap_name="${8:-Automattic/kandelo-homebrew}" + local root_url="https://ghcr.io/v2/$(printf '%s' "$tap_repository" | tr '[:upper:]' '[:lower:]')" local paths bottle bottle_json mapfile -t paths < <( make_fixture "$label" "$arch" "$payload" "$support_payload" \ - "$archived_formula_mode" "$formula_extra" + "$archived_formula_mode" "$formula_extra" "$tap_repository" "$tap_name" ) bottle="${paths[0]}" bottle_json="${paths[1]}" @@ -97,10 +105,11 @@ build_child() { --formula hello \ --arch "$arch" \ --abi "$ABI" \ - --tap-repository Automattic/kandelo-homebrew \ + --tap-repository "$tap_repository" \ + --tap-name "$tap_name" \ --tap-commit "$TAP_COMMIT" \ --kandelo-commit "$KANDELO_COMMIT" \ - --bottle-root-url "$ROOT_URL" \ + --bottle-root-url "$root_url" \ --bottle "$bottle" \ --bottle-json "$bottle_json" \ --kandelo-root "$REPO_ROOT" \ @@ -228,6 +237,31 @@ done build_child child32 wasm32 build_child child64 wasm64 +build_child generic32 wasm32 "hello fixture" "fixture support v1" 0644 "" \ + Acme/homebrew-tools Acme/tools +expect_failure protected-first-party-alias \ + "protected first-party tap name cannot be derived from another repository" \ + build_child protected-first-party-alias wasm32 "hello fixture" "fixture support v1" \ + 0644 "" Automattic/homebrew-kandelo-homebrew Automattic/kandelo-homebrew +python3 - "$TMP_ROOT/generic32/layout" "$TMP_ROOT/generic32/receipt.json" <<'PY' +import json +import pathlib +import sys + +layout = pathlib.Path(sys.argv[1]) +receipt = json.loads(pathlib.Path(sys.argv[2]).read_text()) +assert receipt["tap_repository"] == "Acme/homebrew-tools" +assert receipt["tap_name"] == "acme/tools" +root = json.loads((layout / "index.json").read_text())["manifests"][0] +manifest = json.loads( + (layout / "blobs/sha256" / root["digest"].removeprefix("sha256:")).read_text() +) +annotations = manifest["annotations"] +assert annotations["dev.kandelo.homebrew.tap_repository"] == "acme/homebrew-tools" +assert annotations["org.opencontainers.image.source"] == "https://github.com/acme/homebrew-tools" +assert annotations["org.opencontainers.image.title"] == "acme/tools/hello" +assert "dev.kandelo.homebrew.tap_name" not in annotations +PY expect_failure archived-formula-mode "tap Formula mode differs from the archived" \ build_child archived-formula-mode wasm32 "hello fixture" "fixture support v1" 0755 expect_failure require-relative "not a bounded canonical closure" \ diff --git a/scripts/test-homebrew-patched-launcher.sh b/scripts/test-homebrew-patched-launcher.sh index 9c0897cf6f..e898f60085 100755 --- a/scripts/test-homebrew-patched-launcher.sh +++ b/scripts/test-homebrew-patched-launcher.sh @@ -87,6 +87,18 @@ case "${1:-}" in assert-working-directory) [ "$(pwd -P)" = "$2" ] ;; + assert-source-aliases) + [ "$#" -eq 6 ] + [ "${HOMEBREW_KANDELO_ROOT:-}" = "$2" ] + [ "${KANDELO_HOMEBREW_KANDELO_ROOT:-}" = "$2" ] + [ -r "$2/source-marker" ] + [ -r "$3/tap-marker" ] + [ ! -e "$4" ] + [ ! -e "$5" ] + [ ! -e "$6" ] + if ( : >"$2/write-probe" ) 2>/dev/null; then exit 1; fi + if ( : >"$3/write-probe" ) 2>/dev/null; then exit 1; fi + ;; assert-argv) [ "$#" -eq 6 ] [ "$2" = "" ] @@ -190,6 +202,27 @@ HOMEBREW_PATCHED_SUDO_BIN="" HOMEBREW_PATCHED_PGREP_BIN="" HOMEBREW_PATCHED_BUILD_UID="" +audit_probe_dir="$TMPDIR/audit-probe" +mkdir -p "$audit_probe_dir/tree" +cat >"$audit_probe_dir/sudo" <<'EOF' +#!/usr/bin/env bash +echo "fixture traversal denied" >&2 +exit 13 +EOF +chmod +x "$audit_probe_dir/sudo" +HOMEBREW_PATCHED_SUDO_BIN="$audit_probe_dir/sudo" +set +e +audit_error="$(homebrew_assert_tree_not_writable_by_user \ + fixture-user "$audit_probe_dir/tree" 2>&1)" +audit_status="$?" +set -e +[ "$audit_status" -eq 2 ] || fail "failed source audit did not return its contract error" +[[ "$audit_error" == *"fixture traversal denied"* ]] || + fail "failed source audit suppressed the underlying traversal error" +[[ "$audit_error" == *"could not inspect protected source"* ]] || + fail "failed source audit did not identify the rejected tree" +HOMEBREW_PATCHED_SUDO_BIN="" + if [ "$(uname -s)" = "Linux" ] && [ -x /usr/bin/sudo ] && \ [ -x /usr/bin/systemd-run ] && [ -x /usr/bin/systemctl ] && \ [ -x /usr/bin/getent ] && [ -x /usr/bin/pgrep ] && [ -x /usr/bin/pkill ] && \ @@ -204,15 +237,21 @@ if [ "$(uname -s)" = "Linux" ] && [ -x /usr/bin/sudo ] && \ isolated_work="$ISOLATION_ROOT/work" isolated_cache="$ISOLATION_ROOT/cache" isolated_temp="$ISOLATION_ROOT/temp" - isolated_kandelo="$ISOLATION_ROOT/kandelo" - isolated_tap="$ISOLATION_ROOT/tap" + isolated_source_parent="$ISOLATION_ROOT/private-runner-home" + isolated_kandelo="$isolated_source_parent/kandelo" + isolated_tap="$isolated_source_parent/tap" + isolated_output="$isolated_source_parent/output" isolated_home="/home/$ISOLATION_BUILD_USER" daemon_marker="$isolated_work/detached-process-survived" daemon_started="$isolated_work/detached-process-started" mkdir -p "$isolated_repo/bin" "$isolated_prefix/bin" "$isolated_work" \ - "$isolated_cache" "$isolated_temp" "$isolated_kandelo" "$isolated_tap" + "$isolated_cache" "$isolated_temp" "$isolated_kandelo" "$isolated_tap" \ + "$isolated_output" + printf 'reviewed source\n' >"$isolated_kandelo/source-marker" + printf 'reviewed tap\n' >"$isolated_tap/tap-marker" mkdir "$isolated_kandelo/runner-control" chmod 0700 "$isolated_kandelo/runner-control" + chmod 0700 "$isolated_source_parent" cp "$prefix/bin/brew" "$isolated_repo/bin/brew" chmod +x "$isolated_repo/bin/brew" printf 'unpatched\n' >"$isolated_repo/marker.txt" @@ -239,10 +278,15 @@ if [ "$(uname -s)" = "Linux" ] && [ -x /usr/bin/sudo ] && \ homebrew_patched_launcher_prepare \ "$isolated_prefix/bin/brew" "$patch_file" "$isolated_work" homebrew_patched_launcher_isolate \ - "$ISOLATION_BUILD_USER" "$isolated_work" "$isolated_kandelo" "$isolated_tap" + "$ISOLATION_BUILD_USER" "$isolated_work" "$isolated_kandelo" "$isolated_tap" \ + "$isolated_output" "$HOMEBREW_PATCHED_BREW_BIN" assert-identity \ "$(id -u "$ISOLATION_BUILD_USER")" "$(id -g "$ISOLATION_BUILD_USER")" "$HOMEBREW_PATCHED_BREW_BIN" assert-working-directory "$isolated_work" + "$HOMEBREW_PATCHED_BREW_BIN" assert-source-aliases \ + "$HOMEBREW_PATCHED_SOURCE_ALIAS_DIR/kandelo" \ + "$HOMEBREW_PATCHED_SOURCE_ALIAS_DIR/tap" \ + "$isolated_kandelo" "$isolated_tap" "$isolated_output" "$HOMEBREW_PATCHED_BREW_BIN" assert-argv \ "" "with spaces" '$dollar' '%percent' $'line one\nline two' "$HOMEBREW_PATCHED_BREW_BIN" assert-bottle-tags "" "" diff --git a/scripts/test-homebrew-publish-workflow.sh b/scripts/test-homebrew-publish-workflow.sh index 4ce50a8e58..551be3d2a9 100755 --- a/scripts/test-homebrew-publish-workflow.sh +++ b/scripts/test-homebrew-publish-workflow.sh @@ -267,6 +267,7 @@ assert_generator_validates_homebrew_commit_as_data() { KANDELO_HOMEBREW_ARCH="wasm32" \ KANDELO_HOMEBREW_RELEASE_TAG="bottles-abi-v${abi}" \ KANDELO_HOMEBREW_TAP_REPOSITORY="Automattic/kandelo-homebrew" \ + KANDELO_HOMEBREW_TAP_NAME="automattic/kandelo-homebrew" \ KANDELO_HOMEBREW_BOTTLE_ARCHIVE="$bottle" \ KANDELO_HOMEBREW_BOTTLE_JSON="$bottle_json" \ KANDELO_HOMEBREW_BOTTLE_ROOT_URL="https://ghcr.io/v2/automattic/kandelo-homebrew" \ @@ -277,7 +278,9 @@ assert_generator_validates_homebrew_commit_as_data() { KANDELO_HOMEBREW_RUNTIME_EVIDENCE="$runtime_evidence" \ KANDELO_HOMEBREW_FORBIDDEN_ROOTS_JSON='["/trusted/publisher/build-root"]' \ bash "$REPO_ROOT/scripts/dev-shell.sh" \ - env KANDELO_HOMEBREW_FORBIDDEN_ROOTS_JSON='["/trusted/publisher/build-root"]' \ + env \ + KANDELO_HOMEBREW_TAP_NAME="automattic/kandelo-homebrew" \ + KANDELO_HOMEBREW_FORBIDDEN_ROOTS_JSON='["/trusted/publisher/build-root"]' \ bash "$REPO_ROOT/scripts/homebrew-generate-sidecars-from-env.sh" \ >"$err" 2>&1; then fail "sidecar generator accepted malformed Homebrew commit provenance" @@ -304,6 +307,11 @@ make_build_handoff() { local bottle_json="$source_dir/hello--2.12.1.wasm32_kandelo.bottle.json" local dependency_provenance="$source_dir/dependency-provenance.json" local bottle_stage="$source_dir/stage/hello/2.12.1" + local tap_repository="${BUILD_HANDOFF_TAP_REPOSITORY:-Automattic/kandelo-homebrew}" + local tap_name="${BUILD_HANDOFF_TAP_NAME:-automattic/kandelo-homebrew}" + local bottle_root="https://ghcr.io/v2/$(printf '%s' "$tap_repository" | tr '[:upper:]' '[:lower:]')" + local formula_key="${tap_name}/hello" + local formula_path="Library/Taps/${tap_name%%/*}/homebrew-${tap_name#*/}/Formula/hello.rb" local sha256 mkdir -p "$bottle_stage/.brew" "$bottle_stage/bin" @@ -329,18 +337,19 @@ EOF chmod +x "$bottle_stage/bin/hello.wasm" tar -czf "$bottle" -C "$source_dir/stage" hello sha256="$(sha256sum "$bottle" 2>/dev/null | awk '{print $1}' || shasum -a 256 "$bottle" | awk '{print $1}')" - jq -n --arg sha256 "$sha256" '{ - "automattic/kandelo-homebrew/hello": { + jq -n --arg sha256 "$sha256" --arg formula_key "$formula_key" \ + --arg formula_path "$formula_path" --arg bottle_root "$bottle_root" '{ + ($formula_key): { formula: { name: "hello", - path: "Library/Taps/automattic/homebrew-kandelo-homebrew/Formula/hello.rb", + path: $formula_path, pkg_version: "2.12.1", tap_git_path: "Formula/hello.rb", tap_git_revision: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", desc: "this artifact-only field must not reach Homebrew merge" }, bottle: { - root_url: "https://ghcr.io/v2/automattic/kandelo-homebrew", + root_url: $bottle_root, cellar: "any_skip_relocation", rebuild: 0, tags: { @@ -367,13 +376,15 @@ EOF if [ -n "$dependency_provenance_source" ]; then cp "$dependency_provenance_source" "$dependency_provenance" else - jq -nS '{ - schema: 1, + jq -nS --arg tap_repository "$tap_repository" --arg tap_name "$tap_name" \ + --arg bottle_root "$bottle_root" '{ + schema: 2, formula: "hello", arch: "wasm32", - tap_repository: "Automattic/kandelo-homebrew", + tap_repository: $tap_repository, + tap_name: $tap_name, tap_commit: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", - bottle_root_url: "https://ghcr.io/v2/automattic/kandelo-homebrew", + bottle_root_url: $bottle_root, bottle_tag: "wasm32_kandelo", dependencies: [] }' >"$dependency_provenance" @@ -383,10 +394,11 @@ EOF --formula hello \ --arch wasm32 \ --release-tag bottles-abi-v18 \ - --tap-repository Automattic/kandelo-homebrew \ + --tap-repository "$tap_repository" \ + --tap-name "$tap_name" \ --tap-commit aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa \ --kandelo-commit bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb \ - --bottle-root-url https://ghcr.io/v2/automattic/kandelo-homebrew \ + --bottle-root-url "$bottle_root" \ --bottle "$bottle" \ --bottle-json "$bottle_json" \ --dependency-provenance "$dependency_provenance" \ @@ -394,6 +406,62 @@ EOF --out "$handoff" >/dev/null } +assert_generic_tap_build_handoff_identity() { + local handoff="$TMPDIR/generic-tap-build-handoff" + local canonical_bottle_json="$TMPDIR/generic-tap-canonical-bottle.json" + local tap="$TMPDIR/generic-tap-merge" + BUILD_HANDOFF_TAP_REPOSITORY=Acme/homebrew-tools \ + BUILD_HANDOFF_TAP_NAME=acme/tools \ + make_build_handoff "$handoff" + jq -e ' + .schema == 3 and + .tap_repository == "Acme/homebrew-tools" and + .tap_name == "acme/tools" + ' "$handoff/manifest.json" >/dev/null || + fail "generic tap handoff conflated repository and Homebrew identities" + jq -e ' + keys == ["acme/tools/hello"] and + .["acme/tools/hello"].formula.path == + "Library/Taps/acme/homebrew-tools/Formula/hello.rb" + ' "$handoff/bottle.json" >/dev/null || + fail "generic tap handoff used the GitHub repository as a Homebrew name" + bash "$REPO_ROOT/scripts/homebrew-validate-build-handoff.sh" \ + --handoff "$handoff" \ + --formula hello \ + --arch wasm32 \ + --release-tag bottles-abi-v18 \ + --tap-repository Acme/homebrew-tools \ + --tap-name acme/tools \ + --tap-commit aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa \ + --kandelo-commit bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb \ + --bottle-root-url https://ghcr.io/v2/acme/homebrew-tools \ + --forbidden-root "$TEST_FORBIDDEN_ROOT" \ + --out-bottle-json "$canonical_bottle_json" >/dev/null + mkdir -p "$tap/Formula" + cat >"$tap/Formula/hello.rb" <<'RUBY' +class Hello < Formula + desc "Generic tap merge fixture" + homepage "https://example.invalid/hello" + url "https://example.invalid/hello-2.12.1.tar.gz" + sha256 "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" +end +RUBY + bash "$REPO_ROOT/scripts/homebrew-merge-bottle-json.sh" \ + --tap-root "$tap" \ + --tap-repository Acme/homebrew-tools \ + --tap-name acme/tools \ + --formula hello \ + --arch wasm32 \ + --release-tag bottles-abi-v18 \ + --bottle-json "$canonical_bottle_json" \ + --expected-sha256 "$(jq -er '.hello.bottle.tags.wasm32_kandelo.sha256' "$canonical_bottle_json")" \ + --expected-root-url https://ghcr.io/v2/acme/homebrew-tools \ + --expected-cellar any_skip_relocation >/dev/null + grep -F 'root_url "https://ghcr.io/v2/acme/homebrew-tools"' \ + "$tap/Formula/hello.rb" >/dev/null || + fail "generic tap merge used the Homebrew name as the GHCR repository" +} + refresh_build_handoff_bottle_identity() { local handoff="$1" local archive="$handoff/bottle.tar.gz" @@ -440,7 +508,7 @@ make_dry_upload_receipt() { --arg sha256 "$sha256" \ --argjson bytes "$bytes" \ --arg url "$url" '{ - schema: 1, + schema: 2, kind: "child", formula: "hello", arch: "wasm32", @@ -452,6 +520,7 @@ make_dry_upload_receipt() { formula_source_identity_sha256: ("2" * 64), source_closure_sha256: ("3" * 64), tap_repository: "Automattic/kandelo-homebrew", + tap_name: "automattic/kandelo-homebrew", tap_commit: ("a" * 40), kandelo_commit: ("b" * 40), top_ref: "2.12.1", @@ -474,10 +543,11 @@ make_dry_upload_receipt() { --slurpfile layout "$layout" \ --arg canonical_sha "$canonical_sha" \ --arg mode "$mode" '{ - schema: 2, + schema: 3, kind: "child", formula: "hello", tap_repository: "Automattic/kandelo-homebrew", + tap_name: "automattic/kandelo-homebrew", layout: $layout[0], layout_receipt_sha256: $canonical_sha, publication: { @@ -898,10 +968,11 @@ make_publish_dependency_provenance() { jq -nS \ --arg xz_formula_sha "$xz_formula_sha" --arg zlib_formula_sha "$zlib_formula_sha" \ --argjson xz_direct "$xz_direct" --argjson zlib_direct "$zlib_direct" '{ - schema: 1, + schema: 2, formula: "hello", arch: "wasm32", tap_repository: "Automattic/kandelo-homebrew", + tap_name: "automattic/kandelo-homebrew", tap_commit: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", bottle_root_url: "https://ghcr.io/v2/automattic/kandelo-homebrew", bottle_tag: "wasm32_kandelo", @@ -3688,6 +3759,7 @@ EOF assert_matrix assert_matrix_skips_unchanged_cache_key +bash "$REPO_ROOT/scripts/test-homebrew-tap-identity.sh" bash "$REPO_ROOT/scripts/test-homebrew-oci-layout.sh" assert_sysroot_fingerprint_is_arch_specific assert_bottle_build_trusts_selected_tap @@ -3698,6 +3770,7 @@ bash "$REPO_ROOT/scripts/test-homebrew-provision-formula-browser.sh" assert_dependency_pour_provenance_is_bounded assert_static_formula_closure_is_fail_closed assert_generator_validates_homebrew_commit_as_data +assert_generic_tap_build_handoff_identity assert_build_handoff_is_minimal_and_validated assert_build_handoff_rejects_untrusted_content assert_upload_receipt_is_bound_to_build_handoff diff --git a/scripts/test-homebrew-tap-identity.sh b/scripts/test-homebrew-tap-identity.sh new file mode 100755 index 0000000000..fe121ccf7b --- /dev/null +++ b/scripts/test-homebrew-tap-identity.sh @@ -0,0 +1,137 @@ +#!/usr/bin/env bash +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" +TMPDIR="$(mktemp -d)" +trap 'rm -rf "$TMPDIR"' EXIT + +# shellcheck source=/dev/null +. "$REPO_ROOT/scripts/homebrew-tap-identity.sh" + +fail() { + echo "test-homebrew-tap-identity.sh: $*" >&2 + exit 1 +} + +expect_identity_rejection() { + local label="$1" repository="$2" tap_name="${3:-}" + if homebrew_resolve_tap_name "$repository" "$tap_name" >/dev/null 2>&1; then + fail "accepted $label" + fi +} + +[ "$(homebrew_resolve_tap_name Automattic/kandelo-homebrew '')" = \ + "automattic/kandelo-homebrew" ] || fail "first-party default identity changed" +[ "$(homebrew_resolve_tap_name Acme/homebrew-tools Acme/tools)" = \ + "acme/tools" ] || fail "conventional third-party identity was not normalized" + +expect_identity_rejection "an implicit third-party tap name" Acme/homebrew-tools +expect_identity_rejection "a nonconventional third-party repository" Acme/tools Acme/tools +expect_identity_rejection "a mismatched third-party tap name" Acme/homebrew-tools Acme/other +expect_identity_rejection "a renamed first-party tap" \ + Automattic/kandelo-homebrew Automattic/kandelo +expect_identity_rejection "a conventional repository alias for the first-party tap" \ + Automattic/homebrew-kandelo-homebrew Automattic/kandelo-homebrew + +provenance="$TMPDIR/dependency-provenance.json" +jq -nS '{ + schema: 2, + formula: "hello", + arch: "wasm32", + tap_repository: "Acme/homebrew-tools", + tap_name: "acme/tools", + tap_commit: ("a" * 40), + bottle_root_url: "https://ghcr.io/v2/acme/homebrew-tools", + bottle_tag: "wasm32_kandelo", + dependencies: [] +}' >"$provenance" + +python3 "$REPO_ROOT/scripts/homebrew-dependency-provenance.py" validate \ + --input "$provenance" \ + --formula hello \ + --arch wasm32 \ + --tap-repository Acme/homebrew-tools \ + --tap-name Acme/tools \ + --tap-commit aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa \ + --bottle-root-url https://ghcr.io/v2/acme/homebrew-tools + +if python3 "$REPO_ROOT/scripts/homebrew-dependency-provenance.py" validate \ + --input "$provenance" \ + --formula hello \ + --arch wasm32 \ + --tap-repository Acme/homebrew-tools \ + --tap-name Acme/other \ + --tap-commit aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa \ + --bottle-root-url https://ghcr.io/v2/acme/homebrew-tools >/dev/null 2>&1; then + fail "dependency provenance accepted a mismatched repository and tap name" +fi + +collision_provenance="$TMPDIR/collision-dependency-provenance.json" +jq -nS '{ + schema: 2, + formula: "hello", + arch: "wasm32", + tap_repository: "Automattic/homebrew-kandelo-homebrew", + tap_name: "automattic/kandelo-homebrew", + tap_commit: ("a" * 40), + bottle_root_url: "https://ghcr.io/v2/automattic/homebrew-kandelo-homebrew", + bottle_tag: "wasm32_kandelo", + dependencies: [] +}' >"$collision_provenance" +if python3 "$REPO_ROOT/scripts/homebrew-dependency-provenance.py" validate \ + --input "$collision_provenance" \ + --formula hello \ + --arch wasm32 \ + --tap-repository Automattic/homebrew-kandelo-homebrew \ + --tap-name Automattic/kandelo-homebrew \ + --tap-commit aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa \ + --bottle-root-url https://ghcr.io/v2/automattic/homebrew-kandelo-homebrew \ + >/dev/null 2>&1; then + fail "dependency provenance accepted an alias for the protected first-party tap" +fi + +if python3 "$REPO_ROOT/scripts/homebrew-oci-layout.py" source-closure \ + --tap-root "$REPO_ROOT" \ + --kandelo-root "$REPO_ROOT" \ + --tap-repository Acme/homebrew-tools \ + --tap-name Acme/other \ + --formula hello \ + --out "$TMPDIR/source-closure.json" >/dev/null 2>&1; then + fail "OCI source closure accepted a mismatched repository and tap name" +fi + +if python3 "$REPO_ROOT/scripts/homebrew-oci-layout.py" source-closure \ + --tap-root "$REPO_ROOT" \ + --kandelo-root "$REPO_ROOT" \ + --tap-repository Automattic/homebrew-kandelo-homebrew \ + --tap-name Automattic/kandelo-homebrew \ + --formula hello \ + --out "$TMPDIR/collision-source-closure.json" >/dev/null 2>&1; then + fail "OCI source closure accepted an alias for the protected first-party tap" +fi + +printf '{}\n' >"$TMPDIR/runtime-evidence.json" +runtime_collision_error="$TMPDIR/runtime-collision.err" +if python3 "$REPO_ROOT/scripts/homebrew-bottle-runtime-evidence.py" validate \ + --input "$TMPDIR/runtime-evidence.json" \ + --formula hello \ + --arch wasm32 \ + --abi 1 \ + --tap-repository Automattic/homebrew-kandelo-homebrew \ + --tap-name Automattic/kandelo-homebrew \ + --tap-commit aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa \ + --tap-root "$REPO_ROOT" \ + --bottle-root-url https://ghcr.io/v2/automattic/homebrew-kandelo-homebrew \ + --bottle-json "$TMPDIR/runtime-evidence.json" \ + --bottle-url "https://ghcr.io/v2/automattic/homebrew-kandelo-homebrew/hello/blobs/sha256:$(printf '0%.0s' {1..64})" \ + --bottle-sha256 "$(printf '0%.0s' {1..64})" \ + --bottle-bytes 1 \ + --dependency-provenance "$collision_provenance" \ + >/dev/null 2>"$runtime_collision_error"; then + fail "runtime evidence accepted an alias for the protected first-party tap" +fi +grep -F "protected first-party tap name cannot be derived from another repository" \ + "$runtime_collision_error" >/dev/null || + fail "runtime evidence did not reject the first-party alias at the identity boundary" + +echo "test-homebrew-tap-identity.sh: ok" diff --git a/scripts/test-homebrew-tap-native-sidecars.sh b/scripts/test-homebrew-tap-native-sidecars.sh index 7dcaaee339..1424151a1a 100755 --- a/scripts/test-homebrew-tap-native-sidecars.sh +++ b/scripts/test-homebrew-tap-native-sidecars.sh @@ -150,10 +150,11 @@ write_dependency_provenance() { jq -nS \ --arg formula "$formula" --arg arch "$arch" --arg tap_commit "$tap_commit" \ --arg bottle_tag "${arch}_kandelo" --argjson dependencies "$dependencies" '{ - schema: 1, + schema: 2, formula: $formula, arch: $arch, tap_repository: "Automattic/kandelo-homebrew", + tap_name: "automattic/kandelo-homebrew", tap_commit: $tap_commit, bottle_root_url: "https://ghcr.io/v2/automattic/kandelo-homebrew", bottle_tag: $bottle_tag, @@ -487,12 +488,13 @@ generate_sidecars() { --arg version "$version" \ --arg provenance_sha "$provenance_sha" \ --slurpfile provenance "$dependency_provenance" '{ - schema: 1, + schema: 2, formula: $formula, arch: $arch, abi: $abi, tap: { repository: "Automattic/kandelo-homebrew", + name: "automattic/kandelo-homebrew", commit: $tap_commit }, bottle: { @@ -557,6 +559,7 @@ generate_sidecars() { KANDELO_HOMEBREW_ARCH="$arch" \ KANDELO_HOMEBREW_RELEASE_TAG="bottles-abi-v${ABI_VERSION}" \ KANDELO_HOMEBREW_TAP_REPOSITORY=Automattic/kandelo-homebrew \ + KANDELO_HOMEBREW_TAP_NAME=automattic/kandelo-homebrew \ KANDELO_HOMEBREW_BOTTLE_ARCHIVE="$archive" \ KANDELO_HOMEBREW_BOTTLE_JSON="$canonical_json" \ KANDELO_HOMEBREW_BOTTLE_ROOT_URL=https://ghcr.io/v2/automattic/kandelo-homebrew \