diff --git a/.github/workflows/reusable-homebrew-bottle-maintenance.yml b/.github/workflows/reusable-homebrew-bottle-maintenance.yml index d43560536..527010308 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 380fb475b..79c1e501b 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/architecture.md b/docs/architecture.md index 72fd8f43e..9e0e29ee5 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -625,6 +625,10 @@ const restored = MemoryFileSystem.fromImage(image, { maxByteLength: 1024 * 1024 ``` The image must also have been built with a large enough filesystem maximum, for example `MemoryFileSystem.create(sab, 1024 * 1024 * 1024)`. `fromImage(..., { maxByteLength })` only controls the restored buffer's runtime growth ceiling; `statfs`/`df` and allocation remain capped by the image superblock maximum. +Call `MemoryFileSystem.readImageCapacity(image)` when build tooling needs the +serialized buffer length and superblock ceiling without restoring the image. +Call `fromImagePreservingCapacity(image)` to restore a growable buffer with the +same ceiling the image builder recorded. A consumer that must stage files larger than the image's recorded allocation ceiling first calls `rebaseToNewFileSystem(requiredMaxBytes)`. Shared image diff --git a/docs/homebrew-publishing.md b/docs/homebrew-publishing.md index 60aa02c2a..ae2d827b4 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 @@ -575,8 +596,10 @@ those claims require the trusted publish and bottle validation paths below. The shared planner is `planHomebrewVfs()` in `host/src/homebrew-vfs-planner.ts`. It consumes `Kandelo/metadata.json` plus a caller-provided link-manifest loader and rejects bad ABI, unsupported arch, -cache-key drift, missing packages, dependency cycles, unsafe paths, and -link-manifest bottle drift before any bottle bytes are extracted. +tap-identity drift, duplicate roots or metadata, cache-key drift, missing +packages, dependency cycles, unsafe paths, and link-manifest bottle drift +before any bottle bytes are extracted. It resolves the requested roots and +their single-tap dependency closure in deterministic dependency-first order. The Node-side builder is `buildHomebrewVfs()` in `host/src/homebrew-vfs-builder.ts`. It verifies bottle byte count and sha256, @@ -584,19 +607,80 @@ extracts supported tar entries, stages kegs under the declared prefix, validates receipts, applies link manifests, writes `/etc/kandelo/homebrew-vfs.json`, and emits a build report. +The CLI starts with an empty VFS by default. Pass `--base-image` to overlay the +same verified bottle plan onto an explicit platform-only `.vfs` or `.vfs.zst` +base image. The base must declare the same kernel ABI as the bottle metadata +and must not already carry Homebrew composition metadata or +`/etc/kandelo/homebrew-vfs.json`; merging independently composed Homebrew +prefixes would lose package provenance, so it fails closed. Existing files +remain unchanged except for requested bottle/link paths and the builder-owned +Homebrew manifest (plus the optional profile fragment); path collisions fail +through the normal staging checks. + +The output image metadata binds the exact base input with a bounded object: +SHA-256, byte count, and declared kernel ABI. It also records the selected tap +repository and canonical tap name. The JSON report carries the same binding +plus the base's full source metadata for auditing. Base signatures, +attestations, and other metadata are not copied onto the mutated output, and +large source metadata is not nested into each new image. + +When `--max-bytes` is omitted, the builder restores the base with its recorded +filesystem maximum and does not rebuild existing inodes. Supplying a different +`--max-bytes` explicitly rebases the filesystem to that exact maximum, so +allocation and `statfs` agree with the requested capacity. Explicit maxima +must be multiples of the 4096-byte SharedFS block size. + +For reproducible image composition, use the builder's static Brewfile subset. +For example: + +```ruby +tap "automattic/kandelo-homebrew" +brew "sqlite" +brew "automattic/kandelo-homebrew/xz" +``` + +The subset accepts blank lines, comments, exactly one literal canonical +lowercase `tap "owner/tap"`, and between 1 and 128 literal `brew` entries. +Entries may use a bare formula name or a fully qualified name from that exact +tap. Bare and qualified forms normalize to the same root, so duplicates fail. +The selected tap must exactly match `metadata.json`, and the complete resolved +closure is limited to 128 packages. + +The parser uses Ripper to inspect the syntax tree and never evaluates the file. +Options, interpolation, conditionals, variables, nested Ruby, `cask`, `mas`, +`service`, and every other Homebrew Bundle entry are rejected. Full Bundle DSL +belongs to real Homebrew running inside a Kandelo guest; it is not a safe or +deterministic host-side image specification. This builder intentionally accepts +only one tap, so a root or required dependency from another tap must fail until +multi-tap composition has an explicit metadata and provenance contract. + +This path does not read `Brewfile.lock.json`; Homebrew Bundle does not define a +lock-file contract. Reproducibility instead comes from the exact Brewfile +SHA-256 and byte count, ordered normalized roots, tap commit, base-image digest, +and verified bottle digests recorded in the report and image manifest. The +root digest is SHA-256 over the UTF-8 JSON array of normalized roots in declared +order. The bounded top-level VFS metadata records only root count and digest +rather than embedding arbitrary source text. + Build a precomposed image with: ```bash scripts/dev-shell.sh npx tsx images/vfs/scripts/build-homebrew-vfs-image.ts \ --metadata /path/to/kandelo-homebrew/Kandelo/metadata.json \ --tap-root /path/to/kandelo-homebrew \ - --package hello \ + --brewfile /path/to/Brewfile \ --arch wasm32 \ --runtime node \ + --base-image target/platform-base.vfs.zst \ --out target/homebrew-hello.vfs.zst \ --report target/homebrew-hello.vfs-report.json ``` +Repeatable `--package ` remains available for lower-level tooling and +focused tests. It preserves the provided root order and uses the same planner, +limits, and provenance report. `--package` and `--brewfile` are mutually +exclusive. + The bottle fetcher follows GHCR `WWW-Authenticate` bearer challenges. Public bottle materializers do not need a GitHub token merely to read public GHCR blobs. diff --git a/homebrew/kandelo-homebrew/.github/workflows/dry-run-bottles.yml b/homebrew/kandelo-homebrew/.github/workflows/dry-run-bottles.yml index 4810cf29a..78ba26665 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 a1a26db9d..da47744ea 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/Kandelo/README.md b/homebrew/kandelo-homebrew/Kandelo/README.md index 566464533..afff8d71f 100644 --- a/homebrew/kandelo-homebrew/Kandelo/README.md +++ b/homebrew/kandelo-homebrew/Kandelo/README.md @@ -103,9 +103,9 @@ Host VFS tooling plans a Homebrew-prefix image with shared by Node and browser callers. It consumes parsed `Kandelo/metadata.json` and a caller-provided link-manifest loader, resolves requested packages plus their dependency closure in dependency-first order, and rejects bad ABI, -unsupported arch, cache-key drift, missing packages, dependency cycles, unsafe -paths, and link-manifest bottle URL/sha/byte/cache-key drift before any bottle -bytes are extracted. +unsupported arch, tap-identity drift, duplicate roots or metadata, cache-key +drift, missing packages, dependency cycles, unsafe paths, and link-manifest +bottle URL/sha/byte/cache-key drift before any bottle bytes are extracted. For `failed`, `pending`, or `building` bottle entries, the planner uses the complete last-green fallback fields when available. Without a complete fallback, @@ -113,20 +113,45 @@ the package is not plannable for a VFS image. ## VFS Image Building -Build a precomposed Homebrew-prefix image from generated sidecars and verified -bottle bytes with: +Write a static Brewfile that names one tap and the formula roots for the image: + +```ruby +tap "automattic/kandelo-homebrew" +brew "sqlite" +brew "automattic/kandelo-homebrew/xz" +``` + +Then build a precomposed Homebrew-prefix image from generated sidecars and +verified bottle bytes with: ```bash scripts/dev-shell.sh npx tsx images/vfs/scripts/build-homebrew-vfs-image.ts \ --metadata /path/to/kandelo-homebrew/Kandelo/metadata.json \ --tap-root /path/to/kandelo-homebrew \ - --package hello \ + --brewfile /path/to/Brewfile \ --arch wasm32 \ --runtime node \ + --base-image target/platform-base.vfs.zst \ --out target/homebrew-hello.vfs.zst \ --report target/homebrew-hello.vfs-report.json ``` +This is a deliberately small, non-executing Brewfile subset: blank lines, +comments, exactly one literal lowercase `tap "owner/tap"`, and 1 to 128 literal +`brew` entries. A formula may be bare or fully qualified under that exact tap; +duplicates after normalization are rejected. Ripper validates the syntax tree, +and the builder never evaluates the file. Options, interpolation, conditionals, +variables, nested Ruby, other entry types, and multi-tap selection are rejected. +Use real Homebrew inside a running Kandelo guest when full Homebrew Bundle DSL +behavior is required. + +Homebrew Bundle does not define a `Brewfile.lock.json` contract. The report and +image manifest instead bind the Brewfile SHA-256 and byte count, ordered roots, +tap commit, base image, and exact bottle digests. The root digest is SHA-256 over +the UTF-8 JSON array of normalized roots in declared order. Repeatable +`--package ` is still available to lower-level callers, but it cannot be +combined with `--brewfile`. + The builder consumes only `metadata.json`, link manifests, and bottle tarballs. It does not evaluate Formula Ruby. It verifies the selected bottle byte count and sha256, rejects unsafe or unsupported tar entries, stages files under the @@ -134,6 +159,14 @@ declared keg, validates receipts, applies the link manifest under the declared prefix, writes `/etc/kandelo/homebrew-vfs.json`, saves a `.vfs.zst`, and emits a JSON report beside the image. +`--base-image` is optional and accepts only an ABI-matched platform image that +does not already contain a Homebrew composition. Output image metadata records +a bounded binding with the base SHA-256, byte count, and declared ABI; the JSON +report also retains the full source metadata for auditing. Omit `--max-bytes` +to retain the base filesystem maximum without rebuilding existing inodes, or +set it to rebase the filesystem to an exact, 4096-byte-aligned new maximum +before bottles are staged. + Link and receipt paths starting with `Cellar/` are interpreted relative to the Homebrew prefix. Other link and receipt paths are interpreted relative to the staged keg. Bottle payload entries under `bottle.payload_root` map to the keg; diff --git a/homebrew/kandelo-homebrew/README.md b/homebrew/kandelo-homebrew/README.md index 5b5229194..299f2eac6 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/host/src/browser.ts b/host/src/browser.ts index 9b81f8887..434f97ec8 100644 --- a/host/src/browser.ts +++ b/host/src/browser.ts @@ -37,6 +37,7 @@ export type { LazyDownloadListener, LazyDownloadStatus, LazyFileEntry, + VfsImageCapacity, } from "./vfs/memory-fs"; export { DeviceFileSystem } from "./vfs/device-fs"; export { OpfsFileSystem } from "./vfs/opfs"; diff --git a/host/src/homebrew-vfs-builder.ts b/host/src/homebrew-vfs-builder.ts index a8fc01d7b..c8b40d0ec 100644 --- a/host/src/homebrew-vfs-builder.ts +++ b/host/src/homebrew-vfs-builder.ts @@ -22,6 +22,9 @@ const S_IFLNK = 0xa000; const O_RDONLY = 0; const MODE_BITS = 0o7777; const TEXT_DECODER = new TextDecoder("utf-8", { fatal: true }); +const TEXT_ENCODER = new TextEncoder(); +const SHA256_RE = /^[0-9a-f]{64}$/; +const MAX_BREWFILE_BYTES = 65_536; export class HomebrewVfsBuildError extends Error { constructor(message: string) { @@ -37,6 +40,26 @@ export interface HomebrewVfsBuildOptions { ) => Uint8Array | Promise; writeProfile?: boolean; createdBy?: string; + selectionSource?: HomebrewVfsSelectionSource; +} + +export interface HomebrewVfsSelectionSource { + kind: "brewfile"; + parser: "kandelo-static-brewfile-v1"; + sha256: string; + bytes: number; + requestedPackages: string[]; +} + +export interface HomebrewVfsSelectionReport { + kind: "packages" | "brewfile"; + requested_packages: string[]; + requested_packages_sha256: string; + brewfile?: { + parser: "kandelo-static-brewfile-v1"; + sha256: string; + bytes: number; + }; } export interface HomebrewVfsPackageReport { @@ -62,6 +85,7 @@ export interface HomebrewVfsPackageReport { export interface HomebrewVfsBuildReport { schema: 1; image?: string; + selection: HomebrewVfsSelectionReport; metadata: { tap_repository: string; tap_name: string; @@ -101,6 +125,7 @@ export async function buildHomebrewVfs( ): Promise { const fs = options.fs ?? createDefaultFs(); const packageReports: HomebrewVfsPackageReport[] = []; + const selection = createSelectionReport(plan, options.selectionSource); ensureDirRecursive(fs, "/etc/kandelo"); @@ -135,6 +160,7 @@ export async function buildHomebrewVfs( const report: HomebrewVfsBuildReport = { schema: 1, + selection, metadata: { tap_repository: plan.tapRepository, tap_name: plan.tapName, @@ -153,6 +179,7 @@ export async function buildHomebrewVfs( JSON.stringify({ schema: 1, created_by: options.createdBy ?? "host/src/homebrew-vfs-builder.ts", + selection, metadata: report.metadata, packages: packageReports.map((pkg) => ({ name: pkg.name, @@ -180,6 +207,55 @@ export async function buildHomebrewVfs( return { fs, report }; } +function createSelectionReport( + plan: HomebrewVfsPlan, + source: HomebrewVfsSelectionSource | undefined, +): HomebrewVfsSelectionReport { + const requestedPackages = [...plan.requestedPackages]; + if (requestedPackages.length === 0) { + throw new HomebrewVfsBuildError("Homebrew VFS plan has no requested packages"); + } + const requestedPackagesSha256 = sha256( + TEXT_ENCODER.encode(JSON.stringify(requestedPackages)), + ); + if (source === undefined) { + return { + kind: "packages", + requested_packages: requestedPackages, + requested_packages_sha256: requestedPackagesSha256, + }; + } + if ( + source.kind !== "brewfile" || + source.parser !== "kandelo-static-brewfile-v1" || + !SHA256_RE.test(source.sha256) || + !Number.isInteger(source.bytes) || + source.bytes <= 0 || + source.bytes > MAX_BREWFILE_BYTES + ) { + throw new HomebrewVfsBuildError("Homebrew VFS Brewfile selection provenance is invalid"); + } + if ( + !Array.isArray(source.requestedPackages) || + source.requestedPackages.length !== requestedPackages.length || + source.requestedPackages.some((pkg, index) => pkg !== requestedPackages[index]) + ) { + throw new HomebrewVfsBuildError( + "Homebrew VFS Brewfile requested packages do not match the plan roots", + ); + } + return { + kind: "brewfile", + requested_packages: requestedPackages, + requested_packages_sha256: requestedPackagesSha256, + brewfile: { + parser: source.parser, + sha256: source.sha256, + bytes: source.bytes, + }, + }; +} + function createDefaultFs(): MemoryFileSystem { const SharedArrayBufferCtor = SharedArrayBuffer as new ( byteLength: number, @@ -276,9 +352,10 @@ function applyLinks(fs: MemoryFileSystem, pkg: HomebrewVfsPackagePlan): string[] const seenTargets = new Set(); for (const entry of pkg.linkManifest.links) { - if (!seenTargets.add(entry.target)) { + if (seenTargets.has(entry.target)) { fail(pkg, `link target ${entry.target} is duplicated`); } + seenTargets.add(entry.target); const sourcePath = resolveManifestSource(pkg, entry.source); const targetPath = joinGuestPath(pkg.prefix, entry.target); diff --git a/host/src/homebrew-vfs-planner.ts b/host/src/homebrew-vfs-planner.ts index 721a0cd0e..503f82180 100644 --- a/host/src/homebrew-vfs-planner.ts +++ b/host/src/homebrew-vfs-planner.ts @@ -100,6 +100,7 @@ export interface HomebrewVfsPlanOptions { packages: string[]; arch: HomebrewBottleArch; expectedAbi?: number; + expectedTapName?: string; runtime?: HomebrewRuntime; expectedCacheKeys?: Record; allowFallback?: boolean; @@ -141,6 +142,7 @@ export interface HomebrewVfsPlan { kandeloCommit: string; kandeloAbi: number; releaseTag: string; + requestedPackages: string[]; packages: HomebrewVfsPackagePlan[]; } @@ -163,8 +165,17 @@ interface SelectedBottle { } const PACKAGE_RE = /^[a-z0-9][a-z0-9._-]*$/; +const TAP_NAME_RE = /^[a-z0-9._-]+\/[a-z0-9._-]+$/; +const REPOSITORY_RE = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/; +const FIRST_PARTY_TAP = "automattic/kandelo-homebrew"; +const GIT_SHA_RE = /^[0-9a-f]{40}$/; const SHA256_RE = /^[0-9a-f]{64}$/; const SAFE_REL_SEGMENT_RE = /^[A-Za-z0-9._@%+=:-]+$/; +const MAX_PACKAGE_NAME_BYTES = 255; +const MAX_REQUESTED_PACKAGES = 128; +const MAX_RESOLVED_PACKAGES = 128; +const MAX_METADATA_PACKAGES = 4096; +const MAX_PACKAGE_DEPENDENCIES = 128; export async function planHomebrewVfs( metadataValue: unknown, @@ -173,6 +184,7 @@ export async function planHomebrewVfs( const metadata = parseTapMetadata(metadataValue); const expectedAbi = options.expectedAbi ?? ABI_VERSION; validateExpectedAbi(metadata, expectedAbi); + validateExpectedTapName(metadata, options.expectedTapName); const arch = validateRequestedArch(options.arch); const requestedPackages = validateRequestedPackages(options.packages); const packages = resolvePackageClosure(metadata, requestedPackages); @@ -195,6 +207,7 @@ export async function planHomebrewVfs( kandeloCommit: metadata.kandelo_commit, kandeloAbi: metadata.kandelo_abi, releaseTag: metadata.release_tag, + requestedPackages, packages: planned, }; } @@ -204,17 +217,54 @@ function parseTapMetadata(value: unknown): HomebrewTapMetadata { const schema = requiredInteger(metadata, "schema", "metadata"); if (schema !== 1) fail(`metadata schema must be 1, got ${schema}`); - const packages = requiredArray(metadata, "packages", "metadata").map((pkg, index) => + const packageValues = requiredArray(metadata, "packages", "metadata"); + if (packageValues.length > MAX_METADATA_PACKAGES) { + fail(`metadata packages must contain at most ${MAX_METADATA_PACKAGES} entries`); + } + const packages = packageValues.map((pkg, index) => parseMetadataPackage(pkg, `metadata.packages[${index}]`) ); + const tapRepository = requiredString(metadata, "tap_repository", "metadata"); + const tapName = requiredString(metadata, "tap_name", "metadata"); + const tapCommit = requiredString(metadata, "tap_commit", "metadata"); + const kandeloRepository = requiredString(metadata, "kandelo_repository", "metadata"); + const kandeloCommit = requiredString(metadata, "kandelo_commit", "metadata"); + if (!REPOSITORY_RE.test(tapRepository)) { + fail(`metadata.tap_repository ${quote(tapRepository)} is not a valid owner/repository`); + } + if (!TAP_NAME_RE.test(tapName)) { + fail(`metadata.tap_name ${quote(tapName)} is not a canonical lowercase owner/tap name`); + } + validateTapIdentity(tapRepository, tapName); + if (!GIT_SHA_RE.test(tapCommit)) fail("metadata.tap_commit must be a lowercase 40-char git sha"); + if (!REPOSITORY_RE.test(kandeloRepository)) { + fail(`metadata.kandelo_repository ${quote(kandeloRepository)} is not a valid owner/repository`); + } + if (!GIT_SHA_RE.test(kandeloCommit)) { + fail("metadata.kandelo_commit must be a lowercase 40-char git sha"); + } + + const seenPackages = new Set(); + for (const pkg of packages) { + if (seenPackages.has(pkg.name)) fail(`metadata has duplicate package ${quote(pkg.name)}`); + seenPackages.add(pkg.name); + const expectedFullName = `${tapName}/${pkg.name}`; + if (pkg.full_name !== expectedFullName) { + fail( + `metadata package ${quote(pkg.name)} full_name ${quote(pkg.full_name)} ` + + `does not match tap identity ${quote(expectedFullName)}`, + ); + } + } + return { schema: 1, - tap_repository: requiredString(metadata, "tap_repository", "metadata"), - tap_name: requiredString(metadata, "tap_name", "metadata"), - tap_commit: requiredString(metadata, "tap_commit", "metadata"), - kandelo_repository: requiredString(metadata, "kandelo_repository", "metadata"), - kandelo_commit: requiredString(metadata, "kandelo_commit", "metadata"), + tap_repository: tapRepository, + tap_name: tapName, + tap_commit: tapCommit, + kandelo_repository: kandeloRepository, + kandelo_commit: kandeloCommit, kandelo_abi: requiredInteger(metadata, "kandelo_abi", "metadata"), release_tag: requiredString(metadata, "release_tag", "metadata"), generated_at: requiredString(metadata, "generated_at", "metadata"), @@ -227,9 +277,20 @@ function parseMetadataPackage(value: unknown, label: string): HomebrewMetadataPa const pkg = requireRecord(value, label); const name = requiredString(pkg, "name", label); validatePackageName(name, `${label}.name`); - const dependencies = requiredArray(pkg, "dependencies", label).map((dep, index) => + const dependencyValues = requiredArray(pkg, "dependencies", label); + if (dependencyValues.length > MAX_PACKAGE_DEPENDENCIES) { + fail(`${label}.dependencies must contain at most ${MAX_PACKAGE_DEPENDENCIES} entries`); + } + const dependencies = dependencyValues.map((dep, index) => parseDependency(dep, `${label}.dependencies[${index}]`) ); + const seenDependencies = new Set(); + for (const dependency of dependencies) { + if (seenDependencies.has(dependency.name)) { + fail(`${label}.dependencies has duplicate package ${quote(dependency.name)}`); + } + seenDependencies.add(dependency.name); + } const bottles = requiredArray(pkg, "bottles", label).map((bottle, index) => parseMetadataBottle(bottle, `${label}.bottles[${index}]`) ); @@ -365,6 +426,49 @@ function validateExpectedAbi(metadata: HomebrewTapMetadata, expectedAbi: number) } } +function validateExpectedTapName( + metadata: HomebrewTapMetadata, + expectedTapName: string | undefined, +): void { + if (expectedTapName === undefined) return; + if (!TAP_NAME_RE.test(expectedTapName)) { + fail(`expected tap name ${quote(expectedTapName)} is not a canonical lowercase owner/tap name`); + } + if (metadata.tap_name !== expectedTapName) { + fail( + `metadata tap ${quote(metadata.tap_name)} does not match requested tap ${quote(expectedTapName)}`, + ); + } +} + +function validateTapIdentity(tapRepository: string, tapName: string): void { + const normalizedRepository = tapRepository.toLowerCase(); + let expectedTapName: string; + if (normalizedRepository === FIRST_PARTY_TAP) { + expectedTapName = FIRST_PARTY_TAP; + } else { + const [owner, repositoryName] = normalizedRepository.split("/", 2); + const prefix = "homebrew-"; + if (!repositoryName.startsWith(prefix) || repositoryName.length === prefix.length) { + fail( + `metadata tap repository ${quote(tapRepository)} must use the conventional owner/homebrew-name form`, + ); + } + expectedTapName = `${owner}/${repositoryName.slice(prefix.length)}`; + if (expectedTapName === FIRST_PARTY_TAP) { + fail( + `metadata tap repository ${quote(tapRepository)} cannot claim protected first-party tap ${quote(FIRST_PARTY_TAP)}`, + ); + } + } + if (tapName !== expectedTapName) { + fail( + `metadata tap ${quote(tapName)} does not match repository ${quote(tapRepository)}; ` + + `expected ${quote(expectedTapName)}`, + ); + } +} + function validateRequestedArch(arch: HomebrewBottleArch): HomebrewBottleArch { if (arch !== "wasm32" && arch !== "wasm64") { fail(`requested arch must be wasm32 or wasm64, got ${String(arch)}`); @@ -376,13 +480,17 @@ function validateRequestedPackages(packages: string[]): string[] { if (!Array.isArray(packages) || packages.length === 0) { fail("Homebrew VFS plan requires at least one requested package"); } + if (packages.length > MAX_REQUESTED_PACKAGES) { + fail(`Homebrew VFS plan accepts at most ${MAX_REQUESTED_PACKAGES} requested packages`); + } const seen = new Set(); for (const pkg of packages) { if (typeof pkg !== "string") fail("requested package names must be strings"); validatePackageName(pkg, "requested package"); - if (!seen.add(pkg)) fail(`requested package ${quote(pkg)} is duplicated`); + if (seen.has(pkg)) fail(`requested package ${quote(pkg)} is duplicated`); + seen.add(pkg); } - return packages; + return [...packages]; } function resolvePackageClosure( @@ -416,6 +524,10 @@ function resolvePackageClosure( fail(`requested package ${quote(name)} is not present in metadata`); } + if (state.size >= MAX_RESOLVED_PACKAGES) { + fail(`Homebrew VFS dependency closure exceeds ${MAX_RESOLVED_PACKAGES} packages`); + } + state.set(name, "visiting"); stack.push(name); for (const dep of pkg.dependencies) { @@ -612,9 +724,10 @@ function validateLinkManifest( for (const entry of link.links) { validateRelativePath(entry.source, "link manifest link source"); validateRelativePath(entry.target, "link manifest link target"); - if (!targets.add(entry.target)) { + if (targets.has(entry.target)) { fail(`link manifest duplicate target ${quote(entry.target)}`); } + targets.add(entry.target); if (entry.mode !== undefined && !/^[0-7]{4}$/.test(entry.mode)) { fail(`link manifest link mode ${quote(entry.mode)} is not four octal digits`); } @@ -647,7 +760,9 @@ function parseStatus(value: string, label: string): HomebrewBottleStatus { } function validatePackageName(value: string, label: string): void { - if (!PACKAGE_RE.test(value)) fail(`${label} ${quote(value)} is not a valid package name`); + if (!PACKAGE_RE.test(value) || value.length > MAX_PACKAGE_NAME_BYTES) { + fail(`${label} ${quote(value)} is not a valid package name`); + } } function validateSha256(value: string, label: string): string { diff --git a/host/src/index.ts b/host/src/index.ts index ae8e16de6..70c40bb0d 100644 --- a/host/src/index.ts +++ b/host/src/index.ts @@ -79,6 +79,8 @@ export type { HomebrewVfsBuildReport, HomebrewVfsBuildResult, HomebrewVfsPackageReport, + HomebrewVfsSelectionReport, + HomebrewVfsSelectionSource, } from "./homebrew-vfs-builder"; export { HomebrewBottleFetchError, diff --git a/host/src/vfs/index.ts b/host/src/vfs/index.ts index a1aaaffba..d9ba95a73 100644 --- a/host/src/vfs/index.ts +++ b/host/src/vfs/index.ts @@ -7,6 +7,7 @@ export type { LazyDownloadListener, LazyDownloadStatus, LazyFileEntry, + VfsImageCapacity, VfsImageMetadata, VfsImageOptions, } from "./memory-fs"; diff --git a/host/src/vfs/memory-fs.ts b/host/src/vfs/memory-fs.ts index 03f850fd6..d67f5f487 100644 --- a/host/src/vfs/memory-fs.ts +++ b/host/src/vfs/memory-fs.ts @@ -123,6 +123,13 @@ export interface VfsImageMetadata { [key: string]: unknown; } +export interface VfsImageCapacity { + /** Serialized SharedArrayBuffer length carried by the image. */ + byteLength: number; + /** Filesystem growth ceiling declared by the image superblock. */ + maxByteLength: number; +} + // zstd frame magic (little-endian on the wire: 28 B5 2F FD). // fromImage() auto-detects this and decompresses transparently so callers // don't have to know whether the bytes came from a `.vfs` or `.vfs.zst`. @@ -1397,6 +1404,35 @@ export class MemoryFileSystem implements FileSystemBackend { } } + /** Read the current and maximum filesystem sizes encoded in an image. */ + static readImageCapacity(image: Uint8Array): VfsImageCapacity { + const parsed = parseImageHeader(image); + return SharedFS.inspectImageCapacity( + parsed.image.subarray( + VFS_IMAGE_HEADER_SIZE, + VFS_IMAGE_HEADER_SIZE + parsed.sabLen, + ), + ); + } + + /** + * Restore an image with the growth ceiling recorded in its SharedFS + * superblock. Use fromImage() when a caller intentionally supplies a + * different runtime ceiling. + */ + static fromImagePreservingCapacity(image: Uint8Array): MemoryFileSystem { + const parsed = parseImageHeader(image); + const capacity = SharedFS.inspectImageCapacity( + parsed.image.subarray( + VFS_IMAGE_HEADER_SIZE, + VFS_IMAGE_HEADER_SIZE + parsed.sabLen, + ), + ); + return MemoryFileSystem.restoreParsedImage(parsed, { + maxByteLength: capacity.maxByteLength, + }); + } + /** * Restore a MemoryFileSystem from a previously saved VFS image. * Allocates a new SharedArrayBuffer and populates it from the image. @@ -1410,7 +1446,14 @@ export class MemoryFileSystem implements FileSystemBackend { options?: { maxByteLength?: number }, ): MemoryFileSystem { const parsed = parseImageHeader(image); - image = parsed.image; + return MemoryFileSystem.restoreParsedImage(parsed, options); + } + + private static restoreParsedImage( + parsed: ParsedImageHeader, + options?: { maxByteLength?: number }, + ): MemoryFileSystem { + const image = parsed.image; const view = parsed.view; const flags = parsed.flags; const sabLen = parsed.sabLen; diff --git a/host/src/vfs/sharedfs-vendor.ts b/host/src/vfs/sharedfs-vendor.ts index 46a41f3b1..05fab6c5b 100644 --- a/host/src/vfs/sharedfs-vendor.ts +++ b/host/src/vfs/sharedfs-vendor.ts @@ -157,6 +157,13 @@ export interface SharedFsStats { maxName: number; } +export interface SharedFsImageCapacity { + /** Serialized SharedArrayBuffer length carried by the image. */ + byteLength: number; + /** Filesystem growth ceiling recorded in the serialized superblock. */ + maxByteLength: number; +} + export interface SharedFsIdentityState { ino: number; generation: number; @@ -396,6 +403,39 @@ export class SharedFS { return fs; } + /** + * Inspect the capacity contract stored in a serialized SharedFS buffer. + * Unlike statfs(), this is independent of the runtime buffer used to restore + * the image, so callers can recreate its original growth ceiling. + */ + static inspectImageCapacity(buffer: Uint8Array): SharedFsImageCapacity { + if (buffer.byteLength < SB_MAX_SIZE_BLOCKS + 4) { + throw new SFSError(EINVAL, "SharedFS image is too small"); + } + const view = new DataView( + buffer.buffer, + buffer.byteOffset, + buffer.byteLength, + ); + if (view.getUint32(SB_MAGIC, true) !== MAGIC) { + throw new SFSError(EINVAL, "Bad magic"); + } + if (view.getUint32(SB_VERSION, true) !== VERSION) { + throw new SFSError(EINVAL, "Bad version"); + } + const blockSize = view.getUint32(SB_BLOCK_SIZE, true); + if (blockSize !== BLOCK_SIZE) { + throw new SFSError(EINVAL, "Bad block size"); + } + + const configuredMaxBytes = + view.getUint32(SB_MAX_SIZE_BLOCKS, true) * blockSize; + return { + byteLength: buffer.byteLength, + maxByteLength: Math.max(buffer.byteLength, configuredMaxBytes), + }; + } + static mount( buffer: SharedArrayBuffer, options?: { restoreImage?: boolean }, diff --git a/host/test/homebrew-vfs-builder.test.ts b/host/test/homebrew-vfs-builder.test.ts index 95759eb8b..ce982b816 100644 --- a/host/test/homebrew-vfs-builder.test.ts +++ b/host/test/homebrew-vfs-builder.test.ts @@ -5,6 +5,7 @@ import { ABI_VERSION } from "../src/generated/abi"; import { buildHomebrewVfs, type HomebrewVfsBuildResult, + type HomebrewVfsSelectionSource, } from "../src/homebrew-vfs-builder"; import { planHomebrewVfs, @@ -208,6 +209,7 @@ async function buildFixture( metadataOverrides?: Record; linkOverrides?: Partial; loadBytes?: Uint8Array; + selectionSource?: HomebrewVfsSelectionSource; } = {}, ): Promise { const manifest = linkManifest(bytes, opts.linkOverrides); @@ -220,6 +222,7 @@ async function buildFixture( const fs = MemoryFileSystem.create(new SharedArrayBuffer(8 * 1024 * 1024)); return buildHomebrewVfs(plan, { fs, + selectionSource: opts.selectionSource, loadBottleBytes: () => opts.loadBytes ?? bytes, }); } @@ -250,6 +253,94 @@ describe("Homebrew VFS builder", () => { staged_files: 3, links: ["bin/hello"], }); + expect(result.report.selection).toMatchObject({ + kind: "packages", + requested_packages: ["hello"], + }); + }); + + it("records bounded Brewfile and requested-root provenance", async () => { + const bytes = bottleTar(standardEntries()); + const brewfile = utf8( + 'tap "automattic/kandelo-homebrew"\nbrew "hello"\n', + ); + const result = await buildFixture(bytes, { + selectionSource: { + kind: "brewfile", + parser: "kandelo-static-brewfile-v1", + sha256: sha256(brewfile), + bytes: brewfile.byteLength, + requestedPackages: ["hello"], + }, + }); + const expectedRootsSha = sha256(utf8(JSON.stringify(["hello"]))); + + expect(result.report.selection).toEqual({ + kind: "brewfile", + requested_packages: ["hello"], + requested_packages_sha256: expectedRootsSha, + brewfile: { + parser: "kandelo-static-brewfile-v1", + sha256: sha256(brewfile), + bytes: brewfile.byteLength, + }, + }); + expect(JSON.parse( + readVfsFile(result.fs, "/etc/kandelo/homebrew-vfs.json"), + ).selection).toEqual(result.report.selection); + }); + + it("rejects invalid Brewfile provenance before loading bottle bytes", async () => { + const bytes = bottleTar(standardEntries()); + let loaded = false; + const manifest = linkManifest(bytes); + const plan = await planHomebrewVfs(metadataForBottle(bytes), { + packages: ["hello"], + arch: "wasm32", + loadLinkManifest: () => manifest, + }); + const fs = MemoryFileSystem.create(new SharedArrayBuffer(8 * 1024 * 1024)); + await expect(buildHomebrewVfs(plan, { + fs, + selectionSource: { + kind: "brewfile", + parser: "kandelo-static-brewfile-v1", + sha256: "not-a-sha", + bytes: 10, + requestedPackages: ["hello"], + } as HomebrewVfsSelectionSource, + loadBottleBytes() { + loaded = true; + return bytes; + }, + })).rejects.toThrow("selection provenance is invalid"); + expect(loaded).toBe(false); + }); + + it("rejects Brewfile roots that differ from the plan before loading bottle bytes", async () => { + const bytes = bottleTar(standardEntries()); + let loaded = false; + const plan = await planHomebrewVfs(metadataForBottle(bytes), { + packages: ["hello"], + arch: "wasm32", + loadLinkManifest: () => linkManifest(bytes), + }); + const fs = MemoryFileSystem.create(new SharedArrayBuffer(8 * 1024 * 1024)); + await expect(buildHomebrewVfs(plan, { + fs, + selectionSource: { + kind: "brewfile", + parser: "kandelo-static-brewfile-v1", + sha256: sha256(utf8('brew "other"\n')), + bytes: 13, + requestedPackages: ["other"], + }, + loadBottleBytes() { + loaded = true; + return bytes; + }, + })).rejects.toThrow("requested packages do not match the plan roots"); + expect(loaded).toBe(false); }); it("supports keg-relative link sources and receipts", async () => { diff --git a/host/test/homebrew-vfs-planner.test.ts b/host/test/homebrew-vfs-planner.test.ts index 6950c708b..fe1329eb6 100644 --- a/host/test/homebrew-vfs-planner.test.ts +++ b/host/test/homebrew-vfs-planner.test.ts @@ -154,6 +154,7 @@ describe("Homebrew VFS planner", () => { }); expect(plan.kandeloAbi).toBe(ABI_VERSION); + expect(plan.requestedPackages).toEqual(["hello"]); expect(plan.packages.map((entry) => entry.name)).toEqual(["zlib", "hello"]); expect(plan.packages.map((entry) => entry.linkManifestPath)).toEqual([ "Kandelo/link/zlib-1.3.1-rebuild0-wasm32.json", @@ -191,6 +192,159 @@ describe("Homebrew VFS planner", () => { })).rejects.toThrow('package "hello" dependency "zlib" is not present'); }); + it("rejects a requested tap mismatch before loading link manifests", async () => { + let loaded = false; + await expect(planHomebrewVfs(metadata([packageEntry("hello", "2.12.1")]), { + packages: ["hello"], + arch: "wasm32", + expectedTapName: "example/tools", + loadLinkManifest() { + loaded = true; + return linkManifest("hello", "2.12.1"); + }, + })).rejects.toThrow( + 'metadata tap "automattic/kandelo-homebrew" does not match requested tap "example/tools"', + ); + expect(loaded).toBe(false); + }); + + it("accepts the canonical tap name for a conventional third-party repository", async () => { + const entry = packageEntry("hello", "2.12.1"); + entry.full_name = "example/tools/hello"; + const plan = await planHomebrewVfs(metadata([entry], { + tap_repository: "Example/homebrew-tools", + tap_name: "example/tools", + }), { + packages: ["hello"], + arch: "wasm32", + expectedTapName: "example/tools", + loadLinkManifest: manifestMap({ + "Kandelo/link/hello-2.12.1-rebuild0-wasm32.json": linkManifest("hello", "2.12.1"), + }), + }); + + expect(plan.tapRepository).toBe("Example/homebrew-tools"); + expect(plan.tapName).toBe("example/tools"); + }); + + it("rejects a repository alias for the protected first-party tap", async () => { + let loaded = false; + await expect(planHomebrewVfs(metadata([packageEntry("hello", "2.12.1")], { + tap_repository: "Automattic/homebrew-kandelo-homebrew", + }), { + packages: ["hello"], + arch: "wasm32", + loadLinkManifest() { + loaded = true; + return linkManifest("hello", "2.12.1"); + }, + })).rejects.toThrow( + 'metadata tap repository "Automattic/homebrew-kandelo-homebrew" cannot claim protected first-party tap', + ); + expect(loaded).toBe(false); + }); + + it("rejects a tap name that does not match its conventional repository", async () => { + await expect(planHomebrewVfs(metadata([packageEntry("hello", "2.12.1")], { + tap_repository: "Example/homebrew-tools", + }), { + packages: ["hello"], + arch: "wasm32", + loadLinkManifest: manifestMap({}), + })).rejects.toThrow( + 'metadata tap "automattic/kandelo-homebrew" does not match repository "Example/homebrew-tools"; expected "example/tools"', + ); + }); + + it("rejects a third-party repository without the homebrew- prefix", async () => { + await expect(planHomebrewVfs(metadata([packageEntry("hello", "2.12.1")], { + tap_repository: "Example/tools", + tap_name: "example/tools", + }), { + packages: ["hello"], + arch: "wasm32", + loadLinkManifest: manifestMap({}), + })).rejects.toThrow("must use the conventional owner/homebrew-name form"); + }); + + it("rejects package full names that do not belong to the metadata tap", async () => { + const entry = packageEntry("hello", "2.12.1"); + entry.full_name = "example/tools/hello"; + await expect(planHomebrewVfs(metadata([entry]), { + packages: ["hello"], + arch: "wasm32", + loadLinkManifest: manifestMap({}), + })).rejects.toThrow("does not match tap identity"); + }); + + it("bounds explicit package roots and the resolved dependency closure", async () => { + const names = Array.from({ length: 129 }, (_, index) => `package-${index}`); + const independent = names.map((name) => packageEntry(name, "1.0")); + await expect(planHomebrewVfs(metadata(independent), { + packages: names, + arch: "wasm32", + loadLinkManifest: manifestMap({}), + })).rejects.toThrow("accepts at most 128 requested packages"); + + const chain = names.map((name, index) => packageEntry( + name, + "1.0", + index + 1 < names.length + ? [{ name: names[index + 1], version: "1.0" }] + : [], + )); + await expect(planHomebrewVfs(metadata(chain), { + packages: [names[0]], + arch: "wasm32", + loadLinkManifest: manifestMap({}), + })).rejects.toThrow("dependency closure exceeds 128 packages"); + }); + + it("rejects duplicate dependency declarations", async () => { + await expect(planHomebrewVfs(metadata([ + packageEntry("hello", "2.12.1", [ + { name: "zlib", version: "1.3.1" }, + { name: "zlib", version: "1.3.1" }, + ]), + packageEntry("zlib", "1.3.1"), + ]), { + packages: ["hello"], + arch: "wasm32", + loadLinkManifest: manifestMap({}), + })).rejects.toThrow('dependencies has duplicate package "zlib"'); + }); + + it("rejects duplicate metadata packages and requested roots", async () => { + await expect(planHomebrewVfs(metadata([ + packageEntry("hello", "2.12.1"), + packageEntry("hello", "2.12.1"), + ]), { + packages: ["hello"], + arch: "wasm32", + loadLinkManifest: manifestMap({}), + })).rejects.toThrow('metadata has duplicate package "hello"'); + + await expect(planHomebrewVfs(metadata([ + packageEntry("hello", "2.12.1"), + ]), { + packages: ["hello", "hello"], + arch: "wasm32", + loadLinkManifest: manifestMap({}), + })).rejects.toThrow('requested package "hello" is duplicated'); + }); + + it("rejects duplicate link targets", async () => { + const manifest = linkManifest("hello", "2.12.1"); + manifest.links.push({ ...manifest.links[0] }); + await expect(planHomebrewVfs(metadata([ + packageEntry("hello", "2.12.1"), + ]), { + packages: ["hello"], + arch: "wasm32", + loadLinkManifest: () => manifest, + })).rejects.toThrow('link manifest duplicate target "bin/hello"'); + }); + it("rejects dependency cycles", async () => { const tapMetadata = metadata([ packageEntry("alpha", "1.0", [{ name: "beta", version: "1.0" }]), diff --git a/host/test/vfs-image.test.ts b/host/test/vfs-image.test.ts index 556c30144..3192f77ee 100644 --- a/host/test/vfs-image.test.ts +++ b/host/test/vfs-image.test.ts @@ -783,6 +783,31 @@ describe("VFS image save/restore", () => { // Non-growable SABs have maxByteLength === byteLength expect(buf.maxByteLength).toBe(buf.byteLength); }); + + it("can restore the growth ceiling declared by the image", async () => { + const initialBytes = 1 * 1024 * 1024; + const maxBytes = 8 * 1024 * 1024; + const sab = new SharedArrayBuffer(initialBytes, { + maxByteLength: maxBytes, + }); + const mfs = MemoryFileSystem.create(sab, maxBytes); + writeFile(mfs, "/test.txt", new TextEncoder().encode("hello")); + const image = await mfs.saveImage(); + + expect(MemoryFileSystem.readImageCapacity(image)).toEqual({ + byteLength: initialBytes, + maxByteLength: maxBytes, + }); + + const restored = MemoryFileSystem.fromImagePreservingCapacity(image); + expect(restored.sharedBuffer.byteLength).toBe(initialBytes); + expect(restored.sharedBuffer.maxByteLength).toBe(maxBytes); + const stats = restored.statfs("/"); + expect(stats.blocks * stats.bsize).toBe(maxBytes); + + writeFile(restored, "/grown.bin", new Uint8Array(2 * 1024 * 1024)); + expect(restored.sharedBuffer.byteLength).toBeGreaterThan(initialBytes); + }); }); describe("rebaseToNewFileSystem", () => { diff --git a/images/vfs/scripts/build-homebrew-vfs-image.ts b/images/vfs/scripts/build-homebrew-vfs-image.ts index 1372c800a..583f59cb5 100644 --- a/images/vfs/scripts/build-homebrew-vfs-image.ts +++ b/images/vfs/scripts/build-homebrew-vfs-image.ts @@ -5,19 +5,23 @@ * npx tsx images/vfs/scripts/build-homebrew-vfs-image.ts \ * --metadata homebrew/kandelo-homebrew/Kandelo/metadata.json \ * --tap-root homebrew/kandelo-homebrew \ - * --package hello \ + * --brewfile Brewfile \ * --arch wasm32 \ * --runtime node \ + * --base-image target/platform-base.vfs.zst \ * --out target/homebrew-hello.vfs.zst \ * --report target/homebrew-hello.vfs-report.json */ +import { spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; import { existsSync, + lstatSync, mkdirSync, readFileSync, writeFileSync, } from "node:fs"; -import { dirname, join } from "node:path"; +import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { buildHomebrewVfs } from "../../../host/src/homebrew-vfs-builder"; import { fetchHomebrewBottleBytes } from "../../../host/src/homebrew-vfs-fetch"; @@ -27,13 +31,17 @@ import { type HomebrewRuntime, type HomebrewVfsPackagePlan, } from "../../../host/src/homebrew-vfs-planner"; -import { MemoryFileSystem } from "../../../host/src/vfs/memory-fs"; +import { + MemoryFileSystem, + type VfsImageMetadata, +} from "../../../host/src/vfs/memory-fs"; import { saveImage } from "./vfs-image-helpers"; interface CliOptions { metadata: string; tapRoot: string; packages: string[]; + brewfile?: string; arch: HomebrewBottleArch; runtime?: HomebrewRuntime; out: string; @@ -41,30 +49,81 @@ interface CliOptions { expectedCacheKeys: Record; allowFallback: boolean; bottleCache?: string; - maxBytes: number; + baseImage?: string; + maxBytes?: number; writeProfile: boolean; } const DEFAULT_MAX_BYTES = 128 * 1024 * 1024; +const SHARED_FS_BLOCK_BYTES = 4096; +const HOMEBREW_COMPOSITION_PATH = "/etc/kandelo/homebrew-vfs.json"; +const MAX_SIDECAR_JSON_BYTES = 16_777_216; +const MAX_BREWFILE_BYTES = 65_536; +const MAX_BREWFILE_PACKAGES = 128; +const MAX_BREWFILE_PARSER_OUTPUT_BYTES = 65_536; +const SHA256_RE = /^[0-9a-f]{64}$/; +const TAP_NAME_RE = /^[a-z0-9._-]+\/[a-z0-9._-]+$/; +const PACKAGE_NAME_RE = /^[a-z0-9][a-z0-9._-]*$/; +const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url)); +const BREWFILE_PARSER = resolve( + SCRIPT_DIR, + "../../../scripts/homebrew-brewfile-selection.rb", +); + +interface BrewfileSelection { + schema: 1; + kind: "kandelo-static-brewfile-v1"; + tap_name: string; + sha256: string; + bytes: number; + packages: string[]; +} + +interface BaseImageBinding { + sha256: string; + bytes: number; + kernelAbi: number; +} + +interface LoadedBaseImage { + binding: BaseImageBinding; + metadata: VfsImageMetadata; +} async function main(): Promise { const options = parseArgs(process.argv.slice(2)); const metadata = readJsonFile(options.metadata); + const brewfileSelection = options.brewfile + ? readBrewfileSelection(options.brewfile) + : undefined; + const requestedPackages = brewfileSelection?.packages ?? options.packages; const plan = await planHomebrewVfs(metadata, { - packages: options.packages, + packages: requestedPackages, arch: options.arch, + expectedTapName: brewfileSelection?.tap_name, runtime: options.runtime, expectedCacheKeys: options.expectedCacheKeys, allowFallback: options.allowFallback, loadLinkManifest: (relPath) => readJsonFile(join(options.tapRoot, relPath)), }); - const fs = createFs(options.maxBytes); + const { fs, baseImage } = createFs( + options.baseImage, + options.maxBytes, + plan.kandeloAbi, + ); const result = await buildHomebrewVfs(plan, { fs, writeProfile: options.writeProfile, createdBy: "images/vfs/scripts/build-homebrew-vfs-image.ts", + selectionSource: brewfileSelection ? { + kind: "brewfile", + parser: brewfileSelection.kind, + sha256: brewfileSelection.sha256, + bytes: brewfileSelection.bytes, + requestedPackages: brewfileSelection.packages, + } : undefined, loadBottleBytes: (pkg) => loadBottleBytes(pkg, options), }); @@ -73,10 +132,22 @@ async function main(): Promise { version: 1, kernelAbi: plan.kandeloAbi, createdBy: "images/vfs/scripts/build-homebrew-vfs-image.ts", + ...(baseImage ? { baseImage: baseImage.binding } : {}), homebrew: { tapRepository: plan.tapRepository, + tapName: plan.tapName, tapCommit: plan.tapCommit, releaseTag: plan.releaseTag, + selection: { + kind: result.report.selection.kind, + requestedPackageCount: + result.report.selection.requested_packages.length, + requestedPackagesSha256: + result.report.selection.requested_packages_sha256, + ...(result.report.selection.brewfile + ? { brewfile: result.report.selection.brewfile } + : {}), + }, packages: plan.packages.map((pkg) => ({ name: pkg.name, version: pkg.version, @@ -88,7 +159,16 @@ async function main(): Promise { }, }); - const report = { ...result.report, image: options.out }; + const report = { + ...result.report, + ...(baseImage ? { + base_image: { + ...baseImage.binding, + metadata: baseImage.metadata, + }, + } : {}), + image: options.out, + }; mkdirSync(dirname(options.report), { recursive: true }); writeFileSync(options.report, `${JSON.stringify(report, null, 2)}\n`); console.log(`Homebrew VFS report: ${options.report}`); @@ -103,7 +183,6 @@ function parseArgs(args: string[]): CliOptions { expectedCacheKeys: {}, arch: "wasm32", allowFallback: true, - maxBytes: DEFAULT_MAX_BYTES, writeProfile: false, }; @@ -119,6 +198,12 @@ function parseArgs(args: string[]): CliOptions { case "--package": options.packages.push(requireValue(args, ++i, arg)); break; + case "--brewfile": + if (options.brewfile !== undefined) { + usage("--brewfile may be provided only once"); + } + options.brewfile = requireValue(args, ++i, arg); + break; case "--arch": options.arch = parseArch(requireValue(args, ++i, arg)); break; @@ -143,6 +228,9 @@ function parseArgs(args: string[]): CliOptions { case "--bottle-cache": options.bottleCache = requireValue(args, ++i, arg); break; + case "--base-image": + options.baseImage = parseBaseImagePath(requireValue(args, ++i, arg)); + break; case "--max-bytes": options.maxBytes = parseByteSize(requireValue(args, ++i, arg)); break; @@ -161,7 +249,15 @@ function parseArgs(args: string[]): CliOptions { for (const required of ["metadata", "tapRoot", "out", "report"] as const) { if (!options[required]) usage(`missing --${required.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`)}`); } - if (options.packages.length === 0) usage("at least one --package is required"); + if (options.brewfile && options.packages.length > 0) { + usage("--brewfile cannot be combined with --package"); + } + if (!options.brewfile && options.packages.length === 0) { + usage("exactly one package selection mode is required: --brewfile or --package"); + } + if (options.baseImage && !existsSync(options.baseImage)) { + usage(`base image does not exist: ${options.baseImage}`); + } return options as CliOptions; } @@ -195,20 +291,202 @@ function parseByteSize(value: string): number { if (!Number.isSafeInteger(bytes) || bytes <= 0) { usage(`--max-bytes is too large: ${value}`); } + if (bytes % SHARED_FS_BLOCK_BYTES !== 0) { + usage(`--max-bytes must be a multiple of ${SHARED_FS_BLOCK_BYTES} bytes`); + } return bytes; } -function createFs(maxBytes: number): MemoryFileSystem { +function parseBaseImagePath(value: string): string { + if (!value.endsWith(".vfs") && !value.endsWith(".vfs.zst")) { + usage(`--base-image must end in .vfs or .vfs.zst, got ${value}`); + } + return value; +} + +function createFs( + baseImage: string | undefined, + maxBytes: number | undefined, + expectedAbi: number, +): { fs: MemoryFileSystem; baseImage?: LoadedBaseImage } { + if (baseImage) { + const image = new Uint8Array(readFileSync(baseImage)); + const restored = MemoryFileSystem.fromImagePreservingCapacity(image); + const metadata = restored.getImageMetadata(); + if (metadata?.kernelAbi === undefined) { + throw new Error( + `base image ${baseImage} does not declare its required kernel ABI`, + ); + } + if (metadata.kernelAbi !== expectedAbi) { + throw new Error( + `base image ${baseImage} declares kernel ABI ${metadata.kernelAbi}, ` + + `but bottle metadata requires ABI ${expectedAbi}`, + ); + } + if ( + metadata.homebrew !== undefined || + vfsPathExists(restored, HOMEBREW_COMPOSITION_PATH) + ) { + throw new Error( + `base image ${baseImage} already contains a Homebrew composition; ` + + "use a platform-only base image", + ); + } + + const loadedBase: LoadedBaseImage = { + binding: { + sha256: createHash("sha256").update(image).digest("hex"), + bytes: image.byteLength, + kernelAbi: metadata.kernelAbi, + }, + metadata, + }; + const recordedMaxBytes = + MemoryFileSystem.readImageCapacity(image).maxByteLength; + if (!Number.isSafeInteger(recordedMaxBytes) || recordedMaxBytes <= 0) { + throw new Error( + `base image ${baseImage} declares an invalid filesystem capacity`, + ); + } + + const targetMaxBytes = maxBytes ?? recordedMaxBytes; + if (targetMaxBytes !== recordedMaxBytes) { + console.log( + `Rebasing base VFS capacity from ${formatMib(recordedMaxBytes)} ` + + `to ${formatMib(targetMaxBytes)}...`, + ); + return { + fs: restored.rebaseToNewFileSystem(targetMaxBytes), + baseImage: loadedBase, + }; + } + return { + fs: restored, + baseImage: loadedBase, + }; + } + + const initialBytes = maxBytes ?? DEFAULT_MAX_BYTES; const SharedArrayBufferCtor = SharedArrayBuffer as new ( byteLength: number, options?: { maxByteLength?: number }, ) => SharedArrayBuffer; - const sab = new SharedArrayBufferCtor(maxBytes, { maxByteLength: maxBytes }); - return MemoryFileSystem.create(sab, maxBytes); + const sab = new SharedArrayBufferCtor(initialBytes, { + maxByteLength: initialBytes, + }); + return { fs: MemoryFileSystem.create(sab, initialBytes) }; +} + +function formatMib(bytes: number): string { + return `${Math.round(bytes / 1024 / 1024)} MiB`; +} + +function vfsPathExists(fs: MemoryFileSystem, path: string): boolean { + try { + fs.lstat(path); + return true; + } catch (err) { + if ( + typeof err === "object" && + err !== null && + "code" in err && + err.code === -2 + ) { + return false; + } + throw err; + } } function readJsonFile(path: string): unknown { - return JSON.parse(readFileSync(path, "utf8")); + const bytes = readBoundedRegularFile( + path, + MAX_SIDECAR_JSON_BYTES, + "Homebrew sidecar JSON", + ); + return JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes)); +} + +function readBoundedRegularFile( + path: string, + maximumBytes: number, + label: string, +): Uint8Array { + const stat = lstatSync(path); + if (stat.isSymbolicLink() || !stat.isFile()) { + throw new Error(`${label} must be a regular non-symlink file: ${path}`); + } + if (stat.size > maximumBytes) { + throw new Error(`${label} exceeds ${maximumBytes} bytes: ${path}`); + } + const bytes = new Uint8Array(readFileSync(path)); + if (bytes.byteLength > maximumBytes) { + throw new Error(`${label} exceeds ${maximumBytes} bytes: ${path}`); + } + return bytes; +} + +function readBrewfileSelection(path: string): BrewfileSelection { + const parsed = spawnSync("ruby", [BREWFILE_PARSER, path], { + encoding: "utf8", + maxBuffer: MAX_BREWFILE_PARSER_OUTPUT_BYTES, + }); + if (parsed.error) { + throw new Error(`cannot parse Brewfile ${path}: ${parsed.error.message}`); + } + if (parsed.status !== 0) { + const detail = parsed.stderr.trim() || + `parser exited with status ${String(parsed.status)}`; + throw new Error(`cannot parse Brewfile ${path}: ${detail}`); + } + + let value: unknown; + try { + value = JSON.parse(parsed.stdout); + } catch { + throw new Error(`Brewfile parser returned invalid JSON for ${path}`); + } + if (!isRecord(value)) { + throw new Error(`Brewfile parser returned a non-object for ${path}`); + } + const expectedKeys = ["bytes", "kind", "packages", "schema", "sha256", "tap_name"]; + if (Object.keys(value).sort().join("\0") !== expectedKeys.join("\0")) { + throw new Error(`Brewfile parser returned an unsupported result shape for ${path}`); + } + if (value.schema !== 1 || value.kind !== "kandelo-static-brewfile-v1") { + throw new Error(`Brewfile parser returned an unsupported schema for ${path}`); + } + if (typeof value.tap_name !== "string" || !TAP_NAME_RE.test(value.tap_name)) { + throw new Error(`Brewfile parser returned an invalid tap name for ${path}`); + } + if (typeof value.sha256 !== "string" || !SHA256_RE.test(value.sha256)) { + throw new Error(`Brewfile parser returned an invalid sha256 for ${path}`); + } + if ( + typeof value.bytes !== "number" || + !Number.isInteger(value.bytes) || + value.bytes <= 0 || + value.bytes > MAX_BREWFILE_BYTES + ) { + throw new Error(`Brewfile parser returned an invalid byte count for ${path}`); + } + if ( + !Array.isArray(value.packages) || + value.packages.length === 0 || + value.packages.length > MAX_BREWFILE_PACKAGES || + value.packages.some((pkg) => + typeof pkg !== "string" || !PACKAGE_NAME_RE.test(pkg) + ) || + new Set(value.packages).size !== value.packages.length + ) { + throw new Error(`Brewfile parser returned invalid requested packages for ${path}`); + } + return value as unknown as BrewfileSelection; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); } async function loadBottleBytes( @@ -245,12 +523,13 @@ function usage(message?: string, code = 2): never { console.error(`usage: npx tsx images/vfs/scripts/build-homebrew-vfs-image.ts \\ --metadata \\ --tap-root \\ - --package [--package ...] \\ + (--brewfile | --package [--package ...]) \\ --arch [--runtime ] \\ --out \\ --report \\ [--expected-cache-key =] [--no-fallback] \\ - [--bottle-cache ] [--max-bytes ] [--write-profile]`); + [--bottle-cache ] [--base-image ] \\ + [--max-bytes ] [--write-profile]`); process.exit(code); } diff --git a/scripts/check-homebrew-publish-workflow-trust.rb b/scripts/check-homebrew-publish-workflow-trust.rb index 3068b0c73..fac0fb448 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 cdb4a22f3..417fb6648 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 88f35795f..147e7218d 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-brewfile-selection.rb b/scripts/homebrew-brewfile-selection.rb new file mode 100755 index 000000000..a7e0e2ec6 --- /dev/null +++ b/scripts/homebrew-brewfile-selection.rb @@ -0,0 +1,147 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +require "digest" +require "json" +require "pathname" +require "ripper" +require "set" + +MAX_BREWFILE_BYTES = 65_536 +MAX_PACKAGES = 128 +MAX_NAME_BYTES = 255 +TAP_NAME = /\A[a-z0-9._-]+\/[a-z0-9._-]+\z/ +PACKAGE_NAME = /\A[a-z0-9][a-z0-9._-]*\z/ +ENTRY_LINE = /\A([ \t]*)(tap|brew)[ \t]+(["'])([a-z0-9._\/-]+)\3[ \t]*(?:#[^\r\n]*)?\r?\n?\z/ + +def fail_selection(message) + abort "homebrew-brewfile-selection: #{message}" +end + +unless ARGV.length == 1 + fail_selection "usage: homebrew-brewfile-selection.rb " +end + +input = Pathname.new(ARGV.fetch(0)) +if input.symlink? || !input.file? + fail_selection "Brewfile must be a regular non-symlink file: #{input}" +end +if input.size > MAX_BREWFILE_BYTES + fail_selection "Brewfile exceeds #{MAX_BREWFILE_BYTES} bytes: #{input}" +end + +bytes = input.binread +if bytes.bytesize > MAX_BREWFILE_BYTES + fail_selection "Brewfile exceeds #{MAX_BREWFILE_BYTES} bytes: #{input}" +end +fail_selection "Brewfile contains a NUL byte" if bytes.include?("\0") + +source = bytes.dup.force_encoding(Encoding::UTF_8) +fail_selection "Brewfile is not valid UTF-8" unless source.valid_encoding? + +line_entries = [] +source.lines.each_with_index do |line, index| + stripped = line.strip + next if stripped.empty? || stripped.start_with?("#") + + match = ENTRY_LINE.match(line) + unless match + fail_selection( + "line #{index + 1} is outside the static subset; use only literal tap and brew entries", + ) + end + line_entries << { + method: match[2], + name: match[4], + position: [index + 1, match[1].bytesize], + } +end + +syntax_tree = Ripper.sexp(source) +fail_selection "Brewfile is not valid Ruby syntax" if syntax_tree.nil? + +statements = syntax_tree[1] +unless syntax_tree.first == :program && statements.is_a?(Array) + fail_selection "Brewfile has no canonical top-level body" +end + +ast_entries = statements.each_with_object([]) do |statement, entries| + next if statement == [:void_stmt] + unless statement.is_a?(Array) && statement.first == :command + fail_selection "Brewfile contains executable or nested Ruby structure" + end + + identifier = statement[1] + arguments = statement[2] + unless identifier.is_a?(Array) && identifier.first == :@ident && + %w[tap brew].include?(identifier[1]) && + arguments.is_a?(Array) && arguments.first == :args_add_block && + arguments[2] == false + fail_selection "Brewfile contains an unsupported command or argument form" + end + + values = arguments[1] + literal = values.fetch(0, nil) if values.is_a?(Array) && values.length == 1 + content = literal[1] if literal.is_a?(Array) && literal.first == :string_literal + token = content[1] if content.is_a?(Array) && content.first == :string_content && content.length == 2 + unless token.is_a?(Array) && token.first == :@tstring_content && token[1].is_a?(String) + fail_selection "tap and brew names must be plain string literals without interpolation" + end + + entries << { + method: identifier[1], + name: token[1], + position: identifier[2], + } +end + +unless ast_entries == line_entries + fail_selection "Brewfile syntax does not match its direct literal tap and brew entries" +end + +tap_entries = ast_entries.select { |entry| entry.fetch(:method) == "tap" } +if tap_entries.length != 1 + fail_selection "Brewfile must contain exactly one literal tap entry" +end +tap_name = tap_entries.fetch(0).fetch(:name) +unless TAP_NAME.match?(tap_name) && tap_name.bytesize <= MAX_NAME_BYTES + fail_selection "tap name must be a canonical lowercase owner/tap name" +end + +packages = [] +seen = Set.new +ast_entries.each do |entry| + next unless entry.fetch(:method) == "brew" + + declared = entry.fetch(:name) + package = if declared.include?("/") + parts = declared.split("/", -1) + unless parts.length == 3 && parts.first(2).join("/") == tap_name + fail_selection "brew entry #{declared.inspect} must belong to tap #{tap_name.inspect}" + end + parts.fetch(2) + else + declared + end + + unless PACKAGE_NAME.match?(package) && package.bytesize <= MAX_NAME_BYTES + fail_selection "brew entry #{declared.inspect} has an invalid package name" + end + unless seen.add?(package) + fail_selection "brew entry #{declared.inspect} duplicates requested package #{package.inspect}" + end + packages << package + if packages.length > MAX_PACKAGES + fail_selection "Brewfile requests more than #{MAX_PACKAGES} packages" + end +end +fail_selection "Brewfile must contain at least one literal brew entry" if packages.empty? + +puts JSON.generate({ + "schema" => 1, + "kind" => "kandelo-static-brewfile-v1", + "tap_name" => tap_name, + "sha256" => Digest::SHA256.hexdigest(bytes), + "bytes" => bytes.bytesize, + "packages" => packages, +}) diff --git a/scripts/homebrew-create-build-handoff.sh b/scripts/homebrew-create-build-handoff.sh index e135a41da..378966157 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 503f01f66..b96c8ece7 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 4fc50a18f..2a7e15848 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 8b8b2b57a..32fc89fc9 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 da95cae27..c9903d1d7 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 58daf172f..629efde1e 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 042378656..4e15ddbaf 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 080e2d4a0..94422a921 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 000000000..d34a8a725 --- /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 650a4ab41..747a7ca4e 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 75886b706..2e11a8c29 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 c624acab7..a6b85aff3 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 1d60da81b..f7cc352fd 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 c347124a9..6cfbe9367 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 5290daa31..ecd76e1dc 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-brewfile-selection.sh b/scripts/test-homebrew-brewfile-selection.sh new file mode 100755 index 000000000..eb8756bfc --- /dev/null +++ b/scripts/test-homebrew-brewfile-selection.sh @@ -0,0 +1,127 @@ +#!/usr/bin/env bash +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" +PARSER="$REPO_ROOT/scripts/homebrew-brewfile-selection.rb" +TMP_ROOT="$(mktemp -d)" +trap 'rm -rf "$TMP_ROOT"' EXIT + +expect_failure() { + local name="$1" needle="$2" path="$3" + if ruby "$PARSER" "$path" >"$TMP_ROOT/$name.out" 2>"$TMP_ROOT/$name.err"; then + echo "test-homebrew-brewfile-selection.sh: accepted $name" >&2 + exit 1 + fi + grep -F "$needle" "$TMP_ROOT/$name.err" >/dev/null +} + +VALID="$TMP_ROOT/valid.Brewfile" +printf '%s\r\n' \ + '# A static Kandelo image selection.' \ + 'tap "automattic/kandelo-homebrew"' \ + "brew 'sqlite'" \ + 'brew "automattic/kandelo-homebrew/xz" # fully qualified is equivalent' \ + >"$VALID" +VALID_JSON="$(ruby "$PARSER" "$VALID")" +VALID_SHA="$(ruby -rdigest -e 'print Digest::SHA256.file(ARGV.fetch(0)).hexdigest' "$VALID")" +VALID_BYTES="$(wc -c <"$VALID" | tr -d ' ')" +jq -e \ + --arg sha "$VALID_SHA" \ + --argjson bytes "$VALID_BYTES" ' + keys == ["bytes", "kind", "packages", "schema", "sha256", "tap_name"] and + .schema == 1 and + .kind == "kandelo-static-brewfile-v1" and + .tap_name == "automattic/kandelo-homebrew" and + .sha256 == $sha and + .bytes == $bytes and + .packages == ["sqlite", "xz"] + ' <<<"$VALID_JSON" >/dev/null + +NO_TAP="$TMP_ROOT/no-tap.Brewfile" +printf '%s\n' 'brew "sqlite"' >"$NO_TAP" +expect_failure no-tap "exactly one literal tap entry" "$NO_TAP" + +NO_BREW="$TMP_ROOT/no-brew.Brewfile" +printf '%s\n' 'tap "automattic/kandelo-homebrew"' >"$NO_BREW" +expect_failure no-brew "at least one literal brew entry" "$NO_BREW" + +MULTI_TAP="$TMP_ROOT/multi-tap.Brewfile" +printf '%s\n' \ + 'tap "automattic/kandelo-homebrew"' \ + 'tap "example/tools"' \ + 'brew "sqlite"' >"$MULTI_TAP" +expect_failure multi-tap "exactly one literal tap entry" "$MULTI_TAP" + +FOREIGN="$TMP_ROOT/foreign.Brewfile" +printf '%s\n' \ + 'tap "automattic/kandelo-homebrew"' \ + 'brew "example/tools/sqlite"' >"$FOREIGN" +expect_failure foreign "must belong to tap" "$FOREIGN" + +DUPLICATE="$TMP_ROOT/duplicate.Brewfile" +printf '%s\n' \ + 'tap "automattic/kandelo-homebrew"' \ + 'brew "sqlite"' \ + 'brew "automattic/kandelo-homebrew/sqlite"' >"$DUPLICATE" +expect_failure duplicate "duplicates requested package" "$DUPLICATE" + +OPTIONS="$TMP_ROOT/options.Brewfile" +printf '%s\n' \ + 'tap "automattic/kandelo-homebrew"' \ + 'brew "sqlite", link: false' >"$OPTIONS" +expect_failure options "outside the static subset" "$OPTIONS" + +CONDITIONAL="$TMP_ROOT/conditional.Brewfile" +printf '%s\n' \ + 'tap "automattic/kandelo-homebrew"' \ + 'brew "sqlite" if OS.linux?' >"$CONDITIONAL" +expect_failure conditional "outside the static subset" "$CONDITIONAL" + +INTERPOLATED="$TMP_ROOT/interpolated.Brewfile" +printf '%s\n' \ + 'tap "automattic/kandelo-homebrew"' \ + 'brew "#{ENV.fetch("PACKAGE")}"' >"$INTERPOLATED" +expect_failure interpolated "outside the static subset" "$INTERPOLATED" + +CASK="$TMP_ROOT/cask.Brewfile" +printf '%s\n' \ + 'tap "automattic/kandelo-homebrew"' \ + 'cask "firefox"' >"$CASK" +expect_failure cask "outside the static subset" "$CASK" + +MARKER="$TMP_ROOT/executed" +EXECUTABLE="$TMP_ROOT/executable.Brewfile" +printf 'File.write(%q, "executed")\n' "\"$MARKER\"" >"$EXECUTABLE" +printf '%s\n' \ + 'tap "automattic/kandelo-homebrew"' \ + 'brew "sqlite"' >>"$EXECUTABLE" +expect_failure executable "outside the static subset" "$EXECUTABLE" +if [ -e "$MARKER" ]; then + echo "test-homebrew-brewfile-selection.sh: executed Brewfile Ruby" >&2 + exit 1 +fi + +TOO_MANY="$TMP_ROOT/too-many.Brewfile" +printf '%s\n' 'tap "automattic/kandelo-homebrew"' >"$TOO_MANY" +for index in $(seq 1 129); do + printf 'brew "package-%s"\n' "$index" >>"$TOO_MANY" +done +expect_failure too-many "more than 128 packages" "$TOO_MANY" + +OVERSIZE="$TMP_ROOT/oversize.Brewfile" +dd if=/dev/zero bs=65537 count=1 2>/dev/null | tr '\0' '#' >"$OVERSIZE" +expect_failure oversize "exceeds 65536 bytes" "$OVERSIZE" + +SYMLINK="$TMP_ROOT/symlink.Brewfile" +ln -s "$VALID" "$SYMLINK" +expect_failure symlink "regular non-symlink file" "$SYMLINK" + +NUL="$TMP_ROOT/nul.Brewfile" +printf 'tap "automattic/kandelo-homebrew"\0\nbrew "sqlite"\n' >"$NUL" +expect_failure nul "contains a NUL byte" "$NUL" + +INVALID_UTF8="$TMP_ROOT/invalid-utf8.Brewfile" +printf 'tap "automattic/kandelo-homebrew"\nbrew "sqlite"\n\377' >"$INVALID_UTF8" +expect_failure invalid-utf8 "not valid UTF-8" "$INVALID_UTF8" + +echo "test-homebrew-brewfile-selection.sh: ok" diff --git a/scripts/test-homebrew-oci-layout.sh b/scripts/test-homebrew-oci-layout.sh index 3bbaaf9a5..7c802d721 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 9c0897cf6..e898f6008 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 4ce50a8e5..551be3d2a 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 000000000..fe121ccf7 --- /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 7dcaaee33..0ee5862c2 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 \ @@ -892,6 +895,179 @@ jq -e ' cp "${dep_bottle[0]}" "$BOTTLE_CACHE/${dep_bottle[2]}.tar.gz" cp "${tool_bottle[0]}" "$BOTTLE_CACHE/${tool_bottle[2]}.tar.gz" +BREWFILE="$TMPDIR/Brewfile" +cat > "$BREWFILE" <<'EOF' +tap "automattic/kandelo-homebrew" +brew "automattic/kandelo-homebrew/sidecar-tool" +EOF +BREWFILE_SHA256="$(sha256_file "$BREWFILE")" +BREWFILE_BYTES="$(wc -c < "$BREWFILE" | tr -d ' ')" +BASE_ROOT="$TMPDIR/base-root" +BASE_MANIFEST="$TMPDIR/base.MANIFEST" +BASE_IMAGE="$TMPDIR/base.vfs" +BAD_BASE_IMAGE="$TMPDIR/bad-base.vfs" +UNLABELED_BASE_IMAGE="$TMPDIR/unlabeled-base.vfs" +COMPOSED_MARKER_BASE_IMAGE="$TMPDIR/composed-marker-base.vfs" +BASE_RECORDED_MAX_BYTES=33554432 +BASE_REQUESTED_MAX_BYTES=134217728 +mkdir -p "$BASE_ROOT/etc" +printf '%s\n' 'base-image-marker' > "$BASE_ROOT/etc/base-image-marker" +cat > "$BASE_MANIFEST" <<'EOF' +/etc d 0755 0 0 +/etc/base-image-marker f 0644 0 0 +EOF +npx tsx "$REPO_ROOT/tools/mkrootfs/src/index.ts" build \ + "$BASE_MANIFEST" "$BASE_ROOT" \ + --sab-size 16777216 \ + --max-size "$BASE_RECORDED_MAX_BYTES" \ + --kernel-abi "$ABI_VERSION" \ + -o "$BASE_IMAGE" >/dev/null +npx tsx "$REPO_ROOT/tools/mkrootfs/src/index.ts" build \ + "$BASE_MANIFEST" "$BASE_ROOT" \ + --sab-size 16777216 \ + --max-size 134217728 \ + --kernel-abi "$((ABI_VERSION + 1))" \ + -o "$BAD_BASE_IMAGE" >/dev/null +npx tsx "$REPO_ROOT/tools/mkrootfs/src/index.ts" build \ + "$BASE_MANIFEST" "$BASE_ROOT" \ + --sab-size 16777216 \ + --max-size 134217728 \ + -o "$UNLABELED_BASE_IMAGE" >/dev/null +npx tsx -e " +import { readFileSync, writeFileSync } from 'node:fs'; +import { MemoryFileSystem } from '$REPO_ROOT/host/src/vfs/memory-fs.ts'; +(async () => { + const fs = MemoryFileSystem.fromImage(new Uint8Array(readFileSync('$BASE_IMAGE'))); + const metadata = fs.getImageMetadata(); + if (!metadata) throw new Error('expected base metadata'); + const image = await fs.saveImage({ + metadata: { + ...metadata, + platformBase: { source: 'sidecar-test' }, + signature: 's'.repeat(55_000), + provenance: { issuer: 'fixture-builder', subject: 'base-only' }, + }, + }); + writeFileSync('$BASE_IMAGE', image); +})().catch((err) => { + console.error(err); + process.exit(1); +}); +" >/dev/null +BASE_IMAGE_SHA256="$(sha256_file "$BASE_IMAGE")" +BASE_IMAGE_BYTES="$(wc -c < "$BASE_IMAGE" | tr -d ' ')" +cp "$BASE_IMAGE" "$TMPDIR/base-image.bin" +if npx tsx "$REPO_ROOT/images/vfs/scripts/build-homebrew-vfs-image.ts" \ + --metadata "$TAP/Kandelo/metadata.json" \ + --tap-root "$TAP" \ + --package sidecar-tool \ + --base-image "$TMPDIR/base-image.bin" \ + --out "$TMPDIR/bad-extension.vfs.zst" \ + --report "$TMPDIR/bad-extension-report.json" \ + > /dev/null 2>"$TMPDIR/bad-extension.err"; then + echo "Homebrew VFS builder accepted an ambiguous base-image extension" >&2 + exit 1 +fi +grep -F -- "--base-image must end in .vfs or .vfs.zst" \ + "$TMPDIR/bad-extension.err" >/dev/null +cp "$BASE_IMAGE" "$COMPOSED_MARKER_BASE_IMAGE" +printf '%s\n' '{"schema":1}' > "$TMPDIR/homebrew-vfs.json" +npx tsx "$REPO_ROOT/tools/mkrootfs/src/index.ts" add \ + "$COMPOSED_MARKER_BASE_IMAGE" /etc/kandelo --dir >/dev/null +npx tsx "$REPO_ROOT/tools/mkrootfs/src/index.ts" add \ + "$COMPOSED_MARKER_BASE_IMAGE" /etc/kandelo/homebrew-vfs.json \ + --file "$TMPDIR/homebrew-vfs.json" >/dev/null +if npx tsx "$REPO_ROOT/images/vfs/scripts/build-homebrew-vfs-image.ts" \ + --metadata "$TAP/Kandelo/metadata.json" \ + --tap-root "$TAP" \ + --package sidecar-tool \ + --arch wasm32 \ + --runtime node \ + --bottle-cache "$BOTTLE_CACHE" \ + --base-image "$UNLABELED_BASE_IMAGE" \ + --out "$TMPDIR/unlabeled-base-output.vfs.zst" \ + --report "$TMPDIR/unlabeled-base-report.json" \ + > /dev/null 2>"$TMPDIR/unlabeled-base.err"; then + echo "Homebrew VFS builder accepted an unlabeled base image" >&2 + exit 1 +fi +grep -F "does not declare its required kernel ABI" \ + "$TMPDIR/unlabeled-base.err" >/dev/null +if npx tsx "$REPO_ROOT/images/vfs/scripts/build-homebrew-vfs-image.ts" \ + --metadata "$TAP/Kandelo/metadata.json" \ + --tap-root "$TAP" \ + --package sidecar-tool \ + --arch wasm32 \ + --runtime node \ + --bottle-cache "$BOTTLE_CACHE" \ + --base-image "$COMPOSED_MARKER_BASE_IMAGE" \ + --out "$TMPDIR/composed-marker-output.vfs.zst" \ + --report "$TMPDIR/composed-marker-report.json" \ + > /dev/null 2>"$TMPDIR/composed-marker.err"; then + echo "Homebrew VFS builder accepted a base composition marker" >&2 + exit 1 +fi +grep -F "already contains a Homebrew composition" \ + "$TMPDIR/composed-marker.err" >/dev/null +if npx tsx "$REPO_ROOT/images/vfs/scripts/build-homebrew-vfs-image.ts" \ + --metadata "$TAP/Kandelo/metadata.json" \ + --tap-root "$TAP" \ + --package sidecar-tool \ + --arch wasm32 \ + --runtime node \ + --bottle-cache "$BOTTLE_CACHE" \ + --base-image "$BASE_IMAGE" \ + --max-bytes "$((BASE_RECORDED_MAX_BYTES + 1))" \ + --out "$TMPDIR/unaligned-output.vfs.zst" \ + --report "$TMPDIR/unaligned-report.json" \ + > /dev/null 2>"$TMPDIR/unaligned.err"; then + echo "Homebrew VFS builder accepted an unaligned filesystem maximum" >&2 + exit 1 +fi +grep -F -- "--max-bytes must be a multiple of 4096 bytes" \ + "$TMPDIR/unaligned.err" >/dev/null +if npx tsx "$REPO_ROOT/images/vfs/scripts/build-homebrew-vfs-image.ts" \ + --metadata "$TAP/Kandelo/metadata.json" \ + --tap-root "$TAP" \ + --package sidecar-tool \ + --arch wasm32 \ + --runtime node \ + --bottle-cache "$BOTTLE_CACHE" \ + --base-image "$BAD_BASE_IMAGE" \ + --out "$TMPDIR/bad-base-output.vfs.zst" \ + --report "$TMPDIR/bad-base-report.json" \ + > /dev/null 2>"$TMPDIR/bad-base.err"; then + echo "Homebrew VFS builder accepted an ABI-mismatched base image" >&2 + exit 1 +fi +grep -F "but bottle metadata requires ABI $ABI_VERSION" \ + "$TMPDIR/bad-base.err" >/dev/null +if npx tsx "$REPO_ROOT/images/vfs/scripts/build-homebrew-vfs-image.ts" \ + --metadata "$TAP/Kandelo/metadata.json" \ + --tap-root "$TAP" \ + --brewfile "$BREWFILE" \ + --package sidecar-tool \ + --out "$TMPDIR/mixed-selection.vfs.zst" \ + --report "$TMPDIR/mixed-selection-report.json" \ + > /dev/null 2>"$TMPDIR/mixed-selection.err"; then + echo "Homebrew VFS builder accepted mixed package selection modes" >&2 + exit 1 +fi +grep -F -- "--brewfile cannot be combined with --package" \ + "$TMPDIR/mixed-selection.err" >/dev/null +if npx tsx "$REPO_ROOT/images/vfs/scripts/build-homebrew-vfs-image.ts" \ + --metadata "$TAP/Kandelo/metadata.json" \ + --tap-root "$TAP" \ + --brewfile "$BREWFILE" \ + --brewfile "$BREWFILE" \ + --out "$TMPDIR/repeated-brewfile.vfs.zst" \ + --report "$TMPDIR/repeated-brewfile-report.json" \ + > /dev/null 2>"$TMPDIR/repeated-brewfile.err"; then + echo "Homebrew VFS builder accepted more than one --brewfile" >&2 + exit 1 +fi +grep -F -- "--brewfile may be provided only once" \ + "$TMPDIR/repeated-brewfile.err" >/dev/null npx tsx "$REPO_ROOT/images/vfs/scripts/build-homebrew-vfs-image.ts" \ --metadata "$TAP/Kandelo/metadata.json" \ --tap-root "$TAP" \ @@ -899,18 +1075,243 @@ npx tsx "$REPO_ROOT/images/vfs/scripts/build-homebrew-vfs-image.ts" \ --arch wasm32 \ --runtime node \ --bottle-cache "$BOTTLE_CACHE" \ + --out "$TMPDIR/sidecar-tool-clean.vfs.zst" \ + --report "$TMPDIR/sidecar-tool-clean-report.json" >/dev/null +jq -e 'has("base_image") | not' \ + "$TMPDIR/sidecar-tool-clean-report.json" >/dev/null +npx tsx "$REPO_ROOT/images/vfs/scripts/build-homebrew-vfs-image.ts" \ + --metadata "$TAP/Kandelo/metadata.json" \ + --tap-root "$TAP" \ + --package sidecar-tool \ + --arch wasm32 \ + --runtime node \ + --bottle-cache "$BOTTLE_CACHE" \ + --base-image "$BASE_IMAGE" \ + --out "$TMPDIR/sidecar-tool-base-default.vfs.zst" \ + --report "$TMPDIR/sidecar-tool-base-default-report.json" >/dev/null +if npx tsx "$REPO_ROOT/images/vfs/scripts/build-homebrew-vfs-image.ts" \ + --metadata "$TAP/Kandelo/metadata.json" \ + --tap-root "$TAP" \ + --package sidecar-tool \ + --arch wasm32 \ + --runtime node \ + --bottle-cache "$BOTTLE_CACHE" \ + --base-image "$TMPDIR/sidecar-tool-base-default.vfs.zst" \ + --out "$TMPDIR/recomposed-output.vfs.zst" \ + --report "$TMPDIR/recomposed-report.json" \ + > /dev/null 2>"$TMPDIR/recomposed.err"; then + echo "Homebrew VFS builder accepted composed image metadata" >&2 + exit 1 +fi +grep -F "already contains a Homebrew composition" \ + "$TMPDIR/recomposed.err" >/dev/null +npx tsx "$REPO_ROOT/images/vfs/scripts/build-homebrew-vfs-image.ts" \ + --metadata "$TAP/Kandelo/metadata.json" \ + --tap-root "$TAP" \ + --brewfile "$BREWFILE" \ + --arch wasm32 \ + --runtime node \ + --bottle-cache "$BOTTLE_CACHE" \ + --base-image "$BASE_IMAGE" \ + --max-bytes "$BASE_REQUESTED_MAX_BYTES" \ --out "$TMPDIR/sidecar-tool.vfs.zst" \ --report "$TMPDIR/sidecar-tool-report.json" >/dev/null jq -e ' [.packages[].name] == ["sidecar-dep", "sidecar-tool"] and + .selection.kind == "brewfile" and + .selection.requested_packages == ["sidecar-tool"] and + (.selection.requested_packages_sha256 | test("^[0-9a-f]{64}$")) and + .selection.brewfile == { + "parser":"kandelo-static-brewfile-v1", + "sha256":$brewfile_sha, + "bytes":$brewfile_bytes + } and (.packages[] | select(.name == "sidecar-tool") | .links) == [ "bin/sidecar-tool", "bin/sidecar-tool-helper", "include/sidecar-tool.h", "lib/libsidecar-tool.a", "share/man/man1/sidecar-tool.1" - ] -' "$TMPDIR/sidecar-tool-report.json" >/dev/null + ] and + .base_image.sha256 == $base_sha and + .base_image.bytes == $base_bytes and + .base_image.kernelAbi == $abi and + .base_image.metadata.platformBase == {"source":"sidecar-test"} and + (.base_image.metadata.signature | length) == 55000 and + .base_image.metadata.provenance == { + "issuer":"fixture-builder", + "subject":"base-only" + } +' --arg base_sha "$BASE_IMAGE_SHA256" --argjson base_bytes "$BASE_IMAGE_BYTES" \ + --arg brewfile_sha "$BREWFILE_SHA256" \ + --argjson brewfile_bytes "$BREWFILE_BYTES" \ + --argjson abi "$ABI_VERSION" \ + "$TMPDIR/sidecar-tool-report.json" >/dev/null +npx tsx "$REPO_ROOT/tools/mkrootfs/src/index.ts" extract \ + "$TMPDIR/sidecar-tool-clean.vfs.zst" "$TMPDIR/sidecar-tool-clean-root" >/dev/null +if [ -e "$TMPDIR/sidecar-tool-clean-root/etc/base-image-marker" ]; then + echo "Homebrew VFS builder added the base marker without --base-image" >&2 + exit 1 +fi +npx tsx "$REPO_ROOT/tools/mkrootfs/src/index.ts" extract \ + "$TMPDIR/sidecar-tool.vfs.zst" "$TMPDIR/sidecar-tool-root" >/dev/null +grep -Fx 'base-image-marker' \ + "$TMPDIR/sidecar-tool-root/etc/base-image-marker" >/dev/null +jq -e ' + .selection.kind == "brewfile" and + .selection.requested_packages == ["sidecar-tool"] and + .selection.brewfile.sha256 == $brewfile_sha and + .selection.brewfile.bytes == $brewfile_bytes +' --arg brewfile_sha "$BREWFILE_SHA256" \ + --argjson brewfile_bytes "$BREWFILE_BYTES" \ + "$TMPDIR/sidecar-tool-root/etc/kandelo/homebrew-vfs.json" >/dev/null +npx tsx "$REPO_ROOT/tools/mkrootfs/src/index.ts" inspect \ + "$TMPDIR/sidecar-tool.vfs.zst" --format json --metadata \ + > "$TMPDIR/sidecar-tool-inspect.json" +jq -e ' + .metadata.baseImage == { + "sha256":$base_sha, + "bytes":$base_bytes, + "kernelAbi":$abi + } and + .metadata.homebrew.tapRepository == "Automattic/kandelo-homebrew" and + .metadata.homebrew.tapName == "automattic/kandelo-homebrew" and + .metadata.homebrew.selection == { + "kind":"brewfile", + "requestedPackageCount":1, + "requestedPackagesSha256":$requested_sha, + "brewfile":{ + "parser":"kandelo-static-brewfile-v1", + "sha256":$brewfile_sha, + "bytes":$brewfile_bytes + } + } and + ($requested_sha | test("^[0-9a-f]{64}$")) and + (.metadata.baseImage | has("metadata") | not) and + (.metadata | has("platformBase") | not) and + (.metadata | has("signature") | not) and + (.metadata | has("provenance") | not) and + .metadata.kernelAbi == $abi and + .metadata.createdBy == "images/vfs/scripts/build-homebrew-vfs-image.ts" +' --arg base_sha "$BASE_IMAGE_SHA256" \ + --arg requested_sha "$(jq -r '.selection.requested_packages_sha256' \ + "$TMPDIR/sidecar-tool-report.json")" \ + --arg brewfile_sha "$BREWFILE_SHA256" \ + --argjson brewfile_bytes "$BREWFILE_BYTES" \ + --argjson base_bytes "$BASE_IMAGE_BYTES" \ + --argjson abi "$ABI_VERSION" "$TMPDIR/sidecar-tool-inspect.json" >/dev/null +npx tsx -e " +import { readFileSync } from 'node:fs'; +import { MemoryFileSystem } from '$REPO_ROOT/host/src/vfs/memory-fs.ts'; +for (const [path, expected] of [ + ['$TMPDIR/sidecar-tool-base-default.vfs.zst', $BASE_RECORDED_MAX_BYTES], + ['$TMPDIR/sidecar-tool.vfs.zst', $BASE_REQUESTED_MAX_BYTES], +]) { + const bytes = new Uint8Array(readFileSync(path)); + const capacity = MemoryFileSystem.readImageCapacity(bytes); + if (capacity.maxByteLength !== expected) { + throw new Error(path + ': configured maximum does not match ' + expected); + } + const fs = MemoryFileSystem.fromImagePreservingCapacity(bytes); + const stats = fs.statfs('/'); + const statfsCapacity = stats.blocks * stats.bsize; + if (statfsCapacity !== expected) { + throw new Error(path + ': expected capacity ' + expected + ', got ' + statfsCapacity); + } +} + +const base = MemoryFileSystem.fromImagePreservingCapacity( + new Uint8Array(readFileSync('$BASE_IMAGE')), +); +const composed = MemoryFileSystem.fromImagePreservingCapacity( + new Uint8Array(readFileSync('$TMPDIR/sidecar-tool-base-default.vfs.zst')), +); +const rebased = MemoryFileSystem.fromImagePreservingCapacity( + new Uint8Array(readFileSync('$TMPDIR/sidecar-tool.vfs.zst')), +); +const marker = '/etc/base-image-marker'; +const baseCtimeMs = base.stat(marker).ctimeMs; +const composedCtimeMs = composed.stat(marker).ctimeMs; +const rebasedCtimeMs = rebased.stat(marker).ctimeMs; +if ( + !Number.isFinite(baseCtimeMs) || + !Number.isFinite(composedCtimeMs) || + !Number.isFinite(rebasedCtimeMs) +) { + throw new Error('base marker ctime is unavailable'); +} +if (baseCtimeMs !== composedCtimeMs) { + throw new Error('default composition rebuilt an unchanged base inode'); +} +if (baseCtimeMs === rebasedCtimeMs) { + throw new Error('explicit rebase did not exercise fresh-inode copying'); +} +" >/dev/null + +THIRD_PARTY_TAP="$TMPDIR/third-party-tap" +mkdir -p "$THIRD_PARTY_TAP/Kandelo" +cp -R "$TAP/Kandelo/link" "$THIRD_PARTY_TAP/Kandelo/link" +jq ' + .tap_repository = "Example/homebrew-kandelo-tools" | + .tap_name = "example/kandelo-tools" | + (.packages[].full_name |= sub("^automattic/kandelo-homebrew/"; "example/kandelo-tools/")) | + (.packages[].bottles[].built_from.tap_repository? = "Example/homebrew-kandelo-tools") +' "$TAP/Kandelo/metadata.json" > "$THIRD_PARTY_TAP/Kandelo/metadata.json" +THIRD_PARTY_BREWFILE="$TMPDIR/third-party.Brewfile" +cat > "$THIRD_PARTY_BREWFILE" <<'EOF' +tap "example/kandelo-tools" +brew "sidecar-tool" +EOF +THIRD_PARTY_BREWFILE_SHA256="$(sha256_file "$THIRD_PARTY_BREWFILE")" +THIRD_PARTY_BREWFILE_BYTES="$(wc -c < "$THIRD_PARTY_BREWFILE" | tr -d ' ')" +if npx tsx "$REPO_ROOT/images/vfs/scripts/build-homebrew-vfs-image.ts" \ + --metadata "$THIRD_PARTY_TAP/Kandelo/metadata.json" \ + --tap-root "$THIRD_PARTY_TAP" \ + --brewfile "$BREWFILE" \ + --arch wasm32 \ + --runtime node \ + --bottle-cache "$BOTTLE_CACHE" \ + --base-image "$BASE_IMAGE" \ + --out "$TMPDIR/wrong-tap.vfs.zst" \ + --report "$TMPDIR/wrong-tap-report.json" \ + > /dev/null 2>"$TMPDIR/wrong-tap.err"; then + echo "Homebrew VFS builder accepted a Brewfile for a different tap" >&2 + exit 1 +fi +grep -F 'metadata tap "example/kandelo-tools" does not match requested tap "automattic/kandelo-homebrew"' \ + "$TMPDIR/wrong-tap.err" >/dev/null +npx tsx "$REPO_ROOT/images/vfs/scripts/build-homebrew-vfs-image.ts" \ + --metadata "$THIRD_PARTY_TAP/Kandelo/metadata.json" \ + --tap-root "$THIRD_PARTY_TAP" \ + --brewfile "$THIRD_PARTY_BREWFILE" \ + --arch wasm32 \ + --runtime node \ + --bottle-cache "$BOTTLE_CACHE" \ + --base-image "$BASE_IMAGE" \ + --out "$TMPDIR/third-party.vfs.zst" \ + --report "$TMPDIR/third-party-report.json" >/dev/null +npx tsx "$REPO_ROOT/tools/mkrootfs/src/index.ts" inspect \ + "$TMPDIR/third-party.vfs.zst" --format json --metadata \ + > "$TMPDIR/third-party-inspect.json" +jq -e ' + .metadata.homebrew.tapRepository == "Example/homebrew-kandelo-tools" and + .metadata.homebrew.tapName == "example/kandelo-tools" and + .metadata.homebrew.selection.kind == "brewfile" and + .metadata.homebrew.selection.brewfile.sha256 == $brewfile_sha and + .metadata.homebrew.selection.brewfile.bytes == $brewfile_bytes +' --arg brewfile_sha "$THIRD_PARTY_BREWFILE_SHA256" \ + --argjson brewfile_bytes "$THIRD_PARTY_BREWFILE_BYTES" \ + "$TMPDIR/third-party-inspect.json" >/dev/null +jq -e ' + .metadata.tap_repository == "Example/homebrew-kandelo-tools" and + .metadata.tap_name == "example/kandelo-tools" and + .selection.kind == "brewfile" and + .selection.requested_packages == ["sidecar-tool"] and + .selection.brewfile.sha256 == $brewfile_sha and + .selection.brewfile.bytes == $brewfile_bytes +' --arg brewfile_sha "$THIRD_PARTY_BREWFILE_SHA256" \ + --argjson brewfile_bytes "$THIRD_PARTY_BREWFILE_BYTES" \ + "$TMPDIR/third-party-report.json" >/dev/null echo "test-homebrew-tap-native-sidecars.sh: ok"