diff --git a/.github/scripts/activate-homebrew-prepublication-generation.sh b/.github/scripts/activate-homebrew-prepublication-generation.sh new file mode 100755 index 0000000000..1350321d87 --- /dev/null +++ b/.github/scripts/activate-homebrew-prepublication-generation.sh @@ -0,0 +1,188 @@ +#!/usr/bin/env bash +# Validate a same-run sealed generation artifact and expose its local index. +set -euo pipefail + +BUNDLE="" +EXPECTED_TAG="" +EXPECTED_GENERATION_SHA="" +EXPECTED_CONSUMER_SHA="" +EXPECTED_ABI="" +GITHUB_ENV_FILE="" + +while [ "$#" -gt 0 ]; do + case "$1" in + --bundle) BUNDLE="$2"; shift 2 ;; + --expected-tag) EXPECTED_TAG="$2"; shift 2 ;; + --expected-generation-sha) EXPECTED_GENERATION_SHA="$2"; shift 2 ;; + --expected-consumer-sha) EXPECTED_CONSUMER_SHA="$2"; shift 2 ;; + --expected-abi) EXPECTED_ABI="$2"; shift 2 ;; + --github-env) GITHUB_ENV_FILE="$2"; shift 2 ;; + *) echo "activate-homebrew-prepublication-generation: unknown flag $1" >&2; exit 2 ;; + esac +done + +if [ ! -d "$BUNDLE" ] || [ -L "$BUNDLE" ] || + ! [[ "$EXPECTED_TAG" =~ ^pr-[1-9][0-9]*-staging$ ]] || + ! [[ "$EXPECTED_GENERATION_SHA" =~ ^[0-9a-f]{40}$ ]] || + ! [[ "$EXPECTED_CONSUMER_SHA" =~ ^[0-9a-f]{40}$ ]] || + ! [[ "$EXPECTED_ABI" =~ ^[1-9][0-9]*$ ]] || + [ -z "$GITHUB_ENV_FILE" ]; then + echo "activate-homebrew-prepublication-generation: exact bundle, tag, SHAs, ABI, and GITHUB_ENV are required" >&2 + exit 2 +fi +for name in index.toml manifest.json projection.json expected-ledger.json snapshot.json assets.json; do + [ -f "$BUNDLE/$name" ] && [ ! -L "$BUNDLE/$name" ] || { + echo "activate-homebrew-prepublication-generation: missing regular $name" >&2 + exit 2 + } +done + +manifest="$BUNDLE/manifest.json" +jq -e \ + --arg tag "$EXPECTED_TAG" \ + --arg generation "$EXPECTED_GENERATION_SHA" \ + --arg consumer "$EXPECTED_CONSUMER_SHA" \ + --argjson abi "$EXPECTED_ABI" ' + keys == [ + "abi_version", "consumer_sha", "files", "generation_sha", + "package_count", "repository", "schema", "staging_tag" + ] and + .schema == 2 and + .repository == "Automattic/kandelo" and + .staging_tag == $tag and + .generation_sha == $generation and + .consumer_sha == $consumer and + .abi_version == $abi and + (.package_count | type == "number" and . > 1 and floor == .) and + (.files | keys) == [ + "assets.json", "expected-ledger.json", "index.toml", + "projection.json", "snapshot.json" + ] and + all(.files[]; ( + keys == ["sha256", "size"] and + (.sha256 | type == "string" and test("^[0-9a-f]{64}$")) and + (.size | type == "number" and . > 0 and floor == .) + )) + ' "$manifest" >/dev/null || { + echo "activate-homebrew-prepublication-generation: manifest does not bind the planned generation" >&2 + exit 1 +} + +sha_file() { + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$1" | awk '{print $1}' + else + shasum -a 256 "$1" | awk '{print $1}' + fi +} +for name in index.toml projection.json expected-ledger.json snapshot.json assets.json; do + actual_sha="$(sha_file "$BUNDLE/$name")" + actual_size="$(wc -c <"$BUNDLE/$name" | tr -d '[:space:]')" + expected_sha="$(jq -r --arg name "$name" '.files[$name].sha256' "$manifest")" + expected_size="$(jq -r --arg name "$name" '.files[$name].size' "$manifest")" + [ "$actual_sha" = "$expected_sha" ] && [ "$actual_size" = "$expected_size" ] || { + echo "activate-homebrew-prepublication-generation: $name differs from the sealed manifest" >&2 + exit 1 + } +done + +package_count="$(jq -r '.package_count' "$manifest")" +jq -e --argjson count "$package_count" ' + .schema == 1 and + (.entries | length) == $count and + all(.entries[]; ( + keys == ["arch", "cache_key_sha", "manifest_sha256", "package"] and + (.package | type == "string" and test("^[a-z0-9][a-z0-9._-]*$")) and + .arch == "wasm32" and + (.cache_key_sha | type == "string" and test("^[0-9a-f]{64}$")) and + (.manifest_sha256 | type == "string" and test("^[0-9a-f]{64}$")) + )) and + ([.entries[] | [.package, .arch, .cache_key_sha]] | + length == (unique | length)) +' "$BUNDLE/projection.json" >/dev/null || { + echo "activate-homebrew-prepublication-generation: invalid sealed package projection" >&2 + exit 1 +} +jq -e --argjson count "$package_count" --argjson abi "$EXPECTED_ABI" ' + .abi_version == $abi and + (.entries | length) == $count and + all(.entries[]; ( + (.package | type == "string" and test("^[a-z0-9][a-z0-9._-]*$")) and + .arch == "wasm32" and + (.cache_key_sha | type == "string" and test("^[0-9a-f]{64}$")) + )) and + ([.entries[] | [.package, .arch, .cache_key_sha]] | + length == (unique | length)) +' "$BUNDLE/expected-ledger.json" >/dev/null || { + echo "activate-homebrew-prepublication-generation: invalid sealed expected ledger" >&2 + exit 1 +} +jq -e --argjson count "$package_count" --argjson abi "$EXPECTED_ABI" \ + --arg tag "$EXPECTED_TAG" ' + .abi_version == $abi and .release_tag == $tag and + .complete_current == true and + (.entries | length) == $count and + all(.entries[]; ( + .current == true and + (.package | type == "string" and test("^[a-z0-9][a-z0-9._-]*$")) and + .arch == "wasm32" and + (.cache_key_sha | type == "string" and test("^[0-9a-f]{64}$")) and + (.asset | type == "string" and + test("^[A-Za-z0-9][A-Za-z0-9._-]*\\.tar\\.zst$")) and + (.archive_sha256 | type == "string" and test("^[0-9a-f]{64}$")) and + (.size | type == "number" and . > 0 and floor == .) + )) and + ([.entries[] | [.package, .arch, .cache_key_sha]] | + length == (unique | length)) + ' "$BUNDLE/snapshot.json" >/dev/null || { + echo "activate-homebrew-prepublication-generation: invalid sealed staging snapshot" >&2 + exit 1 +} +jq -e ' + type == "array" and + all(.[]; ( + (.name | type == "string" and test("^[A-Za-z0-9][A-Za-z0-9._-]*$")) and + (.state | type == "string") and + (.size | type == "number" and . >= 0 and floor == .) and + ((.digest == null) or + (.digest | type == "string" and test("^sha256:[0-9a-f]{64}$"))) + )) and + ([.[].name] | length == (unique | length)) +' "$BUNDLE/assets.json" >/dev/null || { + echo "activate-homebrew-prepublication-generation: invalid sealed release asset inventory" >&2 + exit 1 +} + +identity_root="$(mktemp -d)" +trap 'rm -rf "$identity_root"' EXIT +identity_set() { + jq -r '.entries[] | [.package, .arch, .cache_key_sha] | @tsv' "$1" | + LC_ALL=C sort >"$2" +} +identity_set "$BUNDLE/projection.json" "$identity_root/projection" +identity_set "$BUNDLE/expected-ledger.json" "$identity_root/expected" +identity_set "$BUNDLE/snapshot.json" "$identity_root/snapshot" +if ! cmp "$identity_root/projection" "$identity_root/expected" >/dev/null || + ! cmp "$identity_root/projection" "$identity_root/snapshot" >/dev/null; then + echo "activate-homebrew-prepublication-generation: sealed package identities disagree" >&2 + exit 1 +fi + +jq -e --slurpfile assets "$BUNDLE/assets.json" ' + all(.entries[]; + . as $entry | + (([ $assets[0][] | select(.name == $entry.asset) ] | length) == 1 and + ([ $assets[0][] | select(.name == $entry.asset) ][0] | + .state == "uploaded" and + .size == $entry.size and + .digest == ("sha256:" + $entry.archive_sha256)))) +' "$BUNDLE/snapshot.json" >/dev/null || { + echo "activate-homebrew-prepublication-generation: sealed snapshot assets disagree" >&2 + exit 1 +} + +index_path="$(cd "$BUNDLE" && pwd)/index.toml" +printf 'WASM_POSIX_BINARY_INDEX_URL=file://%s\n' "$index_path" >>"$GITHUB_ENV_FILE" +rm -rf "$identity_root" +trap - EXIT +echo "activate-homebrew-prepublication-generation: using file://$index_path" diff --git a/.github/scripts/freeze-homebrew-prepublication-generation.sh b/.github/scripts/freeze-homebrew-prepublication-generation.sh new file mode 100755 index 0000000000..1a17729030 --- /dev/null +++ b/.github/scripts/freeze-homebrew-prepublication-generation.sh @@ -0,0 +1,282 @@ +#!/usr/bin/env bash +# Seal the exact rootfs runtime generation from a mutable PR-staging release. +# +# The output intentionally contains a minimal index rather than the staging +# release's full index. A staging release may contain unrelated or failed +# entries; only archives validated against the exact rootfs closure may become +# resolver inputs for bottle publication. +set -euo pipefail + +TAG="" +GENERATION_ROOT="" +CONSUMER_ROOT="" +EXPECTED_ABI="" +GENERATION_SHA="" +CONSUMER_SHA="" +REPOSITORY="" +OUTPUT_DIR="" +XTASK="" + +while [ "$#" -gt 0 ]; do + case "$1" in + --tag) TAG="$2"; shift 2 ;; + --generation-root) GENERATION_ROOT="$2"; shift 2 ;; + --consumer-root) CONSUMER_ROOT="$2"; shift 2 ;; + --expected-abi) EXPECTED_ABI="$2"; shift 2 ;; + --generation-sha) GENERATION_SHA="$2"; shift 2 ;; + --consumer-sha) CONSUMER_SHA="$2"; shift 2 ;; + --repository) REPOSITORY="$2"; shift 2 ;; + --output-dir) OUTPUT_DIR="$2"; shift 2 ;; + --xtask) XTASK="$2"; shift 2 ;; + *) echo "freeze-homebrew-prepublication-generation: unknown flag $1" >&2; exit 2 ;; + esac +done + +if ! [[ "$TAG" =~ ^pr-[1-9][0-9]*-staging$ ]] || + ! [[ "$EXPECTED_ABI" =~ ^[1-9][0-9]*$ ]] || + ! [[ "$GENERATION_SHA" =~ ^[0-9a-f]{40}$ ]] || + ! [[ "$CONSUMER_SHA" =~ ^[0-9a-f]{40}$ ]] || + [ "$REPOSITORY" != "Automattic/kandelo" ] || + [ ! -d "$GENERATION_ROOT" ] || [ -L "$GENERATION_ROOT" ] || + [ ! -d "$CONSUMER_ROOT" ] || [ -L "$CONSUMER_ROOT" ] || + [ -z "$OUTPUT_DIR" ] || [ "$OUTPUT_DIR" = / ] || + [ ! -x "$XTASK" ]; then + echo "freeze-homebrew-prepublication-generation: exact tag, ABI, SHAs, roots, repository, output, and xtask are required" >&2 + exit 2 +fi +if [ -e "$OUTPUT_DIR" ] || [ -L "$OUTPUT_DIR" ]; then + echo "freeze-homebrew-prepublication-generation: output already exists: $OUTPUT_DIR" >&2 + exit 2 +fi +[ "$(git -C "$GENERATION_ROOT" rev-parse HEAD)" = "$GENERATION_SHA" ] || { + echo "freeze-homebrew-prepublication-generation: generation checkout differs from the trusted SHA" >&2 + exit 2 +} +[ -z "$(git -C "$GENERATION_ROOT" status --porcelain=v1 --untracked-files=all)" ] || { + echo "freeze-homebrew-prepublication-generation: generation checkout is dirty" >&2 + exit 2 +} +[ "$(git -C "$CONSUMER_ROOT" rev-parse HEAD)" = "$CONSUMER_SHA" ] || { + echo "freeze-homebrew-prepublication-generation: consumer checkout differs from the planned SHA" >&2 + exit 2 +} +[ -z "$(git -C "$CONSUMER_ROOT" status --porcelain=v1 --untracked-files=all)" ] || { + echo "freeze-homebrew-prepublication-generation: consumer checkout is dirty" >&2 + exit 2 +} + +# WHY: the GitHub token is needed only for the immutable release snapshot and +# asset downloads. Keep it out of projection work and every package/index +# transformation so package-controlled inputs cannot observe publication +# credentials. +RELEASE_GH_TOKEN="${GH_TOKEN:-}" +RELEASE_GITHUB_TOKEN="${GITHUB_TOKEN:-}" +unset GH_TOKEN GITHUB_TOKEN +unset HOMEBREW_GITHUB_API_TOKEN HOMEBREW_GITHUB_PACKAGES_TOKEN +unset HOMEBREW_DOCKER_REGISTRY_TOKEN + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +PARENT="$(dirname "$OUTPUT_DIR")" +mkdir -p "$PARENT" +TMP_ROOT="$(mktemp -d "$PARENT/.homebrew-prepublication-generation.XXXXXX")" +trap 'rm -rf "$TMP_ROOT"' EXIT + +run_xtask_without_credentials() { + env -u GH_TOKEN -u GITHUB_TOKEN \ + -u HOMEBREW_GITHUB_API_TOKEN \ + -u HOMEBREW_GITHUB_PACKAGES_TOKEN \ + -u HOMEBREW_DOCKER_REGISTRY_TOKEN \ + "$XTASK" "$@" +} + +projection_for() { + local root="$1" + local output="$2" + local source="$root/packages/registry/program-packages.json" + [ -f "$source" ] && [ ! -L "$source" ] || { + echo "freeze-homebrew-prepublication-generation: program package projection must be a regular file" >&2 + return 2 + } + jq -e ' + .format == "kandelo-program-packages-v2" and + (.packages.rootfs.arches == ["wasm32"]) and + (.packages.rootfs.cacheKeys.wasm32 | type == "string" and test("^[0-9a-f]{64}$")) and + (.packages.rootfs.manifestSha256 | type == "string" and test("^[0-9a-f]{64}$")) and + (.packages.rootfs.dependencyClosures.wasm32 | type == "array" and length > 0) and + all(.packages.rootfs.dependencyClosures.wasm32[]; + (.packageName | type == "string" and test("^[a-z0-9][a-z0-9._-]*$")) and + (.manifestSha256 | type == "string" and test("^[0-9a-f]{64}$")) and + (.cacheKey | type == "string" and test("^[0-9a-f]{64}$"))) + ' "$source" >/dev/null || { + echo "freeze-homebrew-prepublication-generation: invalid rootfs program package projection" >&2 + return 2 + } + jq -S ' + { + schema: 1, + entries: ( + ( + .packages.rootfs.dependencyClosures.wasm32 | + map({ + package: .packageName, + arch: "wasm32", + manifest_sha256: .manifestSha256, + cache_key_sha: .cacheKey + }) + ) + [{ + package: "rootfs", + arch: "wasm32", + manifest_sha256: .packages.rootfs.manifestSha256, + cache_key_sha: .packages.rootfs.cacheKeys.wasm32 + }] | + sort_by(.package, .arch) + ) + } + ' "$source" >"$output" + jq -e ' + (.entries | length) > 1 and + ([.entries[].package] | length == (unique | length)) + ' "$output" >/dev/null || { + echo "freeze-homebrew-prepublication-generation: rootfs closure contains duplicate packages" >&2 + return 2 + } +} + +projection_for "$GENERATION_ROOT" "$TMP_ROOT/generation-projection.json" +projection_for "$CONSUMER_ROOT" "$TMP_ROOT/consumer-projection.json" +# WHY: F owns the staging archive identities, while H owns the publishing +# workflow. Comparing both projections prevents a workflow-only descendant +# from silently consuming F's archives after any package/cache-key drift. +cmp "$TMP_ROOT/generation-projection.json" "$TMP_ROOT/consumer-projection.json" || { + echo "freeze-homebrew-prepublication-generation: generation and consumer rootfs identities differ" >&2 + exit 1 +} + +expected_ledger_for() { + local root="$1" + local output="$2" + local full + full="$TMP_ROOT/$(basename "$output").full" + run_xtask_without_credentials staging-reuse expected \ + --registry "$root/packages/registry" \ + --expected-abi "$EXPECTED_ABI" \ + --output "$full" + jq -S --slurpfile projection "$TMP_ROOT/generation-projection.json" ' + ($projection[0].entries | map(.package)) as $packages | + .entries |= map(select(.arch == "wasm32" and (.package as $name | $packages | index($name)))) + ' "$full" >"$output" + jq -e --argjson expected_abi "$EXPECTED_ABI" \ + --slurpfile projection "$TMP_ROOT/generation-projection.json" ' + .abi_version == $expected_abi and + (.entries | length) == ($projection[0].entries | length) and + (.entries | all(.[]; . as $entry | + any($projection[0].entries[]; + .package == $entry.package and + .arch == $entry.arch and + .cache_key_sha == $entry.cache_key_sha))) + ' "$output" >/dev/null + rm "$full" +} + +expected_ledger_for "$GENERATION_ROOT" "$TMP_ROOT/generation-expected.json" +expected_ledger_for "$CONSUMER_ROOT" "$TMP_ROOT/consumer-expected.json" +cmp "$TMP_ROOT/generation-expected.json" "$TMP_ROOT/consumer-expected.json" || { + echo "freeze-homebrew-prepublication-generation: generation and consumer expected ledgers differ" >&2 + exit 1 +} + +GH_TOKEN="$RELEASE_GH_TOKEN" \ + GITHUB_TOKEN="$RELEASE_GITHUB_TOKEN" \ + GITHUB_REPOSITORY="$REPOSITORY" \ + bash "$SCRIPT_DIR/validate-staging-release.sh" \ + --tag "$TAG" \ + --expected-ledger "$TMP_ROOT/generation-expected.json" \ + --mode current \ + --materialize \ + --output-dir "$TMP_ROOT/validated" \ + --xtask "$XTASK" + +# WHY: validate-staging-release localizes relative asset names. Rebuilding from +# only the fully validated archives removes every unrelated staging entry; +# seeding then gives those exact entries stable URLs under the reviewed tag. +run_xtask_without_credentials build-index \ + --abi "$EXPECTED_ABI" \ + --generator "Homebrew prepublication rootfs generation from $GENERATION_SHA" \ + --archives-dir "$TMP_ROOT/validated/archives" \ + --out "$TMP_ROOT/minimal-index.toml" \ + --generated-at "1970-01-01T00:00:00Z" +run_xtask_without_credentials index-candidate seed \ + --canonical-index "$TMP_ROOT/minimal-index.toml" \ + --candidate-index "$TMP_ROOT/index.toml" \ + --canonical-index-url "https://github.com/$REPOSITORY/releases/download/$TAG/index.toml" \ + --expected-abi "$EXPECTED_ABI" \ + --generated-at "1970-01-01T00:00:00Z" \ + --generator "Homebrew sealed prepublication rootfs generation" + +package_count="$(jq -r '.entries | length' "$TMP_ROOT/generation-projection.json")" +archive_url_count="$(grep -c '^archive_url = ' "$TMP_ROOT/index.toml" || true)" +[ "$archive_url_count" = "$package_count" ] || { + echo "freeze-homebrew-prepublication-generation: frozen index entry count differs from the validated closure" >&2 + exit 1 +} +if grep '^archive_url = ' "$TMP_ROOT/index.toml" | + grep -Fv "archive_url = \"https://github.com/$REPOSITORY/releases/download/$TAG/" >/dev/null; then + echo "freeze-homebrew-prepublication-generation: frozen index contains an unsealed archive URL" >&2 + exit 1 +fi + +sha_file() { + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$1" | awk '{print $1}' + else + shasum -a 256 "$1" | awk '{print $1}' + fi +} + +mkdir "$TMP_ROOT/output" +cp "$TMP_ROOT/index.toml" "$TMP_ROOT/output/index.toml" +cp "$TMP_ROOT/generation-projection.json" "$TMP_ROOT/output/projection.json" +cp "$TMP_ROOT/generation-expected.json" "$TMP_ROOT/output/expected-ledger.json" +cp "$TMP_ROOT/validated/snapshot.json" "$TMP_ROOT/output/snapshot.json" +cp "$TMP_ROOT/validated/assets.json" "$TMP_ROOT/output/assets.json" + +# WHY: downstream jobs receive one same-run artifact, but each file still +# needs an explicit byte identity. Binding every evidence file prevents a +# same-count projection, ledger, snapshot, or asset inventory from being +# substituted independently of the frozen index. +printf '{}\n' >"$TMP_ROOT/files.json" +for name in index.toml projection.json expected-ledger.json snapshot.json assets.json; do + file_sha="$(sha_file "$TMP_ROOT/output/$name")" + file_size="$(wc -c <"$TMP_ROOT/output/$name" | tr -d '[:space:]')" + jq -S \ + --arg name "$name" \ + --arg sha256 "$file_sha" \ + --argjson size "$file_size" \ + '. + {($name): {sha256: $sha256, size: $size}}' \ + "$TMP_ROOT/files.json" >"$TMP_ROOT/files.next.json" + mv "$TMP_ROOT/files.next.json" "$TMP_ROOT/files.json" +done + +jq -nS \ + --arg repository "$REPOSITORY" \ + --arg staging_tag "$TAG" \ + --arg generation_sha "$GENERATION_SHA" \ + --arg consumer_sha "$CONSUMER_SHA" \ + --argjson abi_version "$EXPECTED_ABI" \ + --argjson package_count "$package_count" \ + --slurpfile files "$TMP_ROOT/files.json" \ + '{ + schema: 2, + repository: $repository, + staging_tag: $staging_tag, + generation_sha: $generation_sha, + consumer_sha: $consumer_sha, + abi_version: $abi_version, + package_count: $package_count, + files: $files[0] + }' >"$TMP_ROOT/output/manifest.json" + +mv "$TMP_ROOT/output" "$OUTPUT_DIR" +rm -rf "$TMP_ROOT" +trap - EXIT +echo "freeze-homebrew-prepublication-generation: sealed $package_count packages from $TAG" diff --git a/.github/scripts/test-freeze-homebrew-prepublication-generation.sh b/.github/scripts/test-freeze-homebrew-prepublication-generation.sh new file mode 100755 index 0000000000..ab4e40caf3 --- /dev/null +++ b/.github/scripts/test-freeze-homebrew-prepublication-generation.sh @@ -0,0 +1,428 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +FREEZE="$SCRIPT_DIR/freeze-homebrew-prepublication-generation.sh" +ACTIVATE="$SCRIPT_DIR/activate-homebrew-prepublication-generation.sh" +TMP_ROOT="$(mktemp -d)" +trap 'rm -rf "$TMP_ROOT"' EXIT +mkdir -p "$TMP_ROOT/bin" "$TMP_ROOT/assets" + +hex_a="$(printf 'a%.0s' {1..64})" +hex_b="$(printf 'b%.0s' {1..64})" +hex_c="$(printf 'c%.0s' {1..64})" +for root in generation consumer; do + mkdir -p "$TMP_ROOT/$root/packages/registry" + jq -nS --arg a "$hex_a" --arg b "$hex_b" '{ + format: "kandelo-program-packages-v2", + packages: { + rootfs: { + manifestSha256: $b, + arches: ["wasm32"], + cacheKeys: {wasm32: $b}, + dependencyClosures: { + wasm32: [{ + packageName: "dep", + manifestSha256: $a, + cacheKey: $a + }] + }, + members: [] + } + } + }' >"$TMP_ROOT/$root/packages/registry/program-packages.json" + git -C "$TMP_ROOT/$root" init -q + git -C "$TMP_ROOT/$root" config user.name "Kandelo test" + git -C "$TMP_ROOT/$root" config user.email "test@example.invalid" + git -C "$TMP_ROOT/$root" add packages/registry/program-packages.json + git -C "$TMP_ROOT/$root" commit -qm "test projection" +done + +jq -nS --arg a "$hex_a" --arg b "$hex_b" '{ + abi_version: 42, + entries: [ + {package:"dep",kind:"program",arch:"wasm32",version:"1",revision:1,cache_key_sha:$a,git_inputs:[]}, + {package:"rootfs",kind:"program",arch:"wasm32",version:"1",revision:1,cache_key_sha:$b,git_inputs:[]}, + {package:"unvalidated",kind:"program",arch:"wasm32",version:"1",revision:1,cache_key_sha:$a,git_inputs:[]} + ] +}' >"$TMP_ROOT/full-expected.json" +for root in generation consumer; do + cp "$TMP_ROOT/full-expected.json" \ + "$TMP_ROOT/$root/packages/registry/.test-expected.json" + git -C "$TMP_ROOT/$root" add packages/registry/.test-expected.json + git -C "$TMP_ROOT/$root" commit -qm "test expected ledger" +done +generation_sha="$(git -C "$TMP_ROOT/generation" rev-parse HEAD)" +consumer_sha="$(git -C "$TMP_ROOT/consumer" rev-parse HEAD)" + +printf 'dep archive\n' >"$TMP_ROOT/assets/dep.tar.zst" +printf 'rootfs archive\n' >"$TMP_ROOT/assets/rootfs.tar.zst" +printf 'unvalidated archive\n' >"$TMP_ROOT/assets/unvalidated.tar.zst" +cat >"$TMP_ROOT/assets/index.toml" <<'EOF' +abi_version = 42 +archive_url = "https://github.com/Automattic/kandelo/releases/download/pr-1079-staging/dep.tar.zst" +archive_url = "https://github.com/Automattic/kandelo/releases/download/pr-1079-staging/rootfs.tar.zst" +archive_url = "https://github.com/Automattic/kandelo/releases/download/pr-1079-staging/unvalidated.tar.zst" +EOF + +sha_file() { + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$1" | awk '{print $1}' + else + shasum -a 256 "$1" | awk '{print $1}' + fi +} +asset_record() { + local name="$1" + local path="$TMP_ROOT/assets/$name" + jq -n \ + --arg name "$name" \ + --arg digest "sha256:$(sha_file "$path")" \ + --argjson size "$(wc -c <"$path" | tr -d '[:space:]')" \ + '{name:$name,state:"uploaded",size:$size,digest:$digest}' +} +ASSETS="$( + jq -s '.' < <( + asset_record index.toml + asset_record dep.tar.zst + asset_record rootfs.tar.zst + asset_record unvalidated.tar.zst + ) +)" + +cat >"$TMP_ROOT/bin/gh" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +if [ "$1" = api ] && [[ "$*" == *'/releases/tags/'* ]]; then + printf '17\n' +elif [ "$1" = api ] && [[ "$*" == *'/assets?per_page=100'* ]]; then + printf '[%s]\n' "${GH_STUB_ASSETS:?}" +elif [ "$1 $2" = "release download" ]; then + pattern=""; dir="" + while [ "$#" -gt 0 ]; do + case "$1" in + --pattern) pattern="$2"; shift 2 ;; + --dir) dir="$2"; shift 2 ;; + *) shift ;; + esac + done + cp "${GH_STUB_ASSET_ROOT:?}/$pattern" "$dir/$pattern" +else + echo "unexpected gh invocation: $*" >&2 + exit 1 +fi +EOF +chmod +x "$TMP_ROOT/bin/gh" + +cat >"$TMP_ROOT/bin/xtask" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +for credential_name in \ + GH_TOKEN GITHUB_TOKEN \ + HOMEBREW_GITHUB_API_TOKEN HOMEBREW_GITHUB_PACKAGES_TOKEN \ + HOMEBREW_DOCKER_REGISTRY_TOKEN; do + [ -z "${!credential_name:-}" ] || { + echo "xtask inherited $credential_name" >&2 + exit 97 + } +done +action="${1:-} ${2:-}" +if [ "$action" = "staging-reuse expected" ]; then + shift 2 + while [ "$#" -gt 0 ]; do + case "$1" in + --registry) registry="$2"; shift 2 ;; + --output) output="$2"; shift 2 ;; + *) shift 2 ;; + esac + done + cp "${registry:?}/.test-expected.json" "$output" + exit 0 +fi +if [ "$action" = "staging-reuse validate" ]; then + shift 2 + while [ "$#" -gt 0 ]; do + case "$1" in + --expected-ledger) expected="$2"; shift 2 ;; + --output) output="$2"; shift 2 ;; + --localized-index) localized="$2"; shift 2 ;; + *) shift 2 ;; + esac + done + [ "$(jq -r '.entries | length' "$expected")" = 2 ] + cp "${GH_STUB_ASSET_ROOT:?}/index.toml" "$localized" + sha_file() { + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$1" | awk '{print $1}' + else + shasum -a 256 "$1" | awk '{print $1}' + fi + } + jq -nS \ + --arg dep_sha "$(sha_file "${GH_STUB_ASSET_ROOT:?}/dep.tar.zst")" \ + --arg root_sha "$(sha_file "${GH_STUB_ASSET_ROOT:?}/rootfs.tar.zst")" \ + --arg hex_a "${HEX_A:?}" \ + --arg hex_b "${HEX_B:?}" \ + --argjson dep_size "$(wc -c <"${GH_STUB_ASSET_ROOT:?}/dep.tar.zst")" \ + --argjson root_size "$(wc -c <"${GH_STUB_ASSET_ROOT:?}/rootfs.tar.zst")" '{ + abi_version:42, + release_tag:"pr-1079-staging", + complete_current:true, + entries:[ + {package:"dep",kind:"program",arch:"wasm32",version:"1",revision:1,cache_key_sha:$hex_a,current:true,asset:"dep.tar.zst",archive_sha256:$dep_sha,size:$dep_size}, + {package:"rootfs",kind:"program",arch:"wasm32",version:"1",revision:1,cache_key_sha:$hex_b,current:true,asset:"rootfs.tar.zst",archive_sha256:$root_sha,size:$root_size} + ] + }' >"$output" + exit 0 +fi +if [ "$action" = "staging-reuse validate-archives" ]; then + shift 2 + while [ "$#" -gt 0 ]; do + case "$1" in + --archives-dir) archives="$2"; shift 2 ;; + *) shift 2 ;; + esac + done + [ -f "$archives/dep.tar.zst" ] + [ -f "$archives/rootfs.tar.zst" ] + [ ! -e "$archives/unvalidated.tar.zst" ] + exit 0 +fi +if [ "${1:-}" = build-index ]; then + shift + while [ "$#" -gt 0 ]; do + case "$1" in + --archives-dir) archives="$2"; shift 2 ;; + --out) output="$2"; shift 2 ;; + *) shift 2 ;; + esac + done + [ -f "$archives/dep.tar.zst" ] && [ -f "$archives/rootfs.tar.zst" ] + [ ! -e "$archives/unvalidated.tar.zst" ] + cat >"$output" <"$output" + exit 0 +fi +echo "unexpected xtask invocation: $*" >&2 +exit 1 +EOF +chmod +x "$TMP_ROOT/bin/xtask" + +env \ + PATH="$TMP_ROOT/bin:$PATH" \ + GH_TOKEN=test-release-token \ + GITHUB_TOKEN=test-fallback-token \ + HOMEBREW_GITHUB_API_TOKEN=test-api-token \ + HOMEBREW_GITHUB_PACKAGES_TOKEN=test-packages-token \ + HOMEBREW_DOCKER_REGISTRY_TOKEN=test-registry-token \ + GH_STUB_ASSETS="$ASSETS" \ + GH_STUB_ASSET_ROOT="$TMP_ROOT/assets" \ + HEX_A="$hex_a" \ + HEX_B="$hex_b" \ + bash "$FREEZE" \ + --tag pr-1079-staging \ + --generation-root "$TMP_ROOT/generation" \ + --consumer-root "$TMP_ROOT/consumer" \ + --expected-abi 42 \ + --generation-sha "$generation_sha" \ + --consumer-sha "$consumer_sha" \ + --repository Automattic/kandelo \ + --output-dir "$TMP_ROOT/sealed" \ + --xtask "$TMP_ROOT/bin/xtask" + +[ "$(jq -r '.package_count' "$TMP_ROOT/sealed/manifest.json")" = 2 ] +grep -Fq '/pr-1079-staging/dep.tar.zst' "$TMP_ROOT/sealed/index.toml" +grep -Fq '/pr-1079-staging/rootfs.tar.zst' "$TMP_ROOT/sealed/index.toml" +if grep -Fq unvalidated "$TMP_ROOT/sealed/index.toml"; then + echo "unvalidated extra staging entry escaped into the frozen index" >&2 + exit 1 +fi + +env_file="$TMP_ROOT/github-env" +bash "$ACTIVATE" \ + --bundle "$TMP_ROOT/sealed" \ + --expected-tag pr-1079-staging \ + --expected-generation-sha "$generation_sha" \ + --expected-consumer-sha "$consumer_sha" \ + --expected-abi 42 \ + --github-env "$env_file" +grep -Fxq "WASM_POSIX_BINARY_INDEX_URL=file://$TMP_ROOT/sealed/index.toml" "$env_file" + +refresh_manifest_binding() { + local bundle="$1" + local name="$2" + local sha size + sha="$(sha_file "$bundle/$name")" + size="$(wc -c <"$bundle/$name" | tr -d '[:space:]')" + jq -S \ + --arg name "$name" \ + --arg sha "$sha" \ + --argjson size "$size" \ + '.files[$name] = {sha256: $sha, size: $size}' \ + "$bundle/manifest.json" >"$bundle/manifest.next.json" + mv "$bundle/manifest.next.json" "$bundle/manifest.json" +} + +# Every ancillary file is byte-bound, not merely checked for a plausible +# entry count. +for name in projection.json expected-ledger.json snapshot.json assets.json; do + tampered="$TMP_ROOT/tampered-${name%.json}" + cp -R "$TMP_ROOT/sealed" "$tampered" + printf '\n' >>"$tampered/$name" + if bash "$ACTIVATE" \ + --bundle "$tampered" \ + --expected-tag pr-1079-staging \ + --expected-generation-sha "$generation_sha" \ + --expected-consumer-sha "$consumer_sha" \ + --expected-abi 42 \ + --github-env "$TMP_ROOT/rejected-${name%.json}-env"; then + echo "modified $name was activated" >&2 + exit 1 + fi +done + +# Even a manifest-authorized replacement must preserve the same exact +# package/arch/cache-key identity set across all three ledgers. +cp -R "$TMP_ROOT/sealed" "$TMP_ROOT/identity-mismatch" +jq --arg cache_key "$hex_c" \ + '.entries[0].cache_key_sha = $cache_key' \ + "$TMP_ROOT/identity-mismatch/projection.json" \ + >"$TMP_ROOT/identity-mismatch/projection.next.json" +mv "$TMP_ROOT/identity-mismatch/projection.next.json" \ + "$TMP_ROOT/identity-mismatch/projection.json" +refresh_manifest_binding "$TMP_ROOT/identity-mismatch" projection.json +if bash "$ACTIVATE" \ + --bundle "$TMP_ROOT/identity-mismatch" \ + --expected-tag pr-1079-staging \ + --expected-generation-sha "$generation_sha" \ + --expected-consumer-sha "$consumer_sha" \ + --expected-abi 42 \ + --github-env "$TMP_ROOT/rejected-identity-env"; then + echo "mismatched sealed package identities were activated" >&2 + exit 1 +fi + +# Snapshot archive identities must still name the exact release asset record. +cp -R "$TMP_ROOT/sealed" "$TMP_ROOT/asset-mismatch" +jq --arg digest "sha256:$hex_c" \ + '(.[] | select(.name == "dep.tar.zst")).digest = $digest' \ + "$TMP_ROOT/asset-mismatch/assets.json" \ + >"$TMP_ROOT/asset-mismatch/assets.next.json" +mv "$TMP_ROOT/asset-mismatch/assets.next.json" \ + "$TMP_ROOT/asset-mismatch/assets.json" +refresh_manifest_binding "$TMP_ROOT/asset-mismatch" assets.json +if bash "$ACTIVATE" \ + --bundle "$TMP_ROOT/asset-mismatch" \ + --expected-tag pr-1079-staging \ + --expected-generation-sha "$generation_sha" \ + --expected-consumer-sha "$consumer_sha" \ + --expected-abi 42 \ + --github-env "$TMP_ROOT/rejected-asset-env"; then + echo "mismatched sealed release asset identity was activated" >&2 + exit 1 +fi + +# The generation/consumer equality check is the bridge's central safety +# boundary: a workflow descendant may reuse F's bytes only while its package +# identities still describe those exact bytes. +cp -R "$TMP_ROOT/consumer" "$TMP_ROOT/consumer-drift" +jq --arg cache_key "$hex_c" \ + '.packages.rootfs.cacheKeys.wasm32 = $cache_key' \ + "$TMP_ROOT/consumer-drift/packages/registry/program-packages.json" \ + >"$TMP_ROOT/consumer-drift/program-packages.next.json" +mv "$TMP_ROOT/consumer-drift/program-packages.next.json" \ + "$TMP_ROOT/consumer-drift/packages/registry/program-packages.json" +git -C "$TMP_ROOT/consumer-drift" add packages/registry/program-packages.json +git -C "$TMP_ROOT/consumer-drift" commit -qm "drift rootfs identity" +drift_sha="$(git -C "$TMP_ROOT/consumer-drift" rev-parse HEAD)" +if env \ + PATH="$TMP_ROOT/bin:$PATH" \ + GH_TOKEN=test-release-token \ + GITHUB_TOKEN=test-fallback-token \ + HOMEBREW_GITHUB_API_TOKEN=test-api-token \ + HOMEBREW_GITHUB_PACKAGES_TOKEN=test-packages-token \ + HOMEBREW_DOCKER_REGISTRY_TOKEN=test-registry-token \ + GH_STUB_ASSETS="$ASSETS" \ + GH_STUB_ASSET_ROOT="$TMP_ROOT/assets" \ + HEX_A="$hex_a" \ + HEX_B="$hex_b" \ + bash "$FREEZE" \ + --tag pr-1079-staging \ + --generation-root "$TMP_ROOT/generation" \ + --consumer-root "$TMP_ROOT/consumer-drift" \ + --expected-abi 42 \ + --generation-sha "$generation_sha" \ + --consumer-sha "$drift_sha" \ + --repository Automattic/kandelo \ + --output-dir "$TMP_ROOT/drift-sealed" \ + --xtask "$TMP_ROOT/bin/xtask"; then + echo "consumer package-identity drift reused the generation archives" >&2 + exit 1 +fi + +# Expected-ledger drift must fail even when the committed rootfs projection +# and all package cache keys remain byte-identical. +cp -R "$TMP_ROOT/consumer" "$TMP_ROOT/consumer-ledger-drift" +jq '(.entries[] | select(.package == "dep")).revision = 2' \ + "$TMP_ROOT/consumer-ledger-drift/packages/registry/.test-expected.json" \ + >"$TMP_ROOT/consumer-ledger-drift/packages/registry/expected.next.json" +mv "$TMP_ROOT/consumer-ledger-drift/packages/registry/expected.next.json" \ + "$TMP_ROOT/consumer-ledger-drift/packages/registry/.test-expected.json" +git -C "$TMP_ROOT/consumer-ledger-drift" add packages/registry/.test-expected.json +git -C "$TMP_ROOT/consumer-ledger-drift" commit -qm "drift expected ledger only" +ledger_drift_sha="$(git -C "$TMP_ROOT/consumer-ledger-drift" rev-parse HEAD)" +if env \ + PATH="$TMP_ROOT/bin:$PATH" \ + GH_TOKEN=test-release-token \ + GITHUB_TOKEN=test-fallback-token \ + HOMEBREW_GITHUB_API_TOKEN=test-api-token \ + HOMEBREW_GITHUB_PACKAGES_TOKEN=test-packages-token \ + HOMEBREW_DOCKER_REGISTRY_TOKEN=test-registry-token \ + GH_STUB_ASSETS="$ASSETS" \ + GH_STUB_ASSET_ROOT="$TMP_ROOT/assets" \ + HEX_A="$hex_a" \ + HEX_B="$hex_b" \ + bash "$FREEZE" \ + --tag pr-1079-staging \ + --generation-root "$TMP_ROOT/generation" \ + --consumer-root "$TMP_ROOT/consumer-ledger-drift" \ + --expected-abi 42 \ + --generation-sha "$generation_sha" \ + --consumer-sha "$ledger_drift_sha" \ + --repository Automattic/kandelo \ + --output-dir "$TMP_ROOT/ledger-drift-sealed" \ + --xtask "$TMP_ROOT/bin/xtask"; then + echo "consumer expected-ledger drift reused the generation archives" >&2 + exit 1 +fi + +printf '\n# changed\n' >>"$TMP_ROOT/sealed/index.toml" +if bash "$ACTIVATE" \ + --bundle "$TMP_ROOT/sealed" \ + --expected-tag pr-1079-staging \ + --expected-generation-sha "$generation_sha" \ + --expected-consumer-sha "$consumer_sha" \ + --expected-abi 42 \ + --github-env "$TMP_ROOT/rejected-env"; then + echo "modified frozen index was activated" >&2 + exit 1 +fi + +echo "prepublication generation freeze tests passed" diff --git a/.github/scripts/test-validate-staging-release.sh b/.github/scripts/test-validate-staging-release.sh index 56093ce81e..23092a2fb9 100755 --- a/.github/scripts/test-validate-staging-release.sh +++ b/.github/scripts/test-validate-staging-release.sh @@ -59,6 +59,15 @@ chmod +x "$TMP_ROOT/bin/gh" cat > "$TMP_ROOT/bin/xtask" <<'EOF' #!/usr/bin/env bash set -euo pipefail +for credential_name in \ + GH_TOKEN GITHUB_TOKEN \ + HOMEBREW_GITHUB_API_TOKEN HOMEBREW_GITHUB_PACKAGES_TOKEN \ + HOMEBREW_DOCKER_REGISTRY_TOKEN; do + [ -z "${!credential_name:-}" ] || { + echo "xtask inherited $credential_name" >&2 + exit 97 + } +done action="$1 $2" shift 2 mode=""; output=""; localized=""; index=""; assets=""; archives=""; snapshot=""; scope="" @@ -111,6 +120,11 @@ ASSETS="$(jq -nc \ run_helper() { env PATH="$TMP_ROOT/bin:$PATH" \ + GH_TOKEN=test-release-token \ + GITHUB_TOKEN=test-fallback-token \ + HOMEBREW_GITHUB_API_TOKEN=test-api-token \ + HOMEBREW_GITHUB_PACKAGES_TOKEN=test-packages-token \ + HOMEBREW_DOCKER_REGISTRY_TOKEN=test-registry-token \ GITHUB_REPOSITORY=Automattic/kandelo \ GH_STUB_ASSETS="${GH_STUB_ASSETS_OVERRIDE:-$ASSETS}" \ GH_STUB_RELEASE_PRESENT="${GH_STUB_RELEASE_PRESENT:-1}" \ diff --git a/.github/scripts/validate-staging-release.sh b/.github/scripts/validate-staging-release.sh index 54102c78a4..429a75b332 100755 --- a/.github/scripts/validate-staging-release.sh +++ b/.github/scripts/validate-staging-release.sh @@ -48,6 +48,17 @@ mkdir -p "$PARENT" TMP_ROOT="$(mktemp -d "$PARENT/.staging-release.XXXXXX")" trap 'rm -rf "$TMP_ROOT"' EXIT +run_xtask_without_credentials() { + # WHY: this helper needs a token for GitHub release metadata and downloads, + # but the index and archive validators operate only on already-downloaded + # bytes and must not inherit publication credentials. + env -u GH_TOKEN -u GITHUB_TOKEN \ + -u HOMEBREW_GITHUB_API_TOKEN \ + -u HOMEBREW_GITHUB_PACKAGES_TOKEN \ + -u HOMEBREW_DOCKER_REGISTRY_TOKEN \ + "$XTASK" "$@" +} + release_id="$(gh api "/repos/$REPOSITORY/releases/tags/$TAG" --jq .id)" if ! [[ "$release_id" =~ ^[0-9]+$ ]]; then echo "validate-staging-release: invalid release id for $TAG: $release_id" >&2 @@ -88,7 +99,7 @@ if [ "$actual_size" != "$index_size" ] || [ "sha256:$actual_sha" != "$index_dige exit 1 fi -"$XTASK" staging-reuse validate \ +run_xtask_without_credentials staging-reuse validate \ --expected-ledger "$EXPECTED_LEDGER" \ --index "$TMP_ROOT/source-index.toml" \ --assets "$TMP_ROOT/assets.json" \ @@ -125,7 +136,7 @@ while IFS=$'\t' read -r asset sha size; do --output "$TMP_ROOT/archives/$asset" done < "$TMP_ROOT/archive-selection.tsv" -"$XTASK" staging-reuse validate-archives \ +run_xtask_without_credentials staging-reuse validate-archives \ --expected-ledger "$EXPECTED_LEDGER" \ --snapshot "$TMP_ROOT/snapshot.json" \ --archives-dir "$TMP_ROOT/archives" \ diff --git a/.github/workflows/reusable-homebrew-bottle-publish.yml b/.github/workflows/reusable-homebrew-bottle-publish.yml index e51a611d88..80b69ff336 100644 --- a/.github/workflows/reusable-homebrew-bottle-publish.yml +++ b/.github/workflows/reusable-homebrew-bottle-publish.yml @@ -42,6 +42,15 @@ on: require-vfs-acceptance: type: boolean default: false + prepublication-staging-tag: + type: string + default: "" + prepublication-staging-kandelo-sha: + type: string + default: "" + defer-vfs-acceptance-until-postpublication: + type: boolean + default: false jobs: plan: @@ -74,6 +83,10 @@ jobs: TAP_REPOSITORY: ${{ inputs.tap-repository }} TAP_REF: ${{ inputs.tap-ref }} BOTTLE_ROOT_URL: ${{ inputs.bottle-root-url }} + REQUIRE_VFS_ACCEPTANCE: ${{ inputs.require-vfs-acceptance }} + PREPUBLICATION_STAGING_TAG: ${{ inputs.prepublication-staging-tag }} + PREPUBLICATION_STAGING_KANDELO_SHA: ${{ inputs.prepublication-staging-kandelo-sha }} + DEFER_VFS_ACCEPTANCE: ${{ inputs.defer-vfs-acceptance-until-postpublication }} run: | set -euo pipefail normalize_dry_run_source_ref() { @@ -135,6 +148,39 @@ jobs: [ "$KANDELO_REPOSITORY" = "Automattic/kandelo" ] || { echo "::error::publication requires Automattic/kandelo"; exit 2; } + case "$REQUIRE_VFS_ACCEPTANCE" in + true|false) ;; + *) echo "::error::require-vfs-acceptance must be true or false"; exit 2 ;; + esac + case "$DEFER_VFS_ACCEPTANCE" in + true|false) ;; + *) echo "::error::defer-vfs-acceptance-until-postpublication must be true or false"; exit 2 ;; + esac + if [ -n "$PREPUBLICATION_STAGING_TAG" ] || + [ -n "$PREPUBLICATION_STAGING_KANDELO_SHA" ]; then + [[ "$PREPUBLICATION_STAGING_TAG" =~ ^pr-[1-9][0-9]*-staging$ ]] || { + echo "::error::prepublication-staging-tag must be pr-N-staging"; exit 2; + } + [[ "$PREPUBLICATION_STAGING_KANDELO_SHA" =~ ^[0-9a-f]{40}$ ]] || { + echo "::error::prepublication-staging-kandelo-sha must be an exact lowercase commit SHA"; exit 2; + } + [ -n "$PREPUBLICATION_STAGING_TAG" ] && + [ -n "$PREPUBLICATION_STAGING_KANDELO_SHA" ] || { + echo "::error::prepublication staging tag and Kandelo SHA must be supplied together"; exit 2; + } + [ "$DRY_RUN" = "false" ] || { + echo "::error::prepublication package generation is restricted to write publication"; exit 2; + } + [[ "$KANDELO_REF" =~ ^[0-9a-f]{40}$ ]] || { + echo "::error::prepublication package generation requires an exact reviewed Kandelo commit"; exit 2; + } + fi + if [ "$DEFER_VFS_ACCEPTANCE" = "true" ]; then + [ -n "$PREPUBLICATION_STAGING_TAG" ] && + [ "$REQUIRE_VFS_ACCEPTANCE" = "true" ] || { + echo "::error::VFS acceptance may be deferred only for a required sealed prepublication generation"; exit 2; + } + fi [[ "$normalized_tap_repository" =~ ^[a-z0-9_.-]+/homebrew-[a-z0-9_.-]+$ ]] || { echo "::error::tap repositories must use owner/homebrew-name"; exit 2; } @@ -175,6 +221,16 @@ jobs: path: kandelo submodules: false + - name: Checkout exact prepublication package generation + if: ${{ inputs.prepublication-staging-tag != '' }} + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + repository: ${{ inputs.kandelo-repository }} + ref: ${{ inputs.prepublication-staging-kandelo-sha }} + path: kandelo-generation + submodules: false + - name: Checkout tap uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: @@ -201,6 +257,27 @@ jobs: echo "tap-sha=$tap_sha" } >> "$GITHUB_OUTPUT" + - name: Bind prepublication generation to the workflow descendant + if: ${{ inputs.prepublication-staging-tag != '' }} + shell: bash + env: + GH_TOKEN: ${{ github.token }} + KANDELO_REPOSITORY: ${{ inputs.kandelo-repository }} + KANDELO_SHA: ${{ steps.source-commits.outputs.kandelo-sha }} + GENERATION_SHA: ${{ inputs.prepublication-staging-kandelo-sha }} + run: | + set -euo pipefail + [ "$(git -C kandelo-generation rev-parse HEAD)" = "$GENERATION_SHA" ] || { + echo "::error::prepublication generation checkout differs from its exact SHA"; exit 2; + } + compare_status="$(gh api \ + "/repos/$KANDELO_REPOSITORY/compare/$GENERATION_SHA...$KANDELO_SHA" \ + --jq .status)" + case "$compare_status" in + ahead|identical) ;; + *) echo "::error::publishing workflow commit must descend from the staged package generation"; exit 1 ;; + esac + - name: Validate committed dependency tap lock id: dependency-taps shell: bash @@ -288,6 +365,77 @@ jobs: echo "bottle-root-prefix=$root_prefix" } >> "$GITHUB_OUTPUT" + - name: Install Nix for prepublication generation validation + if: ${{ inputs.prepublication-staging-tag != '' }} + uses: DeterminateSystems/nix-installer-action@ef8a148080ab6020fd15196c2084a2eea5ff2d25 # v22 + with: + github-token: "" + + - name: Build prepublication index tooling without credentials + if: ${{ inputs.prepublication-staging-tag != '' }} + id: prepublication-tooling + shell: bash + run: | + set -euo pipefail + host_target="$( + cd kandelo + env -u GH_TOKEN -u GITHUB_TOKEN \ + -u HOMEBREW_GITHUB_API_TOKEN \ + -u HOMEBREW_GITHUB_PACKAGES_TOKEN \ + -u HOMEBREW_DOCKER_REGISTRY_TOKEN \ + bash scripts/dev-shell.sh rustc -vV | + sed -n 's/^host: //p' + )" + [[ "$host_target" =~ ^[a-z0-9_]+(-[a-z0-9_.]+){2,}$ ]] || { + echo "::error::unable to resolve the prepublication tooling host target"; exit 2; + } + ( + cd kandelo + env -u GH_TOKEN -u GITHUB_TOKEN \ + -u HOMEBREW_GITHUB_API_TOKEN \ + -u HOMEBREW_GITHUB_PACKAGES_TOKEN \ + -u HOMEBREW_DOCKER_REGISTRY_TOKEN \ + bash scripts/dev-shell.sh \ + cargo build --release -p xtask --target "$host_target" + ) + echo "host-target=$host_target" >>"$GITHUB_OUTPUT" + + - name: Freeze exact prepublication package generation + if: ${{ inputs.prepublication-staging-tag != '' }} + shell: bash + env: + GH_TOKEN: ${{ github.token }} + HOST_TARGET: ${{ steps.prepublication-tooling.outputs.host-target }} + KANDELO_ABI: ${{ steps.release.outputs.abi }} + KANDELO_SHA: ${{ steps.source-commits.outputs.kandelo-sha }} + GENERATION_SHA: ${{ inputs.prepublication-staging-kandelo-sha }} + STAGING_TAG: ${{ inputs.prepublication-staging-tag }} + run: | + set -euo pipefail + [[ "$HOST_TARGET" =~ ^[a-z0-9_]+(-[a-z0-9_.]+){2,}$ ]] || { + echo "::error::invalid prepublication tooling host target"; exit 2; + } + bash kandelo/.github/scripts/freeze-homebrew-prepublication-generation.sh \ + --tag "$STAGING_TAG" \ + --generation-root "$GITHUB_WORKSPACE/kandelo-generation" \ + --consumer-root "$GITHUB_WORKSPACE/kandelo" \ + --expected-abi "$KANDELO_ABI" \ + --generation-sha "$GENERATION_SHA" \ + --consumer-sha "$KANDELO_SHA" \ + --repository Automattic/kandelo \ + --output-dir "$RUNNER_TEMP/homebrew-prepublication-generation" \ + --xtask "$GITHUB_WORKSPACE/kandelo/target/$HOST_TARGET/release/xtask" + + - name: Upload sealed prepublication package generation + if: ${{ inputs.prepublication-staging-tag != '' }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: homebrew-prepublication-generation-${{ github.run_id }}-attempt-${{ github.run_attempt }} + path: ${{ runner.temp }}/homebrew-prepublication-generation + compression-level: 0 + if-no-files-found: error + retention-days: 2 + - name: Plan formula matrix id: matrix shell: bash @@ -331,6 +479,7 @@ jobs: DRY_RUN: ${{ inputs.dry-run }} PLANNED_MATRIX: ${{ steps.matrix.outputs.matrix }} REQUIRE_VFS_ACCEPTANCE: ${{ inputs.require-vfs-acceptance }} + DEFER_VFS_ACCEPTANCE: ${{ inputs.defer-vfs-acceptance-until-postpublication }} TAP_NAME: ${{ inputs.tap-name }} run: | set -euo pipefail @@ -457,6 +606,12 @@ jobs: echo "::error::required dependency-bearing VFS acceptance target ${selected_formula}/wasm32 is absent from the planned matrix; include it and use force when its bottle is already current" exit 1 } + if [ "$DEFER_VFS_ACCEPTANCE" = "true" ]; then + # WHY: this bootstrap publishes the bottles needed to construct + # the acceptance VFS itself. Keep the missing proof explicit so + # a postpublication invocation must close the cycle. + echo "::notice::dependency-bearing VFS acceptance is explicitly deferred until the sealed generation is canonical" + fi elif [ "$DRY_RUN" = "true" ] || [ "$target_planned" != "true" ]; then echo "::notice::this invocation will produce no dependency-bearing VFS acceptance evidence" fi @@ -631,6 +786,30 @@ jobs: cd kandelo bash scripts/dev-shell.sh true + - name: Download sealed prepublication package generation + if: ${{ inputs.prepublication-staging-tag != '' }} + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + name: homebrew-prepublication-generation-${{ github.run_id }}-attempt-${{ github.run_attempt }} + path: ${{ runner.temp }}/homebrew-prepublication-generation + + - name: Activate sealed prepublication package generation + if: ${{ inputs.prepublication-staging-tag != '' }} + shell: bash + env: + KANDELO_ABI: ${{ needs.plan.outputs.abi }} + KANDELO_SHA: ${{ needs.plan.outputs.kandelo-sha }} + GENERATION_SHA: ${{ inputs.prepublication-staging-kandelo-sha }} + STAGING_TAG: ${{ inputs.prepublication-staging-tag }} + run: | + bash kandelo/.github/scripts/activate-homebrew-prepublication-generation.sh \ + --bundle "$RUNNER_TEMP/homebrew-prepublication-generation" \ + --expected-tag "$STAGING_TAG" \ + --expected-generation-sha "$GENERATION_SHA" \ + --expected-consumer-sha "$KANDELO_SHA" \ + --expected-abi "$KANDELO_ABI" \ + --github-env "$GITHUB_ENV" + - name: Build Kandelo sysroot shell: bash env: @@ -680,6 +859,7 @@ jobs: bash scripts/dev-shell.sh bash scripts/build-fork-instrument-tool.sh - name: Materialize Formula test platform runtime + id: formula-runtime shell: bash run: | set -euo pipefail @@ -691,8 +871,29 @@ jobs: echo "unable to resolve the Rust host target" >&2 exit 2 } + cargo build --release -p xtask --target "$host" --quiet + xtask="$PWD/target/$host/release/xtask" + [ -f "$xtask" ] && [ ! -L "$xtask" ] && [ -x "$xtask" ] && + [ "$(realpath -- "$xtask")" = "$xtask" ] || { + echo "prepared Formula test xtask is not an exact executable" >&2 + exit 2 + } + # Formula tests need the source-projection checker, not Cargo or + # Nix authority. Cargo may hard-link this executable on Linux, so + # detach exact bytes before the launcher exposes one read-only + # source-alias path to the Formula user. + sealed_xtask="$( + bash scripts/seal-homebrew-formula-checker.sh \ + --root "$PWD" \ + --checker "$xtask" + )" + [ "$sealed_xtask" = "$xtask" ] || { + echo "Formula test checker seal selected another executable" >&2 + exit 2 + } + printf "xtask-bin=%s\n" "$xtask" >>"$GITHUB_OUTPUT" for package in dash coreutils grep sed rootfs; do - cargo run --release -p xtask --target "$host" --quiet -- \ + "$xtask" \ build-deps --arch wasm32 \ --binaries-dir "$PWD/binaries" --fetch-only resolve "$package" done @@ -842,6 +1043,7 @@ jobs: 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 }} + WASM_POSIX_XTASK_BIN: ${{ steps.formula-runtime.outputs.xtask-bin }} run: | set -euo pipefail for secret_name in GH_TOKEN GITHUB_TOKEN HOMEBREW_GITHUB_API_TOKEN \ @@ -884,6 +1086,7 @@ jobs: workflow_commands_stopped=1 printf '::stop-commands::%s\n' "$workflow_command_token" bash scripts/dev-shell.sh env \ + WASM_POSIX_XTASK_BIN="$WASM_POSIX_XTASK_BIN" \ KANDELO_HOMEBREW_RESOLVED_TAPS_FILE="$KANDELO_HOMEBREW_RESOLVED_TAPS_FILE" \ bash scripts/homebrew-bottle-build.sh \ --tap-root "$GITHUB_WORKSPACE/tap" \ @@ -1865,6 +2068,30 @@ jobs: cd kandelo bash scripts/dev-shell.sh true + - name: Download sealed prepublication package generation + if: ${{ inputs.prepublication-staging-tag != '' }} + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + name: homebrew-prepublication-generation-${{ github.run_id }}-attempt-${{ github.run_attempt }} + path: ${{ runner.temp }}/homebrew-prepublication-generation + + - name: Activate sealed prepublication package generation + if: ${{ inputs.prepublication-staging-tag != '' }} + shell: bash + env: + KANDELO_ABI: ${{ needs.plan.outputs.abi }} + KANDELO_SHA: ${{ needs.plan.outputs.kandelo-sha }} + GENERATION_SHA: ${{ inputs.prepublication-staging-kandelo-sha }} + STAGING_TAG: ${{ inputs.prepublication-staging-tag }} + run: | + bash kandelo/.github/scripts/activate-homebrew-prepublication-generation.sh \ + --bundle "$RUNNER_TEMP/homebrew-prepublication-generation" \ + --expected-tag "$STAGING_TAG" \ + --expected-generation-sha "$GENERATION_SHA" \ + --expected-consumer-sha "$KANDELO_SHA" \ + --expected-abi "$KANDELO_ABI" \ + --github-env "$GITHUB_ENV" + - name: Download strict build handoff id: build-handoff continue-on-error: true @@ -2106,7 +2333,7 @@ jobs: esac - name: Build Kandelo wasm64 sysroot for the interactive browser graph - if: ${{ matrix.arch == 'wasm32' && (matrix.formula == 'file-formula' || (!inputs.dry-run && matrix.formula == needs.plan.outputs.vfs-acceptance-formula)) }} + if: ${{ matrix.arch == 'wasm32' && ((matrix.formula == 'file-formula' && inputs.prepublication-staging-tag == '') || (!inputs.defer-vfs-acceptance-until-postpublication && !inputs.dry-run && matrix.formula == needs.plan.outputs.vfs-acceptance-formula)) }} shell: bash run: | set -euo pipefail @@ -2141,11 +2368,67 @@ jobs: PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 npm ci --no-audit --no-fund ' + - name: Materialize Formula verification platform runtime + id: formula-verification-runtime + shell: bash + run: | + set -euo pipefail + cd kandelo + bash scripts/dev-shell.sh bash -c ' + set -euo pipefail + host="$(rustc -vV | sed -n "s/^host: //p")" + [ -n "$host" ] || { + echo "unable to resolve the Rust host target" >&2 + exit 2 + } + cargo build --release -p xtask --target "$host" --quiet + xtask="$PWD/target/$host/release/xtask" + [ -f "$xtask" ] && [ ! -L "$xtask" ] && [ -x "$xtask" ] && + [ "$(realpath -- "$xtask")" = "$xtask" ] || { + echo "prepared Formula verification xtask is not an exact executable" >&2 + exit 2 + } + # Verification has the same Cargo hardlink boundary as the build. + # Use the shared byte-for-byte seal so both isolated Formula paths + # receive the same single-link authority. + sealed_xtask="$( + bash scripts/seal-homebrew-formula-checker.sh \ + --root "$PWD" \ + --checker "$xtask" + )" + [ "$sealed_xtask" = "$xtask" ] || { + echo "Formula verification checker seal selected another executable" >&2 + exit 2 + } + printf "xtask-bin=%s\n" "$xtask" >>"$GITHUB_OUTPUT" + for package in dash coreutils grep sed rootfs; do + "$xtask" \ + build-deps --arch wasm32 \ + --binaries-dir "$PWD/binaries" --fetch-only resolve "$package" + done + bash scripts/materialize-resolver-binaries.sh "$PWD/binaries" + ' + - name: Prepare the supported interactive browser demo graph - if: ${{ matrix.arch == 'wasm32' && (matrix.formula == 'file-formula' || (!inputs.dry-run && matrix.formula == needs.plan.outputs.vfs-acceptance-formula)) }} + if: ${{ matrix.arch == 'wasm32' && (matrix.formula == 'file-formula' || (!inputs.defer-vfs-acceptance-until-postpublication && !inputs.dry-run && matrix.formula == needs.plan.outputs.vfs-acceptance-formula)) }} shell: bash + env: + KANDELO_HOMEBREW_FORMULA: ${{ matrix.formula }} + PREPUBLICATION_STAGING_TAG: ${{ inputs.prepublication-staging-tag }} run: | set -euo pipefail + if [ -n "$PREPUBLICATION_STAGING_TAG" ] && + [ "$KANDELO_HOMEBREW_FORMULA" = "file-formula" ]; then + # WHY: the root browser route statically imports the full demo + # graph, including post-T42 packages this bootstrap exists to + # publish. The focused Homebrew VFS page imports only the exact + # local kernel and the externally supplied test VFS, so build its + # host without resolving unrelated demo packages. Postpublication + # main-shell Chromium acceptance closes the full UI integration. + cd kandelo + bash scripts/dev-shell.sh ./run.sh build host + exit 0 + fi for sysroot_name in sysroot sysroot64; do sysroot_source="$GITHUB_WORKSPACE/kandelo-sysroot-build/$sysroot_name" sysroot_destination="$GITHUB_WORKSPACE/kandelo/$sysroot_name" @@ -2164,26 +2447,6 @@ jobs: cd kandelo bash scripts/dev-shell.sh ./run.sh --fetch-only prepare-browser - - name: Materialize Formula verification platform runtime - shell: bash - run: | - set -euo pipefail - cd kandelo - bash scripts/dev-shell.sh bash -c ' - set -euo pipefail - host="$(rustc -vV | sed -n "s/^host: //p")" - [ -n "$host" ] || { - echo "unable to resolve the Rust host target" >&2 - exit 2 - } - for package in dash coreutils grep sed rootfs; do - cargo run --release -p xtask --target "$host" --quiet -- \ - build-deps --arch wasm32 \ - --binaries-dir "$PWD/binaries" --fetch-only resolve "$package" - done - bash scripts/materialize-resolver-binaries.sh "$PWD/binaries" - ' - - name: Compose only reconstructed bottle metadata into the fresh tap shell: bash run: | @@ -2394,6 +2657,7 @@ jobs: KANDELO_HOMEBREW_TAP_COMMIT: ${{ needs.plan.outputs.tap-sha }} KANDELO_HOMEBREW_TAP_REPOSITORY: ${{ inputs.tap-repository }} KANDELO_HOMEBREW_TAP_NAME: ${{ inputs.tap-name }} + WASM_POSIX_XTASK_BIN: ${{ steps.formula-verification-runtime.outputs.xtask-bin }} run: | set -euo pipefail for secret_name in GH_TOKEN GITHUB_TOKEN HOMEBREW_GITHUB_API_TOKEN \ @@ -2429,6 +2693,7 @@ jobs: workflow_commands_stopped=1 printf '::stop-commands::%s\n' "$workflow_command_token" bash scripts/dev-shell.sh env \ + WASM_POSIX_XTASK_BIN="$WASM_POSIX_XTASK_BIN" \ KANDELO_HOMEBREW_RESOLVED_TAPS_FILE="$KANDELO_HOMEBREW_RESOLVED_TAPS_FILE" \ bash scripts/homebrew-verify-poured-bottle.sh \ --tap-root "$RUNNER_TEMP/homebrew-merged-tap" \ @@ -2645,6 +2910,7 @@ jobs: shell: bash env: HOMEBREW_BREW_COMMIT: 34c40c18ffa2029b611b61c73273e32c003d0842 + PREPUBLICATION_STAGING_TAG: ${{ inputs.prepublication-staging-tag }} run: | set -euo pipefail cd kandelo @@ -2683,6 +2949,7 @@ jobs: KANDELO_HOMEBREW_RESOLVED_TAPS_FILE="$KANDELO_HOMEBREW_RESOLVED_TAPS_FILE" \ KANDELO_HOMEBREW_TAP_NAME="$KANDELO_HOMEBREW_TAP_NAME" \ KANDELO_HOMEBREW_FORBIDDEN_ROOTS_JSON="$KANDELO_HOMEBREW_FORBIDDEN_ROOTS_JSON" \ + PREPUBLICATION_STAGING_TAG="$PREPUBLICATION_STAGING_TAG" \ bash -s <<'KANDELO_HOMEBREW_BROWSER_SMOKE' set -euo pipefail browser_vfs_root="$RUNNER_TEMP/homebrew-browser-vfs" @@ -2709,19 +2976,56 @@ jobs: npx tsx images/vfs/scripts/build-homebrew-vfs-image.ts "${vfs_args[@]}" cp "$browser_vfs" "$browser_public_dir/homebrew-file-formula.vfs.zst" - ( - cd apps/browser-demos - playwright_args=( - test test/kandelo-homebrew.spec.ts - --project=chromium - --grep "Homebrew file-formula VFS image boots in browser and runs file --version$" - --reporter=json + if [ -n "$PREPUBLICATION_STAGING_TAG" ]; then + kernel_wasm="$GITHUB_WORKSPACE/kandelo/local-binaries/kernel.wasm" + [ -f "$browser_vfs" ] && [ ! -L "$browser_vfs" ] || { + echo "::error::sealed Homebrew browser VFS must be a regular non-symlink file"; exit 2; + } + [ -f "$kernel_wasm" ] && [ ! -L "$kernel_wasm" ] || { + echo "::error::sealed Homebrew browser kernel must be a regular non-symlink file"; exit 2; + } + image_sha256="$(sha256sum "$browser_vfs" | awk '{print $1}')" + kernel_sha256="$(sha256sum "$kernel_wasm" | awk '{print $1}')" + ( + cd apps/browser-demos + # WHY: the main browser route imports packages that this + # sealed bootstrap has not published yet. This focused route + # is narrower UI coverage, but it is a stronger execution + # binding: Chromium receives the exact generated VFS, exact + # local kernel, executable, argv, and expected output. + # Post-T42 main-shell Chromium acceptance closes the full UI + # integration after those bottles become canonical. + playwright_args=( + test test/homebrew-brewfile-vfs.spec.ts + --project=chromium + --grep "the exact dependency-bearing Brewfile VFS boots in Chromium$" + --reporter=json + ) + KANDELO_PLAYWRIGHT_PORT="$browser_port" \ + KANDELO_BROWSER_DEMO_INPUTS=homebrew-vfs-test \ + KANDELO_HOMEBREW_ACCEPTANCE_VFS_URL="$browser_url" \ + KANDELO_HOMEBREW_ACCEPTANCE_VFS_SHA256="$image_sha256" \ + KANDELO_HOMEBREW_ACCEPTANCE_KERNEL_SHA256="$kernel_sha256" \ + KANDELO_HOMEBREW_ACCEPTANCE_EXECUTABLE=/home/linuxbrew/.linuxbrew/bin/file \ + KANDELO_HOMEBREW_ACCEPTANCE_ARGV_JSON='["file","--version"]' \ + KANDELO_HOMEBREW_ACCEPTANCE_EXPECTED_STDOUT=file-5.45 \ + npx playwright "${playwright_args[@]}" >"$playwright_report" ) - export KANDELO_PLAYWRIGHT_PORT="$browser_port" - export KANDELO_BROWSER_FILE_FORMULA_VFS_URL="$browser_url" - export KANDELO_HOMEBREW_STRICT_PUBLISHER_SMOKE=1 - npx playwright "${playwright_args[@]}" >"$playwright_report" - ) + else + ( + cd apps/browser-demos + playwright_args=( + test test/kandelo-homebrew.spec.ts + --project=chromium + --grep "Homebrew file-formula VFS image boots in browser and runs file --version$" + --reporter=json + ) + KANDELO_PLAYWRIGHT_PORT="$browser_port" \ + KANDELO_BROWSER_FILE_FORMULA_VFS_URL="$browser_url" \ + KANDELO_HOMEBREW_STRICT_PUBLISHER_SMOKE=1 \ + npx playwright "${playwright_args[@]}" >"$playwright_report" + ) + fi jq -e " .stats.expected == 1 and .stats.unexpected == 0 and @@ -2768,6 +3072,7 @@ jobs: KANDELO_HOMEBREW_BROWSER_SMOKE - name: Boot an exact dependency-bearing Brewfile image on Node and Chromium + if: ${{ !inputs.defer-vfs-acceptance-until-postpublication }} shell: bash env: KANDELO_HOMEBREW_ACCEPTANCE_ARCH: ${{ matrix.arch }} @@ -3170,7 +3475,7 @@ jobs: } - name: Prepare exact browser-proven VFS release handoff - if: ${{ !inputs.dry-run && inputs.require-vfs-acceptance && matrix.arch == 'wasm32' && matrix.formula == needs.plan.outputs.vfs-acceptance-formula }} + if: ${{ !inputs.defer-vfs-acceptance-until-postpublication && !inputs.dry-run && inputs.require-vfs-acceptance && matrix.arch == 'wasm32' && matrix.formula == needs.plan.outputs.vfs-acceptance-formula }} shell: bash env: KANDELO_HOMEBREW_ABI: ${{ needs.plan.outputs.abi }} @@ -3211,7 +3516,7 @@ jobs: --out "$RUNNER_TEMP/homebrew-vfs-release-handoff" - name: Upload exact browser-proven VFS release handoff - if: ${{ !inputs.dry-run && inputs.require-vfs-acceptance && matrix.arch == 'wasm32' && matrix.formula == needs.plan.outputs.vfs-acceptance-formula }} + if: ${{ !inputs.defer-vfs-acceptance-until-postpublication && !inputs.dry-run && inputs.require-vfs-acceptance && matrix.arch == 'wasm32' && matrix.formula == needs.plan.outputs.vfs-acceptance-formula }} uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: homebrew-vfs-release-handoff-${{ matrix.formula }}-wasm32-attempt-${{ github.run_attempt }} @@ -3596,7 +3901,7 @@ jobs: publish-vfs-release: needs: [plan, verify-bottle, finalize-tap] - if: ${{ always() && !cancelled() && !inputs.dry-run && inputs.require-vfs-acceptance && needs.plan.result == 'success' && needs.verify-bottle.result == 'success' && needs.finalize-tap.result == 'success' && needs.plan.outputs.vfs-acceptance-formula != '' }} + if: ${{ always() && !cancelled() && !inputs.defer-vfs-acceptance-until-postpublication && !inputs.dry-run && inputs.require-vfs-acceptance && needs.plan.result == 'success' && needs.verify-bottle.result == 'success' && needs.finalize-tap.result == 'success' && needs.plan.outputs.vfs-acceptance-formula != '' }} runs-on: ubuntu-latest timeout-minutes: 60 permissions: diff --git a/docs/homebrew-publishing.md b/docs/homebrew-publishing.md index 8bcb36066d..8333604f6c 100644 --- a/docs/homebrew-publishing.md +++ b/docs/homebrew-publishing.md @@ -817,6 +817,65 @@ source commit an ancestor of `main`; immediately afterward, rotate the tap caller back to Kandelo `main`. Write publication never accepts a non-main Kandelo branch. +An ABI bootstrap may need package archives produced by the reviewed commit +immediately before the publisher workflow plumbing. Every bootstrap batch uses +this explicit staging tag and package-producing SHA pair: + +```yaml +prepublication-staging-tag: pr--staging +prepublication-staging-kandelo-sha: +``` + +Only the designated batch that would otherwise require the dependency-bearing +VFS proof also uses: + +```yaml +require-vfs-acceptance: true +defer-vfs-acceptance-until-postpublication: true +``` + +Other bootstrap batches keep both acceptance booleans false. + +The tag and package-producing SHA are an all-or-none pair. They are accepted +only for a non-dry write publication whose `kandelo-ref` is also an exact +40-character commit. The package-producing commit must be an ancestor of the +workflow commit. Make the workflow commit reachable from a temporary repository +branch first; do not move the package-producing PR head while its staging +release is the publication source. After the exact tap transition and public +shell proof are green, fast-forward the PR head to the workflow commit and run +fresh checks against the canonical bottles before merging that exact head. + +The planner compiles its index tooling in a credential-free step. The freezer +removes GitHub and package credentials before projections and transformations, +reintroduces the workflow token only for the GitHub release snapshot and asset +downloads, and removes it again from the release validator's pure index/archive +commands. The planner then derives the wasm32 `rootfs` package and its exact +runtime dependency closure from each commit's checked-in +`program-packages.json`, recomputes both expected ledgers, and requires the +selected closure projections and filtered ledgers to match. It then validates +and downloads every selected staging archive, including its embedded manifest, +rebuilds an index from only those verified archives, rewrites their relative +names to the exact staging release URLs, and uploads the resulting index and +evidence as one same-run Actions artifact. Its manifest records the SHA-256 +and byte size of the index, projection, expected ledger, staging snapshot, and +release asset inventory. Activation verifies every binding, requires the +exact package/architecture/cache-key identity set to agree across all three +closure ledgers, and checks each selected snapshot archive against its unique +uploaded release asset record. Both the builder and the independent verifier +download that artifact and set `WASM_POSIX_BINARY_INDEX_URL` to its local +`file://` index before any resolver use. Extra entries in the mutable staging +index therefore cannot become resolver candidates. + +The deferral flag is narrower than disabling VFS acceptance. It is valid only +when the sealed generation pair is present and +`require-vfs-acceptance: true` remains set. Formula build/test, public bottle +upload, anonymous verification, version-index publication, and atomic tap +finalization still run. Only the dependency-bearing Node/Chromium VFS proof, +its handoff, and its immutable VFS release wait until the just-published +bottles are canonical. A postpublication descendant must bind the final tap +commit and rerun that acceptance without the deferral. Normal main/canonical +publication supplies none of these inputs and is unchanged. + A dry run keeps those repository identities fixed, but may select a reviewed, valid Git branch name or an exact lowercase 40-character commit SHA from each repository. The trust step normalizes branch names under `refs/heads/`, and the @@ -959,7 +1018,28 @@ only per `(tap, formula)`, so unrelated Formulae retain parallel throughput: workflow-user cache, which the isolated Formula identity cannot access, so the publisher transactionally replaces that link tree with self-contained regular files before it exposes the Kandelo checkout through a read-only - source alias. + source alias. The launcher copies the already-validated `xtask` bytes into + one root-owned, single-link, exact-`0555` inode, rechecks the source and + copy, bind-mounts that inode over the checkout's release path, and verifies + its exact inode and bytes both at each command entry and at final isolation + verification. Because Homebrew reconstructs the ordinary + environment when it re-enters a Formula test, the launcher carries this + exact path across that boundary as `HOMEBREW_KANDELO_XTASK_BIN`. Tap support + validates and freezes the value while loading the trusted support module, + then translates only that frozen value to `WASM_POSIX_XTASK_BIN` for the + Node or Chromium resolver child. The resolver invokes that checker with the + authenticated read-only Kandelo alias as the narrowly scoped + `build-deps program-index-context-check --source-repo-root` argument. This + matters because a relocated executable still contains its compile-time + checkout path: the explicit root makes global toolchain files, fork-tool + Cargo metadata, and repo-relative package inputs all come from the same + protected source projection. The option rejects relative, noncanonical, or + incomplete roots and is invalid for every other `build-deps` subcommand. + Global and fork-tool digest memoization is keyed by that exact root; the + publisher's read-only alias and one-shot checker process keep a selected + root immutable for the command. + Caller-selected checker paths and ambient repository-root overrides are + neither preserved nor trusted. The workflow also materializes the exact `formula_test` and `bottle` groups from pinned Homebrew's frozen Gemfile into the temporary overlay, validates their group @@ -1985,14 +2065,26 @@ checks negative ABI-mismatch and missing-bottle cases. Browser compatibility requires a separate browser smoke. For the current `file-formula` path, the trusted publisher builds a precomposed wasm32 VFS image, -serves it through the browser demo, runs Chromium Playwright against -`apps/browser-demos/test/kandelo-homebrew.spec.ts`, and executes: +serves it through the browser demo, and executes: ```bash /home/linuxbrew/.linuxbrew/bin/file --version ``` -Only after that smoke passes may sidecars record +Normal publication prepares the complete supported browser graph and runs +Chromium Playwright against +`apps/browser-demos/test/kandelo-homebrew.spec.ts`. A sealed prepublication +generation cannot prepare that graph because it contains packages whose +bottles the bootstrap is creating. In that mode, the publisher builds only the +host and selects the existing `homebrew-vfs-test` Vite input and +`homebrew-brewfile-vfs.spec.ts`. That focused test binds the exact generated +VFS SHA-256, exact local kernel SHA-256, executable, argument vector, and +expected output. It is stronger bottle/image/kernel execution evidence but +narrower UI-integration evidence; the required post-transition main-shell +Chromium acceptance closes the full browser integration after the bottles are +canonical. + +Only after the applicable smoke passes may sidecars record `runtime_support = ["node", "browser"]` and `browser_compatible = true`. Packages without a successful browser smoke remain Node-only. @@ -2005,11 +2097,11 @@ fetch only the base command set and `rootfs`; their focused Vite input does not scan the interactive demo. Schema 2 acceptance also boots the image-owned default shell through the full machine UI, so the selected acceptance matrix entry materializes the supported interactive graph through -`./run.sh --fetch-only prepare-browser` before that smoke. The `file-formula` gallery -smoke materializes the same graph. Browser preparation excludes packages whose -demos are provided by the external software gallery. Those platform assets are -not the migrated package under test, and unrelated gallery packages are not -bottle verification prerequisites. +`./run.sh --fetch-only prepare-browser` before that smoke. The normal +`file-formula` gallery smoke materializes the same graph. Browser preparation +excludes packages whose demos are provided by the external software gallery. +Those platform assets are not the migrated package under test, and unrelated +gallery packages are not bottle verification prerequisites. ## Durable Browser-Proven VFS Releases diff --git a/docs/plans/2026-07-21-homebrew-migration-execution-plan.md b/docs/plans/2026-07-21-homebrew-migration-execution-plan.md index d14aa64ef0..f457345db0 100644 --- a/docs/plans/2026-07-21-homebrew-migration-execution-plan.md +++ b/docs/plans/2026-07-21-homebrew-migration-execution-plan.md @@ -1,7 +1,7 @@ # Homebrew Migration Living Execution Plan - Status: active -- Last reconciled: 2026-07-23 +- Last reconciled: 2026-07-24 - Primary repositories: `Automattic/kandelo` and `Kandelo-dev/homebrew-tap-core` - Purpose: preserve the complete Homebrew migration scope, record what has @@ -548,18 +548,94 @@ canonical release): builds only from Kandelo `main`. Break that cycle without accepting a mutable source ref: - 1. finish every non-cyclic #1079 validation and freeze its reviewed head; - 2. have protected tap `main` hardcode that same exact 40-character SHA as - both the reusable-workflow ref and `kandelo-ref`; + 1. finish every non-cyclic #1079 validation and freeze its reviewed + package-producing head `F`; + 2. make the reviewed publisher descendant `H` reachable from a temporary + Automattic/kandelo branch without moving #1079's head, then have protected + tap `main` pin the reusable workflow to exact `H` while separately naming + exact `F` as the staged package generation; `H` must retain `F`'s exact + sealed `rootfs` closure identities; 3. publish only the complete intended ABI-42 closure, then update the shell lock to the exact resulting tap commit and run exact Node/Chromium and staging acceptance; - 4. merge #1079 with a merge commit so the published Kandelo SHA becomes an - ancestor of `main`, verify that ancestry, and immediately rotate the tap - caller back to landed Kandelo `main`; + 4. only after the exact tap commit and public shell proof are green, + fast-forward #1079's head from `F` to `H`, rerun fresh checks against the + canonical ABI-42 bottles, and merge the exact checked head without + rewriting it; then verify ancestry and immediately rotate the tap caller + back to landed Kandelo `main`; 5. restore the repository's normal merge-method setting after that one cutover. + Protocol refinement (2026-07-24): package staging and bottle publication + form a two-stage cycle, so one Kandelo commit cannot honestly provide both + prepublication package inputs and postpublication bottle-backed VFS + acceptance. + + 1. Keep #1079's exact package-producing commit + `437fde2524ea6ad9c44933f8abbf995a46841009` as generation `F`. Validate and + materialize the exact 15-entry `rootfs` wasm32 runtime closure derived + from `F`'s committed `program-packages.json`. + 2. Create and review publisher descendant `H`, then push it to a temporary + Automattic/kandelo branch so its reusable workflow SHA is reachable. + `H` changes the publisher workflow, its tests, and documentation, plus + the two narrow legacy `vim-browser-bundle` and + `nethack-browser-bundle` output-ownership corrections exposed by `F`'s + staging run. Those bundles are neither members of the sealed `rootfs` + closure nor Formulae in the Homebrew tap; their generated cache + identities are refreshed in `H`, while all 15 sealed closure identities + remain exactly equal to `F`. Do **not** move #1079's head from `F` yet: + this keeps its staging tag and package-generation checks undisturbed + during tap publication. The protected tap pins both reusable `uses` and + `kandelo-ref` to exact `H`, while the publisher separately names exact + `F` and `pr-1079-staging`. The publisher must + prove `F` is an ancestor of `H`, derive byte-identical expected ledgers + from both checkouts, validate every selected archive and embedded + manifest, rebuild a minimal index from only those verified archives, and + carry that index between jobs as an immutable same-run artifact. Build + and independent verification resolve through its local `file://` URL. + The artifact manifest binds the hash and size of every evidence file; + activation also requires identical package/architecture/cache-key sets + across the projection, expected ledger, and staging snapshot and binds + each selected snapshot archive to its unique release asset record. + Unselected staging entries remain unusable even when the mutable release + index contains them. + 3. Supply the `F`/staging-tag pair to every bootstrap batch. For the one + designated batch that would otherwise require dependency-bearing VFS + acceptance, retain `require-vfs-acceptance: true` and set the separately + reviewed explicit deferral flag; all other batches keep both acceptance + booleans false. Ordinary Formula build, bottle verification, anonymous + readback, public index publication, and tap finalization still run. Only + the dependency-bearing VFS boot, its handoff, and immutable VFS release + wait, because those proofs require the bottles this stage is publishing. + An absent sealed generation, mutable Kandelo ref, dry run, or deferral + without required acceptance fails closed. + 4. Atomically finalize the ABI-42 tap transition and call its still-symbolic + exact immutable commit `T42`. Replace `T42` with its real SHA as soon as + it exists; do not let the symbolic name enter a workflow input or + artifact record. + 5. Build the postpublication Kandelo descendant tranche against exact `T42`: + `shell`, `lamp`, `nginx-php-vfs`, `nginx-vfs`, `node-vfs`, and + `wordpress`. This is the first stage that can close the previously + deferred dependency-bearing VFS acceptance without a package/bottle + cycle. + 6. Re-run the existing exact Node.js and Chromium main-shell language + acceptance against that descendant and `T42`. It must retain the already + proven lazy Python, Perl, Erlang, and Ruby behavior; the sealed + prepublication exception is complete only when this postpublication + acceptance and immutable VFS publication are green. + 7. After `T42` and the public shell proof are green, fast-forward #1079's + head from `F` to exact `H`. Let fresh PR checks consume the now-canonical + ABI-42 bottles and rebuild the two corrected browser-bundle packages, + then merge that exact checked head without a squash or rebase. The + temporary workflow branch may be removed only after `H` is reachable + from landed `main` and the tap caller has rotated back to `main`. + + This refinement supersedes only the assumption that all ABI-42 package and + VFS acceptance can occur in one commit/run. It does not remove the merge-SHA + preservation, atomic tap transition, full catalog rollout, lazy-shell, + guest-Homebrew, registry-retirement, manual-page, or composable-VFS scope + elsewhere in this plan. + Rebase or squash is not acceptable for this transition because GitHub rewrites the source SHA recorded by the bottle handoffs and sidecars. diff --git a/host/src/binary-resolver.ts b/host/src/binary-resolver.ts index 60fff96395..4e1770d479 100644 --- a/host/src/binary-resolver.ts +++ b/host/src/binary-resolver.ts @@ -643,7 +643,16 @@ function checkProgramIndexesInSourceContext(): void { } const xtaskPath = prepareProgramIndexChecker(sourceRepoRoot); - const args = ["build-deps", "program-index-context-check"]; + // WHY: a relocated, sealed xtask still contains the checkout path where it + // was compiled. Carry the already-authenticated source root in argv so every + // package identity input comes from this protected source projection, not + // from compile-time or caller-controlled ambient state. + const args = [ + "build-deps", + "program-index-context-check", + "--source-repo-root", + sourceRepoRoot, + ]; const result = spawnSync(xtaskPath, args, { cwd: sourceRepoRoot, encoding: "utf8", diff --git a/host/test/binary-resolver.test.ts b/host/test/binary-resolver.test.ts index a69e9f95fb..ac762e44b8 100644 --- a/host/test/binary-resolver.test.ts +++ b/host/test/binary-resolver.test.ts @@ -420,6 +420,76 @@ describe("program package source freshness boundary", () => { ).toThrow("injected stale program projection"); }); + it("uses one prepared checker across every public resolver boundary without host tools", () => { + const relPath = fixtureRelPath(".dat"); + const path = writeCandidate( + localBinariesDir(), + relPath, + new TextEncoder().encode("program data"), + ); + const checkerRoot = mkdtempSync( + join(tmpdir(), "kandelo-resolver-prepared-checker-"), + ); + cleanupDirs.add(checkerRoot); + const checkerPath = join(checkerRoot, "xtask"); + const checkerLog = join(checkerRoot, "calls"); + writeFileSync( + checkerPath, + `#!/bin/sh +[ "$#" = 4 ] +[ "$1" = build-deps ] +[ "$2" = program-index-context-check ] +[ "$3" = --source-repo-root ] +[ "$4" = "${realpathSync(findRepoRoot())}" ] +[ "$WASM_POSIX_DEPS_REGISTRY" = "${fixtureRegistryRoot}" ] +printf 'checked\\n' >>"${checkerLog}" +`, + ); + chmodSync(checkerPath, 0o755); + const savedXtask = process.env.WASM_POSIX_XTASK_BIN; + const hadSavedXtask = Object.prototype.hasOwnProperty.call( + process.env, + "WASM_POSIX_XTASK_BIN", + ); + const savedPath = process.env.PATH; + const hadSavedPath = Object.prototype.hasOwnProperty.call( + process.env, + "PATH", + ); + process.env.WASM_POSIX_XTASK_BIN = checkerPath; + // WHY: an empty PATH makes this the same least-authority environment as + // Formula tests: the explicit checker must work without finding Bash, + // Cargo, rustc, Nix, or scripts/dev-shell.sh. + process.env.PATH = ""; + setProgramIndexContextCheckerForTests(null); + try { + expect(programOutputClosureRelPaths(relPath)).toBeNull(); + expect(resolveBinary(relPath)).toBe(path); + expect(tryResolveBinary(relPath)).toBe(path); + expect(tryResolveBinaries([relPath])).toEqual([path]); + expect(tryResolveBinarySet([relPath])).toEqual([path]); + expect(readFileSync(checkerLog, "utf8").trim().split("\n")).toEqual([ + "checked", + "checked", + "checked", + "checked", + "checked", + ]); + } finally { + if (hadSavedXtask) { + process.env.WASM_POSIX_XTASK_BIN = savedXtask ?? ""; + } else { + delete process.env.WASM_POSIX_XTASK_BIN; + } + if (hadSavedPath) { + process.env.PATH = savedPath ?? ""; + } else { + delete process.env.PATH; + } + setProgramIndexContextCheckerForTests(() => {}); + } + }); + it("executes the production checker command and fails closed on its error", () => { const checkerRoot = mkdtempSync( join(tmpdir(), "kandelo-resolver-checker-command-"), @@ -429,7 +499,8 @@ describe("program package source freshness boundary", () => { writeFileSync( checkerPath, `#!/bin/sh -printf 'checker args: %s %s\\nregistry: %s\\n' "$1" "$2" "$WASM_POSIX_DEPS_REGISTRY" >&2 +printf 'checker args: %s\\n' "$*" >&2 +printf 'registry: %s\\n' "$WASM_POSIX_DEPS_REGISTRY" >&2 exit 23 `, ); @@ -442,12 +513,21 @@ exit 23 process.env.WASM_POSIX_XTASK_BIN = checkerPath; setProgramIndexContextCheckerForTests(null); try { + const sourceRepoRootPattern = realpathSync(findRepoRoot()).replace( + /[.*+?^${}()|[\]\\]/g, + "\\$&", + ); expect(() => programOutputClosureRelPaths( "programs/wasm32/production-check/production-check.wasm", ) ).toThrow( - /program-index-context-check failed with status 23[\s\S]*checker args: build-deps program-index-context-check/, + new RegExp( + "program-index-context-check --source-repo-root " + + `${sourceRepoRootPattern} failed with status 23[\\s\\S]*` + + "checker args: build-deps program-index-context-check " + + `--source-repo-root ${sourceRepoRootPattern}`, + ), ); } finally { if (hadSavedXtask) { diff --git a/host/test/shell-lazy-archive-inputs.test.ts b/host/test/shell-lazy-archive-inputs.test.ts index b9a7e0b125..570b037357 100644 --- a/host/test/shell-lazy-archive-inputs.test.ts +++ b/host/test/shell-lazy-archive-inputs.test.ts @@ -723,5 +723,20 @@ describe("declared shell lazy-archive inputs", () => { expect(nethackZipBuildScript).toContain( 'bash "$SCRIPT_DIR/create-deterministic-zip.sh" "$STAGING" "$OUTPUT_FILE"', ); + // The generated ZIP belongs to the bundle package. Assigning it to the + // underlying executable package makes manifest-driven installation reject + // the ZIP because vim and nethack declare only their .wasm outputs. + expect(vimZipBuildScript).toContain( + 'install_local_binary vim-browser-bundle "$OUTPUT_FILE" vim.zip', + ); + expect(vimZipBuildScript).not.toMatch( + /install_local_binary\s+vim\s+"\$OUTPUT_FILE"/, + ); + expect(nethackZipBuildScript).toContain( + 'install_local_binary nethack-browser-bundle "$OUTPUT_FILE" nethack.zip', + ); + expect(nethackZipBuildScript).not.toMatch( + /install_local_binary\s+nethack\s+"\$OUTPUT_FILE"/, + ); }); }); diff --git a/images/vfs/scripts/build-nethack-zip.sh b/images/vfs/scripts/build-nethack-zip.sh index f13e9050e1..dce294491d 100755 --- a/images/vfs/scripts/build-nethack-zip.sh +++ b/images/vfs/scripts/build-nethack-zip.sh @@ -74,7 +74,9 @@ echo " $(find "$STAGING" -type f | wc -l | tr -d ' ') files" ls -lh "$OUTPUT_FILE" # Install into local-binaries/ so the resolver picks the locally-built -# nethack.zip over a fetched release. Mirrors images/vfs/scripts/ -# build-vim-zip.sh. +# nethack.zip over a fetched release. This ZIP is the declared output of +# nethack-browser-bundle, not nethack: nethack owns nethack.wasm and its +# runtime tree. Keeping that ownership exact lets the resolver validate the +# output against the same package manifest that archive-stage is building. source "$REPO_ROOT/scripts/install-local-binary.sh" -install_local_binary nethack "$OUTPUT_FILE" +install_local_binary nethack-browser-bundle "$OUTPUT_FILE" nethack.zip diff --git a/images/vfs/scripts/build-vim-zip.sh b/images/vfs/scripts/build-vim-zip.sh index 0f4e7677ed..aab4a7220e 100755 --- a/images/vfs/scripts/build-vim-zip.sh +++ b/images/vfs/scripts/build-vim-zip.sh @@ -71,8 +71,9 @@ echo " $(find "$STAGING" -type f | wc -l | tr -d ' ') files" ls -lh "$OUTPUT_FILE" # Install into local-binaries/ so the resolver picks the locally-built -# vim.zip over the fetched release. In the release layout vim is a -# single-asset program (the zip IS the bundle), so no dest-name -# argument — the helper drops it at local-binaries/programs/vim.zip. +# vim.zip over the fetched release. This ZIP is the declared output of +# vim-browser-bundle, not vim: vim owns vim.wasm and its runtime tree. +# Keeping that ownership exact lets the resolver validate the output against +# the same package manifest that archive-stage is currently building. source "$REPO_ROOT/scripts/install-local-binary.sh" -install_local_binary vim "$OUTPUT_FILE" +install_local_binary vim-browser-bundle "$OUTPUT_FILE" vim.zip diff --git a/packages/registry/program-packages.json b/packages/registry/program-packages.json index 16cb27b045..7b63401877 100644 --- a/packages/registry/program-packages.json +++ b/packages/registry/program-packages.json @@ -158,8 +158,8 @@ "lamp": { "manifestSha256": "250b64635d64f8537178188fb5488dbfd2980d79a1c2ffbf346af67008088ba0", "cacheKeys": { - "wasm32": "52b8525aaf27598d1d89e1b3ed0f84f9f7a415052c246d632eb8fda2b4a1fa99", - "wasm64": "2b72b49cf85f25af65fa88a65c552b2aa86bb0953f3f284f551a97b6720673cf" + "wasm32": "5899742b956faf9a43d180a5c2ba237222881d6ef38a113572b3fb68ace75f8e", + "wasm64": "e9a87b6d00bc10108a7ba8ba8962d1829a411830fb4e98bf04ed33f1c6e67c73" } }, "less": { @@ -242,15 +242,15 @@ "mariadb-test": { "manifestSha256": "d4ccce161d659a5a87c80fa1117054bbccea870e0d4a46bd8a070d3930ad0e3f", "cacheKeys": { - "wasm32": "98b28ac6dc6c1c8c1501591a1e56c51ea7a4e9524e469994ffc0920dd54d1424", - "wasm64": "bbeab833155d9f653551aef1de7c89ba4a752589b051f885a30bc7f088f6a5f9" + "wasm32": "6793c1e49769dedf80c692595db76abc462b9e6acfbdc2c37d425616a6dbdfe3", + "wasm64": "225144a55f4bda151a0518cc5ff777e3ff008026aebd8488970196fb83716993" } }, "mariadb-vfs": { "manifestSha256": "26e74ac84d89b2a839ed72783437061a23022ef2f88ad0f52b6aacd0442ee1cf", "cacheKeys": { - "wasm32": "8ea599dea8866e4740a0c566e3699b30d607e0e41338394037676b129e22d655", - "wasm64": "ffd244834c1bed28c31d368b0bfa1393642bccae80e0acb515989beef5a1ff59" + "wasm32": "8b7eb8e415bb55058e4d1c2ab8f027aaab704238a94f51e08e753ec359e7e7d7", + "wasm64": "c71c7f72d38ec16f4342158464a0a76556a78e96746e4c633c6b3365ed125b60" } }, "modeset": { @@ -298,8 +298,8 @@ "nethack-browser-bundle": { "manifestSha256": "b8294981da7ce4299dfb271d4aecb90ababa97a4d1acc9b716ae05bbe52beb53", "cacheKeys": { - "wasm32": "bbde7202fd7ac9d0fe859540f69fa04547a7903705c7abda85861cf9f3a9bb3f", - "wasm64": "299820741076c5835ed6abc7a114143603919f74a9d5f0fb0b5a677a23ada2e5" + "wasm32": "8185e934440c3ff8fafe718d15028ab5dbd5c345a2f5517a2bc1dade96ca2154", + "wasm64": "76bef8bb896c85ae77da653130b6e9a135fd640badce83ddca4f1f517bcf1580" } }, "nginx": { @@ -312,15 +312,15 @@ "nginx-php-vfs": { "manifestSha256": "97976410cb02f8ba710d856b4ac904bcf976d677b50eefdee39fb64176070d4b", "cacheKeys": { - "wasm32": "a87b91753dc5369b7a4ac5f8e57aa0018ac99d8ad2fcd3156656aa152c868718", - "wasm64": "8c05a763de5f2b0f3c76b9fc11a89ca99ada21398fdf8255f7c928e9af45b395" + "wasm32": "087cadef7cc22f8f42d13e442f93991844357f64952ae7b8515a69108e37214f", + "wasm64": "9a3d3a729b322ca9b93130f25604bde43a81fbbcacefa28ab5eed4d8e9b91d41" } }, "nginx-vfs": { "manifestSha256": "46aa2d3250ac5cd0f102a85c3a36a086c7a50ba1f9e05fa1c2aae3d07ad16f10", "cacheKeys": { - "wasm32": "ca664401a0ead71d0761b6f3e15126285d239344ff918cbc28c3d54d0809f01b", - "wasm64": "38db505d84f3bb337960da2f6e8e5e65c80874d8a9f80f992ad9549cdb6c1a47" + "wasm32": "48d4e5c18f3dc8584e94284c701c39656ce1ba331357bc6c5a81867a5597706b", + "wasm64": "a3be4e19fd622b9caad798eb8808dcdebd821c39b0b68e1df88adea14617d0af" } }, "node": { @@ -333,8 +333,8 @@ "node-vfs": { "manifestSha256": "33315fb1b3030a4c187ae075eac08f717de8d7ab017b86c6458778ac9070eece", "cacheKeys": { - "wasm32": "3c15243220cac0639aed4b8952f9a09a39036ea4d01b2e75949ebdbacf796d02", - "wasm64": "6be87229496865f993a2fbd8d27de139f35ce63375c16cc0fcfe5ecdc804b3aa" + "wasm32": "2b1f8c91493442d06eed7bca5bc0aa6c11110b234d7cd7bf1530646de250f1b7", + "wasm64": "9f03cf817901ebd92a6bee10b7a7ba1ff23fa3e1ce040083d09d6f29d5a832df" } }, "openssl": { @@ -396,8 +396,8 @@ "redis-vfs": { "manifestSha256": "234c45c40f94e2a89f98295313b82cb9fce15a412ac829d9e87394e0dc731813", "cacheKeys": { - "wasm32": "dfbdea8ae43c488ebafdbb4e83cb5f4dd6bf0810fadd95ede4c1ced77174feb1", - "wasm64": "a257d8a55786e4faef259a3320f5177aed8307172d52ad2f5b07a4f0ef598146" + "wasm32": "901a01371fd5dbcfff1f04d997ad8b602f640050be2faa4ab594f67ad3da6656", + "wasm64": "57190d1b01a627305571ff8705287917938fcaa2ca9dddb55c9e3852d1a55c71" } }, "rootfs": { @@ -501,8 +501,8 @@ "vim-browser-bundle": { "manifestSha256": "e31d84057db49c3534381604d0c8f3c7d765e33ede560a7ea7b01a5a8f1aa0d3", "cacheKeys": { - "wasm32": "2be4011bff29858481bd2e67c3a9ed5fb8d2424e2ada956ae42bd0a2f506de71", - "wasm64": "b2bc5053848452fe5ae433b6a8a6c104af6de32757cf9b3bb46f2e03508b0ddf" + "wasm32": "f0f9e77e4b4a1003f387d7706cf16a29da27e537d1b354547d1d94721b4d53bb", + "wasm64": "bcb98d055890ef832e911e6a9a2705a94ac267505f581f2f724b630e6d2abadc" } }, "wget": { @@ -515,8 +515,8 @@ "wordpress": { "manifestSha256": "36465e0596a06e855a8524eecfd87da98643bc13b37ee12939f5aab44bf33418", "cacheKeys": { - "wasm32": "225610679e2e521c0d861e2c8a3c36bb1a24c682e97ce909b1b0e786a4147aa0", - "wasm64": "b811c0fe3bb7743061ffc5f3504665845034191104c0bd16fadb8742fbc3425b" + "wasm32": "936ea6ca70ad02647dc6245bc8d1fb41751ade7ba8aecd5a9c3558b57986c690", + "wasm64": "a7d33b8c81a2027ce34b0375e2a329e203a3aad9ecd6f553a945baa3b5d96ffb" } }, "xz": { @@ -1086,7 +1086,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "52b8525aaf27598d1d89e1b3ed0f84f9f7a415052c246d632eb8fda2b4a1fa99" + "wasm32": "5899742b956faf9a43d180a5c2ba237222881d6ef38a113572b3fb68ace75f8e" }, "dependencyClosures": { "wasm32": [ @@ -1325,7 +1325,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "98b28ac6dc6c1c8c1501591a1e56c51ea7a4e9524e469994ffc0920dd54d1424" + "wasm32": "6793c1e49769dedf80c692595db76abc462b9e6acfbdc2c37d425616a6dbdfe3" }, "dependencyClosures": { "wasm32": [ @@ -1378,8 +1378,8 @@ "wasm64" ], "cacheKeys": { - "wasm32": "8ea599dea8866e4740a0c566e3699b30d607e0e41338394037676b129e22d655", - "wasm64": "ffd244834c1bed28c31d368b0bfa1393642bccae80e0acb515989beef5a1ff59" + "wasm32": "8b7eb8e415bb55058e4d1c2ab8f027aaab704238a94f51e08e753ec359e7e7d7", + "wasm64": "c71c7f72d38ec16f4342158464a0a76556a78e96746e4c633c6b3365ed125b60" }, "dependencyClosures": { "wasm32": [ @@ -1658,7 +1658,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "bbde7202fd7ac9d0fe859540f69fa04547a7903705c7abda85861cf9f3a9bb3f" + "wasm32": "8185e934440c3ff8fafe718d15028ab5dbd5c345a2f5517a2bc1dade96ca2154" }, "dependencyClosures": { "wasm32": [ @@ -1711,7 +1711,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "a87b91753dc5369b7a4ac5f8e57aa0018ac99d8ad2fcd3156656aa152c868718" + "wasm32": "087cadef7cc22f8f42d13e442f93991844357f64952ae7b8515a69108e37214f" }, "dependencyClosures": { "wasm32": [ @@ -1803,7 +1803,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "ca664401a0ead71d0761b6f3e15126285d239344ff918cbc28c3d54d0809f01b" + "wasm32": "48d4e5c18f3dc8584e94284c701c39656ce1ba331357bc6c5a81867a5597706b" }, "dependencyClosures": { "wasm32": [ @@ -1887,7 +1887,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "3c15243220cac0639aed4b8952f9a09a39036ea4d01b2e75949ebdbacf796d02" + "wasm32": "2b1f8c91493442d06eed7bca5bc0aa6c11110b234d7cd7bf1530646de250f1b7" }, "dependencyClosures": { "wasm32": [ @@ -2443,7 +2443,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "dfbdea8ae43c488ebafdbb4e83cb5f4dd6bf0810fadd95ede4c1ced77174feb1" + "wasm32": "901a01371fd5dbcfff1f04d997ad8b602f640050be2faa4ab594f67ad3da6656" }, "dependencyClosures": { "wasm32": [ @@ -2871,7 +2871,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "2be4011bff29858481bd2e67c3a9ed5fb8d2424e2ada956ae42bd0a2f506de71" + "wasm32": "f0f9e77e4b4a1003f387d7706cf16a29da27e537d1b354547d1d94721b4d53bb" }, "dependencyClosures": { "wasm32": [ @@ -2919,7 +2919,7 @@ "wasm32" ], "cacheKeys": { - "wasm32": "225610679e2e521c0d861e2c8a3c36bb1a24c682e97ce909b1b0e786a4147aa0" + "wasm32": "936ea6ca70ad02647dc6245bc8d1fb41751ade7ba8aecd5a9c3558b57986c690" }, "dependencyClosures": { "wasm32": [ diff --git a/scripts/check-homebrew-publish-workflow-trust.rb b/scripts/check-homebrew-publish-workflow-trust.rb index 8dcaba241e..c2d88cd757 100644 --- a/scripts/check-homebrew-publish-workflow-trust.rb +++ b/scripts/check-homebrew-publish-workflow-trust.rb @@ -20,11 +20,11 @@ UPLOAD_ACTION = "actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a" DOWNLOAD_ACTION = "actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c" BREW_COMMIT = "34c40c18ffa2029b611b61c73273e32c003d0842" -PUBLISHER_PLAN_DIGEST = "abf9de4c12169a8af47f288a42a73851aec0d0634177958e99bbc9969b90bd9e" -PUBLISHER_BUILD_DIGEST = "85cda2db521caa63926b18931e189652f88948f93fe281c2893a6d26cb1cc282" +PUBLISHER_PLAN_DIGEST = "5764359641eacc708845ceaf075b04940b6d24c6e3fa20135d5e6e75026f87df" +PUBLISHER_BUILD_DIGEST = "6902b3d525fa6aae5205d1896d350afbe908f044f0712e4dab9dd2fb865603d5" PUBLISHER_UPLOAD_DIGEST = "1a9f39031587a5944bce022031d6f84d70f476159d4798bbcb51a4fa8377da9e" PUBLISHER_INDEX_DIGEST = "c0eaec6f01ac64e8744b8c98e35b304aa2adafc4ce7ad96416eac85c593fdf87" -PUBLISHER_VERIFY_DIGEST = "5ae8fd198a019dfb100d2645d0d9362f0998a92713f4f5f1a9376e17dd320539" +PUBLISHER_VERIFY_DIGEST = "b79cbbc10e4173b38f73bdd1dd827061b6d2654963f9f5563bea5b40807d6c53" PUBLISHER_FINALIZE_DIGEST = "b101bc67a1ba796d7986c9cb0e9d0270300e519d2cb6ba60e3ae7fc9b4c6fc2e" PUBLISHER_VFS_RELEASE_DIGEST = "34171400552baefd1efee3e05a294308ea3ba783f191f899d1affa5135a4d4da" MAINTENANCE_VALIDATE_DIGEST = "95802741a715c418fdcda9a75aa4f03a6a9248ac6ef91a24e6de173a9b6b015e" @@ -108,8 +108,12 @@ def caller_validation_result(source, overrides = {}) "CALLER_WORKFLOW_REF" => "kandelo-dev/homebrew-tap-core/.github/workflows/dry-run-bottles.yml@refs/heads/main", "DRY_RUN" => "true", + "DEFER_VFS_ACCEPTANCE" => "false", "KANDELO_REPOSITORY" => "Automattic/kandelo", "KANDELO_REF" => "main", + "PREPUBLICATION_STAGING_KANDELO_SHA" => "", + "PREPUBLICATION_STAGING_TAG" => "", + "REQUIRE_VFS_ACCEPTANCE" => "false", "TAP_NAME" => "kandelo-dev/tap-core", "TAP_REPOSITORY" => "kandelo-dev/homebrew-tap-core", "TAP_REF" => "main", @@ -215,6 +219,94 @@ def check_caller_validation_behavior(workflow) "kandelo-ref=#{kandelo_sha}\ntap-ref=refs/heads/main\n", "publisher write path does not accept an exact reviewed Kandelo commit") + prepublication_sha = "c" * 40 + prepublication_publisher_sha = "d" * 40 + prepublication = caller_validation_result(source, { + "CALLER_WORKFLOW_REF" => + "kandelo-dev/homebrew-tap-core/.github/workflows/publish-bottles.yml@refs/heads/main", + "DRY_RUN" => "false", + "KANDELO_REF" => prepublication_publisher_sha, + "PREPUBLICATION_STAGING_KANDELO_SHA" => prepublication_sha, + "PREPUBLICATION_STAGING_TAG" => "pr-1079-staging", + }) + check(prepublication["status"] == 0 && + prepublication["outputs"].include?( + "kandelo-ref=#{prepublication_publisher_sha}\n" + ), + "publisher write path rejects a sealed prepublication generation") + + deferred_prepublication = caller_validation_result(source, { + "CALLER_WORKFLOW_REF" => + "kandelo-dev/homebrew-tap-core/.github/workflows/publish-bottles.yml@refs/heads/main", + "DEFER_VFS_ACCEPTANCE" => "true", + "DRY_RUN" => "false", + "KANDELO_REF" => prepublication_publisher_sha, + "PREPUBLICATION_STAGING_KANDELO_SHA" => prepublication_sha, + "PREPUBLICATION_STAGING_TAG" => "pr-1079-staging", + "REQUIRE_VFS_ACCEPTANCE" => "true", + }) + check(deferred_prepublication["status"] == 0 && + deferred_prepublication["outputs"].include?( + "kandelo-ref=#{prepublication_publisher_sha}\n" + ), "publisher rejects explicit postpublication VFS acceptance deferral") + + { + "tag without Kandelo SHA" => { + "PREPUBLICATION_STAGING_TAG" => "pr-1079-staging", + }, + "Kandelo SHA without tag" => { + "PREPUBLICATION_STAGING_KANDELO_SHA" => prepublication_sha, + }, + "mutable write source" => { + "PREPUBLICATION_STAGING_KANDELO_SHA" => prepublication_sha, + "PREPUBLICATION_STAGING_TAG" => "pr-1079-staging", + }, + "uppercase staging SHA" => { + "KANDELO_REF" => "C" * 40, + "PREPUBLICATION_STAGING_KANDELO_SHA" => "C" * 40, + "PREPUBLICATION_STAGING_TAG" => "pr-1079-staging", + }, + "noncanonical staging tag" => { + "KANDELO_REF" => prepublication_sha, + "PREPUBLICATION_STAGING_KANDELO_SHA" => prepublication_sha, + "PREPUBLICATION_STAGING_TAG" => "refs/tags/pr-1079-staging", + }, + }.each do |label, overrides| + rejected = caller_validation_result(source, { + "CALLER_WORKFLOW_REF" => + "kandelo-dev/homebrew-tap-core/.github/workflows/publish-bottles.yml@refs/heads/main", + "DRY_RUN" => "false", + }.merge(overrides)) + check(rejected["status"] == 2, + "publisher accepts prepublication generation with #{label}") + end + + dry_prepublication = caller_validation_result(source, { + "KANDELO_REF" => prepublication_sha, + "PREPUBLICATION_STAGING_KANDELO_SHA" => prepublication_sha, + "PREPUBLICATION_STAGING_TAG" => "pr-1079-staging", + }) + check(dry_prepublication["status"] == 2, + "publisher accepts a prepublication generation in dry-run mode") + + { + "without a sealed generation" => {}, + "without required VFS acceptance" => { + "KANDELO_REF" => prepublication_sha, + "PREPUBLICATION_STAGING_KANDELO_SHA" => prepublication_sha, + "PREPUBLICATION_STAGING_TAG" => "pr-1079-staging", + }, + }.each do |label, overrides| + rejected = caller_validation_result(source, { + "CALLER_WORKFLOW_REF" => + "kandelo-dev/homebrew-tap-core/.github/workflows/publish-bottles.yml@refs/heads/main", + "DEFER_VFS_ACCEPTANCE" => "true", + "DRY_RUN" => "false", + }.merge(overrides)) + check(rejected["status"] == 2, + "publisher accepts VFS acceptance deferral #{label}") + end + write_branch = caller_validation_result(source, { "CALLER_WORKFLOW_REF" => "kandelo-dev/homebrew-tap-core/.github/workflows/publish-bottles.yml@refs/heads/main", @@ -310,6 +402,109 @@ def check_forbidden_root_args(run, label, expected) check(actual == expected, "#{label} forbidden-root trust mapping changed") end +def check_prepublication_generation_helpers( + freeze_source, activate_source, staging_validation_source +) + [ + '[[ "$TAG" =~ ^pr-[1-9][0-9]*-staging$ ]]', + '[[ "$GENERATION_SHA" =~ ^[0-9a-f]{40}$ ]]', + '[[ "$CONSUMER_SHA" =~ ^[0-9a-f]{40}$ ]]', + '[ "$REPOSITORY" != "Automattic/kandelo" ]', + '[ "$(git -C "$GENERATION_ROOT" rev-parse HEAD)" = "$GENERATION_SHA" ]', + '[ "$(git -C "$CONSUMER_ROOT" rev-parse HEAD)" = "$CONSUMER_SHA" ]', + '.packages.rootfs.dependencyClosures.wasm32', + 'package: "rootfs"', + 'cmp "$TMP_ROOT/generation-projection.json" "$TMP_ROOT/consumer-projection.json"', + 'RELEASE_GH_TOKEN="${GH_TOKEN:-}"', + 'RELEASE_GITHUB_TOKEN="${GITHUB_TOKEN:-}"', + "unset GH_TOKEN GITHUB_TOKEN", + "unset HOMEBREW_GITHUB_API_TOKEN HOMEBREW_GITHUB_PACKAGES_TOKEN", + "unset HOMEBREW_DOCKER_REGISTRY_TOKEN", + "run_xtask_without_credentials()", + "env -u GH_TOKEN -u GITHUB_TOKEN", + '.entries |= map(select(.arch == "wasm32" and ' \ + '(.package as $name | $packages | index($name))))', + 'cmp "$TMP_ROOT/generation-expected.json" "$TMP_ROOT/consumer-expected.json"', + 'GH_TOKEN="$RELEASE_GH_TOKEN"', + 'GITHUB_TOKEN="$RELEASE_GITHUB_TOKEN"', + 'bash "$SCRIPT_DIR/validate-staging-release.sh"', + '--mode current', + '--materialize', + '--archives-dir "$TMP_ROOT/validated/archives"', + '--canonical-index-url "https://github.com/$REPOSITORY/releases/download/$TAG/index.toml"', + '[ "$archive_url_count" = "$package_count" ]', + 'grep -Fv "archive_url = \\"https://github.com/$REPOSITORY/releases/download/$TAG/"', + "for name in index.toml projection.json expected-ledger.json snapshot.json assets.json", + '. + {($name): {sha256: $sha256, size: $size}}', + '--arg staging_tag "$TAG"', + '--arg generation_sha "$GENERATION_SHA"', + '--arg consumer_sha "$CONSUMER_SHA"', + '--slurpfile files "$TMP_ROOT/files.json"', + "schema: 2", + "files: $files[0]", + ].each do |fragment| + check(freeze_source.include?(fragment), + "prepublication generation freezer lacks #{fragment}") + end + check(freeze_source.scan("run_xtask_without_credentials").length == 4 && + freeze_source.scan('"$XTASK"').length == 3, + "prepublication generation freezer exposes credentials to pure index tooling") + + [ + "run_xtask_without_credentials()", + "env -u GH_TOKEN -u GITHUB_TOKEN", + "-u HOMEBREW_GITHUB_API_TOKEN", + "-u HOMEBREW_GITHUB_PACKAGES_TOKEN", + "-u HOMEBREW_DOCKER_REGISTRY_TOKEN", + "run_xtask_without_credentials staging-reuse validate", + "run_xtask_without_credentials staging-reuse validate-archives", + ].each do |fragment| + check(staging_validation_source.include?(fragment), + "staging release validator lacks credential isolation #{fragment}") + end + check(staging_validation_source.scan("run_xtask_without_credentials").length == 3 && + staging_validation_source.scan('"$XTASK"').length == 2, + "staging release validator exposes credentials to pure archive tooling") + + [ + '[[ "$EXPECTED_TAG" =~ ^pr-[1-9][0-9]*-staging$ ]]', + '[[ "$EXPECTED_GENERATION_SHA" =~ ^[0-9a-f]{40}$ ]]', + '[[ "$EXPECTED_CONSUMER_SHA" =~ ^[0-9a-f]{40}$ ]]', + 'for name in index.toml manifest.json projection.json expected-ledger.json snapshot.json assets.json', + '[ -f "$BUNDLE/$name" ] && [ ! -L "$BUNDLE/$name" ]', + 'keys == [', + '.repository == "Automattic/kandelo"', + '.staging_tag == $tag', + '.generation_sha == $generation', + '.consumer_sha == $consumer', + '.abi_version == $abi', + '(.files | keys) == [', + 'all(.files[]; (', + 'for name in index.toml projection.json expected-ledger.json snapshot.json assets.json', + "'.files[$name].sha256'", + "'.files[$name].size'", + '[ "$actual_sha" = "$expected_sha" ] && [ "$actual_size" = "$expected_size" ]', + '(.entries | length) == $count', + '.complete_current == true', + 'identity_set "$BUNDLE/projection.json" "$identity_root/projection"', + 'identity_set "$BUNDLE/expected-ledger.json" "$identity_root/expected"', + 'identity_set "$BUNDLE/snapshot.json" "$identity_root/snapshot"', + 'cmp "$identity_root/projection" "$identity_root/expected"', + 'cmp "$identity_root/projection" "$identity_root/snapshot"', + '--slurpfile assets "$BUNDLE/assets.json"', + '.digest == ("sha256:" + $entry.archive_sha256)', + "printf 'WASM_POSIX_BINARY_INDEX_URL=file://%s\\n' \"$index_path\"", + ].each do |fragment| + check(activate_source.include?(fragment), + "prepublication generation activator lacks #{fragment}") + end + check(activate_source.scan("(.entries | length) == $count").length == 3, + "prepublication generation activator does not bind projection, ledger, and snapshot counts") + check(activate_source.scan("actual_sha=\"$(sha_file").length == 1 && + activate_source.scan("identity_set \"$BUNDLE/").length == 3, + "prepublication generation activator does not bind every evidence file and identity set") +end + def exact_permissions?(actual, expected) actual.is_a?(Hash) && actual.transform_keys(&:to_s) == expected end @@ -551,6 +746,10 @@ def check_publisher(workflow) "force" => { "type" => "boolean", "default" => false }, "dry-run" => { "type" => "boolean", "default" => false }, "require-vfs-acceptance" => { "type" => "boolean", "default" => false }, + "prepublication-staging-tag" => { "type" => "string", "default" => "" }, + "prepublication-staging-kandelo-sha" => { "type" => "string", "default" => "" }, + "defer-vfs-acceptance-until-postpublication" => + { "type" => "boolean", "default" => false }, }, "publisher inputs changed") check(!workflow.key?("permissions"), "publisher requests workflow-wide permissions") check_common(workflow, "reusable publisher") @@ -647,7 +846,8 @@ def check_publisher(workflow) "needs.plan.result == 'success' && needs.plan.outputs.matrix != '[]' }}", "publisher finalization graph or dry-run isolation changed") check(vfs_release["needs"] == %w[plan verify-bottle finalize-tap] && - vfs_release["if"] == "${{ always() && !cancelled() && !inputs.dry-run && " \ + vfs_release["if"] == "${{ always() && !cancelled() && " \ + "!inputs.defer-vfs-acceptance-until-postpublication && !inputs.dry-run && " \ "inputs.require-vfs-acceptance && needs.plan.result == 'success' && " \ "needs.verify-bottle.result == 'success' && " \ "needs.finalize-tap.result == 'success' && " \ @@ -671,9 +871,16 @@ def check_publisher(workflow) "CALLER_REF" => "${{ github.ref }}", "CALLER_REPOSITORY" => "${{ github.repository }}", "CALLER_WORKFLOW_REF" => "${{ github.workflow_ref }}", + "DEFER_VFS_ACCEPTANCE" => + "${{ inputs.defer-vfs-acceptance-until-postpublication }}", "DRY_RUN" => "${{ inputs.dry-run }}", "KANDELO_REPOSITORY" => "${{ inputs.kandelo-repository }}", "KANDELO_REF" => "${{ inputs.kandelo-ref }}", + "PREPUBLICATION_STAGING_KANDELO_SHA" => + "${{ inputs.prepublication-staging-kandelo-sha }}", + "PREPUBLICATION_STAGING_TAG" => + "${{ inputs.prepublication-staging-tag }}", + "REQUIRE_VFS_ACCEPTANCE" => "${{ inputs.require-vfs-acceptance }}", "TAP_NAME" => "${{ inputs.tap-name }}", "TAP_REPOSITORY" => "${{ inputs.tap-repository }}", "TAP_REF" => "${{ inputs.tap-ref }}", @@ -691,6 +898,15 @@ def check_publisher(workflow) '"$CALLER_REPOSITORY/.github/workflows/publish-bottles.yml@refs/heads/main"', '"$CALLER_REPOSITORY/.github/workflows/maintain-bottles.yml@refs/heads/main"', '[ "$KANDELO_REPOSITORY" = "Automattic/kandelo" ]', + 'case "$REQUIRE_VFS_ACCEPTANCE" in', + 'case "$DEFER_VFS_ACCEPTANCE" in', + '[[ "$PREPUBLICATION_STAGING_TAG" =~ ^pr-[1-9][0-9]*-staging$ ]]', + '[[ "$PREPUBLICATION_STAGING_KANDELO_SHA" =~ ^[0-9a-f]{40}$ ]]', + 'prepublication staging tag and Kandelo SHA must be supplied together', + 'prepublication package generation is restricted to write publication', + 'prepublication package generation requires an exact reviewed Kandelo commit', + 'if [ "$DEFER_VFS_ACCEPTANCE" = "true" ]; then', + 'VFS acceptance may be deferred only for a required sealed prepublication generation', '[[ "$normalized_tap_repository" =~ ^[a-z0-9_.-]+/homebrew-[a-z0-9_.-]+$ ]]', 'tap_short_name="${normalized_tap_repository#*/homebrew-}"', '[ "$normalized_tap_name" = "${tap_owner}/${tap_short_name}" ]', @@ -742,6 +958,8 @@ def check_publisher(workflow) check(vfs_selection.keys.sort == %w[env id name run shell] && vfs_selection["id"] == "vfs-acceptance" && vfs_selection["shell"] == "bash" && vfs_selection["env"] == { + "DEFER_VFS_ACCEPTANCE" => + "${{ inputs.defer-vfs-acceptance-until-postpublication }}", "DRY_RUN" => "${{ inputs.dry-run }}", "PLANNED_MATRIX" => "${{ steps.matrix.outputs.matrix }}", "REQUIRE_VFS_ACCEPTANCE" => "${{ inputs.require-vfs-acceptance }}", @@ -781,6 +999,8 @@ def check_publisher(workflow) 'any(.[]; .formula == $formula and .arch == "wasm32")', 'required dependency-bearing VFS acceptance needs a non-dry-run publication', 'use force when its bottle is already current', + 'if [ "$DEFER_VFS_ACCEPTANCE" = "true" ]; then', + 'dependency-bearing VFS acceptance is explicitly deferred until the sealed generation is canonical', 'echo "formula=$selected_formula" >> "$GITHUB_OUTPUT"', ].each do |fragment| check(vfs_selection_run.include?(fragment), @@ -895,12 +1115,145 @@ def check_publisher(workflow) "vfs-acceptance-formula" => "${{ steps.vfs-acceptance.outputs.formula }}", }, "publisher plan outputs changed") + prepublication_condition = "${{ inputs.prepublication-staging-tag != '' }}" + generation_binding = named_step( + plan_steps, "Bind prepublication generation to the workflow descendant" + ) + check(generation_binding.keys.sort == %w[env if name run shell] && + generation_binding["if"] == prepublication_condition && + generation_binding["shell"] == "bash" && + generation_binding["env"] == { + "GH_TOKEN" => "${{ github.token }}", + "KANDELO_REPOSITORY" => "${{ inputs.kandelo-repository }}", + "KANDELO_SHA" => "${{ steps.source-commits.outputs.kandelo-sha }}", + "GENERATION_SHA" => "${{ inputs.prepublication-staging-kandelo-sha }}", + }, "publisher prepublication generation ancestry mapping changed") + [ + '[ "$(git -C kandelo-generation rev-parse HEAD)" = "$GENERATION_SHA" ]', + '"/repos/$KANDELO_REPOSITORY/compare/$GENERATION_SHA...$KANDELO_SHA"', + "ahead|identical) ;;", + "publishing workflow commit must descend from the staged package generation", + ].each do |fragment| + check(generation_binding.fetch("run").include?(fragment), + "publisher prepublication generation ancestry check lacks #{fragment}") + end + + generation_nix = named_step( + plan_steps, "Install Nix for prepublication generation validation" + ) + check(generation_nix == { + "name" => "Install Nix for prepublication generation validation", + "if" => prepublication_condition, + "uses" => NIX_ACTION, + "with" => { "github-token" => "" }, + }, "publisher prepublication generation Nix setup changed") + + generation_tooling = named_step( + plan_steps, "Build prepublication index tooling without credentials" + ) + check(generation_tooling.keys.sort == %w[id if name run shell] && + generation_tooling["id"] == "prepublication-tooling" && + generation_tooling["if"] == prepublication_condition && + generation_tooling["shell"] == "bash", + "publisher prepublication credential-free tooling mapping changed") + [ + "env -u GH_TOKEN -u GITHUB_TOKEN", + "-u HOMEBREW_GITHUB_API_TOKEN", + "-u HOMEBREW_GITHUB_PACKAGES_TOKEN", + "-u HOMEBREW_DOCKER_REGISTRY_TOKEN", + "bash scripts/dev-shell.sh rustc -vV", + '[[ "$host_target" =~ ^[a-z0-9_]+(-[a-z0-9_.]+){2,}$ ]]', + "bash scripts/dev-shell.sh", + 'cargo build --release -p xtask --target "$host_target"', + 'echo "host-target=$host_target" >>"$GITHUB_OUTPUT"', + ].each do |fragment| + check(generation_tooling.fetch("run").include?(fragment), + "publisher prepublication credential-free tooling lacks #{fragment}") + end + + generation_freeze = named_step( + plan_steps, "Freeze exact prepublication package generation" + ) + check(generation_freeze.keys.sort == %w[env if name run shell] && + generation_freeze["if"] == prepublication_condition && + generation_freeze["shell"] == "bash" && + generation_freeze["env"] == { + "GH_TOKEN" => "${{ github.token }}", + "HOST_TARGET" => "${{ steps.prepublication-tooling.outputs.host-target }}", + "KANDELO_ABI" => "${{ steps.release.outputs.abi }}", + "KANDELO_SHA" => "${{ steps.source-commits.outputs.kandelo-sha }}", + "GENERATION_SHA" => "${{ inputs.prepublication-staging-kandelo-sha }}", + "STAGING_TAG" => "${{ inputs.prepublication-staging-tag }}", + }, "publisher prepublication generation freezer mapping changed") + [ + '[[ "$HOST_TARGET" =~ ^[a-z0-9_]+(-[a-z0-9_.]+){2,}$ ]]', + "bash kandelo/.github/scripts/freeze-homebrew-prepublication-generation.sh", + '--tag "$STAGING_TAG"', + '--generation-root "$GITHUB_WORKSPACE/kandelo-generation"', + '--consumer-root "$GITHUB_WORKSPACE/kandelo"', + '--expected-abi "$KANDELO_ABI"', + '--generation-sha "$GENERATION_SHA"', + '--consumer-sha "$KANDELO_SHA"', + "--repository Automattic/kandelo", + '--output-dir "$RUNNER_TEMP/homebrew-prepublication-generation"', + '--xtask "$GITHUB_WORKSPACE/kandelo/target/$HOST_TARGET/release/xtask"', + ].each do |fragment| + check(generation_freeze.fetch("run").include?(fragment), + "publisher prepublication generation freezer lacks #{fragment}") + end + check(!generation_freeze.fetch("run").include?("cargo build") && + !generation_freeze.fetch("run").include?("scripts/dev-shell.sh"), + "publisher compiles or probes prepublication tooling while holding a release token") + + prepublication_artifact_name = + "homebrew-prepublication-generation-${{ github.run_id }}-attempt-${{ github.run_attempt }}" + generation_upload = named_step( + plan_steps, "Upload sealed prepublication package generation" + ) + check(generation_upload["if"] == prepublication_condition && + generation_upload["uses"] == UPLOAD_ACTION && + generation_upload["with"] == { + "name" => prepublication_artifact_name, + "path" => "${{ runner.temp }}/homebrew-prepublication-generation", + "compression-level" => 0, + "if-no-files-found" => "error", + "retention-days" => 2, + }, "publisher sealed prepublication generation artifact changed") + + generation_checkout = named_step( + plan_steps, "Checkout exact prepublication package generation" + ) + source_commits = named_step(plan_steps, "Resolve source commits") + release = named_step(plan_steps, "Resolve release and bottle root") + matrix = named_step(plan_steps, "Plan formula matrix") + check(plan_steps.index(generation_checkout) < plan_steps.index(source_commits) && + plan_steps.index(source_commits) < plan_steps.index(generation_binding) && + plan_steps.index(generation_binding) < plan_steps.index(release) && + plan_steps.index(release) < plan_steps.index(generation_nix) && + plan_steps.index(generation_nix) < plan_steps.index(generation_tooling) && + plan_steps.index(generation_tooling) < plan_steps.index(generation_freeze) && + plan_steps.index(generation_freeze) < plan_steps.index(generation_upload) && + plan_steps.index(generation_upload) < plan_steps.index(matrix), + "publisher freezes the prepublication generation outside the planned source boundary") + + check_prepublication_generation_helpers( + File.read(File.join( + REPO_ROOT, ".github/scripts/freeze-homebrew-prepublication-generation.sh" + )), + File.read(File.join( + REPO_ROOT, ".github/scripts/activate-homebrew-prepublication-generation.sh" + )), + File.read(File.join( + REPO_ROOT, ".github/scripts/validate-staging-release.sh" + )) + ) + expected_uses = [ - *Array.new(23, CHECKOUT_ACTION), - *Array.new(5, NIX_ACTION), + *Array.new(24, CHECKOUT_ACTION), + *Array.new(6, NIX_ACTION), *Array.new(2, MAGIC_NIX_ACTION), - *Array.new(9, UPLOAD_ACTION), - *Array.new(10, DOWNLOAD_ACTION), + *Array.new(10, UPLOAD_ACTION), + *Array.new(12, DOWNLOAD_ACTION), ].sort check(values_for_key(workflow, "uses").sort == expected_uses, "publisher action set or pin changed") @@ -937,6 +1290,16 @@ def check_publisher(workflow) "path" => "kandelo", "submodules" => false, }, }, + { + "name" => "Checkout exact prepublication package generation", + "if" => "${{ inputs.prepublication-staging-tag != '' }}", + "with" => { + "persist-credentials" => false, + "repository" => "${{ inputs.kandelo-repository }}", + "ref" => "${{ inputs.prepublication-staging-kandelo-sha }}", + "path" => "kandelo-generation", "submodules" => false, + }, + }, { "name" => "Checkout tap", "if" => nil, "with" => { @@ -1214,6 +1577,17 @@ def check_publisher(workflow) GH_TOKEN GITHUB_TOKEN HOMEBREW_GITHUB_API_TOKEN HOMEBREW_GITHUB_PACKAGES_TOKEN HOMEBREW_DOCKER_REGISTRY_TOKEN ] + plan_credential_steps = plan_steps.select do |step| + !(step.fetch("env", {}).keys & credential_names).empty? + end + check(plan_credential_steps.map { |step| step["name"] } == [ + "Bind prepublication generation to the workflow descendant", + "Freeze exact prepublication package generation", + ] && plan_credential_steps.all? do |step| + step.fetch("env").slice(*credential_names) == { + "GH_TOKEN" => "${{ github.token }}", + } + end, "publisher plan credential escapes prepublication generation validation") [build_steps, verify_steps].each do |steps| exposed = steps.flat_map { |step| step.fetch("env", {}).keys & credential_names } check(exposed.empty?, "unprivileged publisher phase exposes a credential environment") @@ -1281,6 +1655,73 @@ def check_publisher(workflow) "homebrew-vfs-release-handoff-${{ matrix.formula }}-wasm32-attempt-${{ github.run_attempt }}" vfs_release_receipt_name = "homebrew-vfs-release-receipt-${{ needs.plan.outputs.vfs-acceptance-formula }}-wasm32-attempt-${{ github.run_attempt }}" + [build_steps, verify_steps].each do |steps| + generation_download = named_step( + steps, "Download sealed prepublication package generation" + ) + check(generation_download == { + "name" => "Download sealed prepublication package generation", + "if" => prepublication_condition, + "uses" => DOWNLOAD_ACTION, + "with" => { + "name" => prepublication_artifact_name, + "path" => "${{ runner.temp }}/homebrew-prepublication-generation", + }, + }, "publisher prepublication generation download changed") + + generation_activation = named_step( + steps, "Activate sealed prepublication package generation" + ) + check(generation_activation.keys.sort == %w[env if name run shell] && + generation_activation["if"] == prepublication_condition && + generation_activation["shell"] == "bash" && + generation_activation["env"] == { + "KANDELO_ABI" => "${{ needs.plan.outputs.abi }}", + "KANDELO_SHA" => "${{ needs.plan.outputs.kandelo-sha }}", + "GENERATION_SHA" => + "${{ inputs.prepublication-staging-kandelo-sha }}", + "STAGING_TAG" => "${{ inputs.prepublication-staging-tag }}", + }, "publisher prepublication generation activation mapping changed") + [ + "bash kandelo/.github/scripts/activate-homebrew-prepublication-generation.sh", + '--bundle "$RUNNER_TEMP/homebrew-prepublication-generation"', + '--expected-tag "$STAGING_TAG"', + '--expected-generation-sha "$GENERATION_SHA"', + '--expected-consumer-sha "$KANDELO_SHA"', + '--expected-abi "$KANDELO_ABI"', + '--github-env "$GITHUB_ENV"', + ].each do |fragment| + check(generation_activation.fetch("run").include?(fragment), + "publisher prepublication generation activation lacks #{fragment}") + end + check(steps.index(generation_download) < steps.index(generation_activation), + "publisher activates the prepublication generation before downloading it") + end + check( + build_steps.index( + named_step(build_steps, "Activate sealed prepublication package generation") + ) < build_steps.index( + named_step(build_steps, "Materialize Formula test platform runtime") + ), + "publisher build resolves packages before activating the frozen generation" + ) + check( + verify_steps.index( + named_step(verify_steps, "Activate sealed prepublication package generation") + ) && + verify_steps.index( + named_step(verify_steps, "Activate sealed prepublication package generation") + ) < verify_steps.index( + named_step(verify_steps, "Materialize Formula verification platform runtime") + ) && + verify_steps.index( + named_step(verify_steps, "Materialize Formula verification platform runtime") + ) < verify_steps.index( + named_step(verify_steps, "Prepare the supported interactive browser demo graph") + ), + "publisher verifier resolves packages before activating the frozen generation" + ) + build_handoff_upload = named_step(build_steps, "Upload strict bottle build handoff") check(build_handoff_upload["uses"] == UPLOAD_ACTION && build_handoff_upload["with"] == { "name" => build_handoff_name, @@ -1383,7 +1824,8 @@ def check_publisher(workflow) vfs_handoff_upload = named_step( verify_steps, "Upload exact browser-proven VFS release handoff" ) - vfs_handoff_condition = "${{ !inputs.dry-run && inputs.require-vfs-acceptance && " \ + vfs_handoff_condition = "${{ !inputs.defer-vfs-acceptance-until-postpublication && " \ + "!inputs.dry-run && inputs.require-vfs-acceptance && " \ "matrix.arch == 'wasm32' && matrix.formula == " \ "needs.plan.outputs.vfs-acceptance-formula }}" check(vfs_handoff_upload["uses"] == UPLOAD_ACTION && @@ -1411,8 +1853,60 @@ def check_publisher(workflow) "if-no-files-found" => "error", "retention-days" => 14, }, "publisher VFS release receipt artifact contract changed") - build_run = named_step(build_steps, - "Build and test Homebrew bottle without publisher credentials").fetch("run") + build_formula_step = named_step( + build_steps, "Build and test Homebrew bottle without publisher credentials" + ) + build_run = build_formula_step.fetch("run") + check(build_formula_step.fetch("env").fetch("WASM_POSIX_XTASK_BIN") == + "${{ steps.formula-runtime.outputs.xtask-bin }}", + "publisher does not scope the prepared checker to Formula execution") + xtask_environment_steps = build_steps.select do |step| + step.fetch("env", {}).key?("WASM_POSIX_XTASK_BIN") + end + check(xtask_environment_steps == [build_formula_step], + "publisher exposes the prepared checker outside Formula execution") + verify_formula_step = named_step( + verify_steps, "Force-pour and test the exact selected bottle without credentials" + ) + check(verify_formula_step.fetch("env").fetch("WASM_POSIX_XTASK_BIN") == + "${{ steps.formula-verification-runtime.outputs.xtask-bin }}", + "publisher does not scope the prepared checker to Formula verification") + verify_xtask_environment_steps = verify_steps.select do |step| + step.fetch("env", {}).key?("WASM_POSIX_XTASK_BIN") + end + check(verify_xtask_environment_steps == [verify_formula_step], + "publisher exposes the prepared verifier checker outside Formula execution") + all_xtask_environment_steps = jobs.values.flat_map do |job| + job.fetch("steps", []) + end.select do |step| + step.fetch("env", {}).key?("WASM_POSIX_XTASK_BIN") + end + check(all_xtask_environment_steps == [build_formula_step, verify_formula_step], + "publisher checker authority is not scoped to the two Formula execution steps") + build_dev_shell_index = build_run.index("bash scripts/dev-shell.sh env") + build_checker_forward_index = build_run.index( + 'WASM_POSIX_XTASK_BIN="$WASM_POSIX_XTASK_BIN"' + ) + build_script_index = build_run.index( + "bash scripts/homebrew-bottle-build.sh", build_checker_forward_index || 0 + ) + check(build_dev_shell_index && build_checker_forward_index && build_script_index && + build_dev_shell_index < build_checker_forward_index && + build_checker_forward_index < build_script_index, + "publisher does not pass the scoped checker through the Formula build command") + verify_run = verify_formula_step.fetch("run") + verify_dev_shell_index = verify_run.index("bash scripts/dev-shell.sh env") + verify_checker_forward_index = verify_run.index( + 'WASM_POSIX_XTASK_BIN="$WASM_POSIX_XTASK_BIN"' + ) + verify_script_index = verify_run.index( + "bash scripts/homebrew-verify-poured-bottle.sh", + verify_checker_forward_index || 0 + ) + check(verify_dev_shell_index && verify_checker_forward_index && verify_script_index && + verify_dev_shell_index < verify_checker_forward_index && + verify_checker_forward_index < verify_script_index, + "publisher does not pass the scoped checker through the Formula verifier command") check(build_run.include?("unprivileged bottle build received $secret_name") && build_run.include?("scripts/homebrew-bottle-build.sh") && build_run.include?('readlink -f "$HOMEBREW_BREW_FILE"') && @@ -1723,6 +2217,29 @@ def check_publisher(workflow) check(bottle_verifier.include?(fragment), "reviewed bottle verifier protected sysroot contract lacks #{fragment}") end + verifier_checker_derivation_index = bottle_verifier.index( + 'XTASK_BIN="$KANDELO_ROOT/target/$HOST_TARGET/release/xtask"' + ) + verifier_checker_match_index = bottle_verifier.index( + '[ "${WASM_POSIX_XTASK_BIN:-}" != "$XTASK_BIN" ]', + verifier_checker_derivation_index || 0 + ) + verifier_checker_export_index = bottle_verifier.index( + "export WASM_POSIX_XTASK_BIN", verifier_checker_match_index || 0 + ) + verifier_checker_isolate_index = bottle_verifier.index( + 'homebrew_patched_launcher_isolate "$BUILD_USER"', + verifier_checker_export_index || 0 + ) + check(verifier_checker_derivation_index && verifier_checker_match_index && + verifier_checker_export_index && verifier_checker_isolate_index && + verifier_checker_derivation_index < verifier_checker_match_index && + verifier_checker_match_index < verifier_checker_export_index && + verifier_checker_export_index < verifier_checker_isolate_index && + bottle_verifier.include?( + "scoped program-index checker differs from the exact host xtask" + ), + "reviewed bottle verifier does not bind the scoped checker to its host target") [ 'PROVENANCE_TAP_ROOT="$(jq -er --arg tap "$TAP_NAME"', 'select(.tap_name == $tap)', @@ -2333,9 +2850,44 @@ def check_publisher(workflow) 'target Formula can modify the selected primary tap', 'HOMEBREW_KANDELO_SYSROOT:-}', 'WASM_POSIX_SYSROOT:-}', + 'xtask_bin="${WASM_POSIX_XTASK_BIN:-}"', + 'Kandelo root must be one exact canonical checkout', + 'prepared program-index checker must be one exact regular executable', + 'prepared program-index checker is outside the exact Kandelo root', + 'program-index checker is not the prepared release xtask', + '[ "$xtask_mode" != "555" ]', + 'prepared program-index checker has an unsafe mode', + 'prepared program-index checker is not single-linked', + 'prepared program-index checker is owned by the Formula user', + 'prepared program-index checker is writable by the Formula user', + 'homebrew_assert_tree_not_replaceable_by_user "$build_user" "$xtask_bin"', + "xtask_state=\"$(/usr/bin/stat -c '%d:%i:%u:%g:%a:%h:%s' \"$xtask_bin\")\"", + 'xtask_sha256="$(/usr/bin/sha256sum "$xtask_bin")"', + 'expected_xtask=%q', + 'expected_xtask_state=%q', + 'expected_xtask_sha256=%q', + 'protected program-index checker changed or is inaccessible', + 'could not inspect protected checker mount', + 'protected checker mount is writable', + 'prepared program-index checker changed after isolation', + 'protected_xtask="$HOMEBREW_PATCHED_PROTECTED_DIR/xtask"', + 'install -o root -g root -m 0555 --', + 'could not stage the root-owned program-index checker', + 'HOMEBREW_PATCHED_PROTECTED_XTASK="$protected_xtask"', + '[ -z "$HOMEBREW_PATCHED_PROTECTED_XTASK_STATE" ]', + 'homebrew_patched_launcher_verify_protected_xtask', + 'protected checker changed; preserving launcher state for inspection', + 'protected launcher state could not be removed; preserving cleanup state for retry', + 'source aliases could not be removed; preserving cleanup state for retry', + 'protected_xtask_state=%q', + 'root-owned program-index checker changed after isolation', + '[ "${HOMEBREW_KANDELO_XTASK_BIN:-}" != "$expected_xtask" ]', + 'HOMEBREW_KANDELO_XTASK_BIN=$xtask_alias', + 'WASM_POSIX_XTASK_BIN=$xtask_alias', "--property=KillMode=control-group", "--property=SendSIGKILL=yes", "--property=NoNewPrivileges=yes", "--expand-environment=no", '"--property=BindReadOnlyPaths=$kandelo_root:$source_alias_dir/kandelo"', + '"--property=BindReadOnlyPaths=$protected_xtask:$xtask_alias"', '"--property=BindReadOnlyPaths=$tap_root:$source_alias_dir/tap"', '"--property=BindReadOnlyPaths=$sysroot:$source_alias_dir/sysroot"', '"--property=BindReadOnlyPaths=$taps_root"', @@ -2444,7 +2996,8 @@ def check_publisher(workflow) 'native Formula bridge rollback failed; preserving launcher state for retry', 'Formula process teardown failed; preserving launcher state for retry', 'return "$teardown_status"', - 'for protected_bin in chmod chown cmp cp id install ln ls mktemp readlink rm stat test; do', + 'for protected_bin in chmod chown cmp cp id install ln ls mktemp readlink rm \\', + 'sha256sum stat test; do', '"$sudo_bin" /usr/bin/install -d -o root -g "$build_group" -m 1775', '"$(/usr/bin/stat -c \'%u:%g:%a\' "$target_state_root")" = "0:$build_gid:1775"', 'target_opt_target="../Cellar/$formula/$native_version"', @@ -2504,6 +3057,10 @@ def check_publisher(workflow) check(launcher.include?(fragment), "protected Formula input staging lacks #{fragment}") end + check(!launcher.include?( + '/usr/bin/test -r "$xtask_bin" -a -x "$xtask_bin"' + ), + "reviewed launcher requires Formula access to the hidden original checker") staged_cleanup_owner_index = launcher.index("homebrew_patched_launcher_cleanup()") staged_cleanup_teardown_index = launcher.index( 'homebrew_patched_launcher_teardown "$HOMEBREW_PATCHED_BUILD_USER"', @@ -2609,9 +3166,16 @@ def check_publisher(workflow) target_environment = launcher[/\n preserved_variables=\((.*?)\n \)/m, 1] check(target_environment&.include?("HOMEBREW_KANDELO_GNU_TAR"), "isolated target Homebrew drops the validated GNU tar path") + check(target_environment && + !target_environment.match?( + /(?:HOMEBREW_KANDELO_XTASK_BIN|WASM_POSIX_XTASK_BIN)/ + ), + "isolated target Homebrew preserves a caller-selected checker path") native_environment = launcher[/native_preserved_variables=\((.*?)\n \)/m, 1] check(native_environment && - !native_environment.match?(/KANDELO|HOMEBREW_CACHE|HOMEBREW_TEMP|XDG_CONFIG_HOME|LLVM|GNU_TAR/), + !native_environment.match?( + /KANDELO|HOMEBREW_CACHE|HOMEBREW_TEMP|XDG_CONFIG_HOME|LLVM|GNU_TAR|XTASK/ + ), "isolated native Homebrew inherits target-only state or Kandelo controls") gnu_tar_executable_index = launcher.index( '"$HOMEBREW_KANDELO_GNU_TAR" "Nix GNU tar" || return' @@ -2626,6 +3190,38 @@ def check_publisher(workflow) launcher_test = File.read( File.join(REPO_ROOT, "scripts/test-homebrew-patched-launcher.sh") ) + [ + "a missing program-index checker", + "a symlinked program-index checker", + "a program-index checker outside Kandelo", + "a non-release program-index checker", + "an inaccessible program-index checker", + "a build-user-writable program-index checker", + "a hard-linked program-index checker", + "a Formula-user-owned program-index checker", + "a build-user-replaceable program-index checker", + "program-index checker fixture does not model a workflow-private checkout", + "isolated launcher did not stage one exact root-owned checker inode", + "isolated launcher accepted changed program-index checker bytes", + "isolated launcher accepted changed root-owned checker bytes", + "isolation verification accepted changed root-owned checker bytes", + "isolated launcher accepted a replaced root-owned checker inode", + "isolation verification accepted a replaced root-owned checker inode", + "isolated cleanup left the protected checker or source aliases", + %q([ "$(/usr/bin/stat -c '%u:%g:%a:%h' "$5")" = "0:0:555:1" ]), + "HOMEBREW_KANDELO_XTASK_BIN=caller-poison", + "WASM_POSIX_XTASK_BIN=caller-poison", + "build-deps program-index-context-check", + '--source-repo-root "$2"', + "assert_real_relocated_xtask_uses_source_alias", + '"--property=BindReadOnlyPaths=$REPO_ROOT:$source_alias"', + '"--property=InaccessiblePaths=$REPO_ROOT"', + '--source-repo-root "$source_alias"', + 'global package build input \"flake.nix\" not found', + ].each do |fragment| + check(launcher_test.include?(fragment), + "launcher checker regression lacks #{fragment}") + end check(launcher_test.include?("assert-protected-gnu-tar") && launcher_test.include?('[ ! -w "$2" ] && [ ! -w "${2%/*}" ]'), "launcher regression does not exercise GNU tar as the dedicated Formula identity") @@ -2659,22 +3255,117 @@ def check_publisher(workflow) check(launcher.include?(fragment), "native bridge cleanup retry contract lacks #{fragment}") end + checker_derivation_index = bottle_builder.index( + 'XTASK_BIN="$KANDELO_ROOT/target/$HOST_TARGET/release/xtask"' + ) + checker_match_index = bottle_builder.index( + '[ "${WASM_POSIX_XTASK_BIN:-}" != "$XTASK_BIN" ]', + checker_derivation_index || 0 + ) + checker_export_index = bottle_builder.index( + "export WASM_POSIX_XTASK_BIN", checker_match_index || 0 + ) + checker_isolate_index = bottle_builder.index( + 'homebrew_patched_launcher_isolate "$BUILD_USER"', + checker_export_index || 0 + ) + check(checker_derivation_index && checker_match_index && + checker_export_index && checker_isolate_index && + checker_derivation_index < checker_match_index && + checker_match_index < checker_export_index && + checker_export_index < checker_isolate_index && + bottle_builder.include?( + "scoped program-index checker differs from the exact host xtask" + ), + "reviewed bottle builder does not bind the scoped checker to its host target") + publisher_test = File.read( + File.join(REPO_ROOT, "scripts/test-homebrew-publish-workflow.sh") + ) + check(publisher_test.include?( + "another safe target-triple checker" + ) && publisher_test.include?( + "mismatched checker authority" + ) && publisher_test.include?( + "isolated bottle verifier accepted another safe target-triple checker" + ) && publisher_test.include?( + "isolated bottle verifier did not explain the mismatched checker authority" + ), + "publisher regressions do not reject a second safe checker identity") teardown_index = bottle_builder.index('homebrew_patched_launcher_teardown "$BUILD_USER"') artifact_index = bottle_builder.index("mapfile -t bottle_jsons") check(teardown_index && artifact_index && teardown_index < artifact_index, "reviewed bottle builder reads artifacts before Formula process teardown") runtime_step = named_step(build_steps, "Materialize Formula test platform runtime") - check(runtime_step.keys.sort == %w[name run shell] && runtime_step["shell"] == "bash", + check(runtime_step.keys.sort == %w[id name run shell] && + runtime_step["id"] == "formula-runtime" && + runtime_step["shell"] == "bash", "publisher Formula test runtime mapping changed") runtime_run = runtime_step.fetch("run") [ "bash scripts/dev-shell.sh bash -c", 'host="$(rustc -vV | sed -n "s/^host: //p")"', - "for package in dash coreutils grep sed rootfs", 'cargo run --release -p xtask --target "$host" --quiet --', + 'cargo build --release -p xtask --target "$host" --quiet', + 'xtask="$PWD/target/$host/release/xtask"', + '[ -f "$xtask" ] && [ ! -L "$xtask" ] && [ -x "$xtask" ]', + '[ "$(realpath -- "$xtask")" = "$xtask" ]', + 'bash scripts/seal-homebrew-formula-checker.sh', + '--root "$PWD"', '--checker "$xtask"', + '[ "$sealed_xtask" = "$xtask" ]', + 'printf "xtask-bin=%s\\n" "$xtask" >>"$GITHUB_OUTPUT"', + "for package in dash coreutils grep sed rootfs", '"$xtask"', "build-deps --arch wasm32", '--binaries-dir "$PWD/binaries"', '--fetch-only resolve "$package"', 'bash scripts/materialize-resolver-binaries.sh "$PWD/binaries"', ].each do |fragment| check(runtime_run.include?(fragment), "publisher Formula test runtime lacks #{fragment}") end + check(!runtime_run.include?("cargo run") && !runtime_run.include?("GITHUB_ENV"), + "publisher Formula test checker is rebuilt or leaked job-wide") + dev_shell = File.read(File.join(REPO_ROOT, "scripts/dev-shell.sh")) + check(!dev_shell.include?("WASM_POSIX_XTASK_BIN"), + "dev shell makes the Formula checker a global package-toolchain input") + check(publisher_test.include?( + "assert_exact_source_program_projection_is_fresh" + ) && publisher_test.include?( + 'WASM_POSIX_DEPS_REGISTRY="$REPO_ROOT/packages/registry"' + ) && publisher_test.include?( + "Formula checker handoff made the exact-source program projection stale" + ), + "publisher regression does not protect exact-source program cache keys") + checker_sealer = File.read( + File.join(REPO_ROOT, "scripts/seal-homebrew-formula-checker.sh") + ) + [ + 'set -euo pipefail', + '[ "$(realpath -- "$ROOT" 2>/dev/null || true)" != "$ROOT" ]', + '[ "$(realpath -- "$CHECKER" 2>/dev/null || true)" != "$CHECKER" ]', + '"$ROOT"/target/*/release/xtask', + '[ $((8#$source_mode & 06022)) -ne 0 ]', + 'sealed="$CHECKER.formula-seal"', + '[ -e "$sealed" ] || [ -L "$sealed" ]', + "Cargo hard-links target//release/xtask", + 'install -m 0555 -- "$CHECKER" "$sealed"', + '[ "$sealed_mode" != "555" ] || [ "$sealed_links" != "1" ]', + '[ "$sealed_sha256" != "$source_sha256" ]', + 'mv -f -- "$sealed" "$CHECKER"', + '[ "$final_mode" != "555" ] || [ "$final_links" != "1" ]', + '[ "$final_sha256" != "$source_sha256" ]', + ].each do |fragment| + check(checker_sealer.include?(fragment), + "Formula checker sealer lacks #{fragment}") + end + checker_sealer_test = File.read( + File.join(REPO_ROOT, "scripts/test-seal-homebrew-formula-checker.sh") + ) + check( + publisher_test.include?( + 'bash "$REPO_ROOT/scripts/test-seal-homebrew-formula-checker.sh"' + ) && + checker_sealer_test.include?("fixture does not model Cargo's Linux hardlink") && + checker_sealer_test.include?("sealed checker still aliases Cargo's deps artifact") && + checker_sealer_test.include?("Cargo's alternate path can mutate the sealed checker") && + checker_sealer_test.include?("sealer accepted a writable source checker") && + checker_sealer_test.include?("sealer overwrote an occupied seal destination"), + "publisher regressions do not prove Cargo hardlink detachment" + ) materializer = File.read(File.join(REPO_ROOT, "scripts/materialize-resolver-binaries.sh")) [ 'cp -aLx -- "$source_dir" "$staged"', @@ -3343,17 +4034,23 @@ def check_publisher(workflow) check(index_verify_run.scan(repository_remote).length == 1 && !index_verify_run.include?('remote="ghcr.io/${tap_name}/'), "publisher public Homebrew index verification is not repository-rooted") + browser_sysroot_condition = + "${{ matrix.arch == 'wasm32' && ((matrix.formula == 'file-formula' && " \ + "inputs.prepublication-staging-tag == '') || " \ + "(!inputs.defer-vfs-acceptance-until-postpublication && !inputs.dry-run && " \ + "matrix.formula == needs.plan.outputs.vfs-acceptance-formula)) }}" browser_graph_condition = "${{ matrix.arch == 'wasm32' && (matrix.formula == 'file-formula' || " \ - "(!inputs.dry-run && matrix.formula == needs.plan.outputs.vfs-acceptance-formula)) }}" + "(!inputs.defer-vfs-acceptance-until-postpublication && !inputs.dry-run && " \ + "matrix.formula == needs.plan.outputs.vfs-acceptance-formula)) }}" browser_sysroot_step = named_step( verify_steps, "Build Kandelo wasm64 sysroot for the interactive browser graph" ) check(browser_sysroot_step.keys.sort == %w[if name run shell] && browser_sysroot_step["shell"] == "bash" && - browser_sysroot_step["if"] == browser_graph_condition, + browser_sysroot_step["if"] == browser_sysroot_condition, "publisher interactive browser wasm64 sysroot is not scoped to " \ - "file-formula or the exact wasm32 VFS acceptance entry") + "normal file-formula or the exact wasm32 VFS acceptance entry") browser_sysroot_run = browser_sysroot_step.fetch("run") [ "set -euo pipefail", "cd kandelo-sysroot-build", @@ -3367,13 +4064,20 @@ def check_publisher(workflow) "publisher builds the browser wasm64 sysroot in the reviewed verifier checkout") browser_demo_step = named_step(verify_steps, "Prepare the supported interactive browser demo graph") - check(browser_demo_step.keys.sort == %w[if name run shell] && + check(browser_demo_step.keys.sort == %w[env if name run shell] && browser_demo_step["shell"] == "bash" && - browser_demo_step["if"] == browser_graph_condition, + browser_demo_step["if"] == browser_graph_condition && + browser_demo_step["env"] == { + "KANDELO_HOMEBREW_FORMULA" => "${{ matrix.formula }}", + "PREPUBLICATION_STAGING_TAG" => "${{ inputs.prepublication-staging-tag }}", + }, "publisher interactive browser graph is not scoped to file-formula or the exact " \ "wasm32 VFS acceptance entry") browser_demo_run = browser_demo_step.fetch("run") [ + 'if [ -n "$PREPUBLICATION_STAGING_TAG" ] &&', + '[ "$KANDELO_HOMEBREW_FORMULA" = "file-formula" ]', + "bash scripts/dev-shell.sh ./run.sh build host", "for sysroot_name in sysroot sysroot64", 'sysroot_source="$GITHUB_WORKSPACE/kandelo-sysroot-build/$sysroot_name"', 'sysroot_destination="$GITHUB_WORKSPACE/kandelo/$sysroot_name"', @@ -3387,18 +4091,30 @@ def check_publisher(workflow) check(browser_demo_run.include?(fragment), "publisher file-formula verification browser graph lacks #{fragment}") end - check(!browser_demo_run.include?("scripts/fetch-binaries.sh"), + check(browser_demo_run.scan("./run.sh build host").length == 1 && + browser_demo_run.scan("./run.sh --fetch-only prepare-browser").length == 1 && + !browser_demo_run.include?("DEFER_VFS_ACCEPTANCE") && + !browser_demo_run.include?("scripts/fetch-binaries.sh"), "publisher file-formula verification bypasses the supported browser package selection") verifier_runtime_step = named_step(verify_steps, "Materialize Formula verification platform runtime") - check(verifier_runtime_step.keys.sort == %w[name run shell] && + check(verifier_runtime_step.keys.sort == %w[id name run shell] && + verifier_runtime_step["id"] == "formula-verification-runtime" && verifier_runtime_step["shell"] == "bash", "publisher Formula verification runtime mapping changed") verifier_runtime_run = verifier_runtime_step.fetch("run") [ "bash scripts/dev-shell.sh bash -c", 'host="$(rustc -vV | sed -n "s/^host: //p")"', + 'cargo build --release -p xtask --target "$host" --quiet', + 'xtask="$PWD/target/$host/release/xtask"', + '[ -f "$xtask" ] && [ ! -L "$xtask" ] && [ -x "$xtask" ]', + '[ "$(realpath -- "$xtask")" = "$xtask" ]', + 'bash scripts/seal-homebrew-formula-checker.sh', + '--root "$PWD"', '--checker "$xtask"', + '[ "$sealed_xtask" = "$xtask" ]', + 'printf "xtask-bin=%s\\n" "$xtask" >>"$GITHUB_OUTPUT"', "for package in dash coreutils grep sed rootfs", - 'cargo run --release -p xtask --target "$host" --quiet --', + '"$xtask"', "build-deps --arch wasm32", '--binaries-dir "$PWD/binaries"', '--fetch-only resolve "$package"', 'bash scripts/materialize-resolver-binaries.sh "$PWD/binaries"', @@ -3406,6 +4122,9 @@ def check_publisher(workflow) check(verifier_runtime_run.include?(fragment), "publisher Formula verification runtime lacks #{fragment}") end + check(!verifier_runtime_run.include?("cargo run") && + !verifier_runtime_run.include?("GITHUB_ENV"), + "publisher Formula verification checker is rebuilt or leaked job-wide") sidecar_run = named_step(verify_steps, "Generate sidecars from the selected bottle").fetch("run") check(sidecar_run.include?('KANDELO_HOMEBREW_BOTTLE_ARCHIVE="$RUNTIME_BOTTLE"') && @@ -3499,17 +4218,46 @@ def check_publisher(workflow) !source.include?("homebrew-patched-launcher"), "post-build verifier evaluates Formula Ruby through Homebrew") end - browser_run = named_step(verify_steps, - "Build and strictly smoke the file-formula browser image").fetch("run") + browser_step = named_step( + verify_steps, "Build and strictly smoke the file-formula browser image" + ) + check(browser_step.keys.sort == %w[env if name run shell] && + browser_step["env"] == { + "HOMEBREW_BREW_COMMIT" => BREW_COMMIT, + "PREPUBLICATION_STAGING_TAG" => "${{ inputs.prepublication-staging-tag }}", + }, "publisher strict browser smoke input mapping changed") + browser_run = browser_step.fetch("run") browser_test_title = "Homebrew file-formula VFS image boots in browser and runs file --version" browser_test_selector = "#{browser_test_title}$" + focused_browser_test_title = + "the exact dependency-bearing Brewfile VFS boots in Chromium" + focused_browser_test_selector = "#{focused_browser_test_title}$" browser_test_source = File.read( File.join(REPO_ROOT, "apps/browser-demos/test/kandelo-homebrew.spec.ts") ) + focused_browser_test_source = File.read( + File.join(REPO_ROOT, "apps/browser-demos/test/homebrew-brewfile-vfs.spec.ts") + ) [ "bash -s <<'KANDELO_HOMEBREW_BROWSER_SMOKE'", "KANDELO_HOMEBREW_STRICT_PUBLISHER_SMOKE=1", + 'PREPUBLICATION_STAGING_TAG="$PREPUBLICATION_STAGING_TAG"', + 'if [ -n "$PREPUBLICATION_STAGING_TAG" ]; then', + 'kernel_wasm="$GITHUB_WORKSPACE/kandelo/local-binaries/kernel.wasm"', + '[ -f "$browser_vfs" ] && [ ! -L "$browser_vfs" ]', + '[ -f "$kernel_wasm" ] && [ ! -L "$kernel_wasm" ]', + 'image_sha256="$(sha256sum "$browser_vfs"', + 'kernel_sha256="$(sha256sum "$kernel_wasm"', + "test test/homebrew-brewfile-vfs.spec.ts", + "KANDELO_BROWSER_DEMO_INPUTS=homebrew-vfs-test", + 'KANDELO_HOMEBREW_ACCEPTANCE_VFS_URL="$browser_url"', + 'KANDELO_HOMEBREW_ACCEPTANCE_VFS_SHA256="$image_sha256"', + 'KANDELO_HOMEBREW_ACCEPTANCE_KERNEL_SHA256="$kernel_sha256"', + "KANDELO_HOMEBREW_ACCEPTANCE_EXECUTABLE=/home/linuxbrew/.linuxbrew/bin/file", + %q{KANDELO_HOMEBREW_ACCEPTANCE_ARGV_JSON='["file","--version"]'}, + "KANDELO_HOMEBREW_ACCEPTANCE_EXPECTED_STDOUT=file-5.45", + "test test/kandelo-homebrew.spec.ts", 'KANDELO_HOMEBREW_BUILD_ROOT="$GITHUB_WORKSPACE/kandelo-sysroot-build"', "'{schema: 1, formula: $formula, arch: $arch,", "KANDELO_HOMEBREW_BROWSER_SMOKE\n", @@ -3521,6 +4269,11 @@ def check_publisher(workflow) check(browser_test_source.include?(%{test("#{browser_test_title}", async}) && browser_run.include?(%{--grep "#{browser_test_selector}"}), "publisher strict browser smoke does not select its exact fully qualified Playwright test") + check( + focused_browser_test_source.include?(%{test("#{focused_browser_test_title}", async}) && + browser_run.include?(%{--grep "#{focused_browser_test_selector}"}), + "publisher sealed browser smoke does not select its exact focused Playwright test" + ) check(!browser_run.include?("bash -c '"), "publisher strict browser smoke exposes its inner script to outer shell expansion") check(browser_run.scan("npx playwright install chromium --with-deps").length == 1 && @@ -3539,7 +4292,9 @@ def check_publisher(workflow) acceptance_step = named_step( verify_steps, "Boot an exact dependency-bearing Brewfile image on Node and Chromium" ) - check(acceptance_step.keys.sort == %w[env name run shell] && + check(acceptance_step.keys.sort == %w[env if name run shell] && + acceptance_step["if"] == + "${{ !inputs.defer-vfs-acceptance-until-postpublication }}" && acceptance_step["shell"] == "bash" && acceptance_step["env"] == { "KANDELO_HOMEBREW_ACCEPTANCE_ARCH" => "${{ matrix.arch }}", "KANDELO_HOMEBREW_ACCEPTANCE_DRY_RUN" => "${{ inputs.dry-run }}", @@ -4468,6 +5223,87 @@ def self_test(publisher, maintenance, repository_canary) ), fingerprint_source) end + freeze_generation_source = File.read( + File.join(REPO_ROOT, ".github/scripts/freeze-homebrew-prepublication-generation.sh") + ) + activate_generation_source = File.read( + File.join(REPO_ROOT, ".github/scripts/activate-homebrew-prepublication-generation.sh") + ) + staging_validation_source = File.read( + File.join(REPO_ROOT, ".github/scripts/validate-staging-release.sh") + ) + expect_rejection("prepublication generation admits entries outside the rootfs closure") do + check_prepublication_generation_helpers( + freeze_generation_source.sub( + '.entries |= map(select(.arch == "wasm32" and ' \ + '(.package as $name | $packages | index($name))))', + ".entries |= ." + ), + activate_generation_source, + staging_validation_source + ) + end + expect_rejection("prepublication generation omits its exact entry-count gate") do + check_prepublication_generation_helpers( + freeze_generation_source.sub( + '[ "$archive_url_count" = "$package_count" ]', + "true" + ), + activate_generation_source, + staging_validation_source + ) + end + expect_rejection("prepublication generation exposes credentials to pure xtask calls") do + check_prepublication_generation_helpers( + freeze_generation_source.sub( + "env -u GH_TOKEN -u GITHUB_TOKEN", + "env" + ), + activate_generation_source, + staging_validation_source + ) + end + expect_rejection("staging release validation exposes credentials to pure xtask calls") do + check_prepublication_generation_helpers( + freeze_generation_source, + activate_generation_source, + staging_validation_source.sub( + "env -u GH_TOKEN -u GITHUB_TOKEN", + "env" + ) + ) + end + expect_rejection("sealed generation manifest binds only the index") do + check_prepublication_generation_helpers( + freeze_generation_source.sub( + "for name in index.toml projection.json expected-ledger.json snapshot.json assets.json", + "for name in index.toml" + ), + activate_generation_source, + staging_validation_source + ) + end + expect_rejection("sealed generation activation skips cross-ledger identities") do + check_prepublication_generation_helpers( + freeze_generation_source, + activate_generation_source.sub( + 'cmp "$identity_root/projection" "$identity_root/expected"', + "true" + ), + staging_validation_source + ) + end + expect_rejection("sealed generation activation skips release asset identities") do + check_prepublication_generation_helpers( + freeze_generation_source, + activate_generation_source.sub( + '.digest == ("sha256:" + $entry.archive_sha256)', + "true" + ), + staging_validation_source + ) + end + publisher_mutations = { "top-level environment injection" => lambda { |w| w["env"] = { "BASH_ENV" => "/tmp/backdoor" } }, "workflow write permission" => lambda { |w| w["permissions"] = "write-all" }, @@ -4482,6 +5318,46 @@ def self_test(publisher, maintenance, repository_canary) "UNREVIEWED_TOKEN" => { "required" => false }, } }, + "missing frozen-index setup in build" => lambda { |w| + w.fetch("jobs").fetch("build-and-test").fetch("steps").reject! do |step| + step["name"] == "Activate sealed prepublication package generation" + end + }, + "missing frozen-index setup in verify" => lambda { |w| + w.fetch("jobs").fetch("verify-bottle").fetch("steps").reject! do |step| + step["name"] == "Activate sealed prepublication package generation" + end + }, + "prepublication tooling receives a release credential" => lambda { |w| + step = mutate_named_step( + w, "plan", "Build prepublication index tooling without credentials" + ) + step["env"] = { "GH_TOKEN" => "${{ github.token }}" } + }, + "prepublication tooling compiled while holding the release credential" => lambda { |w| + step = mutate_named_step( + w, "plan", "Freeze exact prepublication package generation" + ) + step["run"] = "cargo build --release -p xtask\n#{step.fetch('run')}" + }, + "prepublication tooling host target is not revalidated" => lambda { |w| + step = mutate_named_step( + w, "plan", "Freeze exact prepublication package generation" + ) + step["run"] = step.fetch("run").sub( + '[[ "$HOST_TARGET" =~ ^[a-z0-9_]+(-[a-z0-9_.]+){2,}$ ]]', + "true" + ) + }, + "prepublication tooling host target expression enters the shell" => lambda { |w| + step = mutate_named_step( + w, "plan", "Freeze exact prepublication package generation" + ) + step["run"] = step.fetch("run").sub( + 'target/$HOST_TARGET/release/xtask', + 'target/${{ steps.prepublication-tooling.outputs.host-target }}/release/xtask' + ) + }, "package secret reaches finalizer" => lambda { |w| step = mutate_named_step( w, "finalize-tap", "Atomically compose and publish all sidecars under one tap state lock" @@ -4545,6 +5421,19 @@ def self_test(publisher, maintenance, repository_canary) '[ ! -e "$config_candidate" ]' ) }, + "deferred VFS acceptance verifier still runs" => lambda { |w| + mutate_named_step( + w, "verify-bottle", + "Boot an exact dependency-bearing Brewfile image on Node and Chromium" + ).delete("if") + }, + "deferred VFS release job still runs" => lambda { |w| + job = w.fetch("jobs").fetch("publish-vfs-release") + job["if"] = job.fetch("if").sub( + "!inputs.defer-vfs-acceptance-until-postpublication && ", + "" + ) + }, "VFS acceptance Brewfile symlink accepted during planning" => lambda { |w| step = mutate_named_step( w, "plan", "Validate dependency-bearing VFS acceptance selection" @@ -4722,6 +5611,94 @@ def self_test(publisher, maintenance, repository_canary) "Materialize Formula test platform runtime") step["run"] = step.fetch("run").sub("--fetch-only resolve", "resolve") }, + "Formula test checker build bypass" => lambda { |w| + step = mutate_named_step(w, "build-and-test", + "Materialize Formula test platform runtime") + step["run"] = step.fetch("run").sub( + 'cargo build --release -p xtask --target "$host" --quiet', "true" + ) + }, + "Formula test checker validation bypass" => lambda { |w| + step = mutate_named_step(w, "build-and-test", + "Materialize Formula test platform runtime") + step["run"] = step.fetch("run").sub('[ ! -L "$xtask" ]', "true") + }, + "Formula test checker single-link seal bypass" => lambda { |w| + step = mutate_named_step(w, "build-and-test", + "Materialize Formula test platform runtime") + step["run"] = step.fetch("run").sub( + "bash scripts/seal-homebrew-formula-checker.sh", + 'printf "%s\\n" "$xtask"' + ) + }, + "Formula test checker leaks job-wide" => lambda { |w| + step = mutate_named_step(w, "build-and-test", + "Materialize Formula test platform runtime") + step["run"] = step.fetch("run").sub("GITHUB_OUTPUT", "GITHUB_ENV") + }, + "Formula test checker output substitution" => lambda { |w| + step = mutate_named_step( + w, "build-and-test", + "Build and test Homebrew bottle without publisher credentials" + ) + step.fetch("env")["WASM_POSIX_XTASK_BIN"] = "/tmp/unreviewed-xtask" + }, + "Formula test checker command forwarding bypass" => lambda { |w| + step = mutate_named_step( + w, "build-and-test", + "Build and test Homebrew bottle without publisher credentials" + ) + step["run"] = step.fetch("run").sub( + 'WASM_POSIX_XTASK_BIN="$WASM_POSIX_XTASK_BIN"', + "WASM_POSIX_XTASK_BIN=" + ) + }, + "Formula verification checker build bypass" => lambda { |w| + step = mutate_named_step( + w, "verify-bottle", "Materialize Formula verification platform runtime" + ) + step["run"] = step.fetch("run").sub( + 'cargo build --release -p xtask --target "$host" --quiet', "true" + ) + }, + "Formula verification checker validation bypass" => lambda { |w| + step = mutate_named_step( + w, "verify-bottle", "Materialize Formula verification platform runtime" + ) + step["run"] = step.fetch("run").sub('[ ! -L "$xtask" ]', "true") + }, + "Formula verification checker single-link seal bypass" => lambda { |w| + step = mutate_named_step( + w, "verify-bottle", "Materialize Formula verification platform runtime" + ) + step["run"] = step.fetch("run").sub( + "bash scripts/seal-homebrew-formula-checker.sh", + 'printf "%s\\n" "$xtask"' + ) + }, + "Formula verification checker leaks job-wide" => lambda { |w| + step = mutate_named_step( + w, "verify-bottle", "Materialize Formula verification platform runtime" + ) + step["run"] = step.fetch("run").sub("GITHUB_OUTPUT", "GITHUB_ENV") + }, + "Formula verification checker output substitution" => lambda { |w| + step = mutate_named_step( + w, "verify-bottle", + "Force-pour and test the exact selected bottle without credentials" + ) + step.fetch("env")["WASM_POSIX_XTASK_BIN"] = "/tmp/unreviewed-xtask" + }, + "Formula verification checker command forwarding bypass" => lambda { |w| + step = mutate_named_step( + w, "verify-bottle", + "Force-pour and test the exact selected bottle without credentials" + ) + step["run"] = step.fetch("run").sub( + 'WASM_POSIX_XTASK_BIN="$WASM_POSIX_XTASK_BIN"', + "WASM_POSIX_XTASK_BIN=" + ) + }, "Formula test runtime cache-link materialization bypass" => lambda { |w| step = mutate_named_step(w, "build-and-test", "Materialize Formula test platform runtime") @@ -5021,6 +5998,33 @@ def self_test(publisher, maintenance, repository_canary) "./run.sh --fetch-only prepare-browser", "bash scripts/fetch-binaries.sh --fetch-only" ) }, + "sealed browser graph re-enters full demo preparation" => lambda { |w| + step = mutate_named_step( + w, "verify-bottle", "Prepare the supported interactive browser demo graph" + ) + step["run"] = step.fetch("run").sub( + "./run.sh build host", "./run.sh --fetch-only prepare-browser" + ) + }, + "sealed browser graph keys off VFS deferral instead of staging" => lambda { |w| + step = mutate_named_step( + w, "verify-bottle", "Prepare the supported interactive browser demo graph" + ) + step["run"] = step.fetch("run").sub( + '[ -n "$PREPUBLICATION_STAGING_TAG" ] &&', + '[ "$DEFER_VFS_ACCEPTANCE" = "true" ] &&' + ) + }, + "Formula runtime materialization follows browser preparation" => lambda { |w| + steps = w.fetch("jobs").fetch("verify-bottle").fetch("steps") + runtime_index = steps.index do |step| + step["name"] == "Materialize Formula verification platform runtime" + end + browser_index = steps.index do |step| + step["name"] == "Prepare the supported interactive browser demo graph" + end + steps[runtime_index], steps[browser_index] = steps[browser_index], steps[runtime_index] + }, "VFS acceptance interactive browser graph omitted" => lambda { |w| step = mutate_named_step( w, "verify-bottle", "Prepare the supported interactive browser demo graph" @@ -5077,6 +6081,42 @@ def self_test(publisher, maintenance, repository_canary) step["run"] = step.fetch("run").sub("KANDELO_HOMEBREW_STRICT_PUBLISHER_SMOKE=1", "KANDELO_HOMEBREW_STRICT_PUBLISHER_SMOKE=0") }, + "sealed browser smoke omits the focused Vite graph" => lambda { |w| + step = mutate_named_step( + w, "verify-bottle", "Build and strictly smoke the file-formula browser image" + ) + step["run"] = step.fetch("run").sub( + "KANDELO_BROWSER_DEMO_INPUTS=homebrew-vfs-test", + "KANDELO_BROWSER_DEMO_INPUTS=kandelo" + ) + }, + "sealed browser smoke omits the exact VFS digest" => lambda { |w| + step = mutate_named_step( + w, "verify-bottle", "Build and strictly smoke the file-formula browser image" + ) + step["run"] = step.fetch("run").sub( + 'KANDELO_HOMEBREW_ACCEPTANCE_VFS_SHA256="$image_sha256"', + "KANDELO_HOMEBREW_ACCEPTANCE_VFS_SHA256=unchecked" + ) + }, + "sealed browser smoke omits the exact kernel digest" => lambda { |w| + step = mutate_named_step( + w, "verify-bottle", "Build and strictly smoke the file-formula browser image" + ) + step["run"] = step.fetch("run").sub( + 'KANDELO_HOMEBREW_ACCEPTANCE_KERNEL_SHA256="$kernel_sha256"', + "KANDELO_HOMEBREW_ACCEPTANCE_KERNEL_SHA256=unchecked" + ) + }, + "sealed browser smoke changes the exact command" => lambda { |w| + step = mutate_named_step( + w, "verify-bottle", "Build and strictly smoke the file-formula browser image" + ) + step["run"] = step.fetch("run").sub( + %q{KANDELO_HOMEBREW_ACCEPTANCE_ARGV_JSON='["file","--version"]'}, + %q{KANDELO_HOMEBREW_ACCEPTANCE_ARGV_JSON='["file","--help"]'} + ) + }, "file-formula Chromium provisioning reentered the dev shell" => lambda { |w| step = mutate_named_step(w, "verify-bottle", "Build and strictly smoke the file-formula browser image") step["run"] = step.fetch("run").sub( diff --git a/scripts/homebrew-bottle-build.sh b/scripts/homebrew-bottle-build.sh index 650cb9bbaa..836954e15c 100755 --- a/scripts/homebrew-bottle-build.sh +++ b/scripts/homebrew-bottle-build.sh @@ -291,6 +291,16 @@ if [ -z "$HOST_TARGET" ] || [ ! -f "$XTASK_BIN" ] || [ -L "$XTASK_BIN" ] || echo "homebrew-bottle-build.sh: exact prebuilt release xtask is unavailable" >&2 exit 2 fi +# WHY: the workflow-scoped variable crosses the dev-shell boundary, while +# HOST_TARGET is derived independently inside it. Requiring both authorities +# to name the same binary prevents another safe-looking target directory from +# selecting the package-policy checker used by isolated Formula tests. +if [ -n "$BUILD_USER" ] && [ "${WASM_POSIX_XTASK_BIN:-}" != "$XTASK_BIN" ]; then + echo "homebrew-bottle-build.sh: scoped program-index checker differs from the exact host xtask" >&2 + exit 2 +fi +WASM_POSIX_XTASK_BIN="$XTASK_BIN" +export WASM_POSIX_XTASK_BIN ruby "$KANDELO_ROOT/scripts/homebrew-formula-runtime-closure.rb" \ "$TAP_ROOT" "$TAP_NAME" "$FORMULA" --tier2-bridge-json \ >"$TIER2_BRIDGE_PLAN" diff --git a/scripts/homebrew-patched-launcher.sh b/scripts/homebrew-patched-launcher.sh index 9cd5f2c5c2..e78f974cab 100644 --- a/scripts/homebrew-patched-launcher.sh +++ b/scripts/homebrew-patched-launcher.sh @@ -10,6 +10,9 @@ HOMEBREW_PATCHED_LAUNCHER="" HOMEBREW_PATCHED_BREW_BIN="" HOMEBREW_PATCHED_PROTECTED_DIR="" HOMEBREW_PATCHED_SOURCE_ALIAS_DIR="" +HOMEBREW_PATCHED_PROTECTED_XTASK="" +HOMEBREW_PATCHED_PROTECTED_XTASK_STATE="" +HOMEBREW_PATCHED_PROTECTED_XTASK_SHA256="" HOMEBREW_PATCHED_INTEGRITY_SHA256="" HOMEBREW_PATCHED_OVERLAY_OWNER_UID="" HOMEBREW_PATCHED_OVERLAY_SEAL_STATE="" @@ -63,6 +66,36 @@ homebrew_patched_launcher_integrity() { } | homebrew_sha256_stream } +homebrew_patched_launcher_verify_protected_xtask() { + if [ -z "$HOMEBREW_PATCHED_PROTECTED_XTASK" ] && \ + [ -z "$HOMEBREW_PATCHED_PROTECTED_XTASK_STATE" ] && \ + [ -z "$HOMEBREW_PATCHED_PROTECTED_XTASK_SHA256" ]; then + return 0 + fi + if [ -z "$HOMEBREW_PATCHED_PROTECTED_DIR" ] || \ + [ "$HOMEBREW_PATCHED_PROTECTED_XTASK" != \ + "$HOMEBREW_PATCHED_PROTECTED_DIR/xtask" ] || \ + [ -z "$HOMEBREW_PATCHED_PROTECTED_XTASK_STATE" ] || \ + [ -z "$HOMEBREW_PATCHED_PROTECTED_XTASK_SHA256" ]; then + echo "homebrew-patched-launcher: protected program-index checker state is incomplete" >&2 + return 2 + fi + + local actual_sha256 + actual_sha256="$(/usr/bin/sha256sum \ + "$HOMEBREW_PATCHED_PROTECTED_XTASK" 2>/dev/null || true)" + actual_sha256="${actual_sha256%% *}" + if [ ! -f "$HOMEBREW_PATCHED_PROTECTED_XTASK" ] || \ + [ -L "$HOMEBREW_PATCHED_PROTECTED_XTASK" ] || \ + [ "$(/usr/bin/stat -c '%d:%i:%u:%g:%a:%h:%s' \ + "$HOMEBREW_PATCHED_PROTECTED_XTASK" 2>/dev/null || true)" != \ + "$HOMEBREW_PATCHED_PROTECTED_XTASK_STATE" ] || \ + [ "$actual_sha256" != "$HOMEBREW_PATCHED_PROTECTED_XTASK_SHA256" ]; then + echo "homebrew-patched-launcher: root-owned program-index checker changed after isolation" >&2 + return 1 + fi +} + homebrew_patched_launcher_snapshot_target_cellar_layout() { if [ "$#" -ne 0 ]; then echo "homebrew_patched_launcher_snapshot_target_cellar_layout: expected no arguments" >&2 @@ -856,6 +889,10 @@ homebrew_patched_launcher_cleanup() { return "$teardown_status" fi fi + if ! homebrew_patched_launcher_verify_protected_xtask; then + echo "homebrew-patched-launcher: protected checker changed; preserving launcher state for inspection" >&2 + return 1 + fi if ! homebrew_patched_launcher_remove_staged_input; then echo "homebrew-patched-launcher: protected input remains; preserving launcher state for retry" >&2 return 1 @@ -884,13 +921,24 @@ homebrew_patched_launcher_cleanup() { return 1 fi if [ -n "$HOMEBREW_PATCHED_PROTECTED_DIR" ]; then - "$HOMEBREW_PATCHED_SUDO_BIN" rm -rf "$HOMEBREW_PATCHED_PROTECTED_DIR" \ - >/dev/null 2>&1 || true + if ! "$HOMEBREW_PATCHED_SUDO_BIN" rm -rf "$HOMEBREW_PATCHED_PROTECTED_DIR" \ + >/dev/null 2>&1 || [ -e "$HOMEBREW_PATCHED_PROTECTED_DIR" ] || \ + [ -L "$HOMEBREW_PATCHED_PROTECTED_DIR" ]; then + echo "homebrew-patched-launcher: protected launcher state could not be removed; preserving cleanup state for retry" >&2 + return 1 + fi HOMEBREW_PATCHED_PROTECTED_DIR="" + HOMEBREW_PATCHED_PROTECTED_XTASK="" + HOMEBREW_PATCHED_PROTECTED_XTASK_STATE="" + HOMEBREW_PATCHED_PROTECTED_XTASK_SHA256="" 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 + if ! "$HOMEBREW_PATCHED_SUDO_BIN" rm -rf "$HOMEBREW_PATCHED_SOURCE_ALIAS_DIR" \ + >/dev/null 2>&1 || [ -e "$HOMEBREW_PATCHED_SOURCE_ALIAS_DIR" ] || \ + [ -L "$HOMEBREW_PATCHED_SOURCE_ALIAS_DIR" ]; then + echo "homebrew-patched-launcher: source aliases could not be removed; preserving cleanup state for retry" >&2 + return 1 + fi HOMEBREW_PATCHED_SOURCE_ALIAS_DIR="" fi if [ -n "$HOMEBREW_PATCHED_LAUNCHER" ] && [ -L "$HOMEBREW_PATCHED_LAUNCHER" ]; then @@ -1641,7 +1689,7 @@ homebrew_patched_launcher_isolate() { local build_user="$1" work_dir="$2" kandelo_root="$3" tap_root="$4" output_root="$5" local sysroot_build_root="$6" sysroot shift 6 - local build_group build_home protected_brew protected_audit + local build_group build_home protected_brew protected_audit protected_xtask local wrapper_source wrapper_path audit_source native_runner_source native_runner_path local mutable_root protected_root target_state_root native_reported_prefix native_reported_repo local physical_repo physical_prefix @@ -1650,7 +1698,11 @@ homebrew_patched_launcher_isolate() { local build_uid systemd_slice unit_prefix source_alias_dir local config_root config_file unsafe_config_entry trust_file trust_lock local primary_tap_root primary_tap_owner_root taps_root + local xtask_bin xtask_relative xtask_alias xtask_mode xtask_links + local xtask_uid xtask_state xtask_sha256 xtask_state_after xtask_sha256_after + local xtask_alias_state xtask_alias_sha256 local -a preserved_variables native_preserved_variables mutable_roots + local -a xtask_path_parts local -a additional_protected_roots=("$@") if [ -n "$HOMEBREW_PATCHED_NATIVE_PREFIX" ]; then @@ -1735,7 +1787,8 @@ homebrew_patched_launcher_isolate() { "$build_user" /usr/bin/realpath /usr/bin/realpath realpath homebrew_assert_protected_host_executable \ "$build_user" /usr/bin/bash /usr/bin/bash bash - for protected_bin in chmod chown cmp cp id install ln ls mktemp readlink rm stat test; do + for protected_bin in chmod chown cmp cp id install ln ls mktemp readlink rm \ + sha256sum stat test; do homebrew_assert_protected_host_executable \ "$build_user" "/usr/bin/$protected_bin" "/usr/bin/$protected_bin" "$protected_bin" done @@ -1851,6 +1904,71 @@ homebrew_patched_launcher_isolate() { ;; esac done + [ "$(cd "$kandelo_root" && pwd -P)" = "$kandelo_root" ] || { + echo "homebrew-patched-launcher: Kandelo root must be one exact canonical checkout" >&2 + return 2 + } + + # WHY: WASM_POSIX_XTASK_BIN is caller-controlled until this boundary. Only + # Cargo's exact release output below the reviewed checkout may become the + # source-projection authority; accepting a cache, symlink, or adjacent tool + # would let Formula evaluation select different package policy code. + xtask_bin="${WASM_POSIX_XTASK_BIN:-}" + if [ -z "$xtask_bin" ] || [ "${xtask_bin#/}" = "$xtask_bin" ] || \ + [ ! -f "$xtask_bin" ] || [ -L "$xtask_bin" ] || [ ! -x "$xtask_bin" ] || \ + [ "$(/usr/bin/realpath -- "$xtask_bin" 2>/dev/null || true)" != "$xtask_bin" ]; then + echo "homebrew-patched-launcher: prepared program-index checker must be one exact regular executable" >&2 + return 2 + fi + case "$xtask_bin" in + "$kandelo_root"/*) xtask_relative="${xtask_bin#"$kandelo_root"/}" ;; + *) + echo "homebrew-patched-launcher: prepared program-index checker is outside the exact Kandelo root" >&2 + return 2 + ;; + esac + IFS=/ read -r -a xtask_path_parts <<<"$xtask_relative" + if [ "${#xtask_path_parts[@]}" -ne 4 ] || \ + [ "${xtask_path_parts[0]}" != "target" ] || \ + ! [[ "${xtask_path_parts[1]}" =~ ^[A-Za-z0-9_.+-]+$ ]] || \ + [ "${xtask_path_parts[2]}" != "release" ] || \ + [ "${xtask_path_parts[3]}" != "xtask" ]; then + echo "homebrew-patched-launcher: program-index checker is not the prepared release xtask" >&2 + return 2 + fi + xtask_mode="$(/usr/bin/stat -c '%a' "$xtask_bin" 2>/dev/null || true)" + xtask_links="$(/usr/bin/stat -c '%h' "$xtask_bin" 2>/dev/null || true)" + xtask_uid="$(/usr/bin/stat -c '%u' "$xtask_bin" 2>/dev/null || true)" + if [ "$xtask_mode" != "555" ]; then + echo "homebrew-patched-launcher: prepared program-index checker has an unsafe mode" >&2 + return 2 + fi + if [ "$xtask_links" != "1" ]; then + echo "homebrew-patched-launcher: prepared program-index checker is not single-linked" >&2 + return 2 + fi + if [ "$xtask_uid" = "$build_uid" ]; then + echo "homebrew-patched-launcher: prepared program-index checker is owned by the Formula user" >&2 + return 2 + fi + if "$sudo_bin" -H -u "$build_user" -- /usr/bin/test -w "$xtask_bin"; then + echo "homebrew-patched-launcher: prepared program-index checker is writable by the Formula user" >&2 + return 2 + fi + # WHY: GitHub places the reviewed checkout below the workflow user's private + # home. Formula execution never reads that original path: systemd exposes the + # exact inode through the root-created read-only alias audited below, then + # makes the original Kandelo root inaccessible. Requiring direct Formula-user + # read access here rejects that secure runner layout without protecting the + # actual execution boundary. + homebrew_assert_tree_not_replaceable_by_user "$build_user" "$xtask_bin" || return + xtask_state="$(/usr/bin/stat -c '%d:%i:%u:%g:%a:%h:%s' "$xtask_bin")" || return 2 + xtask_sha256="$(/usr/bin/sha256sum "$xtask_bin")" || return 2 + xtask_sha256="${xtask_sha256%% *}" + [[ "$xtask_sha256" =~ ^[0-9a-f]{64}$ ]] || { + echo "homebrew-patched-launcher: could not seal the prepared program-index checker" >&2 + return 2 + } if [ -n "$HOMEBREW_PATCHED_NATIVE_PREFIX" ]; then for mutable_root in "$HOMEBREW_PATCHED_NATIVE_PREFIX" \ @@ -2015,11 +2133,37 @@ 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" + protected_xtask="$HOMEBREW_PATCHED_PROTECTED_DIR/xtask" + # WHY: a read-only bind preserves the source inode's uid. Stage the already + # validated bytes as one root-owned inode before Formula code runs so tap + # support can authenticate the checker without trusting a workflow-user uid. + "$sudo_bin" /usr/bin/install -o root -g root -m 0555 -- \ + "$xtask_bin" "$protected_xtask" + xtask_state_after="$(/usr/bin/stat -c '%d:%i:%u:%g:%a:%h:%s' "$xtask_bin")" || + return 2 + xtask_sha256_after="$(/usr/bin/sha256sum "$xtask_bin")" || return 2 + xtask_sha256_after="${xtask_sha256_after%% *}" + xtask_alias_state="$(/usr/bin/stat -c '%d:%i:%u:%g:%a:%h:%s' "$protected_xtask")" || + return 2 + xtask_alias_sha256="$(/usr/bin/sha256sum "$protected_xtask")" || return 2 + xtask_alias_sha256="${xtask_alias_sha256%% *}" + if [ "$xtask_state_after" != "$xtask_state" ] || \ + [ "$xtask_sha256_after" != "$xtask_sha256" ] || \ + [ "$xtask_alias_sha256" != "$xtask_sha256" ] || \ + [ "$(/usr/bin/stat -c '%u:%g:%a:%h' "$protected_xtask")" != "0:0:555:1" ] || \ + ! /usr/bin/cmp -s -- "$xtask_bin" "$protected_xtask"; then + echo "homebrew-patched-launcher: could not stage the root-owned program-index checker" >&2 + return 2 + fi + HOMEBREW_PATCHED_PROTECTED_XTASK="$protected_xtask" + HOMEBREW_PATCHED_PROTECTED_XTASK_STATE="$xtask_alias_state" + HOMEBREW_PATCHED_PROTECTED_XTASK_SHA256="$xtask_alias_sha256" 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" \ "$source_alias_dir/sysroot" HOMEBREW_PATCHED_SOURCE_ALIAS_DIR="$source_alias_dir" + xtask_alias="$source_alias_dir/kandelo/$xtask_relative" protected_brew="$HOMEBREW_PATCHED_PROTECTED_DIR/brew" "$sudo_bin" ln -s "$HOMEBREW_PATCHED_OVERLAY/bin/brew" "$protected_brew" @@ -2030,7 +2174,12 @@ homebrew_patched_launcher_isolate() { printf 'expected_kandelo=%q\n' "$source_alias_dir/kandelo" printf 'expected_tap=%q\n' "$source_alias_dir/tap" printf 'expected_sysroot=%q\n' "$source_alias_dir/sysroot" + printf 'expected_xtask=%q\n' "$xtask_alias" + printf 'expected_xtask_state=%q\n' "$xtask_alias_state" + printf 'expected_xtask_sha256=%q\n' "$xtask_alias_sha256" printf 'expected_primary_tap=%q\n' "$primary_tap_root" + printf 'actual_xtask_sha256="$(/usr/bin/sha256sum "$expected_xtask" 2>/dev/null || true)"\n' + printf 'actual_xtask_sha256="${actual_xtask_sha256%%%% *}"\n' 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' @@ -2042,6 +2191,22 @@ homebrew_patched_launcher_isolate() { printf 'if [ "${HOMEBREW_KANDELO_PRIMARY_TAP_ROOT:-}" != "$expected_primary_tap" ]; then\n' printf ' echo "homebrew-patched-launcher: isolated primary tap root changed" >&2\n' printf ' exit 2\nfi\n' + printf 'if [ "${WASM_POSIX_XTASK_BIN:-}" != "$expected_xtask" ] || ' + printf '[ "${HOMEBREW_KANDELO_XTASK_BIN:-}" != "$expected_xtask" ] || ' + printf '[ ! -f "$expected_xtask" ] || [ -L "$expected_xtask" ] || ' + printf '[ ! -r "$expected_xtask" ] || [ ! -x "$expected_xtask" ] || ' + printf '[ -w "$expected_xtask" ] || ' + printf '[ "$(/usr/bin/realpath -- "$expected_xtask")" != "$expected_xtask" ] || ' + printf '[ "$(/usr/bin/stat -c '\''%%d:%%i:%%u:%%g:%%a:%%h:%%s'\'' "$expected_xtask")" != "$expected_xtask_state" ] || ' + printf '[ "$actual_xtask_sha256" != "$expected_xtask_sha256" ]; then\n' + printf ' echo "homebrew-patched-launcher: protected program-index checker changed or is inaccessible" >&2\n' + printf ' exit 2\nfi\n' + printf 'xtask_mount_options="$(/usr/bin/findmnt --noheadings --output VFS-OPTIONS --target "$expected_xtask")" || {\n' + printf ' echo "homebrew-patched-launcher: could not inspect protected checker mount" >&2; exit 2;\n}\n' + printf 'case ",${xtask_mount_options// /}," in\n' + printf ' *,ro,*) ;;\n' + printf ' *) echo "homebrew-patched-launcher: protected checker mount is writable" >&2; exit 1 ;;\n' + printf 'esac\n' printf 'if [ ! -f "$expected_sysroot/lib/libc.a" ] || [ -L "$expected_sysroot/lib/libc.a" ]; then\n' printf ' echo "homebrew-patched-launcher: protected sysroot libc archive is invalid" >&2\n' printf ' exit 2\nfi\n' @@ -2115,6 +2280,30 @@ homebrew_patched_launcher_isolate() { ) { printf '#!/usr/bin/env bash\nset -euo pipefail\n' + printf 'xtask_path=%q\n' "$xtask_bin" + printf 'xtask_state=%q\n' "$xtask_state" + printf 'xtask_sha256=%q\n' "$xtask_sha256" + printf 'protected_xtask_path=%q\n' "$protected_xtask" + printf 'protected_xtask_state=%q\n' "$xtask_alias_state" + printf 'protected_xtask_sha256=%q\n' "$xtask_alias_sha256" + printf 'actual_xtask_sha256="$(/usr/bin/sha256sum "$xtask_path" 2>/dev/null || true)"\n' + printf 'actual_xtask_sha256="${actual_xtask_sha256%%%% *}"\n' + printf 'actual_protected_xtask_sha256="$(/usr/bin/sha256sum "$protected_xtask_path" 2>/dev/null || true)"\n' + printf 'actual_protected_xtask_sha256="${actual_protected_xtask_sha256%%%% *}"\n' + # WHY: the source checkout is trusted but workflow-owned. Rechecking its + # inode and bytes for every Formula entry prevents a later workflow step + # from silently turning the already-reviewed checker path into new code. + printf 'if [ ! -f "$xtask_path" ] || [ -L "$xtask_path" ] || [ ! -x "$xtask_path" ] || ' + printf '[ "$(/usr/bin/realpath -- "$xtask_path")" != "$xtask_path" ] || ' + printf '[ "$(/usr/bin/stat -c '\''%%d:%%i:%%u:%%g:%%a:%%h:%%s'\'' "$xtask_path")" != "$xtask_state" ] || ' + printf '[ "$actual_xtask_sha256" != "$xtask_sha256" ]; then\n' + printf ' echo "homebrew-patched-launcher: prepared program-index checker changed after isolation" >&2\n' + printf ' exit 2\nfi\n' + printf 'if [ ! -f "$protected_xtask_path" ] || [ -L "$protected_xtask_path" ] || ' + printf '[ "$(/usr/bin/stat -c '\''%%d:%%i:%%u:%%g:%%a:%%h:%%s'\'' "$protected_xtask_path")" != "$protected_xtask_state" ] || ' + printf '[ "$actual_protected_xtask_sha256" != "$protected_xtask_sha256" ]; then\n' + printf ' echo "homebrew-patched-launcher: root-owned program-index checker changed after isolation" >&2\n' + printf ' exit 2\nfi\n' printf 'bottle_tag_env=()\n' for variable in KANDELO_HOMEBREW_BOTTLE_TAG HOMEBREW_KANDELO_BOTTLE_TAG; do printf 'if [ -n "${%s+x}" ]; then bottle_tag_env+=("%s=${%s}"); fi\n' \ @@ -2135,6 +2324,7 @@ homebrew_patched_launcher_isolate() { "--property=KillMode=control-group" "--property=SendSIGKILL=yes" \ "--property=TimeoutStopSec=10s" "--property=NoNewPrivileges=yes" \ "--property=BindReadOnlyPaths=$kandelo_root:$source_alias_dir/kandelo" \ + "--property=BindReadOnlyPaths=$protected_xtask:$xtask_alias" \ "--property=BindReadOnlyPaths=$tap_root:$source_alias_dir/tap" \ "--property=BindReadOnlyPaths=$sysroot:$source_alias_dir/sysroot" \ "--property=BindReadOnlyPaths=$taps_root" \ @@ -2168,10 +2358,16 @@ homebrew_patched_launcher_isolate() { printf ' %q' "$variable=$value" fi done - printf ' %q %q %q %q' "HOMEBREW_KANDELO_ROOT=$source_alias_dir/kandelo" \ + # WHY: Homebrew preserves HOMEBREW_* variables across its Formula-test + # re-exec but rebuilds the ordinary environment. Give tap support a + # protected alias it can freeze, while direct resolver callers still get + # the conventional WASM_POSIX_XTASK_BIN name. + printf ' %q %q %q %q %q %q' "HOMEBREW_KANDELO_ROOT=$source_alias_dir/kandelo" \ "KANDELO_HOMEBREW_KANDELO_ROOT=$source_alias_dir/kandelo" \ "HOMEBREW_KANDELO_SYSROOT=$source_alias_dir/sysroot" \ - "WASM_POSIX_SYSROOT=$source_alias_dir/sysroot" + "WASM_POSIX_SYSROOT=$source_alias_dir/sysroot" \ + "HOMEBREW_KANDELO_XTASK_BIN=$xtask_alias" \ + "WASM_POSIX_XTASK_BIN=$xtask_alias" printf ' "${bottle_tag_env[@]}" "$command_path" "$@"\n' } >"$wrapper_source" "$sudo_bin" install -o root -g root -m 0555 "$wrapper_source" "$wrapper_path" @@ -2483,12 +2679,17 @@ homebrew_patched_launcher_teardown() { } homebrew_patched_launcher_verify_isolation() { - if [ -z "$HOMEBREW_PATCHED_PROTECTED_DIR" ] || [ -z "$HOMEBREW_PATCHED_INTEGRITY_SHA256" ]; then + if [ -z "$HOMEBREW_PATCHED_PROTECTED_DIR" ] || \ + [ -z "$HOMEBREW_PATCHED_PROTECTED_XTASK" ] || \ + [ -z "$HOMEBREW_PATCHED_PROTECTED_XTASK_STATE" ] || \ + [ -z "$HOMEBREW_PATCHED_PROTECTED_XTASK_SHA256" ] || \ + [ -z "$HOMEBREW_PATCHED_INTEGRITY_SHA256" ]; then echo "homebrew-patched-launcher: isolated execution was not initialized" >&2 return 2 fi homebrew_patched_launcher_verify_overlay_seal \ "$HOMEBREW_PATCHED_BUILD_USER" || return + homebrew_patched_launcher_verify_protected_xtask || return [ "$(homebrew_patched_launcher_integrity)" = "$HOMEBREW_PATCHED_INTEGRITY_SHA256" ] || { echo "homebrew-patched-launcher: patched Homebrew source changed during Formula execution" >&2 return 1 diff --git a/scripts/homebrew-verify-poured-bottle.sh b/scripts/homebrew-verify-poured-bottle.sh index d94aa2f6e9..2e53bd80cd 100755 --- a/scripts/homebrew-verify-poured-bottle.sh +++ b/scripts/homebrew-verify-poured-bottle.sh @@ -198,6 +198,25 @@ PUBLISHER_ISOLATION_PATCH_FILE="$KANDELO_ROOT/homebrew/patches/0002-support-isol # shellcheck source=/dev/null . "$KANDELO_ROOT/scripts/homebrew-patched-launcher.sh" homebrew_patched_launcher_select_host_git +if [ -n "$BUILD_USER" ]; then + HOST_TARGET="$(rustc -vV | sed -n 's/^host: //p')" + XTASK_BIN="$KANDELO_ROOT/target/$HOST_TARGET/release/xtask" + if [ -z "$HOST_TARGET" ] || [ ! -f "$XTASK_BIN" ] || [ -L "$XTASK_BIN" ] || + [ ! -x "$XTASK_BIN" ]; then + echo "homebrew-verify-poured-bottle.sh: exact prebuilt release xtask is unavailable" >&2 + exit 2 + fi + # WHY: the workflow-scoped path and the independently derived Rust host + # target must agree before the checker enters the Formula identity's + # read-only source alias. A second valid-looking release binary must not be + # able to choose which package projection the isolated test validates. + if [ "${WASM_POSIX_XTASK_BIN:-}" != "$XTASK_BIN" ]; then + echo "homebrew-verify-poured-bottle.sh: scoped program-index checker differs from the exact host xtask" >&2 + exit 2 + fi + WASM_POSIX_XTASK_BIN="$XTASK_BIN" + export WASM_POSIX_XTASK_BIN +fi OUT_PARENT="$(dirname "$OUT")" mkdir -p "$OUT_PARENT" diff --git a/scripts/resolve-binary.bundle.mjs b/scripts/resolve-binary.bundle.mjs index 7499490c33..7858f40b77 100644 --- a/scripts/resolve-binary.bundle.mjs +++ b/scripts/resolve-binary.bundle.mjs @@ -1,7 +1,7 @@ // Generated by scripts/build-resolve-binary-bundle.sh; do not edit. Third-party notices: resolve-binary.bundle.LICENSES.txt var hs=Object.defineProperty;var bn=(r,e,t)=>()=>{if(t)throw t[0];try{return r&&(e=r(r=0)),e}catch(n){throw t=[n],n}};var xr=(r,e)=>{for(var t in e)hs(r,t,{get:e[t],enumerable:!0})};import{createRequire as ho}from"module";function gi(r,e){return yi(r,{i:2},e&&e.out,e&&e.dictionary)}var yo,ft,go,po,J,ct,mo,ai,ci,wo,li,ft,fi,vo,ui,Eo,_c,Zn,be,M,Rt,Bt,M,M,M,M,di,M,So,zo,Gn,ge,Wn,hi,tn,bo,fe,yi,ko,xo,lt,pi,Io,Ao,Hn=bn(()=>{yo=ho("/");try{ft=yo("worker_threads"),go=ft.Worker,po=ft.isMarkedAsUntransferable}catch{}J=Uint8Array,ct=Uint16Array,mo=Int32Array,ai=new J([0,0,0,0,0,0,0,0,1,1,1,1,2,2,2,2,3,3,3,3,4,4,4,4,5,5,5,5,0,0,0,0]),ci=new J([0,0,0,0,1,1,2,2,3,3,4,4,5,5,6,6,7,7,8,8,9,9,10,10,11,11,12,12,13,13,0,0]),wo=new J([16,17,18,0,8,7,9,6,10,5,11,4,12,3,13,2,14,1,15]),li=function(r,e){for(var t=new ct(31),n=0;n<31;++n)t[n]=e+=1<>1|(M&21845)<<1,be=(be&52428)>>2|(be&13107)<<2,be=(be&61680)>>4|(be&3855)<<4,Zn[M]=((be&65280)>>8|(be&255)<<8)>>1;Rt=(function(r,e,t){for(var n=r.length,i=0,o=new ct(e);i>c]=l}else for(a=new ct(n),i=0;i>15-r[i]);return a}),Bt=new J(288);for(M=0;M<144;++M)Bt[M]=8;for(M=144;M<256;++M)Bt[M]=9;for(M=256;M<280;++M)Bt[M]=7;for(M=280;M<288;++M)Bt[M]=8;di=new J(32);for(M=0;M<32;++M)di[M]=5;So=Rt(Bt,9,1),zo=Rt(di,5,1),Gn=function(r){for(var e=r[0],t=1;te&&(e=r[t]);return e},ge=function(r,e,t){var n=e/8|0;return(r[n]|r[n+1]<<8)>>(e&7)&t},Wn=function(r,e){var t=e/8|0;return(r[t]|r[t+1]<<8|r[t+2]<<16)>>(e&7)},hi=function(r){return(r+7)/8|0},tn=function(r,e,t){return(e==null||e<0)&&(e=0),(t==null||t>r.length)&&(t=r.length),new J(r.subarray(e,t))},bo=["unexpected EOF","invalid block type","invalid length/literal","invalid distance","stream finished","no stream handler",,"no callback","invalid UTF-8 data","extra field too long","date not in range 1980-2099","filename too long","stream finishing","invalid zip data"],fe=function(r,e,t){var n=new Error(e||bo[r]);if(n.code=r,Error.captureStackTrace&&Error.captureStackTrace(n,fe),!t)throw n;return n},yi=function(r,e,t,n){var i=r.length,o=n?n.length:0;if(!i||e.f&&!e.l)return t||new J(0);var s=!t,a=s||e.i!=2,c=e.i;s&&(t=new J(i*3));var l=function(_e){var Le=t.length;if(_e>Le){var Gt=new J(Math.max(Le*2,_e));Gt.set(t),t=Gt}},h=e.f||0,f=e.p||0,d=e.b||0,y=e.l,g=e.d,p=e.m,w=e.n,u=i*8;do{if(!y){h=ge(r,f,1);var m=ge(r,f+1,3);if(f+=3,m)if(m==1)y=So,g=zo,p=9,w=5;else if(m==2){var S=ge(r,f,31)+257,k=ge(r,f+10,15)+4,I=S+ge(r,f+5,31)+1;f+=14;for(var A=new J(I),B=new J(19),N=0;N>4;if(v<16)A[N++]=v;else{var _=0,Z=0;for(v==16?(Z=3+ge(r,f,3),f+=2,_=A[N-1]):v==17?(Z=3+ge(r,f,7),f+=3):v==18&&(Z=11+ge(r,f,127),f+=7);Z--;)A[N++]=_}}var Me=A.subarray(0,S),re=A.subarray(S);p=Gn(Me),w=Gn(re),y=Rt(Me,p,1),g=Rt(re,w,1)}else fe(1);else{var v=hi(f)+4,E=r[v-4]|r[v-3]<<8,z=v+E;if(z>i){c&&fe(0);break}a&&l(d+E),t.set(r.subarray(v,z),d),e.b=d+=E,e.p=f=z*8,e.f=h;continue}if(f>u){c&&fe(0);break}}a&&l(d+131072);for(var gt=(1<>4;if(f+=_&15,f>u){c&&fe(0);break}if(_||fe(2),Ee<256)t[d++]=Ee;else if(Ee==256){Fe=f,y=null;break}else{var pt=Ee-254;if(Ee>264){var N=Ee-257,Ie=ai[N];pt=ge(r,f,(1<>4;je||fe(3),f+=je&15;var re=Eo[he];if(he>3){var Ie=ci[he];re+=Wn(r,f)&(1<u){c&&fe(0);break}a&&l(d+131072);var Ae=d+pt;if(d>3&1)+(e>>4&1);n>0;n-=!r[t++]);return t+(e&2)},lt=(function(){function r(e,t){typeof e=="function"&&(t=e,e={}),this.ondata=t;var n=e&&e.dictionary&&e.dictionary.subarray(-32768);this.s={i:0,b:n?n.length:0},this.o=new J(32768),this.p=new J(0),n&&this.o.set(n)}return r.prototype.e=function(e){if(this.ondata||fe(5),this.d&&fe(4),!this.p.length)this.p=e;else if(e.length){var t=new J(this.p.length+e.length);t.set(this.p),t.set(e,this.p.length),this.p=t}},r.prototype.c=function(e){this.s.i=+(this.d=e||!1);var t=this.s.b,n=yi(this.p,this.s,this.o);this.ondata(tn(n,t,this.s.b),this.d),this.o=tn(n,this.s.b-32768),this.s.b=this.o.length,this.p=tn(this.p,this.s.p/8|0),this.s.p&=7},r.prototype.push=function(e,t){this.e(e),this.c(t)},r})();pi=(function(){function r(e,t){this.v=1,this.r=0,lt.call(this,e,t)}return r.prototype.push=function(e,t){if(lt.prototype.e.call(this,e),this.r+=e.length,this.v){var n=this.p.subarray(this.v-1),i=n.length>3?xo(n):4;if(i>n.length){if(!t)return}else this.v>1&&this.onmember&&this.onmember(this.r-n.length);this.p=n.subarray(i),this.v=0}lt.prototype.c.call(this,0),this.s.f&&!this.s.l?(this.v=hi(this.s.p)+9,this.s={i:0},this.o=new J(0),this.push(new J(0),t)):t&<.prototype.c.call(this,t)},r})(),Io=typeof TextDecoder<"u"&&new TextDecoder,Ao=0;try{Io.decode(ko,{stream:!0}),Ao=1}catch{}});var jn={};xr(jn,{extractZipEntry:()=>No,extractZipEntryBounded:()=>Co,fetchZipCentralDirectory:()=>Fo,parseZipCentralDirectory:()=>Nt});function zi(r){let e=new DataView(r.buffer,r.byteOffset,r.byteLength),t=Math.max(0,r.length-vi);for(let n=r.length-Po;n>=t;n--)if(e.getUint32(n,!0)===_o)return n;throw new Error("Zip EOCD record not found")}function Nt(r){let e=new DataView(r.buffer,r.byteOffset,r.byteLength),t=zi(r),n=e.getUint16(t+10,!0),i=e.getUint32(t+16,!0),o=[],s=i;for(let a=0;a>8,E;v===mi?E=p>>16&65535:m.startsWith("bin/")||m.startsWith("sbin/")||m.includes("/bin/")||m.includes("/sbin/")?E=493:E=420;let z=m.endsWith("/"),S=v===mi&&(E&To)===Oo;o.push({fileName:m,fileNameBytes:u,compressedSize:h,uncompressedSize:f,compressionMethod:l,localHeaderOffset:w,mode:E,isDirectory:z,isSymlink:S,externalAttrs:p,creatorOS:v}),s+=Vn+d+y+g}return o}function bi(r,e){if(r.byteLength!==e.byteLength)return!1;for(let t=0;t{if(a.byteLength>t-o)throw new Error(`ZIP member ${e.fileName} expands beyond ${t} bytes`);i.set(a,o),o+=a.byteLength}).push(n,!0),o!==t)throw new Error(`ZIP member ${e.fileName} expanded ${o} bytes, expected ${t}`);return i}function Mo(r,e){let t=new DataView(r.buffer,r.byteOffset,r.byteLength),n=e.localHeaderOffset;if(n<0||n>r.byteLength-qn||t.getUint32(n,!0)!==wi)throw new Error(`Invalid local file header signature at offset ${n}`);let i=t.getUint16(n+8,!0),o=t.getUint16(n+26,!0),s=t.getUint16(n+28,!0),a=n+qn,c=a+o+s,l=c+e.compressedSize;if(i!==e.compressionMethod||cr.byteLength||!bi(r.subarray(a,a+o),e.fileNameBytes))throw new Error(`ZIP member ${e.fileName} has inconsistent local metadata`);return r.subarray(c,l)}async function Fo(r){let e=await fetch(r,{method:"HEAD"});if(!e.ok)throw new Error(`HEAD request failed: ${e.status} ${e.statusText}`);let t=parseInt(e.headers.get("content-length")||"0",10),n=e.headers.get("accept-ranges");if(!t||n!=="bytes"){let u=await fetch(r);if(!u.ok)throw new Error(`Fetch failed: ${u.status} ${u.statusText}`);let m=new Uint8Array(await u.arrayBuffer());return{entries:Nt(m),totalSize:m.length}}let i=Math.min(t,vi),o=t-i,s=await fetch(r,{headers:{Range:`bytes=${o}-${t-1}`}});if(s.status!==206){let u=await fetch(r);if(!u.ok)throw new Error(`Fetch failed: ${u.status} ${u.statusText}`);let m=new Uint8Array(await u.arrayBuffer());return{entries:Nt(m),totalSize:m.length}}let a=new Uint8Array(await s.arrayBuffer()),c=new DataView(a.buffer,a.byteOffset,a.byteLength),l=zi(a),h=c.getUint32(l+12,!0),f=c.getUint32(l+16,!0);if(f>=o){let u=t,m=new Uint8Array(u);return m.set(a,o),{entries:Nt(m),totalSize:u}}let d=f+h-1,y=await fetch(r,{headers:{Range:`bytes=${f}-${d}`}});if(y.status!==206)throw new Error(`Range request for CD failed: ${y.status}`);let g=new Uint8Array(await y.arrayBuffer()),p=t,w=new Uint8Array(p);return w.set(g,f),w.set(a,o),{entries:Nt(w),totalSize:p}}var _o,Lo,wi,vi,Po,Vn,qn,Ei,Si,mi,Oo,To,Ro,Bo,Yn=bn(()=>{"use strict";Hn();_o=101010256,Lo=33639248,wi=67324752,vi=65557,Po=22,Vn=46,qn=30,Ei=0,Si=8,mi=3,Oo=40960,To=61440,Ro=new TextDecoder("utf-8",{fatal:!0,ignoreBOM:!0}),Bo=new TextEncoder});var Pi={};xr(Pi,{DEFAULT_TAR_GZIP_LIMITS:()=>Li,TarParseError:()=>L,parseTarGzip:()=>Ko});function Ko(r,e={}){let t=e.label??"TAR gzip archive",n=Wo(e.limits,t);if(r.byteLength===0||r.byteLength>n.maxCompressedBytes)throw new L(`${t}: compressed byte count ${r.byteLength} is outside 1..${n.maxCompressedBytes}`);let i=Zo(r,t);if(i===0||i>n.maxUncompressedBytes)throw new L(`${t}: declared uncompressed byte count ${i} is outside 1..${n.maxUncompressedBytes}`);let o=Ho(r,t,i);if(o.byteLength!==i)throw new L(`${t}: gzip expanded to ${o.byteLength} bytes, expected ${i}`);let s=new DataView(r.buffer,r.byteOffset,r.byteLength).getUint32(r.byteLength-8,!0);if(Vo(o)!==s)throw new L(`${t}: gzip CRC32 mismatch`);return Go(o,t,n)}function Go(r,e,t){if(r.byteLength%ke!==0)throw new L(`${e}: TAR byte count is not block-aligned`);let n=[],i=0,o=0,s=0,a=null,c={},l=!1;for(;i+ke<=r.byteLength;){let h=r.subarray(i,i+ke);if(i+=ke,Jn(h)){if(i+ke>r.byteLength)throw new L(`${e}: TAR end marker is truncated`);let z=r.subarray(i,i+ke);if(!Jn(z))throw new L(`${e}: TAR has only one zero end block`);if(i+=ke,!Jn(r.subarray(i)))throw new L(`${e}: TAR has nonzero data after its end marker`);l=!0;break}Xo(h,e);let f=Ct(h,156,1,e)||"0",d=er(h,124,12,`${e}: TAR entry size`),y=er(h,100,8,`${e}: TAR entry mode`)&$o,g=Jo(h,e,t.maxPathBytes),p=Ct(h,157,100,e);if(f==="x"||f==="g"){if(s+=1,s>t.maxEntries+1)throw new L(`${e}: TAR extension header count exceeds ${t.maxEntries+1}`);let z=xi(r,i,d,e);i=Ii(i,d,r.byteLength,e);let S=jo(z,e,t);f==="x"?a=S:c={...c,...S};continue}if(o+=1,o>t.maxEntries)throw new L(`${e}: TAR entry count exceeds ${t.maxEntries}`);let w={...c,...a??{}};a=null;let u=w.size===void 0?d:Yo(w.size,`${e}: PAX entry size`),m=xi(r,i,u,e);i=Ii(i,u,r.byteLength,e);let v=Qn(w.path??g,e,t.maxPathBytes),E=w.linkpath??p;switch(f){case"0":case"\0":n.push({path:v,type:"file",mode:y,data:m});break;case"5":Xn(u,e,"directory",v),n.push({path:v,type:"directory",mode:y});break;case"2":Xn(u,e,"symlink",v),Ai(E,e,v,t.maxLinkBytes,!1),n.push({path:v,type:"symlink",mode:y,linkName:E});break;case"1":Xn(u,e,"hardlink",v),Ai(E,e,v,t.maxLinkBytes,!0),n.push({path:v,type:"hardlink",mode:y,linkName:Qn(E,`${e}: hardlink target`,t.maxPathBytes)});break;case"3":case"4":case"6":throw new L(`${e}: unsupported TAR device/FIFO entry ${v}`);default:throw new L(`${e}: unsupported TAR entry type ${JSON.stringify(f)} for ${v}`)}}if(!l)throw new L(`${e}: TAR is missing its two-block end marker`);if(a!==null)throw new L(`${e}: local PAX header has no following entry`);return n}function Wo(r,e){let t={...Li,...r};for(let[n,i]of Object.entries(t))if(!Number.isSafeInteger(i)||i<=0)throw new L(`${e}: ${n} must be a positive safe integer`);return t}function Zo(r,e){if(r.byteLength<18||r[0]!==31||r[1]!==139||r[2]!==8)throw new L(`${e}: invalid gzip header`);return new DataView(r.buffer,r.byteOffset,r.byteLength).getUint32(r.byteLength-4,!0)}function Ho(r,e,t){let n=new Uint8Array(t),i=0,o=!1,s=new pi(a=>{if(a.byteLength>t-i)throw new L(`${e}: gzip expansion exceeds its declared ${t} bytes`);n.set(a,i),i+=a.byteLength});s.onmember=()=>{throw o=!0,new L(`${e}: concatenated gzip members are unsupported`)};try{s.push(r,!0)}catch(a){throw a instanceof L?a:new L(`${e}: cannot gunzip archive: ${ea(a)}`)}if(o)throw new L(`${e}: concatenated gzip members are unsupported`);return n.subarray(0,i)}function Vo(r){let e=4294967295;for(let t of r)e=Uo[(e^t)&255]^e>>>8;return(e^4294967295)>>>0}function qo(){let r=new Uint32Array(256);for(let e=0;e>>1^((t&1)===0?0:3988292384);r[e]=t>>>0}return r}function xi(r,e,t,n){if(t>r.byteLength-e)throw new L(`${n}: TAR entry is truncated`);return r.subarray(e,e+t)}function Ii(r,e,t,n){let o=Math.ceil(e/ke)*ke;if(!Number.isSafeInteger(o)||o>t-r)throw new L(`${n}: TAR entry padding is truncated`);return r+o}function jo(r,e,t){let n={},i=0;for(;i9)throw new L(`${e}: invalid PAX record length`);if(s=s*10+p,!Number.isSafeInteger(s))throw new L(`${e}: invalid PAX record length`)}let a=i+s;if(s<=o-i+2||a>r.byteLength||r[a-1]!==10)throw new L(`${e}: truncated PAX record`);let c=o+1;for(;c=a-1)throw new L(`${e}: invalid PAX record`);let l=r.subarray(o+1,c);if(l.byteLength>256)throw new L(`${e}: PAX record key is too long`);let h=tr(l,`${e}: PAX record key`),f=r.subarray(c+1,a-1),d=h==="path"?t.maxPathBytes:h==="linkpath"?t.maxLinkBytes:h==="size"?32:0;if(d===0){i=a;continue}if(f.byteLength>d)throw new L(`${e}: PAX ${h} value is too long`);let y=tr(f,`${e}: PAX record value`);n[h]=y,i=a}return n}function Yo(r,e){if(!/^(0|[1-9][0-9]*)$/.test(r))throw new L(`${e} is invalid`);let t=Number(r);if(!Number.isSafeInteger(t)||t<0)throw new L(`${e} is invalid`);return t}function Xo(r,e){let t=er(r,148,8,`${e}: TAR checksum`),n=0;for(let i=0;i=148&&i<156?32:r[i];if(t!==n)throw new L(`${e}: TAR checksum mismatch`)}function Jo(r,e,t){let n=Ct(r,0,100,e),i=Ct(r,345,155,e);return Qn(i?`${i}/${n}`:n,e,t)}function Qn(r,e,t){let n=r;for(;n.startsWith("./");)n=n.slice(2);return n=n.replace(/\/+$/g,""),Qo(n,`${e}: TAR path`,t),n}function Ct(r,e,t,n){let i=e,o=e+t;for(;in||r.includes("\0"))throw new L(`${e}: link target for ${t} is invalid`);if(i&&r.includes("\\"))throw new L(`${e}: hardlink target for ${t} is invalid`)}function Qo(r,e,t){if(r.length===0||r.startsWith("/")||r.includes("\0")||r.includes("\\")||_i.encode(r).byteLength>t)throw new L(`${e} ${JSON.stringify(r)} must be a bounded relative POSIX path`);for(let n of r.split("/"))if(n.length===0||n==="."||n==="..")throw new L(`${e} ${JSON.stringify(r)} contains an unsafe path segment`)}function Jn(r){for(let e of r)if(e!==0)return!1;return!0}function tr(r,e){try{return Do.decode(r)}catch{throw new L(`${e} contains non-UTF-8 text`)}}function ea(r){return r instanceof Error?r.message:String(r)}var ke,$o,ki,Do,_i,Uo,Li,L,Oi=bn(()=>{"use strict";Hn();ke=512,$o=4095,ki=1024*1024,Do=new TextDecoder("utf-8",{fatal:!0,ignoreBOM:!0}),_i=new TextEncoder,Uo=qo(),Li=Object.freeze({maxCompressedBytes:256*ki,maxUncompressedBytes:512*ki,maxEntries:1e5,maxPathBytes:4096,maxLinkBytes:65536}),L=class extends Error{constructor(e){super(e),this.name="TarParseError"}}});import{existsSync as $t,lstatSync as En,readdirSync as Ra,readFileSync as Ze,realpathSync as me,statSync as He}from"node:fs";import{createHash as rs}from"node:crypto";import{spawnSync as wr}from"node:child_process";import{basename as Ba,dirname as Ut,isAbsolute as Sn,join as $,relative as Na,resolve as pe,sep as Ca}from"node:path";import{fileURLToPath as Ma}from"node:url";var mt="kandelo.wpk_fork.linked_frames";var Ir=[75,76,67,70],wt=24,Ar=8,kn=3,_r=[{bytes:4,chunkHeaderSize:32,nodeHeaderSize:24},{bytes:8,chunkHeaderSize:56,nodeHeaderSize:32}],Xe=[{module:"env",name:"__wpk_fork_frame_commit",params:["ptr"],results:[]},{module:"env",name:"__wpk_fork_frame_next",params:["ptr"],results:["ptr"]},{module:"env",name:"__wpk_fork_frame_reserve",params:["ptr"],results:["ptr"]}],vt=[{name:"wpk_fork_abort_begin",params:["ptr"],results:[]},{name:"wpk_fork_abort_end",params:[],results:[]},{name:"wpk_fork_rewind_begin",params:["ptr"],results:[]},{name:"wpk_fork_rewind_end",params:[],results:[]},{name:"wpk_fork_state",params:[],results:["i32"]},{name:"wpk_fork_unwind_begin",params:["ptr"],results:[]},{name:"wpk_fork_unwind_end",params:[],results:[]}];var Lr=["__abi_version","kernel_alloc_scratch","kernel_create_process","kernel_create_process_with_stdio","kernel_dequeue_signal","kernel_exec_prepare","kernel_exec_setup_for_thread","kernel_fork_process","kernel_get_parent_pid","kernel_get_process_exit_signal","kernel_get_process_state","kernel_handle_channel","kernel_has_sa_nocldstop","kernel_host_adapter_manifest_len","kernel_host_adapter_manifest_ptr","kernel_ipc_shmat_for_process","kernel_ipc_shmat_for_task","kernel_ipc_shmdt_for_process","kernel_ipc_shmdt_for_task","kernel_mark_process_signaled","kernel_pipe_has_readers","kernel_posix_timer_fire","kernel_prepare_write_operation","kernel_reap_exited_child","kernel_remove_process","kernel_set_current_tid","kernel_spawn_process","kernel_thread_exit","kernel_validate_task","kernel_wait_child_poll"];var G={LINK_MAX:0,MAX_CANON:1,MAX_INPUT:2,NAME_MAX:3,PATH_MAX:4,PIPE_BUF:5,CHOWN_RESTRICTED:6,NO_TRUNC:7,VDISABLE:8,SYNC_IO:9,ASYNC_IO:10,PRIO_IO:11,SOCK_MAXBUF:12,FILESIZEBITS:13,REC_INCR_XFER_SIZE:14,REC_MAX_XFER_SIZE:15,REC_MIN_XFER_SIZE:16,REC_XFER_ALIGN:17,ALLOC_SIZE_MIN:18,SYMLINK_MAX:19,POSIX2_SYMLINKS:20,FALLOC:21,TEXTDOMAIN_MAX:22,TIMESTAMP_RESOLUTION:23};function x(r,e){let t=0,n=0,i=e;for(;;){let o=r[i++];if(t|=(o&127)<=21&&n<=34?Et(e,t):n===84||n>=92&&n<=99||n>=112&&n<=123||n>=124&&n<=131||n>=156&&n<=159?t+1:t:r===254?n===0||n===1||n===2?Et(e,t):n===3?t:n>=16&&n<=79?Et(e,t):null:null}function ws(r,e,t){let[n,i]=x(r,e);e+=i+n;let[o,s]=x(r,e);e+=s+o;let a=r[e++];if(a===0){t.funcImports++;let[,c]=x(r,e);e+=c}else if(a===1){e++;let c=r[e++],[,l]=x(r,e);if(e+=l,c&1){let[,h]=x(r,e);e+=h}}else if(a===2){let c=r[e++],[,l]=x(r,e);if(e+=l,c&1){let[,h]=x(r,e);e+=h}}else a===3&&(t.globalImports++,e+=2);return e}function Wt(r){return r.length>=8&&r[0]===0&&r[1]===97&&r[2]===115&&r[3]===109}function Pe(r,e){let[t,n]=x(r,e);return e+=n,[new TextDecoder().decode(r.subarray(e,e+t)),e+t]}function vs(r,e){if(e.length===0)return!0;let t=new TextEncoder().encode(e);e:for(let n=0;n<=r.length-t.length;n++){for(let i=0;ir);function Pr(r,e,t){if(!t)throw new Error(`function ${e} refers to an unknown type`);let n=r.get(e)??[];n.push(t),r.set(e,n)}function xn(r,e){let[t,n]=x(r,e);e+=n;let[,i]=x(r,e);if(e+=i,(t&1)!==0){let[,o]=x(r,e);e+=o}return{flags:t,next:e}}function Ss(r){let e=new Uint8Array(r);if(!Wt(e))throw new Error("not a wasm binary");let t=[],n=[],i=[],o={functionImports:new Map,functionExports:new Map,memoryPointerWidths:[],linkedFrameDescriptors:[],importsKernelFork:!1},s=8;for(;se.length)throw new Error("wasm section exceeds file size");let d=h,y=!1;if(a===0){let[g,p]=Pe(e,d);g===mt&&o.linkedFrameDescriptors.push(e.slice(p,f))}else if(a===1){y=!0;let[g,p]=x(e,d);d+=p;for(let w=0;wr[c]===a))throw new Error("linked-frame descriptor has invalid magic");let e=new DataView(r.buffer,r.byteOffset,r.byteLength),t=e.getUint16(4,!0);if(t!==1)throw new Error(`linked-frame descriptor version ${t} is unsupported`);let n=e.getUint16(6,!0);if(n!==wt)throw new Error(`linked-frame descriptor declares size ${n}, expected ${wt}`);let i=e.getUint8(8),o=_r.find(({bytes:a})=>a===i);if(!o)throw new Error(`linked-frame descriptor pointer width ${i} is unsupported`);if(e.getUint8(9)!==Ar)throw new Error(`linked-frame descriptor alignment ${e.getUint8(9)} is unsupported`);let s=e.getUint16(10,!0);if(s!==kn)throw new Error(`linked-frame descriptor flags 0x${s.toString(16)} do not equal required flags 0x${kn.toString(16)}`);if(e.getUint32(12,!0)!==o.chunkHeaderSize||e.getUint32(16,!0)!==o.nodeHeaderSize)throw new Error(`linked-frame descriptor header sizes do not match its ${i}-byte pointer width`);return o.bytes}function Or(r,e){return r==="i32"?127:e===8?126:127}function Tr(r,e,t,n){return r.params.length===e.length&&r.results.length===t.length&&r.params.every((i,o)=>i===Or(e[o],n))&&r.results.every((i,o)=>i===Or(t[o],n))}function Rr(r,e,t){let n=i=>i==="ptr"&&t===8?"i64":"i32";return`(${r.map(n).join(", ")}) -> (${e.map(n).join(", ")})`}function bs(r){let e=[];for(let s of vt){let a=r.functionExports.get(s.name);a&&a.length!==1&&e.push(`duplicate ABI 42 wasm-fork-instrument export ${s.name}`)}let t=vt.filter(({name:s})=>!r.functionExports.has(s)).map(({name:s})=>s);t.length>0&&e.push(`incomplete wasm-fork-instrument exports; missing ${t.join(", ")}`);let n=null;if(r.linkedFrameDescriptors.length===0)e.push(`missing required ${mt} descriptor`);else if(r.linkedFrameDescriptors.length!==1)e.push(`has ${r.linkedFrameDescriptors.length} ${mt} descriptors, expected exactly one`);else try{n=zs(r.linkedFrameDescriptors[0])}catch(s){e.push(s instanceof Error?s.message:String(s))}let i=Xe.filter(({module:s,name:a})=>r.functionImports.has(`${s}.${a}`)),o=r.importsKernelFork||i.length>0;if(o){let s=Xe.filter(({module:a,name:c})=>!r.functionImports.has(`${a}.${c}`)).map(({module:a,name:c})=>`${a}.${c}`);s.length>0&&e.push(`incomplete ABI 42 linked-frame imports; missing ${s.join(", ")}`);for(let a of Xe){let c=`${a.module}.${a.name}`,l=r.functionImports.get(c);l&&l.length!==1&&e.push(`duplicate ABI 42 linked-frame import ${c}`)}}if(n!==null){if(r.memoryPointerWidths.length!==1)e.push(`ABI 42 fork instrumentation requires exactly one module memory, found ${r.memoryPointerWidths.length}`);else if(r.memoryPointerWidths[0]!==n){let s=n===8?"an":"a";e.push(`ABI 42 linked-frame descriptor declares ${s} ${n}-byte pointer but the module memory uses ${r.memoryPointerWidths[0]}-byte addresses`)}for(let s of vt){let a=r.functionExports.get(s.name);a?.length===1&&!Tr(a[0],s.params,s.results,n)&&e.push(`ABI 42 wasm-fork-instrument export ${s.name} has the wrong signature; expected ${Rr(s.params,s.results,n)}`)}if(o)for(let s of Xe){let a=`${s.module}.${s.name}`,c=r.functionImports.get(a);c?.length===1&&!Tr(c[0],s.params,s.results,n)&&e.push(`ABI 42 linked-frame import ${a} has the wrong signature; expected ${Rr(s.params,s.results,n)}`)}}return e}function ks(r){let e=new Uint8Array(r);if(!Wt(e))return[];let t=[],n=8;for(;nt.startsWith("reloc."))}function Nr(r,e={}){let t=[];if(Is(r)&&t.push("contains asyncify_"),e.expectedAbi!==void 0&&e.expectedAbi!==null){let f=Ls(r);f!==null&&f!==e.expectedAbi&&t.push(`ABI ${f}, expected ${e.expectedAbi}`)}let n=new Set(xs(r));if(e.requiredExports){let f=e.requiredExports.filter(d=>!n.has(d));f.length>0&&t.push(`missing required exports: ${f.join(", ")}`)}let i=Es.filter(f=>n.has(f)),o=ks(r),s=Br(r),a=Xe.filter(({module:f,name:d})=>o.includes(`${f}.${d}`)),c=s.filter(f=>f===mt).length,l=i.length>0||a.length>0||c>0;if(e.forbidForkInstrumentation&&l&&t.push("contains ABI 42 wasm-fork-instrument metadata, imports, or exports"),(e.requireForkInstrumentation??!As(r))&&(l||o.includes("kernel.kernel_fork")))try{t.push(...bs(Ss(r)))}catch(f){t.push(`cannot validate ABI 42 fork-artifact contract: ${f instanceof Error?f.message:String(f)}`)}return t}function _s(r,e){let t=new Uint8Array(r);if(t.length<8)return null;let n=0,i=null,o=null,s=8;for(;s=c)return null;let p=a;for(let m=0;m=g)return null;let[p,w]=x(t,y);y+=w;for(let u=0;ug)return null}return y}function d(y,g=0){if(g>4)return null;let p=h(y);if(!p)return null;let w=f(p.start,p.end);if(w===null)return null;let u=w,m=p.end;for(;u=32&&v<=38||v===208){let[,E]=x(t,u);u+=E}else if(v>=40&&v<=62)u=Et(t,u);else if(v===63||v===64)u++;else if(v===66){let[,E]=gs(t,u);u+=E}else if(v===67)u+=4;else if(v===68)u+=8;else if(v===252||v===253||v===254){let E=ms(v,t,u);if(E===null)return null;u=E}}return null}return d(i)}function Ls(r){return _s(r,"__abi_version")}var Ps=ArrayBuffer,H=Uint8Array,Zt=Uint16Array,Os=Int16Array;var Ht=Int32Array,An=function(r,e,t){if(H.prototype.slice)return H.prototype.slice.call(r,e,t);(e==null||e<0)&&(e=0),(t==null||t>r.length)&&(t=r.length);var n=new H(t-e);return n.set(r.subarray(e,t)),n},zt=function(r,e,t,n){if(H.prototype.fill)return H.prototype.fill.call(r,e,t,n);for((t==null||t<0)&&(t=0),(n==null||n>r.length)&&(n=r.length);tr.length)&&(n=r.length);t2046MB)","invalid block type","FSE accuracy too high","match distance too far back","unexpected EOF"],V=function(r,e,t){var n=new Error(e||Rs[r]);if(n.code=r,Error.captureStackTrace&&Error.captureStackTrace(n,V),!t)throw n;return n},Cr=function(r,e,t){for(var n=0,i=0;n>>0},Ns=function(r,e){var t=r[0]|r[1]<<8|r[2]<<16;if(t==3126568&&r[3]==253){var n=r[4],i=n>>5&1,o=n>>2&1,s=n&3,a=n>>6;n&8&&V(0);var c=6-i,l=s==3?4:s,h=Cr(r,c,l);c+=l;var f=a?1<>3);y=g+(g>>3)*(r[5]&7)}y>2145386496&&V(1);var p=new H((e==1?d||y:e?0:y)+12);return p[0]=1,p[4]=4,p[8]=8,{b:c+f,y:0,l:0,d:h,w:e&&e!=1?e:p.subarray(12),e:y,o:new Ht(p.buffer,0,3),u:d,c:o,m:Math.min(131072,y)}}else if((t>>4|r[3]<<20)==25481893)return Bs(r,4)+8;V(0)},De=function(r){for(var e=0;1<t&&V(3);for(var o=1<0;){var m=De(s+1),v=n>>3,E=(1<>(n&7)&E,S=(1<S&&(z-=k)),d[++a]=--z,z==-1?(s+=z,w[--h]=a):s-=z,!z)do{var A=n>>3;c=(r[A]|r[A+1]<<8)>>(n&7)&3,n+=2,a+=c}while(c==3)}(a>255||s)&&V(0);for(var B=0,N=(o>>1)+(o>>3)+3,te=o-1,j=0;j<=a;++j){var R=d[j];if(R<1){y[j]=-R;continue}for(l=0;l=h)}}for(B&&V(0),l=0;l>3,{b:i,s:w,n:u,t:g}]},Cs=function(r,e){var t=0,n=-1,i=new H(292),o=r[e],s=i.subarray(0,256),a=i.subarray(256,268),c=new Zt(i.buffer,268);if(o<128){var l=bt(r,e+1,6),h=l[0],f=l[1];e+=o;var d=h<<3,y=r[e];y||V(0);for(var g=0,p=0,w=f.b,u=w,m=(++e<<3)-8+De(y);m-=w,!(m>3;if(g+=(r[v]|r[v+1]<<8)>>(m&7)&(1<>3,p+=(r[v]|r[v+1]<<8)>>(m&7)&(1<255&&V(0)}else{for(n=o-127;t>4,s[t+1]=E&15}++e}var z=0;for(t=0;t11&&V(0),z+=S&&1<0;--t){var j=c[t];zt(te,t,j,c[t-1]=j+a[t]*(1<a&&f>3,y=(r[d]|r[d+1]<<8|r[d+2]<<16)>>(h&7);c=(c<>2,s=o<<1,a=o+s;St(r.subarray(n,n+=r[0]|r[1]<<8),e.subarray(0,o),t),St(r.subarray(n,n+=r[2]|r[3]<<8),e.subarray(o,s),t),St(r.subarray(n,n+=r[4]|r[5]<<8),e.subarray(s,a),t),St(r.subarray(n),e.subarray(a),t)},Gs=function(r,e,t){var n,i=e.b,o=r[i],s=o>>1&3;e.l=o&1;var a=o>>3|r[i+1]<<5|r[i+2]<<13,c=(i+=3)+a;if(s==1)return i>=r.length?void 0:(e.b=i+1,t?(zt(t,r[i],e.y,e.y+=a),t):zt(new H(a),r[i]));if(!(c>r.length)){if(s==0)return e.b=c,t?(t.set(r.subarray(i,c),e.y),e.y+=a,t):An(r,i,c);if(s==2){var l=r[i],h=l&3,f=l>>2&3,d=l>>4,y=0,g=0;h<2?f&1?d|=r[++i]<<4|(f&2&&r[++i]<<12):d=l>>3:(g=f,f<2?(d|=(r[++i]&63)<<4,y=r[i]>>6|r[++i]<<2):f==2?(d|=r[++i]<<4|(r[++i]&3)<<12,y=r[i]>>2|r[++i]<<6):(d|=r[++i]<<4|(r[++i]&63)<<12,y=r[i]>>6|r[++i]<<2|r[++i]<<10)),++i;var p=t?t.subarray(e.y,e.y+e.m):new H(e.m),w=p.length-d;if(h==0)p.set(r.subarray(i,i+=d),w);else if(h==1)zt(p,r[i++],w);else{var u=e.h;if(h==2){var m=Cs(r,i);y+=i-(i=m[0]),e.h=u=m[1]}else u||V(0);(g?Ks:St)(r.subarray(i,i+=y),p.subarray(w),u)}var v=r[i++];if(v){v==255?v=(r[i++]|r[i++]<<8)+32512:v>127&&(v=v-128<<8|r[i++]);var E=r[i++];E&3&&V(0);for(var z=[Fs,$s,Ms],S=2;S>-1;--S){var k=E>>(S<<1)+2&3;if(k==1){var I=new H([0,0,r[i++]]);z[S]={s:I.subarray(2,3),n:I.subarray(0,1),t:new Zt(I.buffer,0,1),b:0}}else k==2?(n=bt(r,i,9-(S&1)),i=n[0],z[S]=n[1]):k==3&&(e.t||V(0),z[S]=e.t[S])}var A=e.t=z,B=A[0],N=A[1],te=A[2],j=r[c-1];j||V(0);var R=(c<<3)-8+De(j)-te.b,P=R>>3,_=0,Z=(r[P]|r[P+1]<<8)>>(R&7)&(1<>3;var Me=(r[P]|r[P+1]<<8)>>(R&7)&(1<>3;var re=(r[P]|r[P+1]<<8)>>(R&7)&(1<>3;var je=1<>>(R&7)&je-1);P=(R-=Ln[Fe])>>3;var Ae=Us[Fe]+((r[P]|r[P+1]<<8|r[P+2]<<16)>>(R&7)&(1<>3;var $e=Ds[gt]+((r[P]|r[P+1]<<8|r[P+2]<<16)>>(R&7)&(1<<_n[gt])-1);if(P=(R-=Kt)>>3,Z=te.t[Z]+((r[P]|r[P+1]<<8)>>(R&7)&(1<>3,re=B.t[re]+((r[P]|r[P+1]<<8)>>(R&7)&(1<>3,Me=N.t[Me]+((r[P]|r[P+1]<<8)>>(R&7)&(1<3)e.o[2]=e.o[1],e.o[1]=e.o[0],e.o[0]=he-=3;else{var Ye=he-($e!=0);Ye?(he=Ye==3?e.o[0]-1:e.o[Ye],Ye>1&&(e.o[2]=e.o[1]),e.o[1]=e.o[0],e.o[0]=he):he=e.o[0]}for(var S=0;S<$e;++S)p[_+S]=p[w+S];_+=$e,w+=$e;var _e=_-he;if(_e<0){var Le=-_e,Gt=e.e+_e;Le>Ae&&(Le=Ae);for(var S=0;S=i){let I=(y+1)*4096;try{e.grow(I)}catch{throw new b(X)}if(i=Math.floor(e.byteLength/4096),y>=i)throw new b(X)}new Uint8Array(e).fill(0);let g=new r(e);g.w32(Bn,Tn),g.w32(Nn,Rn),g.w32(jt,4096),g.w32(Qe,i),g.w32(Oe,s),g.w32(Ue,h),g.w32(Xt,f),g.w32(Ur,d),g.w32(Jt,y),g.w32(eo,a),g.w32(to,c),g.w32(no,l),g.w32(It,o),g.w32(Kr,256);let p=f*4096;for(let I=0;I>2)+(I>>5);g.i32[A]|=1<<(I&31)}let w=i-y;Atomics.store(g.i32,et>>2,w),g.blockAllocHint=y;let u=h*4096;g.i32[u>>2]|=3,Atomics.store(g.i32,Yt>>2,s-2),g.inodeAllocHint=2;let m=g.inodeOffset(1);g.w32(m+C,U|493),g.w32(m+D,2),g.w64(m+oe,1);let v=g.blockAlloc();if(v<0)throw new b(X);g.w32(m+Y,v);let E=v*4096,z=Re(O+1),S=Re(O+2);g.w32(E,1),g.view.setUint16(E+4,z,!0),g.view.setUint16(E+6,1,!0),g.u8[E+O]=46;let k=E+z;return g.w32(k,1),g.view.setUint16(k+4,S,!0),g.view.setUint16(k+6,2,!0),g.u8[k+O]=46,g.u8[k+O+1]=46,g.w64(m+T,z+S),Atomics.store(g.i32,Cn>>2,1),g}static inspectImageCapacity(e){if(e.byteLengththis.snapshotBytesUnlocked(e))}snapshotState(e){return this.withNamespaceLock(()=>({bytes:this.snapshotBytesUnlocked(e),identities:this.collectIdentityStateUnlocked()}))}identityState(){return this.withNamespaceLock(()=>this.collectIdentityStateUnlocked())}snapshotBytesUnlocked(e){let t=e?.normalizeTimestampsMs;if(t!==void 0&&(!Number.isSafeInteger(t)||t<0))throw new b(W,"Snapshot timestamp must be a non-negative safe integer in milliseconds");let n=t===void 0?void 0:BigInt(t);for(let a=0;a>2)!==0)throw new b(Fn,"Cannot save a VFS image with open descriptors")}let i=this.r32(Oe);for(let a=0;a=1&&this.inodeIsAllocated(a)?n:0n;s.setBigUint64(c+At,l,!0),s.setBigUint64(c+ie,l,!0),s.setBigUint64(c+q,l,!0)}}return o}collectIdentityStateUnlocked(){let e=new Map,t=[{ino:1,path:"/"}],n=new Set;for(;t.length>0;){let i=t.pop();if(n.has(i.ino))throw new b(F);n.add(i.ino);let o=this.inodeOffset(i.ino);if((this.r32(o+C)&K)!==U)throw new b(F);let s=this.r64(o+T),a=0;for(;a>2)>>>0,paths:[]},e.set(S,k)),k.paths.push(v),(this.r32(E+C)&K)===U&&t.push({ino:p,path:v})}}y+=w}a+=d}}return e}statfs(){let e=this.r32(jt),t=this.r32(Qe),n=this.r32(It),i=typeof this.buffer.maxByteLength=="number"?this.buffer.maxByteLength:this.buffer.byteLength,o=Math.floor(i/e),s=Math.max(t,Math.min(n,o)),a=Atomics.load(this.i32,et>>2),c=Math.max(0,s-t);return{blockSize:e,totalBlocks:s,freeBlocks:a+c,totalInodes:this.r32(Oe),freeInodes:Atomics.load(this.i32,Yt>>2),maxName:255}}r32(e){return this.view.getUint32(e,!0)}w32(e,t){this.view.setUint32(e,t,!0)}r64(e){return Number(this.view.getBigUint64(e,!0))}w64(e,t){this.view.setBigUint64(e,BigInt(t),!0)}waitForAtomicChange(e,t){if(this.atomicsWaitAllowed!==!1)try{Atomics.wait(this.i32,e,t),this.atomicsWaitAllowed=!0;return}catch(n){if(!(n instanceof TypeError))throw n;this.atomicsWaitAllowed=!1}for(;Atomics.load(this.i32,e)===t;);}resetAllocationHints(){this.blockAllocHint=this.findNextFreeBlockHint(),this.inodeAllocHint=this.findNextFreeInodeHint()}findNextFreeBlockHint(){let e=this.r32(Qe),t=this.r32(Jt),n=this.r32(Xt)*4096;for(let i=t;i>2)+(i>>5),s=i&31;if((Atomics.load(this.i32,o)&1<>2)+(n>>5),o=n&31;if((Atomics.load(this.i32,i)&1<>2;for(;;){if(Atomics.compareExchange(this.i32,e,0,1)===0)return;this.waitForAtomicChange(e,1)}}sbUnlock(){let e=Qt>>2;Atomics.store(this.i32,e,0),Atomics.notify(this.i32,e,1/0)}namespaceLock(){let e=en>>2;for(;;){if(Atomics.compareExchange(this.i32,e,0,1)===0)return;this.waitForAtomicChange(e,1)}}namespaceUnlock(){let e=en>>2;Atomics.store(this.i32,e,0),Atomics.notify(this.i32,e,1/0)}withNamespaceLock(e){this.namespaceLock();try{return e()}finally{this.namespaceUnlock()}}resetRestoredRuntimeState(){Atomics.store(this.i32,Qt>>2,0),Atomics.store(this.i32,en>>2,0),this.u8.fill(0,256,4096);let e=this.r32(Oe),t=this.r32(Ue)*4096;for(let n=0;n>5)*4)&1<<(n&31))===0||this.r32(i+D)!==0)continue;let s=this.r32(i+C),a=this.r64(i+T);(s&K)===xt&&a<=40?(this.u8.fill(0,i+Y,i+Y+40),this.w64(i+T,0)):this.inodeTruncate(n,0),this.inodeFree(n)}}blockAlloc(){let e=this.r32(Qe),t=this.r32(Xt)*4096,n=this.r32(Jt),i=this.blockAllocHint>=n&&this.blockAllocHint>2)+(a>>5),l=a&31,h=Atomics.load(this.i32,c);if(h&1<>2,1),this.blockAllocHint=a+1>2)+(e>>5),i=e&31;for(;;){let o=Atomics.load(this.i32,n),s=o&~(1<>2,1),e>=this.r32(Jt)&&e>2)>0)return 0;let e=this.r32(Qe),t=this.r32(It),n=this.r32(Kr),i=e+n;if(i>t&&(i=t,n=i-e,n===0))return X;let o=i*4096;if(this.buffer.byteLength>2,n),Atomics.add(this.i32,Cn>>2,1),this.blockAllocHint=e,0}finally{this.sbUnlock()}}inodeOffset(e){let n=this.r32(Ur)+Math.floor(e/32),i=e%32*128;return n*4096+i}inodeAlloc(){let e=this.r32(Oe),t=this.r32(Ue)*4096,n=this.inodeAllocHint>=2&&this.inodeAllocHint>2)+(s>>5),c=s&31,l=Atomics.load(this.i32,a);if(l&1<>2,1),this.inodeAllocHint=s+1>2,1)+1}inodeFree(e){let n=(this.r32(Ue)*4096>>2)+(e>>5),i=e&31;for(;;){let o=Atomics.load(this.i32,n);if((o&1<>2,1),e>=2&&e0&&this.w32(n+Te,i-1),i<=1&&this.r32(n+D)===0&&(this.inodeTruncate(e,0),t=!0)}finally{this.inodeWriteUnlock(e)}t&&this.inodeFree(e)}inodeDropLinkRefLocked(e){let t=this.inodeOffset(e),n=this.r32(t+D);return n>1?(this.w32(t+D,n-1),this.w64(t+q,Date.now()),!1):this.inodeOrphanLocked(e)}inodeOrphanLocked(e){let t=this.inodeOffset(e);if(this.w32(t+D,0),this.w64(t+q,Date.now()),this.r32(t+Te)>0)return!1;let n=this.r32(t+C),i=this.r64(t+T);return(n&K)===xt&&i<=40?(this.u8.fill(0,t+Y,t+Y+40),this.w64(t+T,0)):this.inodeTruncate(e,0),!0}inodeReadLock(e){let t=this.inodeOffset(e)+tt>>2;for(;;){let n=Atomics.load(this.i32,t);if(n&jr){this.waitForAtomicChange(t,n);continue}if(Atomics.compareExchange(this.i32,t,n,n+1)===n)return}}inodeReadUnlock(e){let t=this.inodeOffset(e)+tt>>2;(Atomics.sub(this.i32,t,1)&ro)===1&&Atomics.notify(this.i32,t,1)}inodeWriteLock(e){let t=this.inodeOffset(e)+tt>>2;for(;;){let n=Atomics.load(this.i32,t);if(n!==0){this.waitForAtomicChange(t,n);continue}if(Atomics.compareExchange(this.i32,t,0,jr)===0)return}}inodeWriteUnlock(e){let t=this.inodeOffset(e)+tt>>2;Atomics.store(this.i32,t,0),Atomics.notify(this.i32,t,1/0)}inodeBlockMap(e,t,n){let i=this.inodeOffset(e);if(t<10){let o=this.r32(i+Y+t*4);if(o!==0)return o;if(!n)return 0;let s=this.blockAllocWithGrow();return s<0||this.w32(i+Y+t*4,s),s}if(t-=10,t<1024){let o=this.r32(i+_t),s=!1;if(o===0){if(!n)return 0;if(o=this.blockAllocWithGrow(),o<0)return o;this.w32(i+_t,o),s=!0}let a=o*4096+t*4,c=this.r32(a);if(c!==0)return c;if(!n)return 0;let l=this.blockAllocWithGrow();return l<0?(s&&(this.w32(i+_t,0),this.blockFree(o)),l):(this.w32(a,l),l)}if(t-=1024,t<1024*1024){let o=Math.floor(t/1024),s=t%1024,a=this.r32(i+nt),c=!1;if(a===0){if(!n)return 0;if(a=this.blockAllocWithGrow(),a<0)return a;this.w32(i+nt,a),c=!0}let l=a*4096+o*4,h=this.r32(l),f=!1;if(h===0){if(!n)return 0;if(h=this.blockAllocWithGrow(),h<0)return c&&(this.w32(i+nt,0),this.blockFree(a)),h;this.w32(l,h),f=!0}let d=h*4096+s*4,y=this.r32(d);if(y!==0)return y;if(!n)return 0;let g=this.blockAllocWithGrow();return g<0?(f&&(this.w32(l,0),this.blockFree(h)),c&&(this.w32(i+nt,0),this.blockFree(a)),g):(this.w32(d,g),g)}return W}inodeReadData(e,t,n,i){let o=this.inodeOffset(e),s=this.r64(o+T);if(t>=s)return 0;t+i>s&&(i=s-t);let a=0,c=0;for(;i>0;){let l=Math.floor(t/4096),h=t%4096,f=4096-h;f>i&&(f=i);let d=this.inodeBlockMap(e,l,!1);if(d<=0)n.fill(0,c,c+f);else{let y=d*4096+h;n.set(this.u8.subarray(y,y+f),c)}c+=f,t+=f,i-=f,a+=f}return a}inodeWriteData(e,t,n,i){let o=this.inodeOffset(e),s=this.r64(o+T);t>s&&this.zeroOldEofTail(e,s);let a=0,c=0;for(;i>0;){let l=Math.floor(t/4096),h=t%4096,f=4096-h;f>i&&(f=i);let d=this.inodeBlockMap(e,l,!0);if(d<0){if(a===0)return d;break}let y=d*4096+h;this.u8.set(n.subarray(c,c+f),y),c+=f,t+=f,i-=f,a+=f}if(a>0&&t>this.r64(o+T)&&this.w64(o+T,t),a>0){let l=Date.now();this.w64(o+ie,l),this.w64(o+q,l),Atomics.add(this.i32,o+ae>>2,1)}return a}zeroInodeRange(e,t,n){for(;t0){let c=a*4096+o;this.u8.fill(0,c,c+s)}t+=s}}zeroOldEofTail(e,t){let n=t%4096;if(n===0)return;let i=Math.floor(t/4096),o=this.inodeBlockMap(e,i,!1);if(o<=0)return;let s=o*4096+n;this.u8.fill(0,s,o*4096+4096)}freeBlocksFrom(e,t){let n=this.inodeOffset(e);for(let s=t;s<10;s++){let a=this.r32(n+Y+s*4);a&&(this.blockFree(a),this.w32(n+Y+s*4,0))}let i=this.r32(n+_t);if(i){let s=t>10?t-10:0;for(let a=s;a<1024;a++){let c=i*4096+a*4,l=this.r32(c);l&&(this.blockFree(l),this.w32(c,0))}s===0&&(this.blockFree(i),this.w32(n+_t,0))}let o=this.r32(n+nt);if(o){let s=t>1034?t-10-1024:0,a=Math.floor(s/1024);for(let c=a;c<1024;c++){let l=o*4096+c*4,h=this.r32(l);if(!h)continue;let f=c===a?s%1024:0;for(let d=f;d<1024;d++){let y=h*4096+d*4,g=this.r32(y);g&&(this.blockFree(g),this.w32(y,0))}f===0&&(this.blockFree(h),this.w32(l,0))}a===0&&(this.blockFree(o),this.w32(n+nt,0))}}inodeTruncate(e,t,n=!1){let i=this.inodeOffset(e),o=this.r64(i+T),s=t!==o;if(t>=o){if(t>o&&this.zeroOldEofTail(e,o),this.w64(i+T,t),s||n){let c=Date.now();this.w64(i+ie,c),this.w64(i+q,c),Atomics.add(this.i32,i+ae>>2,1)}return}t%4096!==0&&this.zeroInodeRange(e,t,Math.ceil(t/4096)*4096);let a=Math.ceil(t/4096);if(this.freeBlocksFrom(e,a),this.w64(i+T,t),s||n){let c=Date.now();this.w64(i+ie,c),this.w64(i+q,c),Atomics.add(this.i32,i+ae>>2,1)}}validateFileSize(e){if(!Number.isSafeInteger(e)||e<0)throw new b(W);if(e>rt)throw new b(Lt)}validateSeekPosition(e){if(!Number.isSafeInteger(e))throw new b(Qr);if(e<0)throw new b(W);if(e>rt)throw new b(Lt)}touchDirectoryMutation(e){let t=this.inodeOffset(e),n=Date.now();this.w64(t+ie,n),this.w64(t+q,n);let i=Atomics.add(this.i32,t+Zr>>2,1)+1>>>0,o=this.dirIndexes.get(e);o&&(o.mutationSequence=i,o.size=this.r64(t+T))}dirNameKey(e){return it(e)}dirEntryNameMatches(e,t){if(this.view.getUint16(e+6,!0)!==t.length)return!1;for(let i=0;i=O&&n%4===0&&e+n<=t&&i<=n-O}inodeIsAllocated(e){let t=this.r32(Oe);if(e<=0||e>=t)return!1;let n=this.r32(Ue)*4096;return(Atomics.load(this.i32,(n>>2)+(e>>5))&1<<(e&31))!==0}rebuildDirIndex(e,t,n,i){let o=new Map,s=[],a=0;for(;a4096-h&&(y=4096-h);let g=h;for(;g=O&&s.push({abs:p,recLen:u});g+=u}a+=y}let c={generation:t,mutationSequence:n,size:i,entries:o,free:s};return this.dirIndexes.set(e,c),c}getDirIndex(e){let t=this.inodeOffset(e),n=this.r64(t+T),i=this.r64(t+oe),o=Atomics.load(this.i32,t+Zr>>2)>>>0,s=this.dirIndexes.get(e);return s&&s.generation===i&&s.mutationSequence===o&&s.size===n?s:(s&&this.dirIndexes.delete(e),n=0;s--){let a=e.free[s];if(!(a.recLen4096-c&&(f=4096-c);let d=c;for(;dn)return-1;a=c,s+=l}return s===n?a:-1}dirAppendEntry(e,t,n,i=-1){let o=this.inodeOffset(e),s=this.r64(o+T),a=Re(O+t.length),c=s,l=Math.floor(c/4096),h=c%4096,f=0;if(h!==0&&h+a>4096){let g=4096-h,p=0;if(g>=O){if(p=this.inodeBlockMap(e,l,!1),p<=0)return F}else if(i<0&&(i=this.findLastDirEntryInBlock(e,l,h)),i<0)return F;if(f=this.inodeBlockMap(e,l+1,!0),f<0)return f;if(g>=O){let w=p*4096+h;this.w32(w,0),this.view.setUint16(w+4,g,!0),this.view.setUint16(w+6,0,!0)}else{let u=this.view.getUint16(i+4,!0)+g;this.view.setUint16(i+4,u,!0),this.updateDirIndexRecLen(e,i,u)}c=(l+1)*4096,l++,h=0}let d;if(h===0){if(d=f||this.inodeBlockMap(e,l,!0),d<0)return d}else if(d=this.inodeBlockMap(e,l,!1),d<=0)return F;let y=d*4096+h;return this.w32(y,n),this.view.setUint16(y+4,a,!0),this.view.setUint16(y+6,t.length,!0),this.u8.set(t,y+O),this.w64(o+T,c+a),this.touchDirectoryMutation(e),this.updateDirIndexAdd(e,t,n,y,a),0}dirAddEntry(e,t,n){let i=this.getDirIndex(e);if(typeof i=="number")return i;if(i)return this.useDirIndexFreeSlot(i,e,t,n)?0:this.dirAppendEntry(e,t,n);let o=this.inodeOffset(e),s=this.r64(o+T),a=Re(O+t.length),c=-1,l=0;for(;l4096-f&&(g=4096-f);let p=f;for(;pf+g||v>m-O)return F;if(u===0&&m>=a)return this.w32(w,n),this.view.setUint16(w+6,t.length,!0),this.u8.set(t,w+O),this.touchDirectoryMutation(e),this.updateDirIndexAdd(e,t,n,w,m),0;let E=Re(O+v),z=m-E;if(u!==0&&z>=a){this.view.setUint16(w+4,E,!0);let S=w+E;return this.w32(S,n),this.view.setUint16(S+4,z,!0),this.view.setUint16(S+6,t.length,!0),this.u8.set(t,S+O),this.touchDirectoryMutation(e),this.updateDirIndexAdd(e,t,n,S,z),0}c=w,p+=m}l+=g}return this.dirAppendEntry(e,t,n,c)}dirRemoveEntry(e,t){let n=this.getDirIndex(e);if(typeof n=="number")return n;if(n){let a=this.dirNameKey(t),c=n.entries.get(a);if(!c)return ye;if(this.r32(c.abs)===c.ino&&this.view.getUint16(c.abs+4,!0)===c.recLen&&this.view.getUint16(c.abs+6,!0)===c.nameLen&&this.dirEntryNameMatches(c.abs,t))return this.w32(c.abs,0),n.entries.delete(a),n.free.push({abs:c.abs,recLen:c.recLen}),this.touchDirectoryMutation(e),0;n.entries.delete(a)}let i=this.inodeOffset(e),o=this.r64(i+T),s=0;for(;s4096-c&&(f=4096-c);let d=c;for(;d4096-l&&(d=4096-l);let y=l;for(;y4096-s&&(l=4096-s);let h=s;for(;hs+l||g>y-O)throw new b(F);if(d!==0){if(g===1&&this.u8[f+O]===46){h+=y;continue}if(g===2&&this.u8[f+O]===46&&this.u8[f+O+1]===46){h+=y;continue}return!1}h+=y}i+=l}return!0}dirIsAncestor(e,t){let n=t;for(let i=0;i<8*1024;i++){if(n===e)return!0;if(n===1)return!1;let o=this.dirLookup(n,Yr);if(o<0||o===n)throw new b(F);n=o}throw new b(F)}pathResolve(e,t){if(!e.startsWith("/"))return ye;let n=1,i=e.split("/").filter(s=>s.length>0),o=0;for(let s=0;s255)return $n;let c=ce.encode(a),l;this.inodeReadLock(n);try{let d=this.inodeOffset(n);if((this.r32(d+C)&K)!==U)return Se;l=this.dirLookup(n,c)}finally{this.inodeReadUnlock(n)}if(l<0)return l;let h=this.inodeOffset(l);if((this.r32(h+C)&K)===xt&&(!(s===i.length-1)||t)){if(++o>8)return Jr;let y=this.r64(h+T),g;if(y<=40)g=it(this.u8.subarray(h+Y,h+Y+y));else{let p=new Uint8Array(y);this.inodeReadData(l,0,p,y),g=Ot.decode(p)}if(g.startsWith("/")){n=1;let p=g.split("/").filter(u=>u.length>0),w=i.slice(s+1);i.length=0,i.push(...p,...w),s=-1}else{let p=g.split("/").filter(u=>u.length>0),w=i.slice(s+1);i.length=s,i.push(...p,...w),s--}continue}n=l}return n}pathResolveParent(e){if(!e.startsWith("/"))throw new b(W,"Path must be absolute");let t=e.split("/").filter(c=>c.length>0);if(t.length===0)throw new b(W,"Cannot operate on /");let n=t.pop();if(n.length>255)throw new b($n);let i="/"+t.join("/"),o=this.pathResolve(i,!0);if(o<0)throw new b(o);let s=this.inodeOffset(o);if((this.r32(s+C)&K)!==U)throw new b(Se);return{parentIno:o,name:n}}fdAlloc(e,t,n){for(let i=0;i>2;if(Atomics.compareExchange(this.i32,s,0,1)===0)return this.w32(o+Hr,e),this.w64(o+Ke,0),this.w32(o+Vr,t),this.w32(o+qr,n?1:0),this.inodeAddOpenRef(e)?i:(Atomics.store(this.i32,s,0),ye)}return Xr}fdGet(e){if(e<0||e>=Vt)return null;let t=256+e*24;return Atomics.load(this.i32,t>>2)?{base:t,ino:this.r32(t+Hr),offset:this.r64(t+Ke),flags:this.r32(t+Vr),isDir:this.r32(t+qr)!==0}:null}fdFree(e){if(e>=0&&e>2,0)}}buildStat(e){let t=this.inodeOffset(e);return{ino:e,generation:this.r64(t+oe),dataSequence:this.r32(t+ae),mode:this.r32(t+C),linkCount:this.r32(t+D),size:this.r64(t+T),mtime:this.r64(t+ie),ctime:this.r64(t+q),atime:this.r64(t+At),uid:this.r32(t+Gr),gid:this.r32(t+Wr)}}namespaceEntryIdentity(e){let t=this.inodeOffset(e);return{ino:e,generation:this.r64(t+oe),linkCount:this.r32(t+D),mode:this.r32(t+C)}}open(e,t,n=420){return this.withNamespaceLock(()=>this.openUnlocked(e,t,n))}createLazyStub(e,t){return this.withNamespaceLock(()=>{let n=this.openUnlocked(e,Dr|Pt,t);try{let i=this.fdGet(n);if(!i)throw new b(Q);this.inodeWriteLock(i.ino);try{return this.inodeTruncate(i.ino,0,!0),this.buildStat(i.ino)}finally{this.inodeWriteUnlock(i.ino)}}finally{this.closeUnlocked(n)}})}replaceIfIdentity(e,t,n,i,o){return this.withNamespaceLock(()=>{let s=this.pathResolve(e,!0);if(s<0||s!==t)return!1;let a=this.inodeOffset(s);if(this.r64(a+oe)!==n||this.r32(a+ae)!==i||(this.r32(a+C)&K)!==kt)return!1;this.validateFileSize(o.byteLength),this.inodeWriteLock(s);try{if(this.r64(a+oe)!==n||this.r32(a+ae)!==i||this.r64(a+T)!==0)return!1;let c=this.r64(a+ie),l=this.r64(a+q);this.inodeTruncate(s,0,!0);let h=o.byteLength>0?this.inodeWriteData(s,0,o,o.byteLength):0;if(h!==o.byteLength)throw this.inodeTruncate(s,0,!0),Atomics.store(this.i32,a+ae>>2,i),this.w64(a+ie,c),this.w64(a+q,l),new b(h<0?h:X);return!0}finally{this.inodeWriteUnlock(s)}})}replaceManyIfIdentities(e){return e.length===0?!0:this.withNamespaceLock(()=>{let t=[],n=new Set;for(let o of e){this.validateFileSize(o.data.byteLength);let s=-1;for(let a of o.paths){let c=this.pathResolve(a,!0);if(c!==o.expectedIno)continue;let l=this.inodeOffset(c);if(this.r64(l+oe)===o.expectedGeneration&&this.r32(l+ae)===o.expectedDataSequence&&(this.r32(l+C)&K)===kt&&this.r64(l+T)===0){s=c;break}}if(s<0)return!1;if(n.has(s))throw new b(W,"duplicate conditional replacement inode");n.add(s),t.push({...o,ino:s})}let i=[...n].sort((o,s)=>o-s);for(let o of i)this.inodeWriteLock(o);try{for(let a of t){let c=this.inodeOffset(a.ino);if(this.r64(c+oe)!==a.expectedGeneration||this.r32(c+ae)!==a.expectedDataSequence||(this.r32(c+C)&K)!==kt||this.r64(c+T)!==0)return!1}let o=t.map(a=>{let c=this.inodeOffset(a.ino);return{ino:a.ino,dataSequence:this.r32(c+ae),mtime:this.r64(c+ie),ctime:this.r64(c+q)}}),s=0;try{for(let a of t){s++,this.inodeTruncate(a.ino,0,!0);let c=a.data.byteLength>0?this.inodeWriteData(a.ino,0,a.data,a.data.byteLength):0;if(c!==a.data.byteLength)throw new b(c<0?c:X)}}catch(a){for(let c=s-1;c>=0;c--){let l=o[c],h=this.inodeOffset(l.ino);this.inodeTruncate(l.ino,0,!0),Atomics.store(this.i32,h+ae>>2,l.dataSequence),this.w64(h+ie,l.mtime),this.w64(h+q,l.ctime)}throw a}return!0}finally{for(let o=i.length-1;o>=0;o--)this.inodeWriteUnlock(i[o])}})}openUnlocked(e,t,n=420){let i=t&qt,o=(t&Pt)!==0,s=(t&Un)!==0;if(o&&s){let f=this.pathResolve(e,!1);if(f>=0)throw new b(st);if(f!==ye)throw new b(f)}let a=this.pathResolve(e,!0);if(a<0&&a===ye&&o){let{parentIno:f,name:d}=this.pathResolveParent(e);this.inodeWriteLock(f);try{let y=ce.encode(d),g=this.dirLookup(f,y);if(g>=0){if(s)throw new b(st);a=g}else{let p=this.inodeAlloc();if(p<0)throw new b(X);let w=this.inodeOffset(p);this.w32(w+C,kt|n&4095),this.w32(w+D,1),this.w64(w+T,0);let u=Date.now();this.w64(w+At,u),this.w64(w+ie,u),this.w64(w+q,u);let m=this.dirAddEntry(f,y,p);if(m<0)throw this.inodeFree(p),new b(m);a=p}}finally{this.inodeWriteUnlock(f)}}if(a<0)throw new b(a);let c=this.inodeOffset(a),l=this.r32(c+C);if((l&K)===U&&i!==Je)throw new b(Ge);if(t&js&&(l&K)!==U)throw new b(Se);if(t&Tt){if((l&K)===U)throw new b(Ge);this.inodeWriteLock(a),this.inodeTruncate(a,0,!0),this.inodeWriteUnlock(a)}let h=this.fdAlloc(a,t,!1);if(h<0)throw new b(h);return h}close(e){this.withNamespaceLock(()=>this.closeUnlocked(e))}closeUnlocked(e){let t=this.fdGet(e);if(!t)throw new b(Q);this.fdFree(e),this.inodeDropOpenRef(t.ino)}read(e,t){let n=this.fdGet(e);if(!n)throw new b(Q);let i=this.inodeOffset(n.ino);if((this.r32(i+C)&K)===U)throw new b(Ge);this.inodeReadLock(n.ino);try{let s=this.inodeReadData(n.ino,n.offset,t,t.length),a=256+e*24;return this.w64(a+Ke,n.offset+s),s}finally{this.inodeReadUnlock(n.ino)}}readAt(e,t,n){let i=this.fdGet(e);if(!i)throw new b(Q);let o=this.inodeOffset(i.ino);if((this.r32(o+C)&K)===U)throw new b(Ge);this.validateSeekPosition(n),this.inodeReadLock(i.ino);try{return this.inodeReadData(i.ino,n,t,t.length)}finally{this.inodeReadUnlock(i.ino)}}write(e,t){let n=this.fdGet(e);if(!n)throw new b(Q);if((n.flags&qt)===Je)throw new b(Q);this.inodeWriteLock(n.ino);try{let o=n.offset;if(n.flags&qs){let c=this.inodeOffset(n.ino);o=this.r64(c+T)}if(!Number.isSafeInteger(o)||o<0)throw new b(W);if(o>rt||t.length>rt-o)throw new b(Lt);let s=this.inodeWriteData(n.ino,o,t,t.length);if(s<0)return s;let a=256+e*24;return this.w64(a+Ke,o+s),s}finally{this.inodeWriteUnlock(n.ino)}}writeAt(e,t,n){let i=this.fdGet(e);if(!i)throw new b(Q);if((i.flags&qt)===Je)throw new b(Q);this.validateSeekPosition(n),this.inodeWriteLock(i.ino);try{if(n>rt||t.length>rt-n)throw new b(Lt);return this.inodeWriteData(i.ino,n,t,t.length)}finally{this.inodeWriteUnlock(i.ino)}}lseek(e,t,n){let i=this.fdGet(e);if(!i)throw new b(Q);let o;if(n===Ys)o=t;else if(n===Xs)o=i.offset+t;else if(n===Js){let a=this.inodeOffset(i.ino);o=this.r64(a+T)+t}else throw new b(W);this.validateSeekPosition(o);let s=256+e*24;return this.w64(s+Ke,o),o}ftruncate(e,t){let n=this.fdGet(e);if(!n)throw new b(Q);if((n.flags&qt)===Je)throw new b(Q);this.validateFileSize(t),this.inodeWriteLock(n.ino);try{this.inodeTruncate(n.ino,t,!0)}finally{this.inodeWriteUnlock(n.ino)}}fstat(e){let t=this.fdGet(e);if(!t)throw new b(Q);this.inodeReadLock(t.ino);try{return this.buildStat(t.ino)}finally{this.inodeReadUnlock(t.ino)}}stat(e){return this.withNamespaceLock(()=>this.statUnlocked(e))}statUnlocked(e){let t=this.pathResolve(e,!0);if(t<0)throw new b(t);this.inodeReadLock(t);try{return this.buildStat(t)}finally{this.inodeReadUnlock(t)}}lstat(e){return this.withNamespaceLock(()=>this.lstatUnlocked(e))}lstatUnlocked(e){let t=this.pathResolve(e,!1);if(t<0)throw new b(t);this.inodeReadLock(t);try{return this.buildStat(t)}finally{this.inodeReadUnlock(t)}}unlink(e){return this.withNamespaceLock(()=>this.unlinkUnlocked(e))}unlinkUnlocked(e){let{parentIno:t,name:n}=this.pathResolveParent(e),i=ce.encode(n),o=e.length>1&&e.endsWith("/");this.inodeWriteLock(t);try{let s=this.dirLookup(t,i);if(s<0)throw new b(s);let a=this.inodeOffset(s),c=this.r32(a+C);if(o&&(c&K)!==U)throw new b(Se);if((c&K)===U)throw new b(Ge);let l=this.namespaceEntryIdentity(s),h=this.dirRemoveEntry(t,i);if(h<0)throw new b(h);let f=!1;this.inodeWriteLock(s);try{f=this.inodeDropLinkRefLocked(s)}finally{this.inodeWriteUnlock(s)}return f&&this.inodeFree(s),l}finally{this.inodeWriteUnlock(t)}}rename(e,t){return this.withNamespaceLock(()=>this.renameUnlocked(e,t))}renameUnlocked(e,t){let{parentIno:n,name:i}=this.pathResolveParent(e),{parentIno:o,name:s}=this.pathResolveParent(t);if(Mn(i)||Mn(s))throw new b(W);let a=ce.encode(i),c=ce.encode(s),l=e.length>1&&e.endsWith("/"),h=t.length>1&&t.endsWith("/"),f=Math.min(n,o),d=Math.max(n,o);this.inodeWriteLock(f),f!==d&&this.inodeWriteLock(d);try{let y=this.dirLookup(n,a);if(y<0)throw new b(y);let g=this.inodeOffset(y),w=this.r32(g+C)&K,u=this.namespaceEntryIdentity(y);if((l||h)&&w!==U)throw new b(Se);if(w===U&&this.dirIsAncestor(y,o))throw new b(W);let m=this.dirLookup(o,c),v=!1,E;if(m>=0){if(m===y)return{source:u,replaced:u};E=this.namespaceEntryIdentity(m);let S=this.inodeOffset(m),I=this.r32(S+C)&K;if(w===U&&I!==U)throw new b(Se);if(w!==U&&I===U)throw new b(Ge);let A=!1,B=m===n||m===o;B||this.inodeWriteLock(m);try{if(I===U&&!this.dirIsEmpty(m))throw new b(Dn);let N=this.dirReplaceEntryIno(o,c,y);if(N<0)throw new b(N);A=I===U?this.inodeOrphanLocked(m):this.inodeDropLinkRefLocked(m)}finally{B||this.inodeWriteUnlock(m)}A&&this.inodeFree(m),v=I===U}else{let S=this.dirAddEntry(o,c,y);if(S<0)throw new b(S)}let z=this.dirRemoveEntry(n,a);if(z<0)throw new b(z);if(w===U){if(n!==o){let S=this.inodeOffset(n);this.w32(S+D,this.r32(S+D)-1);let k=this.inodeOffset(o);this.w32(k+D,this.r32(k+D)+1),this.inodeWriteLock(y);try{let I=this.dirReplaceEntryIno(y,Yr,o);if(I<0)throw new b(I);this.w64(g+q,Date.now())}finally{this.inodeWriteUnlock(y)}}if(v){let S=this.inodeOffset(o);this.w32(S+D,this.r32(S+D)-1)}}else if(v){let S=this.inodeOffset(o);this.w32(S+D,this.r32(S+D)-1)}return{source:u,replaced:E}}finally{f!==d&&this.inodeWriteUnlock(d),this.inodeWriteUnlock(f)}}mkdir(e,t=493){this.withNamespaceLock(()=>this.mkdirUnlocked(e,t))}mkdirUnlocked(e,t=493){let{parentIno:n,name:i}=this.pathResolveParent(e),o=ce.encode(i);this.inodeWriteLock(n);try{if(this.dirLookup(n,o)>=0)throw new b(st);let a=this.inodeAlloc();if(a<0)throw new b(X);let c=this.inodeOffset(a);this.w32(c+C,U|t),this.w32(c+D,2),this.w64(c+T,0);let l=Date.now();this.w64(c+At,l),this.w64(c+ie,l),this.w64(c+q,l);let h=this.blockAllocWithGrow();if(h<0)throw this.inodeFree(a),new b(X);this.w32(c+Y,h);let f=h*4096,d=Re(O+1),y=Re(O+2);this.w32(f,a),this.view.setUint16(f+4,d,!0),this.view.setUint16(f+6,1,!0),this.u8[f+O]=46;let g=f+d;this.w32(g,n),this.view.setUint16(g+4,y,!0),this.view.setUint16(g+6,2,!0),this.u8[g+O]=46,this.u8[g+O+1]=46,this.w64(c+T,d+y);let p=this.dirAddEntry(n,o,a);if(p<0)throw this.blockFree(h),this.inodeFree(a),new b(p);let w=this.inodeOffset(n);this.w32(w+D,this.r32(w+D)+1)}finally{this.inodeWriteUnlock(n)}}rmdir(e){this.withNamespaceLock(()=>this.rmdirUnlocked(e))}rmdirUnlocked(e){let{parentIno:t,name:n}=this.pathResolveParent(e);if(Mn(n))throw new b(W);let i=ce.encode(n);this.inodeWriteLock(t);try{let o=this.dirLookup(t,i);if(o<0)throw new b(o);let s=this.inodeOffset(o);if((this.r32(s+C)&K)!==U)throw new b(Se);let c=!1;this.inodeWriteLock(o);try{if(!this.dirIsEmpty(o))throw new b(Dn);let h=this.dirRemoveEntry(t,i);if(h<0)throw new b(h);c=this.inodeOrphanLocked(o)}finally{this.inodeWriteUnlock(o)}c&&this.inodeFree(o);let l=this.inodeOffset(t);this.w32(l+D,this.r32(l+D)-1)}finally{this.inodeWriteUnlock(t)}}symlink(e,t){this.withNamespaceLock(()=>this.symlinkUnlocked(e,t))}symlinkUnlocked(e,t){let{parentIno:n,name:i}=this.pathResolveParent(t),o=ce.encode(i),s=ce.encode(e);this.inodeWriteLock(n);try{if(this.dirLookup(n,o)>=0)throw new b(st);let c=this.inodeAlloc();if(c<0)throw new b(X);let l=this.inodeOffset(c);if(this.w32(l+C,xt|511),this.w32(l+D,1),s.length<=40)this.u8.set(s,l+Y),this.w64(l+T,s.length);else{this.w64(l+T,0);let f=this.inodeWriteData(c,0,s,s.length);if(f!==s.length)throw f>0&&this.inodeTruncate(c,0),this.inodeFree(c),new b(f<0?f:X)}let h=this.dirAddEntry(n,o,c);if(h<0)throw s.length<=40?(this.u8.fill(0,l+Y,l+Y+40),this.w64(l+T,0)):this.inodeTruncate(c,0),this.inodeFree(c),new b(h)}finally{this.inodeWriteUnlock(n)}}chmod(e,t){this.withNamespaceLock(()=>this.chmodUnlocked(e,t))}chmodUnlocked(e,t){let n=this.pathResolve(e,!0);if(n<0)throw new b(n);this.inodeWriteLock(n);try{let i=this.inodeOffset(n),o=this.r32(i+C);this.w32(i+C,o&K|t&4095),this.w64(i+q,Date.now())}finally{this.inodeWriteUnlock(n)}}fchmod(e,t){let n=this.fdGet(e);if(!n)throw new b(Q);this.inodeWriteLock(n.ino);try{let i=this.inodeOffset(n.ino),o=this.r32(i+C);this.w32(i+C,o&K|t&4095),this.w64(i+q,Date.now())}finally{this.inodeWriteUnlock(n.ino)}}chown(e,t,n){this.withNamespaceLock(()=>this.chownUnlocked(e,t,n))}chownUnlocked(e,t,n){let i=this.pathResolve(e,!0);if(i<0)throw new b(i);this.inodeWriteLock(i);try{this.chownInodeUnlocked(i,t,n)}finally{this.inodeWriteUnlock(i)}}fchown(e,t,n){let i=this.fdGet(e);if(!i)throw new b(Q);this.inodeWriteLock(i.ino);try{this.chownInodeUnlocked(i.ino,t,n)}finally{this.inodeWriteUnlock(i.ino)}}lchown(e,t,n){this.withNamespaceLock(()=>this.lchownUnlocked(e,t,n))}lchownUnlocked(e,t,n){let i=this.pathResolve(e,!1);if(i<0)throw new b(i);this.inodeWriteLock(i);try{this.chownInodeUnlocked(i,t,n)}finally{this.inodeWriteUnlock(i)}}chownInodeUnlocked(e,t,n){let i=this.inodeOffset(e);t!==$r&&this.w32(i+Gr,t),n!==$r&&this.w32(i+Wr,n);let o=this.r32(i+C);(o&K)===kt&&(o&Vs)!==0&&this.w32(i+C,o&~(Zs|Hs)),this.w64(i+q,Date.now())}utimens(e,t,n,i,o){this.withNamespaceLock(()=>this.utimensUnlocked(e,t,n,i,o))}utimensUnlocked(e,t,n,i,o){let s=this.pathResolve(e,!0);if(s<0)throw new b(s);this.inodeWriteLock(s);try{let a=this.inodeOffset(s),c=1073741823,l=1073741822,h=Date.now();if(n!==l){let f=n===c?h:t*1e3+Math.floor(n/1e6);this.w64(a+At,f)}if(o!==l){let f=o===c?h:i*1e3+Math.floor(o/1e6);this.w64(a+ie,f)}this.w64(a+q,h)}finally{this.inodeWriteUnlock(s)}}link(e,t){return this.withNamespaceLock(()=>this.linkUnlocked(e,t))}linkUnlocked(e,t){let n=this.pathResolve(e,!1);if(n<0)throw new b(n);let i=this.inodeOffset(n);if((this.r32(i+C)&K)===U)throw new b(Qs);let{parentIno:s,name:a}=this.pathResolveParent(t),c=ce.encode(a);this.inodeWriteLock(s);try{if(this.dirLookup(s,c)>=0)throw new b(st);let h=this.dirAddEntry(s,c,n);if(h<0)throw new b(h);this.inodeWriteLock(n);try{let f=this.r32(i+D);this.w32(i+D,f+1),this.w64(i+q,Date.now())}finally{this.inodeWriteUnlock(n)}return{...this.namespaceEntryIdentity(n),linkCount:this.r32(i+D)}}finally{this.inodeWriteUnlock(s)}}readlink(e){return this.withNamespaceLock(()=>this.readlinkUnlocked(e))}readlinkUnlocked(e){let t=this.pathResolve(e,!1);if(t<0)throw new b(t);let n=this.inodeOffset(t);if((this.r32(n+C)&K)!==xt)throw new b(W);let o=this.r64(n+T);if(o<=40)return it(this.u8.subarray(n+Y,n+Y+o));this.inodeReadLock(t);try{let s=new Uint8Array(o);return this.inodeReadData(t,0,s,o),Ot.decode(s)}finally{this.inodeReadUnlock(t)}}opendir(e){return this.withNamespaceLock(()=>this.opendirUnlocked(e))}opendirUnlocked(e){let t=this.pathResolve(e,!0);if(t<0)throw new b(t);let n=this.inodeOffset(t);if((this.r32(n+C)&K)!==U)throw new b(Se);let o=this.fdAlloc(t,Je,!0);if(o<0)throw new b(o);return o}readdirEntry(e){return this.withNamespaceLock(()=>this.readdirEntryUnlocked(e))}readdirEntryUnlocked(e){let t=this.fdGet(e);if(!t||!t.isDir)throw new b(Q);let n=this.inodeOffset(t.ino),i=this.r64(n+T);for(;t.offset=this.r32(Oe))throw new b(F);let p=this.r32(Ue)*4096;if((this.r32(p+(h>>5)*4)&1<<(h&31))===0)throw new b(F);let u=it(this.u8.subarray(l+O,l+O+d)),m=this.buildStat(h);return this.w64(g+Ke,y),t.offset=y,{name:u,stat:m}}return null}closedir(e){this.close(e)}readdir(e){let t=this.opendir(e),n=[];try{let i;for(;(i=this.readdirEntry(t))!==null;)i.name!=="."&&i.name!==".."&&n.push(i.name)}finally{this.closedir(t)}return n}writeFile(e,t){let n=typeof t=="string"?ce.encode(t):t,i=this.open(e,Dr|Pt|Tt);try{this.write(i,n)}finally{this.close(i)}}readFile(e){let t=this.open(e,Je);try{let n=this.fstat(t),i=new Uint8Array(n.size);return this.read(t,i),i}finally{this.close(t)}}readFileText(e){return Ot.decode(this.readFile(e))}};function ei(r,e){let t=new Map,n=new Map;for(let s of r){if(t.has(s.path))throw new Error(`${e} duplicates path ${s.path}`);if(t.set(s.path,s),s.type==="file"){if(!s.inodeGroup)throw new Error(`${e} file ${s.path} has no inode group`);if(n.has(s.inodeGroup))throw new Error(`${e} inode group ${s.inodeGroup} has multiple files`);n.set(s.inodeGroup,s)}}let i=new Set,o=new Map;for(let s of r){if(s.type!=="hardlink"||o.has(s.path))continue;let a=[],c=s,l;for(;c.type==="hardlink";){let f=o.get(c.path);if(f){l=f;break}if(i.has(c.path))throw new Error(`${e} hardlink cycle reaches ${c.path}`);if(i.add(c.path),a.push(c),!c.target)throw new Error(`${e} hardlink ${c.path} has no target`);let d=t.get(c.target);if(!d)throw new Error(`${e} hardlink ${c.path} target ${c.target} is missing`);if(d.type!=="file"&&d.type!=="hardlink"||!c.inodeGroup||d.inodeGroup!==c.inodeGroup||d.size!==c.size||d.mode!==c.mode)throw new Error(`${e} hardlink ${c.path} has an invalid target`);c=d}l??=c.type==="file"?c:void 0;let h=n.get(s.inodeGroup??"");if(!l||l!==h)throw new Error(`${e} hardlink ${s.path} does not resolve to its inode`);for(let f=a.length-1;f>=0;f-=1){let d=a[f];if(n.get(d.inodeGroup??"")!==l)throw new Error(`${e} hardlink ${d.path} does not resolve to its inode`);i.delete(d.path),o.set(d.path,l)}}return{canonicalByGroup:n,canonicalTargetByPath:o}}var le={maxArchiveBytes:268435456,maxExpandedBytes:268435456,maxPayloadBytes:268435456,maxEntries:1e5,maxPathBytes:4096,maxSymlinkTargetBytes:65536,maxStringBytes:8192,maxTransportsPerTree:8,maxActivationCapabilities:32,maxActivationRoots:64,maxActivationCapabilityBytes:255},we={maxArchiveBytes:512*1024*1024,maxExpandedBytes:512*1024*1024,maxPayloadBytes:512*1024*1024,maxEntries:1e5,maxGroups:512};function ti(r,e="Deferred tree collection"){for(let[t,n]of Object.entries(r))if(!Number.isSafeInteger(n)||n<0)throw new Error(`${e} ${t} usage is invalid`);if(r.groups>we.maxGroups)throw new Error(`${e} exceeds the ${we.maxGroups}-group cap`);if(r.archiveBytes>we.maxArchiveBytes)throw new Error(`${e} exceeds the archive-byte cap`);if(r.expandedBytes>we.maxExpandedBytes)throw new Error(`${e} exceeds the expansion cap`);if(r.payloadBytes>we.maxPayloadBytes)throw new Error(`${e} exceeds the payload-byte cap`);if(r.entries>we.maxEntries)throw new Error(`${e} exceeds the entry-count cap`)}var ot="/home/linuxbrew/.linuxbrew",ii=[["@@HOMEBREW_PREFIX@@",ot],["@@HOMEBREW_CELLAR@@",`${ot}/Cellar`],["@@HOMEBREW_REPOSITORY@@",ot],["@@HOMEBREW_LIBRARY@@",`${ot}/Library`],["@@HOMEBREW_PERL@@",`${ot}/opt/perl/bin/perl`]],Kn="@@HOMEBREW_JAVA@@",oo=/^openjdk(?:@\d+(?:\.\d+)*)?/,at=new TextEncoder,ao=[...ii.map(([r])=>r),Kn].map(r=>({placeholder:r,bytes:at.encode(r)}));function si(r){let e;try{e=JSON.parse(new TextDecoder("utf-8",{fatal:!0}).decode(r))}catch(a){throw new Error("INSTALL_RECEIPT.json is not valid UTF-8 JSON: "+uo(a))}if(typeof e!="object"||e===null||Array.isArray(e))throw new Error("INSTALL_RECEIPT.json must contain an object");let t=e,n=t.changed_files;if(n!=null&&!Array.isArray(n))throw new Error("INSTALL_RECEIPT.json changed_files must be an array or null when present");let i=Array.isArray(n)?n:[];if(i.length>1e5)throw new Error(`INSTALL_RECEIPT.json declares ${i.length} changed files, limit 100000`);let o=[],s=new Set;for(let[a,c]of i.entries()){if(typeof c!="string")throw new Error(`INSTALL_RECEIPT.json changed_files[${a}] is not a string`);if(lo(c,"Homebrew changed file"),s.has(c))throw new Error(`INSTALL_RECEIPT.json repeats changed file ${c}`);s.add(c),o.push(c)}return{changedFiles:o,runtimeDependencies:t.runtime_dependencies}}function oi(r,e,t){let n=r;for(let[s,a]of ii)n=ri(n,at.encode(s),at.encode(a));let i=at.encode(Kn);if(ni(n,i)){let s=co(e.runtimeDependencies);if(s===void 0)throw new Error(`Homebrew changed file ${t} uses ${Kn} without exactly one OpenJDK runtime dependency`);n=ri(n,i,at.encode(s))}let o=ao.find(({bytes:s})=>ni(n,s));if(o!==void 0)throw new Error(`Homebrew changed file ${t} retains ${o.placeholder}`);return n}function co(r){if(!Array.isArray(r))return;let e=[];for(let n of r){if(typeof n!="object"||n===null||Array.isArray(n))continue;let i=n,o=typeof i.full_name=="string"?i.full_name.split("/").at(-1):typeof i.name=="string"?i.name.split("/").at(-1):void 0,s=o===void 0?null:oo.exec(o);o!==void 0&&s?.[0]===o&&e.push(o)}let t=[...new Set(e)];return t.length===1?`${ot}/opt/${t[0]}/libexec`:void 0}function lo(r,e){if(r.length===0||r.startsWith("/")||r.includes("\\")||r.includes("\0")||fo(r)||at.encode(r).byteLength>4096||r.split("/").some(t=>t===""||t==="."||t===".."))throw new Error(`${e} has an unsafe path segment: ${r}`)}function fo(r){for(let e=0;e57343)){if(t<=56319&&e+1=56320&&r.charCodeAt(e+1)<=57343){e+=1;continue}return!0}}return!1}function ni(r,e){if(e.byteLength===0||e.byteLength>r.byteLength)return!1;e:for(let t=0;t<=r.byteLength-e.byteLength;t+=1){for(let n=0;ngn||r.includes("\0")||r.includes("\\"))throw new Error(`Lazy archive mount prefix must be an absolute POSIX path: ${JSON.stringify(r)}`);let e=r.replace(/\/+$/,"");if(e==="")return"/";if(e.slice(1).split("/").some(n=>n===""||n==="."||n===".."))throw new Error(`Lazy archive mount prefix is not canonical: ${JSON.stringify(r)}`);return e}function ga(r,e,t,n){let i=pn(t),o=new Map,s=e.map(a=>{let c=a.fileName,l=`Lazy archive ${JSON.stringify(r)} member ${JSON.stringify(c)}`;if(c.length===0)throw new Error(`${l} has an empty path`);if(c.includes("\0"))throw new Error(`${l} contains a NUL byte`);if(c.includes("\\"))throw new Error(`${l} contains a backslash`);if(c.startsWith("/")||/^[A-Za-z]:\//.test(c))throw new Error(`${l} must be relative, not absolute`);if(a.isDirectory&&a.isSymlink)throw new Error(`${l} has conflicting directory and symlink types`);if(a.isDirectory!==c.endsWith("/"))throw new Error(`${l} has inconsistent directory metadata`);let h=a.isDirectory?c.slice(0,-1):c,f=h.split("/");if(h.length===0||f.some(d=>d===""||d==="."||d===".."))throw new Error(`${l} is not a canonical relative POSIX path`);if(o.has(h))throw new Error(`${l} collides with another member at ${JSON.stringify(h)}`);if(a.isSymlink&&!n?.has(c))throw new Error(`Lazy archive symlink target was not provided: ${c}`);return o.set(h,a),{entry:a,archivePath:h,vfsPath:i==="/"?`/${h}`:`${i}/${h}`}});for(let{archivePath:a}of s){let c=a.split("/");for(let l=1;ldt)throw new Error(`VFS image metadata exceeds ${dt} bytes`);let e;try{e=JSON.parse(new TextDecoder().decode(r))}catch(t){let n=t instanceof Error?t.message:String(t);throw new Error(`Invalid VFS image metadata JSON: ${n}`)}return hr(e)}function wa(r){if(r===null)return new Uint8Array(0);let e=hr(r),t=new TextEncoder().encode(JSON.stringify(e));if(t.byteLength>dt)throw new Error(`VFS image metadata exceeds ${dt} bytes`);return t}function va(r){return r.byteLength>=Mt.length&&r[0]===Mt[0]&&r[1]===Mt[1]&&r[2]===Mt[2]&&r[3]===Mt[3]?Ta(r):r}function rn(r){let e=va(r);if(e.byteLengthon)throw new Error(`VFS image lazy metadata exceeds ${on} bytes`);if(r.byteLengthan)throw new Error(`VFS image lazy archive metadata exceeds ${an} bytes`);if(r.byteLength=0?n:void 0}function za(r){return r===408||r===429||r>=500&&r<=599}function ba(r,e=Date.now()){let t=r?.get("retry-after")?.trim();if(!t)return;let n;if(/^\d+$/.test(t))n=Number(t)*1e3;else{let i=Date.parse(t);if(!Number.isFinite(i))return;n=Math.max(0,i-e)}if(!(!Number.isSafeInteger(n)||n<0))return Math.min(n,Ui)}function ka(r){if(!(typeof r!="object"||r===null||!("cause"in r)))return r.cause}function Ki(r){if(!(typeof r!="object"||r===null||!("name"in r)))return typeof r.name=="string"?r.name:void 0}function Gi(r){if(!(typeof r!="object"||r===null||!("code"in r)))return typeof r.code=="string"?r.code:void 0}function Wi(r,e){let t=new Set,n=r;for(let i=0;n!==void 0&&i<8;i+=1){if(t.has(n))return!1;if(t.add(n),e(n))return!0;n=ka(n)}return!1}function Zi(r){return Wi(r,e=>Ki(e)==="AbortError"||Gi(e)==="ABORT_ERR")}function xa(r){return Zi(r)?!1:Wi(r,e=>{let t=Ki(e),n=Gi(e);return e instanceof TypeError||t==="NetworkError"||t==="TimeoutError"||n!==void 0&&ya.has(n)})}function Ia(r,e){if(r instanceof un){if(!za(r.status))return null;if(r.retryAfterMs!==void 0)return r.retryAfterMs}else if(!xa(r))return null;return Math.min(da*2**e,Ui)}function ee(r){if(r?.aborted)throw r.reason}function Aa(r,e){return ee(e),r===0?Promise.resolve():new Promise((t,n)=>{let i=setTimeout(()=>a(!1),r),o=()=>a(!0,e.reason),s=!1;function a(c,l){s||(s=!0,clearTimeout(i),e?.removeEventListener("abort",o),c?n(l):t())}e?.addEventListener("abort",o,{once:!0}),e?.aborted&&o()})}async function or(r,e){try{await r.body?.cancel(e)}catch{}}function _a(r,e){if(r.length===1)return r[0];let t=new Uint8Array(e),n=0;for(let i of r)t.set(i,n),n+=i.byteLength;return t}function Ft(r){if(r===void 0)return;if(typeof r!="object"||r===null||Array.isArray(r))throw new Error("Lazy archive integrity must be an object");let e=r;if(Object.keys(e).length!==2||!("sha256"in e)||!("bytes"in e))throw new Error("Lazy archive integrity has unexpected fields");if(typeof e.sha256!="string"||!ha.test(e.sha256))throw new Error("Lazy archive integrity has an invalid SHA-256 digest");if(!Number.isSafeInteger(e.bytes)||Number(e.bytes)<=0||Number(e.bytes)>Ti)throw new Error(`Lazy archive integrity byte count must be between 1 and ${Ti}`);return{sha256:e.sha256,bytes:Number(e.bytes)}}function We(r,e,t){if(typeof r!="object"||r===null||Array.isArray(r))throw new Error(`${t} must be an object`);let n=r;if(Object.keys(n).length!==e.length||e.some(o=>!Object.prototype.hasOwnProperty.call(n,o)))throw new Error(`${t} has unexpected or missing fields`);return n}function fr(r,e,t,n){if(typeof r!="object"||r===null||Array.isArray(r))throw new Error(`${n} must be an object`);let i=r,o=new Set(e);if(Object.keys(i).some(s=>!o.has(s))||t.some(s=>!Object.prototype.hasOwnProperty.call(i,s)))throw new Error(`${n} has unexpected or missing fields`);return i}function xe(r,e,t,n){if(!Array.isArray(r)||r.lengthn)throw new Error(`${e} must contain ${t} to ${n} items`);return r}function Be(r,e,t){if(typeof r!="string"||r.length===0||r.includes("\0")||new TextEncoder().encode(r).byteLength>t)throw new Error(`${e} is invalid or exceeds ${t} bytes`);return r}function ne(r,e,t,n){if(!Number.isSafeInteger(r)||Number(r)n)throw new Error(`${e} must be an integer between ${t} and ${n}`);return Number(r)}function dn(r,e=1){let t=r,n=typeof t=="object"&&t!==null&&!Array.isArray(t)&&t.source!==void 0,i=typeof t=="object"&&t!==null&&!Array.isArray(t)&&t.modePolicy!==void 0,o=We(r,["decoder","mediaType","sha256","bytes","expandedBytes","sourceEntryCount","transports",...i?["modePolicy"]:[],...n?["source"]:[]],"Lazy tree content"),s=o.decoder==="zip-v1"?"application/zip":o.decoder==="homebrew-bottle-tar-gzip-v1"?"application/vnd.oci.image.layer.v1.tar+gzip":null;if(s===null||o.mediaType!==s)throw new Error("Lazy tree decoder and media type are inconsistent");let a=Ft({sha256:o.sha256,bytes:o.bytes});if(!a)throw new Error("Lazy tree integrity is required");let c=xe(o.transports,"Lazy tree transports",e,le.maxTransportsPerTree).map((y,g)=>Be(y,`Lazy tree transport ${g}`,dr));if(new Set(c).size!==c.length)throw new Error("Lazy tree transports contain duplicates");let l=ne(o.expandedBytes,"Lazy tree expanded byte count",0,aa),h=ne(o.sourceEntryCount,"Lazy tree source entry count",1,ht),f=n?La(o.source,o.decoder):void 0,d=i?o.modePolicy:void 0;if(d!==void 0&&(d!=="portable-posix-v1"||o.decoder!=="zip-v1"||n))throw new Error("Lazy tree mode policy is invalid for its decoder");if(f!==void 0&&f.entries.length!==h)throw new Error("Lazy tree source inventory count differs from its content");return{decoder:o.decoder,mediaType:s,sha256:a.sha256,bytes:a.bytes,expandedBytes:l,sourceEntryCount:h,transports:c,...d===void 0?{}:{modePolicy:d},...f===void 0?{}:{source:f}}}function Hi(r){let e={groups:r.length,archiveBytes:0,expandedBytes:0,payloadBytes:0,entries:0};for(let t of r)t.content===void 0||t.inventory===void 0||(e.archiveBytes+=t.content.bytes,e.expandedBytes+=t.content.expandedBytes,e.payloadBytes+=t.inventory.filter(n=>n.type==="file").reduce((n,i)=>n+i.size,0),e.entries+=t.inventory.length+(t.content.source?.entries.length??0));return e}function ur(r){ti(r,"Serialized lazy tree collection")}function Fi(r){ur(Hi(r))}function La(r,e){if(e!=="homebrew-bottle-tar-gzip-v1")throw new Error("Lazy tree source inventory is valid only for original bottles");let t=We(r,["schema","kind","entries"],"Lazy tree source inventory");if(t.schema!==1||t.kind!=="homebrew-bottle-tar-gzip-v1")throw new Error("Lazy tree source inventory has an unsupported identity");let n=new Map,i=xe(t.entries,"Lazy tree source entries",1,ht).map((s,a)=>{let c=s,l=typeof c=="object"&&c!==null&&!Array.isArray(c)?c.type:void 0,h=l==="directory"||l==="file"?["sourcePath","type","mode","size"]:l==="symlink"||l==="hardlink"?["sourcePath","type","mode","size","target"]:null;if(h===null)throw new Error(`Lazy tree source entry ${a} has invalid type`);let f=We(s,h,`Lazy tree source entry ${a}`),d=ue(f.sourcePath,!1,`Lazy tree source entry ${a} path`);if(n.has(d))throw new Error(`Lazy tree source inventory duplicates ${d}`);let y=ne(f.mode,`Lazy tree source entry ${d} mode`,0,4095),g=ne(f.size,`Lazy tree source entry ${d} size`,0,cn),p;if((l==="directory"||l==="symlink"||l==="hardlink")&&g!==0)throw new Error(`Lazy tree source ${d} has payload for ${String(l)}`);l==="symlink"?p=Be(f.target,`Lazy tree source symlink ${d} target`,Di):l==="hardlink"&&(p=ue(f.target,!1,`Lazy tree source hardlink ${d} target`));let w={sourcePath:d,type:l,mode:y,size:g,...p===void 0?{}:{target:p}};return n.set(d,w),w}),o=i.map(s=>s.sourcePath);if(o.some((s,a)=>a>0&&o[a-1]>=s))throw new Error("Lazy tree source inventory is not in canonical path order");return{schema:1,kind:"homebrew-bottle-tar-gzip-v1",entries:i}}function Vi(r){let e=new Map(r.map(n=>[n.sourcePath,n])),t=new Map;for(let n of r){if(n.type!=="hardlink"||t.has(n.sourcePath))continue;let i=[],o=new Set,s=n,a;for(;s.type==="hardlink"&&(a=t.get(s.sourcePath),a===void 0);){if(o.has(s.sourcePath))throw new Error(`Lazy tree source hardlink cycle includes ${s.sourcePath}`);o.add(s.sourcePath),i.push(s);let c=e.get(s.target);if(c===void 0)throw new Error(`Lazy tree source hardlink ${s.sourcePath} target is absent`);if(c.type!=="file"&&c.type!=="hardlink")throw new Error(`Lazy tree source hardlink ${s.sourcePath} target is not regular`);s=c}a===void 0&&(a=s);for(let c of i)t.set(c.sourcePath,a)}return t}function ue(r,e,t,n=!1){if(typeof r!="string"||r.length===0||new TextEncoder().encode(r).byteLength>gn||r.includes("\0")||r.includes("\\")||r.startsWith("/")!==e)throw new Error(`${t} is not a canonical ${e?"absolute":"relative"} path`);if(n&&e&&r==="/")return r;if(r.slice(e?1:0).split("/").some(o=>o===""||o==="."||o===".."))throw new Error(`${t} has an unsafe path segment`);return r}function Pa(r){let e=We(r,["uid","gid"],"Lazy tree registration owner");return{uid:ne(e.uid,"Lazy tree registration owner uid",0,Ri),gid:ne(e.gid,"Lazy tree registration owner gid",0,Ri)}}function qi(r,e,t,n,i=1){let o=dn(r,i),s=pn(t),a=We(n,["mode","capabilities","roots"],"Lazy tree activation");if(a.mode!=="boot-prefetch"&&a.mode!=="first-use")throw new Error("Lazy tree activation mode is invalid");let c=xe(a.capabilities,"Lazy tree activation capabilities",1,fa).map((E,z)=>{let S=Be(E,`Lazy tree activation capability ${z}`,le.maxActivationCapabilityBytes);if(!/^[a-z0-9][a-z0-9:._-]*$/.test(S))throw new Error(`Lazy tree activation capability ${z} is invalid`);return S}),l=xe(a.roots,"Lazy tree activation roots",1,ua).map((E,z)=>ue(E,!0,`Lazy tree activation root ${z}`,!0));if(new Set(c).size!==c.length||new Set(l).size!==l.length)throw new Error("Lazy tree activation contains duplicates");let h={mode:a.mode,capabilities:c,roots:l},f=xe(e,"Lazy tree inventory",1,ht),d=[],y=new Map,g=new Map,p=o.source===void 0?void 0:new Map(o.source.entries.map(E=>[E.sourcePath,E])),w=o.source===void 0?void 0:Vi(o.source.entries),u=0;for(let[E,z]of f.entries()){if(typeof z!="object"||z===null||Array.isArray(z))throw new Error(`Lazy tree entry ${E} must be an object`);let S=z.type,k=S==="directory"?["vfsPath","sourcePath","type","mode","size"]:S==="file"?["vfsPath","sourcePath","type","mode","size","inodeGroup"]:S==="symlink"?["vfsPath","sourcePath","type","mode","size","target"]:S==="hardlink"?["vfsPath","sourcePath","type","mode","size","target","inodeGroup"]:null;if(!k)throw new Error(`Lazy tree entry ${E} has an invalid type`);let I=We(z,[...k,...p===void 0?[]:["materialization"]],`Lazy tree entry ${E}`),A=ue(I.vfsPath,!0,`Lazy tree entry ${E} VFS path`),B=ue(I.sourcePath,!1,`Lazy tree entry ${E} source path`),N=p===void 0?void 0:I.materialization;if(p!==void 0&&N!=="archive"&&N!=="archive-homebrew-relocate"&&N!=="archive-copy"&&N!=="archive-copy-mode"&&N!=="descriptor")throw new Error(`Lazy tree entry ${A} has invalid materialization provenance`);if(s!=="/"&&A!==s&&!A.startsWith(`${s}/`))throw new Error(`Lazy tree entry ${A} escapes its mount prefix`);if(y.has(A))throw new Error(`Lazy tree duplicates VFS path ${A}`);let te=ne(I.mode,`Lazy tree entry ${A} mode`,0,4095),j=ne(I.size,`Lazy tree entry ${A} size`,0,cn),R,P;if(S==="directory"){if(j!==0)throw new Error(`Lazy tree directory ${A} has nonzero size`)}else if(S==="symlink"){if(R=Be(I.target,`Lazy tree symlink ${A} target`,Di),new TextEncoder().encode(R).byteLength!==j)throw new Error(`Lazy tree symlink ${A} size differs from its target`)}else P=Be(I.inodeGroup,`Lazy tree entry ${A} inode group`,gn),S==="hardlink"&&(R=ue(I.target,!0,`Lazy tree hardlink ${A} target`));if(S!=="hardlink"&&(u+=j,u>cn))throw new Error("Lazy tree inventory exceeds the expansion limit");let _={vfsPath:A,sourcePath:B,...N===void 0?{}:{materialization:N},type:S,mode:te,size:j,...R===void 0?{}:{target:R},...P===void 0?{}:{inodeGroup:P}};if(p===void 0){let Z=g.get(B);if(Z){if(o.decoder!=="zip-v1"||_.type!=="hardlink"||Z.inodeGroup!==_.inodeGroup)throw new Error(`Lazy tree duplicates source path ${B}`)}else{if(o.decoder==="zip-v1"&&_.type==="hardlink")throw new Error(`Lazy ZIP hardlink ${A} does not reuse a canonical source path`);g.set(B,_)}}else if(_.materialization==="descriptor"){if(_.type!=="directory"&&_.type!=="symlink")throw new Error(`Lazy tree descriptor entry ${A} is not structural`);if(p.has(B))throw new Error(`Lazy tree descriptor entry ${A} impersonates a source member`)}else{let Z=p.get(B);if(Z===void 0)throw new Error(`Lazy tree entry ${A} names absent source ${B}`);if(_.materialization==="archive-copy"||_.materialization==="archive-copy-mode"){if(_.type!=="file"||Z.type!=="file"||_.materialization==="archive-copy"&&_.mode!==Z.mode)throw new Error(`Lazy tree archive copy ${A} differs from its source`)}else if(_.materialization==="archive-homebrew-relocate"){if(_.type!=="file"&&_.type!=="hardlink"||Z.type!==_.type||_.type==="file"&&Z.mode!==_.mode)throw new Error(`Lazy tree receipt-relocated entry ${A} differs from its source`)}else if(Z.type!==_.type||_.type==="symlink"&&Z.target!==_.target||_.type!=="hardlink"&&Z.mode!==_.mode)throw new Error(`Lazy tree archive entry ${A} differs from its source`)}d.push(_),y.set(A,_)}for(let E of d){let z=E.vfsPath.split("/").filter(Boolean);for(let S=1;S({path:E.vfsPath,type:E.type,mode:E.mode,size:E.size,target:E.target,inodeGroup:E.inodeGroup})),"Lazy tree");if(p!==void 0){let E=new Set;for(let z of d){if(z.materialization!=="archive-homebrew-relocate")continue;let S=p.get(z.sourcePath),k=S.type==="file"?S:w.get(S.sourcePath);if(k?.type!=="file")throw new Error(`Lazy tree receipt-relocated entry ${z.vfsPath} is not regular`);E.add(k.sourcePath)}for(let z of d){if(z.materialization==="descriptor"||z.type!=="file"&&z.type!=="hardlink")continue;let S=p.get(z.sourcePath),k=S.type==="file"?S:w.get(S.sourcePath);if(k?.type!=="file"||!E.has(k.sourcePath)&&z.size!==k.size)throw new Error(`Lazy tree archive entry ${z.vfsPath} differs from its source`)}for(let z of d){if(z.type!=="hardlink"||z.materialization!=="archive"&&z.materialization!=="archive-homebrew-relocate")continue;let S=p.get(z.sourcePath),k=y.get(z.target),I=w.get(S.sourcePath);if(S.target!==k?.sourcePath||I?.type!=="file"||I.mode!==z.mode||k?.mode!==z.mode)throw new Error(`Lazy tree hardlink ${z.vfsPath} differs from its source`)}}if(o.sourceEntryCount!==(p===void 0?g.size:p.size))throw new Error("Lazy tree source entry count differs from its inventory");if(o.source===void 0&&o.expandedBytesz.vfsPath===E||z.vfsPath.startsWith(`${E}/`)))throw new Error(`Lazy tree activation root ${E} is not owned by its inventory`);let v=new Map;for(let E of d)E.type==="file"&&v.set(E.inodeGroup,E);if(v.size!==m.canonicalByGroup.size)throw new Error("Lazy tree regular inode inventory is inconsistent");return{content:o,entries:d,mountPrefix:s,activation:h,canonicalByGroup:v}}function hn(r){return JSON.stringify([r.sourcePath,r.type,r.inodeGroup,r.target])}function $i(r,e){let t=fr(r,["kind","content","url","mountPrefix","integrity","materialized","entries"],["url","mountPrefix","materialized","entries"],"Serialized legacy lazy archive");if(t.kind===void 0){if(!e)throw new Error("Serialized lazy archive is missing its kind discriminator")}else if(t.kind!==ln)throw new Error("Serialized legacy lazy archive has an unsupported kind");let n=Be(t.url,"Serialized legacy lazy archive URL",dr),i=pn(t.mountPrefix),o=Ft(t.integrity);if(t.content!==void 0){if(!e||t.kind!==void 0)throw new Error("Typed legacy lazy archives cannot carry generic content");let c=dn(t.content);if(c.decoder!=="zip-v1"||c.transports.length!==1||c.transports[0]!==n||!o||c.sha256!==o.sha256||c.bytes!==o.bytes)throw new Error("Untagged legacy ZIP content identity is inconsistent")}if(t.materialized!==!1)throw new Error("Serialized legacy lazy archive must describe pending content");let s=new Set,a=xe(t.entries,"Serialized legacy lazy archive entries",1,ht).map((c,l)=>{let h=fr(c,["vfsPath","ino","generation","dataSequence","size","isSymlink","deleted","materialized","archivePath","sourcePath","type","inodeGroup","target"],["vfsPath","ino","size","isSymlink","deleted"],`Serialized legacy lazy archive entry ${l}`),f=ue(h.vfsPath,!0,`Serialized legacy lazy archive entry ${l} VFS path`);if(s.has(f))throw new Error(`Serialized legacy lazy archive duplicates path ${f}`);s.add(f);let d=ne(h.ino,`Serialized legacy lazy archive entry ${f} inode`,1,Number.MAX_SAFE_INTEGER),y=h.generation===void 0?void 0:ne(h.generation,`Serialized legacy lazy archive entry ${f} generation`,0,Number.MAX_SAFE_INTEGER),g=h.dataSequence===void 0?void 0:ne(h.dataSequence,`Serialized legacy lazy archive entry ${f} data sequence`,0,Number.MAX_SAFE_INTEGER),p=ne(h.size,`Serialized legacy lazy archive entry ${f} size`,0,cn);if(h.isSymlink!==!1||h.deleted!==!1||h.materialized!==void 0&&h.materialized!==!1)throw new Error(`Serialized legacy lazy archive entry ${f} is not pending`);if(h.type!==void 0&&h.type!=="file")throw new Error(`Serialized legacy lazy archive entry ${f} has an invalid type`);let w=h.archivePath===void 0?void 0:ue(h.archivePath,!1,`Serialized legacy lazy archive entry ${f} archive path`),u=h.sourcePath===void 0?void 0:ue(h.sourcePath,!1,`Serialized legacy lazy archive entry ${f} source path`),m=h.inodeGroup===void 0?void 0:Be(h.inodeGroup,`Serialized legacy lazy archive entry ${f} inode group`,gn);if(h.target!==void 0)throw new Error(`Serialized legacy lazy archive entry ${f} has a link target`);return{vfsPath:f,ino:d,...y===void 0?{}:{generation:y},...g===void 0?{}:{dataSequence:g},size:p,isSymlink:!1,deleted:!1,materialized:!1,...w===void 0?{}:{archivePath:w},...u===void 0?{}:{sourcePath:u},type:"file",...m===void 0?{}:{inodeGroup:m}}});return{kind:ln,url:n,mountPrefix:i,...o===void 0?{}:{integrity:o},materialized:!1,entries:a}}function Oa(r,e){let t=We(r,["kind","content","inventory","activation","url","mountPrefix","integrity","materialized","entries"],"Serialized lazy tree");if(t.kind!==e)throw new Error("Serialized lazy tree has an unsupported kind");let n=qi(t.content,t.inventory,t.mountPrefix,t.activation);if(e===fn!=(n.content.source===void 0))throw new Error(e===fn?"Serialized deferred-tree-v1 cannot contain original-bottle source metadata":"Serialized deferred-tree-v2 requires original-bottle source metadata");let i=Be(t.url,"Serialized lazy tree URL",dr);if(i!==n.content.transports[0])throw new Error("Serialized lazy tree URL differs from its primary transport");let o=Ft(t.integrity);if(!o||o.sha256!==n.content.sha256||o.bytes!==n.content.bytes)throw new Error("Serialized lazy tree integrity differs from its content");if(t.materialized!==!1)throw new Error("Serialized lazy tree must describe pending content");let s=new Map(n.entries.map(f=>[f.vfsPath,f])),a=new Map(n.entries.map(f=>[hn(f),f])),c=xe(t.entries,"Serialized lazy tree entries",0,ht),l=new Set,h=c.map((f,d)=>{let y=fr(f,["vfsPath","ino","generation","dataSequence","size","isSymlink","deleted","materialized","archivePath","sourcePath","type","inodeGroup","target"],["vfsPath","ino","generation","dataSequence","size","isSymlink","deleted","materialized","archivePath","sourcePath","type","inodeGroup"],`Serialized lazy tree entry ${d}`),g=ue(y.vfsPath,!0,`Serialized lazy tree entry ${d} VFS path`);if(l.has(g))throw new Error(`Serialized lazy tree duplicates pending path ${g}`);l.add(g);let p=ue(y.sourcePath,!1,`Serialized lazy tree entry ${d} source path`),w=ue(y.archivePath,!1,`Serialized lazy tree entry ${d} archive path`),u=s.get(g),m=a.get(hn({sourcePath:p,type:typeof y.type=="string"?y.type:void 0,inodeGroup:typeof y.inodeGroup=="string"?y.inodeGroup:void 0,target:typeof y.target=="string"?y.target:void 0}))??u;if(!m||m.type!=="file"&&m.type!=="hardlink"||u?.inodeGroup!==void 0&&u.inodeGroup!==m.inodeGroup)throw new Error(`Serialized lazy tree entry ${g} is absent from its inventory`);let v=n.canonicalByGroup.get(m.inodeGroup);if(y.type!==m.type||y.inodeGroup!==m.inodeGroup||y.size!==m.size||w!==v?.sourcePath||y.target!==m.target||y.isSymlink!==!1||y.deleted!==!1||y.materialized!==!1)throw new Error(`Serialized lazy tree entry ${g} disagrees with its inventory`);let E=ne(y.ino,`Serialized lazy tree entry ${g} inode`,1,Number.MAX_SAFE_INTEGER),z=ne(y.generation,`Serialized lazy tree entry ${g} generation`,0,Number.MAX_SAFE_INTEGER),S=ne(y.dataSequence,`Serialized lazy tree entry ${g} data sequence`,0,Number.MAX_SAFE_INTEGER);return{vfsPath:g,ino:E,generation:z,dataSequence:S,size:m.size,isSymlink:!1,deleted:!1,materialized:!1,archivePath:w,sourcePath:p,type:m.type,inodeGroup:m.inodeGroup,...m.target===void 0?{}:{target:m.target}}});return{kind:e,content:n.content,inventory:n.entries,activation:n.activation,url:i,mountPrefix:n.mountPrefix,integrity:o,materialized:!1,entries:h}}async function ar(r,e,t){if(t===void 0)return;if(r.byteLength!==t.bytes)throw new Error(`Lazy ${e} byte count ${r.byteLength} does not match expected ${t.bytes}`);let n=globalThis.crypto?.subtle;if(!n)throw new Error(`Lazy ${e} integrity verification is unavailable`);let i=new Uint8Array(r.byteLength);i.set(r);let o=new Uint8Array(await n.digest("SHA-256",i)),s=Array.from(o,a=>a.toString(16).padStart(2,"0")).join("");if(s!==t.sha256)throw new Error(`Lazy ${e} SHA-256 ${s} does not match expected ${t.sha256}`)}var yn=class r{fs;imageMetadata;lazyFiles=new Map;lazyArchiveGroups=[];deferredTreeMaterializationHandles=new WeakMap;lazyArchiveInodes=new Map;lazyDownloadListeners=new Set;lazyPreparations=new Map;lazyTransport={fetcher:(e,t)=>globalThis.fetch(e,t)};constructor(e,t=null){this.fs=e,this.imageMetadata=t}static inodeKey(e,t){return`${e}:${t}`}static canAdoptLegacyLazyStub(e){return(e.mode&ut)===sr&&e.size===0&&e.dataSequence<=1}reconcileLazyIdentityState(e){for(let[t,n]of this.lazyFiles){let i=e.get(t);if(!i||i.dataSequence!==n.dataSequence||i.paths.length===0){this.lazyFiles.delete(t);continue}n.paths=new Set(i.paths),n.paths.has(n.path)||(n.path=i.paths[0])}this.lazyArchiveInodes.clear();for(let t of this.lazyArchiveGroups){let n=t.content!==void 0&&t.inventory!==void 0&&!t.materialized,i=new Map;for(let s of t.entries.values()){if(s.deleted||s.materialized||s.generation===void 0)continue;let a=r.inodeKey(s.ino,s.generation);i.has(a)||i.set(a,s)}let o=new Map;for(let[s,a]of i){let c=e.get(s);if(!(!c||c.dataSequence!==(a.dataSequence??0))){for(let l of c.paths)o.set(l,{...a,ino:c.ino,generation:c.generation,dataSequence:c.dataSequence,deleted:!1,materialized:!1});c.paths.length>0&&this.lazyArchiveInodes.set(s,t)}}t.entries=o,t.materialized=o.size===0&&!n}}lazyFileForStat(e){let t=r.inodeKey(e.ino,e.generation),n=this.lazyFiles.get(t);if(n&&n.dataSequence!==e.dataSequence){this.lazyFiles.delete(t);return}return n}lazyArchiveForStat(e){let t=r.inodeKey(e.ino,e.generation),n=this.lazyArchiveInodes.get(t);if(!n)return;let i=Array.from(n.entries.values()).filter(o=>o.ino===e.ino&&o.generation===e.generation&&!o.deleted&&!o.materialized);if(i.some(o=>o.dataSequence===e.dataSequence))return n;this.lazyArchiveInodes.delete(t);for(let o of i)o.materialized=!0}lazyBackingForStat(e){let t=r.inodeKey(e.ino,e.generation),n=this.lazyFiles.get(t);if(n)return{token:n,path:n.path};let i=this.lazyArchiveInodes.get(t);if(!i)return null;let o=Array.from(i.entries.entries()).find(([,s])=>s.ino===e.ino&&s.generation===e.generation&&!s.deleted&&!s.materialized)?.[0];return o===void 0?null:{token:i,path:o}}lazyBackingForPath(e){let t=this.lazyArchiveGroups.find(n=>!n.materialized&&n.content!==void 0&&n.inventory!==void 0&&n.activation!==void 0&&Array.from(n.entries.values()).every(i=>i.deleted||i.materialized||i.isSymlink)&&n.activation.roots.some(i=>i==="/"||e===i||e.startsWith(`${i}/`)));if(t)return{token:t,path:e,directGroup:t};try{let n=this.fs.stat(e),i=this.lazyBackingForStat(n);return i?{token:i.token,path:e}:null}catch{return null}}startLazyPreparation(e){let{path:t,token:n}=e,i={status:"pending",promise:Promise.resolve(!1)},o=e.directGroup?this.ensureArchiveMaterialized(e.directGroup).then(()=>!0):this.materializePath(t);return i.promise=o.then(s=>(i.status="fulfilled",this.lazyPreparations.get(n)===i&&this.lazyPreparations.delete(n),s),s=>{throw i.status="rejected",i.error=s,s}),i.promise.catch(()=>{}),this.lazyPreparations.set(n,i),i}guardSynchronousLazyAccess(e){let t=this.lazyBackingForPath(e);if(!t)return;let n=this.lazyPreparations.get(t.token);if(n?.status==="fulfilled"){this.lazyPreparations.delete(t.token);let o=this.lazyBackingForPath(e);if(!o)return;n=this.lazyPreparations.get(o.token)??this.startLazyPreparation(o)}else if(n?.status==="rejected"){this.lazyPreparations.delete(t.token);let o=n.error instanceof Error?n.error.message:String(n.error),s=new Error(`EIO: lazy backing for ${e} failed: ${o}`);throw s.code="EIO",s.cause=n.error,s}else n||(n=this.startLazyPreparation(t));let i=new Error(`EAGAIN: lazy backing for ${e} is being prepared`);throw i.code="EAGAIN",i}invalidateLazyData(e){let t=r.inodeKey(e.ino,e.generation);this.lazyFiles.delete(t);let n=this.lazyArchiveInodes.get(t);if(n){this.lazyArchiveInodes.delete(t);for(let i of n.entries.values())i.ino===e.ino&&i.generation===e.generation&&(i.materialized=!0)}}rewriteLazyNamespacePaths(e,t,n){let i=t.length>1?t.replace(/\/+$/,""):t,o=n.length>1?n.replace(/\/+$/,""):n,s=`${i}/`,a=`${o}/`,c=r.inodeKey(e.ino,e.generation),l=(e.mode&ut)===nn,h=f=>f===i?o:l&&f.startsWith(s)?a+f.slice(s.length):f;for(let[f,d]of this.lazyFiles)!l&&f!==c||(d.paths=new Set(Array.from(d.paths,h)),d.path=h(d.path));for(let f of this.lazyArchiveGroups){let d=new Map;for(let[y,g]of f.entries){let p=g.generation===void 0?null:r.inodeKey(g.ino,g.generation);d.set(l||p===c?h(y):y,g)}f.entries=d,f.inventory&&(f.inventory=f.inventory.map(y=>({...y,vfsPath:h(y.vfsPath),...y.type==="hardlink"&&y.target!==void 0?{target:h(y.target)}:{}}))),f.activation&&(f.activation={...f.activation,roots:f.activation.roots.map(h)})}}get sharedBuffer(){return this.fs.buffer}static create(e,t){return new r(ze.mkfs(e,t))}static fromExisting(e){return new r(ze.mount(e))}rebaseToNewFileSystem(e){if(!Number.isSafeInteger(e)||e<=0)throw new Error(`Invalid MemoryFileSystem maxByteLength: ${e}`);let t=SharedArrayBuffer,{bytes:n,identities:i}=this.fs.snapshotState();this.reconcileLazyIdentityState(i);let o=this.serializeLazyEntries(),s=this.serializeLazyArchiveEntries(),a=new t(n.byteLength);new Uint8Array(a).set(n);let c=new r(ze.mount(a,{restoreImage:!0}),this.imageMetadata);c.importLazyEntries(o),c.importLazyArchiveEntries(s);let l=Math.min(e,Math.max(n.byteLength,oa)),h=new t(l,{maxByteLength:e}),f=r.create(h,e);f.setImageMetadata(this.imageMetadata);let d=new Set(o.flatMap(g=>g.paths??[g.path])),y=new Set;for(let g of s)if(!g.materialized)for(let p of g.entries)!p.deleted&&!p.isSymlink&&y.add(p.vfsPath);return c.copyPathToFreshFileSystem("/",f,d,y,new Map),f.importLazyEntries(o.map(g=>{let p=f.fs.lstat(g.path);return{...g,ino:p.ino,generation:p.generation,dataSequence:p.dataSequence}})),f.importLazyArchiveEntries(s.map(g=>({...g,entries:g.entries.map(p=>{if(p.deleted)return{...p,ino:0,generation:void 0};let w=f.fs.lstat(p.vfsPath);return{...p,ino:w.ino,generation:w.generation,dataSequence:w.dataSequence}})}))),f}getImageMetadata(){return pa(this.imageMetadata)}setImageMetadata(e){this.imageMetadata=e===null?null:hr(e)}subscribeLazyDownloads(e){return this.lazyDownloadListeners.add(e),()=>this.lazyDownloadListeners.delete(e)}setLazyFetcher(e,t={}){this.lazyTransport={fetcher:e,...t.signal===void 0?{}:{signal:t.signal}}}emitLazyDownload(e){if(this.lazyDownloadListeners.size===0)return;let t={...e,t:Ea()};for(let n of this.lazyDownloadListeners)try{n(t)}catch{}}async fetchLazyBytes(e,t){let n=0,i=e.integrity?.bytes??e.fallbackTotalBytes,o={id:e.id,kind:e.kind,url:e.url,path:e.path,mountPrefix:e.mountPrefix};for(let s=0;se.integrity.bytes)throw new Error(`Lazy ${e.kind} exceeded expected byte count ${e.integrity.bytes}`);this.emitLazyDownload({...o,status:"progress",loadedBytes:n,totalBytes:i})}}}catch(f){try{await c.cancel(f)}catch{}throw f}}finally{c.releaseLock()}let h=_a(l,n);return ee(t.signal),await ar(h,e.kind,e.integrity),ee(t.signal),this.emitLazyDownload({...o,status:"complete",loadedBytes:n,totalBytes:i??n}),h}catch(a){if(t.signal?.aborted){let h=t.signal.reason,f=h instanceof Error?h.message:String(h);throw this.emitLazyDownload({...o,status:"error",loadedBytes:n,totalBytes:i,error:f}),h}let c=s+1({...u})),activation:f,entries:new Map},p=u=>{let m=u.split("/").filter(Boolean),v="";for(let E=0;Em.vfsPath.split("/").length-v.vfsPath.split("/").length))if(u.type==="directory"){p(u.vfsPath);try{this.fs.mkdir(u.vfsPath,u.mode),this.fs.chmod(u.vfsPath,u.mode)}catch{if((this.fs.lstat(u.vfsPath).mode&ut)!==nn)throw new Error(`Lazy tree directory collides at ${u.vfsPath}`)}}for(let u of l){if(u.type!=="symlink")continue;p(u.vfsPath),this.fs.symlink(u.target,u.vfsPath);let m=this.fs.lstat(u.vfsPath);g.entries.set(u.vfsPath,{ino:m.ino,generation:m.generation,dataSequence:m.dataSequence,size:u.size,isSymlink:!0,deleted:!1,materialized:!0,archivePath:u.sourcePath,sourcePath:u.sourcePath,type:"symlink",target:u.target})}let w=new Map;for(let u of l){if(u.type!=="file")continue;p(u.vfsPath);let m=this.fs.createLazyStub(u.vfsPath,u.mode);this.invalidateLazyData(m),w.set(u.inodeGroup,m);let v={ino:m.ino,generation:m.generation,dataSequence:m.dataSequence,size:u.size,isSymlink:!1,deleted:!1,materialized:!1,archivePath:u.sourcePath,sourcePath:u.sourcePath,type:"file",inodeGroup:u.inodeGroup};g.entries.set(u.vfsPath,v)}for(let u of l){if(u.type!=="hardlink")continue;let m=d.get(u.inodeGroup);p(u.vfsPath),this.fs.link(m.vfsPath,u.vfsPath);let v=this.fs.lstat(u.vfsPath),E=w.get(u.inodeGroup);if(v.ino!==E.ino||v.generation!==E.generation)throw new Error(`Lazy tree hardlink ${u.vfsPath} did not share its inode`);g.entries.set(u.vfsPath,{ino:v.ino,generation:v.generation,dataSequence:v.dataSequence,size:u.size,isSymlink:!1,deleted:!1,materialized:!1,archivePath:m.sourcePath,sourcePath:u.sourcePath,type:"hardlink",inodeGroup:u.inodeGroup,target:u.target})}if(y!==void 0)for(let u of l)this.lchown(u.vfsPath,y.uid,y.gid);for(let u of g.entries.values())u.isSymlink||u.generation===void 0||this.lazyArchiveInodes.set(r.inodeKey(u.ino,u.generation),g);return this.lazyArchiveGroups.push(g),g}registerLazyTreeWithMaterializationHandle(e,t,n="/",i,o){let s=this.registerLazyTreeInternal(e,t,n,i,!0,o),a=Object.freeze({[ta]:!0});return this.deferredTreeMaterializationHandles.set(a,s),a}registerLazyArchiveFromEntries(e,t,n,i,o){let s=ga(e,t,n,i);s.some(({entry:c})=>!c.isDirectory&&!c.isSymlink)&&this.assertCanRegisterPendingLazyArchiveGroup();let a={...o?{content:dn({decoder:"zip-v1",mediaType:"application/zip",sha256:o.sha256,bytes:o.bytes,expandedBytes:s.reduce((c,l)=>c+l.entry.uncompressedSize,0),sourceEntryCount:s.length,transports:[e]})}:{},url:e,mountPrefix:n,integrity:Ft(o),materialized:!1,entries:new Map};for(let{entry:c,vfsPath:l}of s){if(c.isDirectory)continue;let h=l.split("/").filter(Boolean),f="";for(let d=0;dc.deleted||c.materialized),this.lazyArchiveGroups.push(a),a}importLazyArchiveEntries(e){this.importLazyArchiveEntriesInternal(e,!1,!0)}importLazyArchiveEntriesInternal(e,t,n){let i=xe(e,"Serialized lazy archive groups",0,la).map((a,c)=>{if(typeof a!="object"||a===null||Array.isArray(a))throw new Error(`Serialized lazy archive group ${c} must be an object`);let l=a.kind;if(l===fn||l===Ni)return Oa(a,l);if(l===ln)return $i(a,!1);if(l!==void 0)throw new Error(`Serialized lazy archive group ${c} has an unsupported kind`);if(n)throw new Error(`Serialized lazy archive group ${c} is missing its kind discriminator`);return $i(a,!0)});Fi([...this.serializeLazyArchiveEntries(),...i]);let o=[],s=new Map;for(let a of i){let c=new Map,l=a.mountPrefix.replace(/\/+$/,""),h=a.content!==void 0&&a.inventory!==void 0&&a.activation!==void 0,f=h?new Map(a.inventory.map(u=>[u.vfsPath,u])):null,d=h?new Map(a.inventory.map(u=>[hn(u),u])):null,y=new Map,g=new Map;for(let u of a.entries){let m=null,v=a.materialized||u.materialized===!0||u.isSymlink;if(!u.deleted&&!v){if((u.generation===void 0||u.dataSequence===void 0)&&!t)throw new Error("Live lazy-archive metadata requires inode generation and data sequence");try{m=this.fs.lstat(u.vfsPath)}catch{if(h)throw new Error(`Serialized lazy tree stub ${u.vfsPath} is missing from the filesystem`);continue}if(m.ino!==u.ino){if(h)throw new Error(`Serialized lazy tree stub ${u.vfsPath} has a different inode`);continue}if(u.generation!==void 0&&m.generation!==u.generation){if(h)throw new Error(`Serialized lazy tree stub ${u.vfsPath} has a different generation`);continue}if(u.dataSequence===void 0){if(!r.canAdoptLegacyLazyStub(m)){if(h)throw new Error(`Serialized lazy tree stub ${u.vfsPath} is not pristine`);continue}}else if(m.dataSequence!==u.dataSequence){if(h)throw new Error(`Serialized lazy tree stub ${u.vfsPath} has a different data sequence`);continue}if(h){let z=f.get(u.vfsPath),S=d.get(hn(u))??z;if(!S||(m.mode&ut)!==sr||m.size!==0||(m.mode&4095)!==S.mode||z?.inodeGroup!==void 0&&z.inodeGroup!==S.inodeGroup)throw new Error(`Serialized lazy tree stub ${u.vfsPath} disagrees with its inventory`);let k=r.inodeKey(m.ino,m.generation),I=u.inodeGroup,A=y.get(I),B=g.get(k);if(A!==void 0&&A!==k||B!==void 0&&B!==I)throw new Error(`Serialized lazy tree inode group ${I} disagrees with the filesystem`);y.set(I,k),g.set(k,I)}}c.set(u.vfsPath,{ino:u.ino,generation:m?.generation??u.generation,dataSequence:m?.dataSequence??u.dataSequence,size:u.size,isSymlink:u.isSymlink,deleted:u.deleted,materialized:v,archivePath:u.archivePath??u.vfsPath.slice(l.length+1),sourcePath:u.sourcePath??u.archivePath??u.vfsPath.slice(l.length+1),type:u.type??(u.isSymlink?"symlink":"file"),inodeGroup:u.inodeGroup,target:u.target})}let p=a.content===void 0?void 0:dn(a.content),w={content:p,url:p?.transports[0]??a.url,mountPrefix:a.mountPrefix,integrity:p?{sha256:p.sha256,bytes:p.bytes}:Ft(a.integrity),materialized:a.materialized||!(p&&a.inventory)&&Array.from(c.values()).every(u=>u.deleted||u.materialized),inventory:a.inventory?.map(u=>({...u})),activation:a.activation?{mode:a.activation.mode,capabilities:[...a.activation.capabilities],roots:[...a.activation.roots]}:void 0,entries:c};if(o.push(w),!w.materialized){for(let[,u]of c)if(!u.deleted&&!u.materialized&&u.generation!==void 0){let m=r.inodeKey(u.ino,u.generation),v=s.get(m);if(v!==void 0&&v!==w)throw new Error(`Serialized lazy archive groups share pending inode ${m}`);if(this.lazyArchiveInodes.has(m))throw new Error(`Serialized lazy archive group collides with pending inode ${m}`);s.set(m,w)}}}this.lazyArchiveGroups.push(...o);for(let[a,c]of s)this.lazyArchiveInodes.set(a,c)}rewriteLazyArchiveUrls(e){for(let t of this.lazyArchiveGroups)t.content?(t.content={...t.content,transports:t.content.transports.map(e)},t.url=t.content.transports[0]):t.url=e(t.url)}serializeLazyArchiveEntries(){let e=[];for(let t of this.lazyArchiveGroups){let n=Array.from(t.entries,([o,s])=>({vfsPath:o,ino:s.ino,generation:s.generation,dataSequence:s.dataSequence,size:s.size,isSymlink:s.isSymlink,deleted:s.deleted,materialized:s.materialized,archivePath:s.archivePath,sourcePath:s.sourcePath,type:s.type,inodeGroup:s.inodeGroup,target:s.target})).filter(o=>!o.deleted&&!o.materialized);if(n.length===0&&!(t.content&&t.inventory&&!t.materialized))continue;let i=t.content!==void 0&&t.inventory!==void 0&&t.activation!==void 0;if(i&&t.content.transports.length===0)throw new Error("Direct-materialization tree must be materialized before serialization");e.push(i?{kind:t.content.source===void 0?fn:Ni,content:t.content,inventory:t.inventory,activation:t.activation,url:t.url,mountPrefix:t.mountPrefix,integrity:t.integrity,materialized:!1,entries:n}:{kind:ln,url:t.url,mountPrefix:t.mountPrefix,integrity:t.integrity,materialized:!1,entries:n})}return e}exportLazyArchiveEntries(){return this.reconcileLazyIdentityState(this.fs.identityState()),this.serializeLazyArchiveEntries()}pendingDeferredTreeUsage(){return this.reconcileLazyIdentityState(this.fs.identityState()),Hi(this.serializeLazyArchiveEntries())}assertCanAppendDeferredTreeUsage(e){ur(e);let t=this.pendingDeferredTreeUsage();ur({groups:t.groups+e.groups,archiveBytes:t.archiveBytes+e.archiveBytes,expandedBytes:t.expandedBytes+e.expandedBytes,payloadBytes:t.payloadBytes+e.payloadBytes,entries:t.entries+e.entries})}assertCanRegisterPendingLazyArchiveGroup(){if(this.reconcileLazyIdentityState(this.fs.identityState()),this.lazyArchiveGroups.filter(t=>!t.materialized&&(t.content!==void 0&&t.inventory!==void 0||Array.from(t.entries.values()).some(n=>!n.deleted&&!n.materialized))).length>=we.maxGroups)throw new Error(`Cannot register another lazy archive group: ${we.maxGroups} pending groups already exist`)}async preparePath(e){let t=!1,n=Math.max(3,this.lazyArchiveGroups.length+1);for(let i=0;i!o.materialized&&o.activation?.mode==="boot-prefetch"),t=0,n,i=Array.from({length:Math.min(e.length,ca)},async()=>{for(;n===void 0;){let o=t;if(t+=1,o>=e.length)return;try{await this.prepareLazyTreeGroup(e[o])}catch(s){n??=s}}});if(await Promise.all(i),n!==void 0)throw n;return e.length}async materializeRegisteredDeferredTree(e,t){let n=this.deferredTreeMaterializationHandles.get(e);if(n===void 0)throw new Error("Deferred-tree handle was not issued by this filesystem");if(n.materialized)return!1;let i=this.lazyPreparations.get(n);if(i!==void 0)return i.promise;let o=new Uint8Array(t.byteLength);o.set(t);let s={status:"pending",promise:Promise.resolve(!1)};s.promise=Promise.resolve().then(async()=>(await ar(o,"tree",n.integrity),await this.materializeArchiveBytes(n,o),!0)).then(a=>(s.status="fulfilled",a),a=>{throw s.status="rejected",s.error=a,a}),s.promise.catch(()=>{}),this.lazyPreparations.set(n,s);try{return await s.promise}finally{this.lazyPreparations.get(n)===s&&this.lazyPreparations.delete(n)}}async prepareLazyTreeGroup(e){if(e.materialized)return!1;let t={token:e,path:e.activation?.roots[0]??e.mountPrefix,directGroup:e},n=this.lazyPreparations.get(e)??this.startLazyPreparation(t);try{return await n.promise}finally{this.lazyPreparations.get(e)===n&&this.lazyPreparations.delete(e)}}async ensureMaterialized(e){return this.preparePath(e)}async materializePath(e){if(this.lazyFiles.size===0&&this.lazyArchiveInodes.size===0)return!1;let t;try{t=this.fs.stat(e)}catch{return!1}let n=r.inodeKey(t.ino,t.generation),i=this.lazyFiles.get(n);if(i){let s=this.lazyTransport,a=await this.fetchLazyBytes({id:`file:${t.ino}`,kind:"file",url:i.url,path:i.path,fallbackTotalBytes:i.size},s);for(let c=0;c<3;c++){if(this.lazyFiles.get(n)!==i)return!1;for(let l of new Set([e,...i.paths]))if(ee(s.signal),this.fs.replaceIfIdentity(l,i.ino,i.generation,i.dataSequence,a))return i.path=l,this.lazyFiles.delete(n),!0;this.reconcileLazyIdentityState(this.fs.identityState())}throw new Error(`Lazy file kept changing names while materializing: ${e}`)}let o=this.lazyArchiveInodes.get(n);return o?(await this.ensureArchiveMaterialized(o,{path:e,ino:t.ino,generation:t.generation}),!this.lazyArchiveInodes.has(n)):!1}async decodeAndValidateLazyTree(e,t){let n=e.content,i=e.inventory;if(!n||!i)throw new Error("Lazy tree is missing its decoder or complete inventory");let o=new Map,s=new Map(i.map(f=>[f.vfsPath,f]));if(n.source!==void 0)for(let f of n.source.entries)o.set(f.sourcePath,f);else for(let f of i){if(f.type==="hardlink"){let y=s.get(f.target);if(!y)throw new Error(`Lazy tree hardlink target disappeared: ${f.target}`);if(f.sourcePath===y.sourcePath)continue}if(o.get(f.sourcePath))throw new Error(`Lazy tree inventory duplicates source member ${f.sourcePath}`);o.set(f.sourcePath,{sourcePath:f.sourcePath,type:f.type,mode:f.mode,size:f.size,...f.type==="symlink"?{target:f.target}:{},...f.type==="hardlink"?{target:s.get(f.target)?.sourcePath}:{}})}let a=new Map,c=0;if(n.decoder==="zip-v1"){let{parseZipCentralDirectory:f,extractZipEntryBounded:d}=await Promise.resolve().then(()=>(Yn(),jn)),y=f(t);if(y.length!==n.sourceEntryCount||y.length!==o.size)throw new Error("Lazy ZIP tree decoded inventory counts differ from its descriptor");for(let g of y){let p=g.isDirectory?g.fileName.replace(/\/$/,""):g.fileName;if(a.has(p))throw new Error(`Lazy ZIP tree duplicates source member ${p}`);let w=o.get(p);if(!w)throw new Error(`Lazy ZIP tree has undeclared source member ${p}`);if(c+=g.uncompressedSize,c>n.expandedBytes||g.uncompressedSize!==w.size)throw new Error(`Lazy ZIP tree member ${p} exceeds its inventory`);let u=g.isDirectory?"directory":g.isSymlink?"symlink":"file",m=n.modePolicy==="portable-posix-v1"?u==="directory"?493:u==="symlink"?511:(g.mode&73)!==0?493:420:g.mode&4095;if(u!==w.type||m!==w.mode)throw new Error(`Lazy ZIP tree member ${p} differs from inventory`);if(g.isDirectory)a.set(p,{type:"directory",mode:m});else{let v=d(t,g,w.size);if(g.isSymlink){let E;try{E=new TextDecoder("utf-8",{fatal:!0}).decode(v)}catch{throw new Error(`Lazy ZIP tree symlink ${p} is not UTF-8`)}a.set(p,{type:"symlink",mode:m,target:E})}else a.set(p,{type:"file",mode:m,data:v})}}}else{let{parseTarGzip:f}=await Promise.resolve().then(()=>(Oi(),Pi)),d=f(t,{label:`Lazy tree ${n.sha256}`,limits:{maxCompressedBytes:n.bytes,maxUncompressedBytes:n.expandedBytes,maxEntries:n.sourceEntryCount}});c=new DataView(t.buffer,t.byteOffset,t.byteLength).getUint32(t.byteLength-4,!0);for(let y of d){if(a.has(y.path))throw new Error(`Lazy TAR tree duplicates source member ${y.path}`);y.type==="file"?a.set(y.path,{type:"file",mode:y.mode,data:y.data}):y.type==="directory"?a.set(y.path,{type:"directory",mode:y.mode}):a.set(y.path,{type:y.type,mode:y.mode,target:y.linkName})}}if(a.size!==n.sourceEntryCount||a.size!==o.size||c!==n.expandedBytes)throw new Error("Lazy tree decoded inventory counts differ from its descriptor");for(let[f,d]of o){let y=a.get(f);if(!y)throw new Error(`Lazy tree is missing source member ${f}`);let g=d.type;if(y.type!==g)throw new Error(`Lazy tree member ${f} is ${y.type}, expected ${g}`);if((y.mode&4095)!==d.mode)throw new Error(`Lazy tree member ${f} mode differs from inventory`);if(g==="file"&&y.data?.byteLength!==d.size)throw new Error(`Lazy tree member ${f} size differs from inventory`);if(g==="symlink"&&y.target!==d.target)throw new Error(`Lazy tree symlink ${f} target differs from inventory`);if(g==="hardlink"&&y.target!==d.target)throw new Error(`Lazy tree hardlink ${f} target differs from inventory`)}let l=new Set(i.flatMap(f=>f.materialization==="archive-homebrew-relocate"?[f.sourcePath]:[]));if(n.source!==void 0){let f=new Map(n.source.entries.map(g=>[g.sourcePath,g])),d=Vi(n.source.entries),y=n.source.entries.filter(g=>g.sourcePath==="INSTALL_RECEIPT.json"||g.sourcePath.endsWith("/INSTALL_RECEIPT.json"));if(y.length>1)throw new Error(`Lazy Homebrew bottle has ${y.length} INSTALL_RECEIPT.json source members, expected at most one`);if(y.length===0){if(l.size>0)throw new Error("Lazy Homebrew bottle marks receipt relocation without INSTALL_RECEIPT.json")}else{let g=y[0],p=g.type==="file"?g:d.get(g.sourcePath),w=p===void 0?void 0:a.get(p.sourcePath);if(p?.type!=="file"||w?.type!=="file"||w.data===void 0)throw new Error("Lazy Homebrew bottle INSTALL_RECEIPT.json is not regular");let u=si(w.data),m=g.sourcePath.lastIndexOf("/"),v=m<0?"":g.sourcePath.slice(0,m),E=new Set(u.changedFiles.map(S=>v.length===0?S:`${v}/${S}`));if(l.size!==E.size||[...l].some(S=>!E.has(S)))throw new Error("Lazy Homebrew bottle relocation markers differ from INSTALL_RECEIPT.json");let z=new Set;for(let S of E){let k=f.get(S),I=k?.type==="file"?k:k===void 0?void 0:d.get(k.sourcePath),A=I===void 0?void 0:a.get(I.sourcePath);if(I?.type!=="file"||A?.type!=="file"||A.data===void 0)throw new Error(`Lazy Homebrew bottle changed source ${S} is not regular`);z.has(I.sourcePath)||(A.data=oi(A.data,u,S),z.add(I.sourcePath))}}}else if(l.size>0)throw new Error("Lazy tree receipt relocation requires original-bottle source truth");let h=new Map;for(let f of i){if(f.type!=="file"||f.materialization==="descriptor")continue;let d=a.get(f.sourcePath);if(d?.type!=="file"||!d.data)throw new Error(`Lazy tree has no file content for ${f.sourcePath}`);h.set(f.sourcePath,d.data)}return h}async ensureArchiveMaterialized(e,t){if(e.materialized)return;let n=e.content!==void 0&&e.inventory!==void 0,i=this.lazyTransport,o=n?e.content.transports:[e.url],s=[],a=null;for(let[c,l]of o.entries())try{a=await this.fetchLazyBytes({id:`archive:${e.mountPrefix}:${e.content?.sha256??l}:${c}`,kind:n?"tree":"archive",url:l,mountPrefix:e.mountPrefix,integrity:e.integrity},i);break}catch(h){if(ee(i.signal),Zi(h))throw h;s.push(h instanceof Error?h.message:String(h))}if(ee(i.signal),a===null)throw new Error(`All ${o.length} lazy ${n?"tree":"archive"} transports failed: ${s.join("; ")}`);ee(i.signal),await this.materializeArchiveBytes(e,a,t,i.signal)}async materializeArchiveBytes(e,t,n,i){if(ee(i),e.materialized)return;let s=e.content!==void 0&&e.inventory!==void 0?await this.decodeAndValidateLazyTree(e,t):null;ee(i);let{parseZipCentralDirectory:a,extractZipEntry:c}=await Promise.resolve().then(()=>(Yn(),jn));ee(i);let l=s?[]:a(t),h=new Map;for(let g of l){if(h.has(g.fileName))throw new Error(`Lazy archive contains duplicate member: ${g.fileName}`);h.set(g.fileName,g)}let f=e.mountPrefix.replace(/\/+$/,""),d=new Map;for(let[g,p]of e.entries){if(p.deleted||p.materialized)continue;let w=p.archivePath??g.slice(f.length+1),u=s?void 0:h.get(w),m=s?.get(w);if(s){if(m===void 0||m.byteLength!==p.size)throw new Error(`Lazy tree member ${w} does not match its registered metadata`)}else if(u===void 0||u.isDirectory||u.isSymlink||u.uncompressedSize!==p.size)throw new Error(`Lazy archive member ${w} does not match its registered metadata`);if(p.generation===void 0)continue;let v=r.inodeKey(p.ino,p.generation),E=d.get(v);if(E&&E.archivePath!==w)throw new Error(`Lazy archive aliases for inode ${v} name different members`);if(!E){let z=m??c(t,u);if(z.byteLength!==p.size)throw new Error(`Lazy archive member ${w} extracted ${z.byteLength} bytes, expected ${p.size}`);d.set(v,{archivePath:w,content:z})}}let y=n?r.inodeKey(n.ino,n.generation):null;for(let g=0;g<3;g++){let p=new Map;for(let[w,u]of e.entries){if(u.deleted||u.materialized||u.generation===void 0)continue;let m=r.inodeKey(u.ino,u.generation);if(this.lazyArchiveInodes.get(m)!==e)continue;let v=d.get(m);if(!v)throw new Error(`Lazy archive has no extracted content for inode ${m}`);let E=p.get(m);E||(E={ino:u.ino,generation:u.generation,dataSequence:u.dataSequence??0,paths:new Set,content:v.content},p.set(m,E)),E.paths.add(w),n&&n.ino===u.ino&&n.generation===u.generation&&E.paths.add(n.path)}if(p.size>0&&(ee(i),!this.fs.replaceManyIfIdentities(Array.from(p.values(),u=>({paths:Array.from(u.paths),expectedIno:u.ino,expectedGeneration:u.generation,expectedDataSequence:u.dataSequence,data:u.content}))))){if(this.reconcileLazyIdentityState(this.fs.identityState()),y&&!this.lazyArchiveInodes.has(y))return;continue}ee(i);for(let[w,u]of p){this.lazyArchiveInodes.delete(w);for(let m of e.entries.values())m.ino===u.ino&&m.generation===u.generation&&(m.materialized=!0)}if(e.materialized=Array.from(e.entries.values()).every(w=>w.deleted||w.materialized),e.materialized||(this.reconcileLazyIdentityState(this.fs.identityState()),y&&!this.lazyArchiveInodes.has(y)))return}if(y&&this.lazyArchiveInodes.has(y))throw new Error(`Lazy archive member kept changing names while materializing: ${n?.path}`)}async materializeAllLazyEntries(){for(let t=0;t<3;t++){this.reconcileLazyIdentityState(this.fs.identityState());let n=this.lazyArchiveGroups.filter(s=>!s.materialized&&s.content!==void 0&&s.inventory!==void 0);if(this.lazyFiles.size===0&&this.lazyArchiveInodes.size===0&&n.length===0)return;let i=Array.from(this.lazyFiles.values(),s=>s.path);for(let s of i)await this.ensureMaterialized(s);let o=new Set(this.lazyArchiveInodes.values());for(let s of n)o.add(s);for(let s of o)await this.prepareLazyTreeGroup(s)}this.reconcileLazyIdentityState(this.fs.identityState());let e=this.lazyArchiveGroups.some(t=>!t.materialized&&t.content!==void 0&&t.inventory!==void 0);if(this.lazyFiles.size!==0||this.lazyArchiveInodes.size!==0||e)throw new Error("Cannot create a self-contained VFS image while lazy entries remain pending")}async saveImage(e){e?.materializeAll&&await this.materializeAllLazyEntries();let{bytes:t,identities:n}=this.fs.snapshotState({normalizeTimestampsMs:e?.normalizeTimestampsMs});this.reconcileLazyIdentityState(n);let i=this.serializeLazyEntries(),o=i.length>0,s=o?new TextEncoder().encode(JSON.stringify(i)):new Uint8Array(0);if(s.byteLength>on)throw new Error(`VFS image lazy metadata exceeds ${on} bytes`);let a=this.serializeLazyArchiveEntries();Fi(a);let c=a.length>0,l=c?new TextEncoder().encode(JSON.stringify(a)):new Uint8Array(0);if(l.byteLength>an)throw new Error(`VFS image lazy archive metadata exceeds ${an} bytes`);let h=e?.metadata===void 0?this.imageMetadata:e.metadata,f=wa(h),d=f.byteLength>0,y=c?4+l.byteLength:0,g=d?4+f.byteLength:0,p=se+t.byteLength+4+s.byteLength+y+g,w=new Uint8Array(p),u=new DataView(w.buffer);u.setUint32(0,cr,!0),u.setUint32(4,lr,!0),u.setUint32(8,(o?nr:0)|(c?sn:0)|(c?ir:0)|(d?rr:0),!0),u.setUint32(12,t.byteLength,!0),w.set(t,se);let m=se+t.byteLength;if(u.setUint32(m,s.byteLength,!0),s.byteLength>0&&w.set(s,m+4),c){let v=m+4+s.byteLength;u.setUint32(v,l.byteLength,!0),w.set(l,v+4)}if(d){let v=m+4+s.byteLength+y;u.setUint32(v,f.byteLength,!0),w.set(f,v+4)}return w}static readImageMetadata(e){let t=rn(e);if(!(t.flags&rr))return null;let{metadataOffset:n}=Ci(t.image,t.view,t.flags,t.sabLen);if(t.image.byteLengthdt)throw new Error(`VFS image metadata exceeds ${dt} bytes`);if(t.image.byteLength0){let w=n.subarray(g+4,g+4+p),u=xe(Mi(w,"VFS image lazy metadata"),"VFS image lazy entries",0,ht);y.importLazyEntriesInternal(u,!0)}if(o&sn){let w=a.archiveOffset,u=i.getUint32(w,!0);if(u>0){let m=n.subarray(w+4,w+4+u),v=Mi(m,"VFS image lazy archive metadata");y.importLazyArchiveEntriesInternal(v,!0,!!(o&ir))}}return y}adaptStat(e){return{dev:0,ino:e.ino,mode:e.mode,nlink:e.linkCount,uid:e.uid,gid:e.gid,size:e.size,atimeMs:e.atime,mtimeMs:e.mtime,ctimeMs:e.ctime}}adaptStatWithLazySize(e){let t=this.adaptStat(e),n=this.lazyFileForStat(e);if(n)return t.size=n.size,t;let i=this.lazyArchiveForStat(e);if(i){for(let o of i.entries.values())if(o.ino===e.ino&&o.generation===e.generation&&!o.deleted){t.size=o.size;break}}return t}open(e,t,n){(t&Tt)===0&&!((t&Pt)!==0&&(t&Un)!==0)&&this.guardSynchronousLazyAccess(e);let i=this.fs.open(e,t,n);return(t&Tt)!==0&&this.invalidateLazyData(this.fs.fstat(i)),i}close(e){return this.fs.close(e),0}read(e,t,n,i){if(i>0){let o=this.lazyBackingForStat(this.fs.fstat(e));o&&(this.reconcileLazyIdentityState(this.fs.identityState()),o=this.lazyBackingForStat(this.fs.fstat(e)),o&&this.guardSynchronousLazyAccess(o.path))}return n!==null?this.fs.readAt(e,t.subarray(0,i),n):this.fs.read(e,t.subarray(0,i))}write(e,t,n,i){if(n!==null){let s=this.fs.writeAt(e,t.subarray(0,i),n);return s>0&&this.invalidateLazyData(this.fs.fstat(e)),s}let o=this.fs.write(e,t.subarray(0,i));return o>0&&this.invalidateLazyData(this.fs.fstat(e)),o}seek(e,t,n){return this.fs.lseek(e,t,n)}fstat(e){return this.adaptStatWithLazySize(this.fs.fstat(e))}fpathconf(e,t){let n=this.fstat(e);return On(n,t,{supportsSymlinks:!0,timestampResolutionNs:1e6})}ftruncate(e,t){this.fs.ftruncate(e,t),this.invalidateLazyData(this.fs.fstat(e))}fsync(e){}fchmod(e,t){this.fs.fchmod(e,t)}fchown(e,t,n){this.fs.fchown(e,t,n)}stat(e){return this.adaptStatWithLazySize(this.fs.stat(e))}lstat(e){return this.adaptStatWithLazySize(this.fs.lstat(e))}statfs(e){this.fs.stat(e);let t=this.fs.statfs();return{type:1397114451,bsize:t.blockSize,blocks:t.totalBlocks,bfree:t.freeBlocks,bavail:t.freeBlocks,files:t.totalInodes,ffree:t.freeInodes,fsid:0,namelen:t.maxName,frsize:t.blockSize,flags:0}}pathconf(e,t){let n=this.stat(e);return On(n,t,{supportsSymlinks:!0,timestampResolutionNs:1e6})}mkdir(e,t){this.fs.mkdir(e,t)}rmdir(e){this.fs.rmdir(e)}unlink(e){let t=this.fs.unlink(e),n=r.inodeKey(t.ino,t.generation);if(t.linkCount>1&&(this.lazyFiles.has(n)||this.lazyArchiveInodes.has(n))){this.reconcileLazyIdentityState(this.fs.identityState());return}let i=this.lazyFiles.get(n);i&&(i.paths.delete(e),t.linkCount<=1?this.lazyFiles.delete(n):i.path===e&&(i.path=i.paths.values().next().value));let o=this.lazyArchiveInodes.get(n);if(o){let s=o.entries.get(e);if(t.linkCount<=1){for(let a of o.entries.values())a.ino===t.ino&&a.generation===t.generation&&(a.deleted=!0);this.lazyArchiveInodes.delete(n)}else s&&o.entries.delete(e)}}rename(e,t){let{source:n,replaced:i}=this.fs.rename(e,t);if(i&&i.ino===n.ino&&i.generation===n.generation)return;let o=!1;if(i){let s=r.inodeKey(i.ino,i.generation);i.linkCount>1&&(this.lazyFiles.has(s)||this.lazyArchiveInodes.has(s))&&(this.reconcileLazyIdentityState(this.fs.identityState()),o=!0);let a=this.lazyFiles.get(s);!o&&a&&(a.paths.delete(t),i.linkCount<=1?this.lazyFiles.delete(s):a.path===t&&(a.path=a.paths.values().next().value));let c=this.lazyArchiveInodes.get(s);if(!o&&c){let l=c.entries.get(t);i.linkCount<=1?(l&&(l.deleted=!0),this.lazyArchiveInodes.delete(s)):l&&c.entries.delete(t)}}o||this.rewriteLazyNamespacePaths(n,e,t)}link(e,t){let n=this.fs.link(e,t),i=r.inodeKey(n.ino,n.generation),o=this.lazyFiles.get(i);o&&o.paths.add(t);let s=this.lazyArchiveInodes.get(i);if(s){let a=Array.from(s.entries.values()).find(c=>c.ino===n.ino&&c.generation===n.generation);a&&s.entries.set(t,{...a})}}symlink(e,t){this.fs.symlink(e,t)}readlink(e){return this.fs.readlink(e)}chmod(e,t){this.fs.chmod(e,t)}chown(e,t,n){this.fs.chown(e,t,n)}lchown(e,t,n){this.fs.lchown(e,t,n)}createFileWithOwner(e,t,n,i,o){let s=this.open(e,577,t);o.length>0&&this.write(s,o,null,o.length),this.close(s),this.chown(e,n,i),this.chmod(e,t)}mkdirWithOwner(e,t,n,i){this.mkdir(e,t),this.chown(e,n,i),this.chmod(e,t)}symlinkWithOwner(e,t,n,i){this.symlink(e,t),this.lchown(t,n,i)}copyPathToFreshFileSystem(e,t,n,i,o){let s=this.lstat(e),a=s.mode&ut,c=s.mode&4095;if(a===nn){e==="/"?(t.chown(e,s.uid,s.gid),t.chmod(e,c)):t.mkdirWithOwner(e,c,s.uid,s.gid);let d=this.opendir(e);try{for(;;){let y=this.readdir(d);if(!y)break;y.name==="."||y.name===".."||this.copyPathToFreshFileSystem(e==="/"?`/${y.name}`:`${e}/${y.name}`,t,n,i,o)}}finally{this.closedir(d)}r.applyTimes(t,e,s);return}let l=s.nlink>1?`${s.dev}:${s.ino}`:null,h=l?o.get(l):void 0;if(h){t.link(h,e);return}if(a===na){t.symlinkWithOwner(this.readlink(e),e,s.uid,s.gid),l&&o.set(l,e);return}if(a!==sr)throw new Error(`Unsupported file type while rebasing VFS: ${e}`);if(n.has(e)||i.has(e)){t.createFileWithOwner(e,c,s.uid,s.gid,new Uint8Array(0)),r.applyTimes(t,e,s),l&&o.set(l,e);return}this.copyRegularFileToFreshFileSystem(e,t,s,c),l&&o.set(l,e)}copyRegularFileToFreshFileSystem(e,t,n,i){let o=this.open(e,ra,0),s=null;try{s=t.open(e,ia,i);let a=new Uint8Array(Math.min(sa,Math.max(1,n.size))),c=n.size;for(;c>0;){let l=Math.min(a.byteLength,c),h=this.read(o,a,null,l);if(h<=0)throw new Error(`Unexpected EOF while rebasing VFS file: ${e}`);let f=0;for(;f!e||e==="."||e===".."))throw new Error(`Binary resolver path must be a normalized portable relative path: ${JSON.stringify(r)}`);return r}var qe=new Set(["wasm32","wasm64"]);function Ce(r){if(Ka(r),!r.startsWith("programs/"))return r;let e=r.slice(9),t=e.split("/",1)[0];return qe.has(t)?r:`programs/wasm32/${e}`}function Ga(r,e=$(zn(),"wasm")){let t=Ce(r),n=[$(e,t)];return r==="kernel.wasm"?n.push($(e,"kandelo-kernel.wasm")):r==="userspace.wasm"?n.push($(e,"wasm_posix_userspace.wasm")):r==="rootfs.vfs"&&n.push($(e,"rootfs.vfs")),n}var vn=class extends Error{constructor(e){super(e),this.name="BinaryNotFoundError"}};function ss(){let r=[],e=!1;try{let n=Ve();e=!0;for(let[i,o]of[["local-binaries",$(n,"local-binaries")],["binaries",$(n,"binaries")]])r.push({label:i,root:o,identity:i==="local-binaries"?"local-generation":"program-cache",allowRegularFileClosure:!1,candidatesFor(s){return[$(o,Ce(s))]}})}catch{}let t=$(zn(),"wasm");return r.push({label:"installed package",root:t,identity:"installed-package",allowRegularFileClosure:!e,candidatesFor(n){return Ga(n,t)}}),r}function yt(r,e){return new Error(`Invalid package manifest ${r}: ${e}`)}function de(r){try{return En(r),!0}catch(e){if(e instanceof Error&&"code"in e&&e.code==="ENOENT")return!1;throw e}}function Yi(r,e,t){if(r.length===0||r.startsWith("/")||r.includes("\\")||r.includes("\0")||r.split("/").some(n=>!n||n==="."||n===".."))throw yt(e,`${t} must be a normalized portable relative path`);return r}function mn(r,e,t,n=!0){if(r.length===0||r==="."||r===".."||r.includes("/")||r.includes("\\")||r.includes("\0")||!n&&r.includes("@"))throw yt(e,`${t} must be a safe single path component`);return r}var Xi="kandelo-program-packages-v2",ve="program-packages.json",Ji=null,wn=null,gr=0;function os(){if(Object.prototype.hasOwnProperty.call(process.env,"WASM_POSIX_DEPS_REGISTRY")){let r=null;return(process.env.WASM_POSIX_DEPS_REGISTRY??"").split(":").filter(Boolean).map(e=>e.startsWith("~/")&&process.env.HOME!==void 0?$(process.env.HOME,e.slice(2)):Sn(e)?pe(e):(r??=Ve(),pe(r,e)))}try{return[$(Ve(),"packages","registry")]}catch{return null}}function Wa(){let r;try{r=Ve()}catch{return null}if(!$t($(r,"tools","xtask","Cargo.toml"))||!$t($(r,"scripts","dev-shell.sh")))return null;try{let e=me(vr()),t=me(r);return[$(t,"host"),$(t,"scripts")].some(i=>$t(i)&&kr(me(i),e))?t:null}catch{return null}}function Er(r,e,t){let n=[typeof t.stderr=="string"?t.stderr.trim():"",typeof t.stdout=="string"?t.stdout.trim():"",t.error?.message??""].filter(Boolean).join(` `);return`${r} ${e.join(" ")} failed${t.status===null?"":` with status ${t.status}`}${n?`: -${n}`:""}`}function Za(r){let e=process.env.KANDELO_DEV_SHELL_TOOL_PATH!==void 0,t=e?"rustc":"bash",n=e?["-vV"]:[$(r,"scripts","dev-shell.sh"),"rustc","-vV"],i=wr(t,n,{cwd:r,encoding:"utf8"});if(i.status!==0)throw new Error(Er(t,n,i));let o=i.stdout.split(/\r?\n/).find(s=>s.startsWith("host: "))?.slice(6).trim();if(!o)throw new Error(`Could not determine the Rust host target for ${r}`);return o}function pr(r){try{if(En(r).isFile())return me(r)}catch{}throw new Error(`Prepared xtask is not a regular file: ${r}`)}function Ha(r){let e=process.env.WASM_POSIX_XTASK_BIN;if(e!==void 0){let l=Sn(e)?pe(e):pe(r,e);return pr(l)}if(wn?.sourceRepoRoot===r)return pr(wn.xtaskPath);let t=Za(r),n=$(r,"target",t,"release",process.platform==="win32"?"xtask.exe":"xtask"),i=["build","--release","-p","xtask","--target",t,"--quiet"],o=process.env.KANDELO_DEV_SHELL_TOOL_PATH!==void 0,s=o?"cargo":"bash",a=o?i:[$(r,"scripts","dev-shell.sh"),"cargo",...i],c=wr(s,a,{cwd:r,encoding:"utf8"});if(c.status!==0)throw new Error(Er(s,a,c));return wn={sourceRepoRoot:r,xtaskPath:pr(n)},wn.xtaskPath}function Va(){let r=Wa();if(r===null)return;let e=os();if(e===null)return;if(Ji){Ji(r,e);return}let t=Ha(r),n=["build-deps","program-index-context-check"],i=wr(t,n,{cwd:r,encoding:"utf8",env:{...process.env,WASM_POSIX_DEPS_REGISTRY:e.join(":")}});if(i.status!==0)throw new Error(`Program package source projection is not current: +${n}`:""}`}function Za(r){let e=process.env.KANDELO_DEV_SHELL_TOOL_PATH!==void 0,t=e?"rustc":"bash",n=e?["-vV"]:[$(r,"scripts","dev-shell.sh"),"rustc","-vV"],i=wr(t,n,{cwd:r,encoding:"utf8"});if(i.status!==0)throw new Error(Er(t,n,i));let o=i.stdout.split(/\r?\n/).find(s=>s.startsWith("host: "))?.slice(6).trim();if(!o)throw new Error(`Could not determine the Rust host target for ${r}`);return o}function pr(r){try{if(En(r).isFile())return me(r)}catch{}throw new Error(`Prepared xtask is not a regular file: ${r}`)}function Ha(r){let e=process.env.WASM_POSIX_XTASK_BIN;if(e!==void 0){let l=Sn(e)?pe(e):pe(r,e);return pr(l)}if(wn?.sourceRepoRoot===r)return pr(wn.xtaskPath);let t=Za(r),n=$(r,"target",t,"release",process.platform==="win32"?"xtask.exe":"xtask"),i=["build","--release","-p","xtask","--target",t,"--quiet"],o=process.env.KANDELO_DEV_SHELL_TOOL_PATH!==void 0,s=o?"cargo":"bash",a=o?i:[$(r,"scripts","dev-shell.sh"),"cargo",...i],c=wr(s,a,{cwd:r,encoding:"utf8"});if(c.status!==0)throw new Error(Er(s,a,c));return wn={sourceRepoRoot:r,xtaskPath:pr(n)},wn.xtaskPath}function Va(){let r=Wa();if(r===null)return;let e=os();if(e===null)return;if(Ji){Ji(r,e);return}let t=Ha(r),n=["build-deps","program-index-context-check","--source-repo-root",r],i=wr(t,n,{cwd:r,encoding:"utf8",env:{...process.env,WASM_POSIX_DEPS_REGISTRY:e.join(":")}});if(i.status!==0)throw new Error(`Program package source projection is not current: ${Er(t,n,i)}`)}function qa(r,e){if(gr>0||!r.some(t=>t.startsWith("programs/")))return e();gr+=1;try{return Va(),e()}finally{gr-=1}}function Ne(r,e){let t=Object.keys(r).sort(),n=[...e].sort();return t.length===n.length&&t.every((i,o)=>i===n[o])}function mr(r){let e;try{e=JSON.parse(Ze(r,"utf8"))}catch(s){throw new Error(`Invalid program package index ${r}: ${s instanceof Error?s.message:String(s)}`)}if(typeof e!="object"||e===null||!Ne(e,["format","identities","packages"])||e.format!==Xi||typeof e.identities!="object"||e.identities===null||Array.isArray(e.identities)||typeof e.packages!="object"||e.packages===null||Array.isArray(e.packages))throw new Error(`Invalid program package index ${r}: expected ${Xi}`);let t=new Map,n=e.identities;for(let[s,a]of Object.entries(n)){if(mn(s,r,"identity package name",!1),typeof a!="object"||a===null||!Ne(a,["manifestSha256","cacheKeys"])||typeof a.manifestSha256!="string"||!/^[a-f0-9]{64}$/.test(a.manifestSha256)||typeof a.cacheKeys!="object"||a.cacheKeys===null||Array.isArray(a.cacheKeys))throw new Error(`Invalid program package index ${r}: malformed identity ${JSON.stringify(s)}`);let c=a.cacheKeys;if(!Ne(c,["wasm32","wasm64"])||Object.values(c).some(l=>typeof l!="string"||!/^[a-f0-9]{64}$/.test(l)))throw new Error(`Invalid program package index ${r}: identity ${JSON.stringify(s)} has invalid contextual cache keys`);t.set(s,{manifestSha256:a.manifestSha256,cacheKeys:c})}let i=new Map,o=e.packages;for(let[s,a]of Object.entries(o)){if(mn(s,r,"package name",!1),typeof a!="object"||a===null||!Ne(a,["manifestSha256","arches","cacheKeys","dependencyClosures","members"])||!Array.isArray(a.arches)||typeof a.cacheKeys!="object"||a.cacheKeys===null||Array.isArray(a.cacheKeys)||typeof a.dependencyClosures!="object"||a.dependencyClosures===null||Array.isArray(a.dependencyClosures)||!Array.isArray(a.members)||typeof a.manifestSha256!="string"||!/^[a-f0-9]{64}$/.test(a.manifestSha256))throw new Error(`Invalid program package index ${r}: malformed package ${JSON.stringify(s)}`);let c=a.arches;if(c.length===0||new Set(c).size!==c.length||c.some(p=>typeof p!="string"||!qe.has(p)))throw new Error(`Invalid program package index ${r}: package ${JSON.stringify(s)} has invalid arches`);let l=a.cacheKeys;if(!Ne(l,c)||Object.values(l).some(p=>typeof p!="string"||!/^[a-f0-9]{64}$/.test(p)))throw new Error(`Invalid program package index ${r}: package ${JSON.stringify(s)} has invalid cache keys`);let h=a.dependencyClosures;if(!Ne(h,c))throw new Error(`Invalid program package index ${r}: package ${JSON.stringify(s)} has invalid dependency closure arches`);let f={};for(let p of c){let w=h[p];if(!Array.isArray(w))throw new Error(`Invalid program package index ${r}: package ${JSON.stringify(s)} has a malformed dependency closure for ${p}`);let u=new Set;f[p]=w.map((m,v)=>{if(typeof m!="object"||m===null||!Ne(m,["packageName","manifestSha256","cacheKey"])||typeof m.packageName!="string"||typeof m.manifestSha256!="string"||!/^[a-f0-9]{64}$/.test(m.manifestSha256)||typeof m.cacheKey!="string"||!/^[a-f0-9]{64}$/.test(m.cacheKey))throw new Error(`Invalid program package index ${r}: package ${JSON.stringify(s)} dependency ${v+1} for ${p} is malformed`);let E=m;if(mn(E.packageName,r,`${s} dependency packageName`,!1),E.packageName===s||u.has(E.packageName))throw new Error(`Invalid program package index ${r}: package ${JSON.stringify(s)} dependency closure for ${p} must contain unique dependencies other than itself`);u.add(E.packageName);let z=t.get(E.packageName);if(!z||z.manifestSha256!==E.manifestSha256||z.cacheKeys[p]!==E.cacheKey)throw new Error(`Invalid program package index ${r}: package ${JSON.stringify(s)} dependency ${JSON.stringify(E.packageName)} for ${p} does not match the index's authoritative contextual identity`);return E})}let d=a.members.map((p,w)=>{if(typeof p!="object"||p===null||p.kind!=="output"&&p.kind!=="runtime-file"||typeof p.sourceArtifact!="string"||typeof p.mirrorPath!="string")throw new Error(`Invalid program package index ${r}: package ${JSON.stringify(s)} member ${w+1} is malformed`);let u=p,m=u.kind==="output"?["kind","sourceArtifact","mirrorPath","outputName","forkInstrumentation"]:["kind","sourceArtifact","mirrorPath","guestPath","mode"];if(!Ne(u,m))throw new Error(`Invalid program package index ${r}: package ${JSON.stringify(s)} member ${w+1} has unknown or missing fields`);if(Yi(u.sourceArtifact,r,`${s} sourceArtifact`),Yi(u.mirrorPath,r,`${s} mirrorPath`),u.kind==="output"){if(typeof u.outputName!="string"||u.forkInstrumentation!=="auto"&&u.forkInstrumentation!=="disabled")throw new Error(`Invalid program package index ${r}: package ${JSON.stringify(s)} output member lacks outputName or forkInstrumentation`);mn(u.outputName,r,`${s} outputName`)}else if(typeof u.guestPath!="string"||!u.guestPath.startsWith("/")||!Number.isInteger(u.mode)||u.mode<0||u.mode>511)throw new Error(`Invalid program package index ${r}: package ${JSON.stringify(s)} runtime member lacks valid guestPath or mode`);return u});if(d.length===0||new Set(d.map(p=>p.sourceArtifact)).size!==d.length||new Set(d.map(p=>p.mirrorPath)).size!==d.length||d.length===1&&d[0].mirrorPath.includes("/")||d.length>1&&d.some(p=>!p.mirrorPath.startsWith(`${s}/`)))throw new Error(`Invalid program package index ${r}: package ${JSON.stringify(s)} members are empty, collide, or violate scalar/package-directory layout`);let y=a.manifestSha256,g=t.get(s);if(!g||g.manifestSha256!==y||c.some(p=>g.cacheKeys[p]!==l[p]))throw new Error(`Invalid program package index ${r}: package ${JSON.stringify(s)} does not match its contextual package identity`);i.set(s,{manifestSha256:y,arches:c,cacheKeys:l,dependencyClosures:f,members:d})}return{identities:t,packages:i,indexPath:r}}function as(r){return JSON.stringify({manifestSha256:r.manifestSha256,arches:r.arches,cacheKeys:Object.fromEntries(r.arches.map(e=>[e,r.cacheKeys[e]])),dependencyClosures:Object.fromEntries(r.arches.map(e=>[e,[...r.dependencyClosures[e]].sort((t,n)=>t.packageNamen.packageName?1:0)])),members:r.members.map(e=>e.kind==="output"?{kind:e.kind,sourceArtifact:e.sourceArtifact,mirrorPath:e.mirrorPath,outputName:e.outputName,forkInstrumentation:e.forkInstrumentation}:{kind:e.kind,sourceArtifact:e.sourceArtifact,mirrorPath:e.mirrorPath,guestPath:e.guestPath,mode:e.mode})})}function Sr(){let r=$(zn(),"wasm",ve);return de(r)?mr(r):null}function ja(r){let e=Sr();if(!e)return null;let t=r.split("/");if(t[0]!=="programs"||!qe.has(t[1]))return null;let n=t[1];if(t.length>=4){let o=t[2];return e.packages.get(o)?.arches.includes(n)?o:null}if(t.length!==3)return null;let i=t[2];for(let[o,s]of e.packages)if(s.arches.includes(n)&&s.members.some(a=>a.kind==="output"&&a.mirrorPath.split("/").at(-1)===i))return o;return null}function Qi(r){let e=ja(r);if(e)throw new Error(`Installed package resolver path ${JSON.stringify(r)} is owned by ${JSON.stringify(e)}, but that package is not selected by the configured program registry`)}function cs(){let r=os(),e=new Map,t=new Map,n=new Map,i=new Map,o=[];if(r===null){let l=$(zn(),"wasm",ve);if(!de(l))return{identities:e,unidentifiedPackages:t,packages:n,unprojectedPackages:i,physicalProgramClaims:o};let h=mr(l);for(let[f,d]of h.identities)e.set(f,{...d,packageName:f,policyPath:`${h.indexPath}#identities.${f}`});for(let[f,d]of h.packages)o.push({packageName:f,projection:d,selected:!0}),n.set(f,{...d,packageName:f,policyPath:`${h.indexPath}#${f}`});return{identities:e,unidentifiedPackages:t,packages:n,unprojectedPackages:i,physicalProgramClaims:o}}let s=new Set,a=null,c=null;for(let l of r){if(!de(l))continue;if(!He(l).isDirectory())throw new Error(`Program registry root is not a directory: ${l}`);let h=$(l,ve);if(!de(h))throw new Error(`Program registry ${l} is missing ${ve}; generate it with xtask build-deps program-index`);let f=mr(h);a??=f.identities,c??=f.packages;let d=Ra(l,{withFileTypes:!0}).filter(y=>y.isDirectory()||y.isSymbolicLink()).sort((y,g)=>y.name.localeCompare(g.name));for(let y of d){let g=y.name,p=$(l,g,"package.toml");if(!de(p))continue;let w=!1;try{w=He(p).isFile()}catch{w=!1}if(!w)continue;let u=f.packages.get(g),m=!s.has(g);if(u&&o.push({packageName:g,projection:u,selected:m}),!m)continue;s.add(g);let v=a.get(g);v?e.set(g,{...v,packageName:g,manifestPath:p,policyPath:p}):t.set(g,p);let E=c.get(g);if(!E){i.set(g,p);continue}n.set(g,{...E,packageName:g,manifestPath:p,policyPath:p})}}return{identities:e,unidentifiedPackages:t,packages:n,unprojectedPackages:i,physicalProgramClaims:o}}function es(r){if(!r.manifestPath)return;let e;try{e=Ze(r.manifestPath)}catch(n){throw new Error(`Program package identity cannot verify ${r.manifestPath}: ${n instanceof Error?n.message:String(n)}`)}if(rs("sha256").update(e).digest("hex")!==r.manifestSha256)throw new Error(`Program package identity is stale for ${r.manifestPath}; regenerate ${ve}`)}function Ya(r){if(!r.manifestPath)return;let e;try{e=Ze(r.manifestPath)}catch(n){throw new Error(`Program package projection cannot verify ${r.manifestPath}: ${n instanceof Error?n.message:String(n)}`)}if(rs("sha256").update(e).digest("hex")!==r.manifestSha256)throw new Error(`Program package projection is stale for ${r.manifestPath}; regenerate ${ve}`)}function Dt(r){let e=zr(),t=e.packages.get(r);if(t)return Ya(t),t;let n=e.unprojectedPackages.get(r);if(n)throw new Error(`Package ${JSON.stringify(r)} is selected at ${n} but is absent from ${ve}; regenerate the registry projection`);return null}function Xa(r,e){let t=r.dependencyClosures[e];if(!t)throw yt(r.policyPath,`package ${JSON.stringify(r.packageName)} lacks a dependency identity closure for ${e}`);let n=cs(),i=n.identities.get(r.packageName);if(!i){let s=n.unidentifiedPackages.get(r.packageName);throw new Error(`Program package ${JSON.stringify(r.packageName)} has no authoritative contextual identity for ${e}${s?` at ${s}`:""}; regenerate ${ve} with the exact ordered registry roots`)}es(i);let o=i.cacheKeys[e];if(i.manifestSha256!==r.manifestSha256||o!==r.cacheKeys[e])throw new Error(`Program package ${JSON.stringify(r.packageName)} was projected with manifest ${r.manifestSha256} and cache key ${r.cacheKeys[e]} for ${e}, but the authoritative first-hit registry context at ${i.policyPath} requires manifest ${i.manifestSha256} and cache key ${o??""}. Regenerate this program projection with the exact ordered registry roots; the highest-priority index must carry the complete combined-context projection rather than relying on a lower suffix-context build identity.`);for(let s of t){let a=n.identities.get(s.packageName);if(!a){let l=n.unidentifiedPackages.get(s.packageName);throw l?new Error(`Program package ${JSON.stringify(r.packageName)} was generated against dependency ${JSON.stringify(s.packageName)}, but the first-hit package at ${l} has no contextual identity in ${ve}`):new Error(`Program package ${JSON.stringify(r.packageName)} was generated against dependency ${JSON.stringify(s.packageName)}, but that dependency is absent from the configured first-hit registry roots`)}es(a);let c=a.cacheKeys[e];if(a.manifestSha256!==s.manifestSha256||c!==s.cacheKey)throw new Error(`Program package ${JSON.stringify(r.packageName)} has a contextual cache identity mismatch for ${e}: its projection expects dependency ${JSON.stringify(s.packageName)} manifest ${s.manifestSha256} and cache key ${s.cacheKey}, but first-hit selection at ${a.policyPath} provides manifest ${a.manifestSha256} and cache key ${c??""}. Regenerate the program projection with the exact ordered registry roots; the complete highest-priority projection must bind every selected program to the same combined dependency context.`)}}function zr(){let r=cs(),{physicalProgramClaims:e,...t}=r,n={...t,legacyFlatOutputs:new Map,forkInstrumentationDisabledOutputs:new Map},i=[];for(let o of r.packages.values()){let s=o.members.length>1;for(let a of o.arches)for(let c of o.members){let l=i.find(y=>y.arch===a&&(y.path===c.mirrorPath||y.path.startsWith(`${c.mirrorPath}/`)||c.mirrorPath.startsWith(`${y.path}/`)));if(l)throw new Error(`Program resolver paths programs/${a}/${l.path} and programs/${a}/${c.mirrorPath} conflict between selected packages ${JSON.stringify(l.packageName)} and ${JSON.stringify(o.packageName)}`);if(i.push({arch:a,path:c.mirrorPath,packageName:o.packageName}),c.kind!=="output")continue;let h=c.mirrorPath.split("/").at(-1),f=`${a}/${h}`,d=n.legacyFlatOutputs.get(f);d||(d={scalarOwners:new Set,packagePaths:new Map,shadowedOwners:new Set},n.legacyFlatOutputs.set(f,d)),s?d.packagePaths.set(`programs/${a}/${c.mirrorPath}`,o.packageName):d.scalarOwners.add(o.packageName),c.forkInstrumentation==="disabled"&&n.forkInstrumentationDisabledOutputs.set(`${a}/${c.mirrorPath}`,o.packageName)}}for(let{packageName:o,projection:s,selected:a}of e)if(!(a&&r.packages.has(o)))for(let c of s.arches)for(let l of s.members){if(l.kind!=="output")continue;let h=l.mirrorPath.split("/").at(-1),f=`${c}/${h}`,d=n.legacyFlatOutputs.get(f);d||(d={scalarOwners:new Set,packagePaths:new Map,shadowedOwners:new Set},n.legacyFlatOutputs.set(f,d)),d.shadowedOwners.add(o)}return n}function Ja(r){let e=r.split("/");if(e.length!==3||e[0]!=="programs"||!qe.has(e[1]))return null;let t=zr().legacyFlatOutputs.get(`${e[1]}/${e[2]}`);if(!t)return null;for(let n of t.scalarOwners){let i=Dt(n);if(i)return i}for(let n of t.packagePaths.values())Dt(n);if(t.packagePaths.size>0)throw new Error(`Legacy flat resolver path ${JSON.stringify(r)} belongs to a multi-member package; use ${[...t.packagePaths.keys()].sort().map(n=>JSON.stringify(n)).join(" or ")}`);for(let n of t.shadowedOwners){let i=Dt(n);if(i)return i;throw new Error(`Legacy flat resolver path ${JSON.stringify(r)} is claimed by a lower-root program package ${JSON.stringify(n)}, but its first-hit selected package does not project that program; stale scalar mirror fallback is forbidden`)}return null}function ts(r,e,t){if(!r.arches.includes(e))throw yt(r.policyPath,`package ${JSON.stringify(r.packageName)} does not declare resolver artifacts for ${e}`);let n=r.cacheKeys[e];if(!n)throw yt(r.policyPath,`package ${JSON.stringify(r.packageName)} lacks a cache identity for ${e}`);Xa(r,e);let i=as(r),o=r.members.map(s=>({packageName:r.packageName,relPath:`programs/${e}/${s.mirrorPath}`,sourceArtifact:s.sourceArtifact,cacheKey:n,forkInstrumentation:s.kind==="output"?s.forkInstrumentation??null:null,projectionIdentity:i}));if(!o.some(s=>s.relPath===t))throw yt(r.policyPath,`resolver path ${JSON.stringify(t)} is not a declared member of package ${JSON.stringify(r.packageName)}`);return{manifestPath:r.policyPath,packageName:r.packageName,members:o}}function Qa(r){let e=Ce(r),t=e.split("/");if(t[0]==="programs"&&!Da()&&Sr()===null)throw new Error(`Installed host package is missing wasm/${ve}; program artifacts cannot be resolved without packaged policy`);if(t.length===3){let s=Ja(e);return s?ts(s,t[1],e):(Qi(e),null)}if(t.length<4||t[0]!=="programs"||!qe.has(t[1]))return null;let n=t[1],i=t[2],o=Dt(i);return o?ts(o,n,e):(Qi(e),null)}function ec(r){let e=Ce(r);for(let t of["programs/wasm32/","programs/wasm64/"])if(e.startsWith(t))return e.slice(t.length);return null}function tc(r){let e=Ce(r);for(let t of qe){let n=`programs/${t}/`;if(e.startsWith(n)){let i=zr().forkInstrumentationDisabledOutputs.get(`${t}/${e.slice(n.length)}`);return i?Dt(i)!==null:!1}}return!1}function nc(r){let e=Ce(r);if(e==="kernel.wasm")return Lr;let t=ec(e);if(t&&t.endsWith(".wasm"))return Fa}function rc(r,e,t){if(!r.endsWith(".wasm"))return!1;try{let n=Ze(r),i=n.buffer.slice(n.byteOffset,n.byteOffset+n.byteLength),o=t===void 0?tc(e):t==="disabled";return Nr(i,{expectedAbi:42,requiredExports:nc(e),requireForkInstrumentation:o?!1:void 0,forbidForkInstrumentation:o}).length>0}catch{return!0}}function ic(r){if(!r.endsWith(".vfs")&&!r.endsWith(".vfs.zst"))return!1;try{let t=yn.readImageMetadata(Ze(r))?.kernelAbi;return t!==void 0&&t!==42}catch{return!0}}function br(r,e,t){return rc(r,e,t)||ic(r)}function ls(r,e,t){let n=r.filter(de);return n.length===0?null:n.find(i=>{try{return He(i).isFile()&&!br(i,e,t)}catch{return!1}})??null}function fs(r,e,t){try{if(!En(r).isSymbolicLink())return r;let i=me(r);if(!He(i).isFile()||br(i,e,t))throw new Error("canonical target is not an accepted regular file");if(Ce(e).startsWith("programs/")&&sc(i))throw new Error("resolver-owned program generation has no matching selected package projection");return i}catch(n){throw new Error(`Binary changed or became invalid while pinning ${e}: ${n instanceof Error?n.message:String(n)}`)}}function sc(r){let e=[is()];try{e.push($(Ve(),"local-binaries",".kandelo-local-generations"))}catch{}return e.some(t=>{try{return de(t)&&kr(me(t),r)}catch{return!1}})}function kr(r,e){let t=Na(r,e);return t===""||t!==".."&&!t.startsWith(`..${Ca}`)&&!Sn(t)}function oc(r,e){let t=e.split("/"),n=r;for(let i=0;ia.packageName!==o))return"declared package members do not share a valid program namespace";if(!He(e).isDirectory())return"shared package generation root is not a directory";let s=t[0].cacheKey;if(!/^[a-f0-9]{64}$/.test(s)||t.some(a=>a.cacheKey!==s))return"declared package members do not share one valid cache identity";if(r.identity==="local-generation"){let a=$(r.root,".kandelo-local-generations",i,o,s);if(!de(a))return"local mirror targets are not one direct immutable local generation";let c=me(a);return Ut(e)===c?null:"local mirror targets are not one direct immutable local generation"}if(r.identity==="program-cache"){let a=is();if(!de(a))return"fetched mirror targets are not one canonical program-cache generation";let c=me(a),l=Ba(e),h=l.startsWith(`${o}-`)&&new RegExp(`-rev[0-9]+-${i}-${s}$`).test(l);return Ut(e)===c&&h?null:"fetched mirror targets are not one canonical program-cache generation"}return"installed-package symlink closures are not an immutable installed identity"}function cc(r,e,t){if(e.length!==t.length)return{failure:"internal member/path count mismatch"};try{let n=e.map(l=>{let h=En(l);return h.isSymbolicLink()?"symlink":h.isFile()?"file":"other"});if(n.includes("other"))return{failure:"a selected mirror member is neither a regular file nor a symlink"};let i=n.every(l=>l==="symlink"),o=n.every(l=>l==="file");if(!i&&!o)return{failure:"regular files and symlinks cannot share one package identity"};if(o){if(!r.allowRegularFileClosure)return{failure:"a mutable source-checkout wasm tree is not an installed package identity"};let l=t[0].packageName,h=t[0].projectionIdentity;if(t.some(p=>p.packageName!==l||p.projectionIdentity!==h))return{failure:"declared members do not share one selected package projection"};let d=Sr()?.packages.get(l);if(!d||as(d)!==h)return{failure:"installed bytes do not match the selected package projection"};let y=me(r.root),g=[];for(let p of e){let w=me(p);if(!kr(y,w)||!He(w).isFile())return{failure:"an installed-package member escapes its immutable wasm tree"};g.push(w)}return{paths:g}}let s=null,a=[];for(let l=0;llc(r))}function lc(r){let e=Ce(r),t=Qa(e);if(t){let s=fc(t.members.map(a=>a.relPath),t.members);if(s)return s[t.members.findIndex(a=>a.relPath===e)];throw new vn(`Package artifacts not found for ${t.packageName}: ${e}`)}let n=[],i=[];for(let s of ss())for(let a of s.candidatesFor(r))n.push(a),i.push(a);let o=ls(i,r);if(o)return fs(o,r);throw i.some(de)?new Error(`Binary exists but was rejected by artifact policy: ${r} `+n.map(s=>` checked: ${s}`).join(` `)):new vn(`Binary not found: ${r} diff --git a/scripts/seal-homebrew-formula-checker.sh b/scripts/seal-homebrew-formula-checker.sh new file mode 100755 index 0000000000..3613537cf5 --- /dev/null +++ b/scripts/seal-homebrew-formula-checker.sh @@ -0,0 +1,108 @@ +#!/usr/bin/env bash +# Detach Cargo's release xtask before it becomes Formula policy authority. +set -euo pipefail + +ROOT="" +CHECKER="" + +while [ "$#" -gt 0 ]; do + case "$1" in + --root) ROOT="$2"; shift 2 ;; + --checker) CHECKER="$2"; shift 2 ;; + *) + echo "seal-homebrew-formula-checker: unknown flag $1" >&2 + exit 2 + ;; + esac +done + +if [ -z "$ROOT" ] || [ -z "$CHECKER" ] || + [ "${ROOT#/}" = "$ROOT" ] || [ "${CHECKER#/}" = "$CHECKER" ] || + [ ! -d "$ROOT" ] || [ -L "$ROOT" ] || + [ ! -f "$CHECKER" ] || [ -L "$CHECKER" ] || [ ! -x "$CHECKER" ] || + [ "$(realpath -- "$ROOT" 2>/dev/null || true)" != "$ROOT" ] || + [ "$(realpath -- "$CHECKER" 2>/dev/null || true)" != "$CHECKER" ]; then + echo "seal-homebrew-formula-checker: exact root and release checker are required" >&2 + exit 2 +fi + +case "$CHECKER" in + "$ROOT"/target/*/release/xtask) + relative="${CHECKER#"$ROOT"/}" + ;; + *) + echo "seal-homebrew-formula-checker: checker is not Cargo's top-level release xtask" >&2 + exit 2 + ;; +esac +IFS=/ read -r -a parts <<<"$relative" +if [ "${#parts[@]}" -ne 4 ] || [ "${parts[0]}" != "target" ] || + ! [[ "${parts[1]}" =~ ^[A-Za-z0-9_.+-]+$ ]] || + [ "${parts[2]}" != "release" ] || [ "${parts[3]}" != "xtask" ]; then + echo "seal-homebrew-formula-checker: checker has an invalid release path" >&2 + exit 2 +fi + +source_mode="$(stat -c '%a' "$CHECKER" 2>/dev/null || true)" +source_uid="$(stat -c '%u' "$CHECKER" 2>/dev/null || true)" +source_size="$(stat -c '%s' "$CHECKER" 2>/dev/null || true)" +source_sha256="$(sha256sum "$CHECKER" 2>/dev/null || true)" +source_sha256="${source_sha256%% *}" +if ! [[ "$source_mode" =~ ^[0-7]{3,4}$ ]] || + [ $((8#$source_mode & 06022)) -ne 0 ] || + ! [[ "$source_uid" =~ ^[0-9]+$ ]] || + ! [[ "$source_size" =~ ^[1-9][0-9]*$ ]] || + ! [[ "$source_sha256" =~ ^[0-9a-f]{64}$ ]]; then + echo "seal-homebrew-formula-checker: source checker is unsafe" >&2 + exit 2 +fi + +sealed="$CHECKER.formula-seal" +if [ -e "$sealed" ] || [ -L "$sealed" ]; then + echo "seal-homebrew-formula-checker: seal destination already exists" >&2 + exit 2 +fi +cleanup() { + rm -f -- "$sealed" +} +trap cleanup EXIT + +# WHY: on Linux, Cargo hard-links target//release/xtask to the hashed +# release/deps artifact. The Formula boundary must not inherit any alternate +# inode alias, even when Cargo's second path is currently protected. Installing +# exact bytes to a new inode keeps the stronger single-link invariant without +# weakening normal Cargo builds. +install -m 0555 -- "$CHECKER" "$sealed" +sealed_mode="$(stat -c '%a' "$sealed" 2>/dev/null || true)" +sealed_links="$(stat -c '%h' "$sealed" 2>/dev/null || true)" +sealed_uid="$(stat -c '%u' "$sealed" 2>/dev/null || true)" +sealed_size="$(stat -c '%s' "$sealed" 2>/dev/null || true)" +sealed_sha256="$(sha256sum "$sealed" 2>/dev/null || true)" +sealed_sha256="${sealed_sha256%% *}" +if [ ! -f "$sealed" ] || [ -L "$sealed" ] || [ ! -x "$sealed" ] || + [ "$(realpath -- "$sealed" 2>/dev/null || true)" != "$sealed" ] || + [ "$sealed_mode" != "555" ] || [ "$sealed_links" != "1" ] || + [ "$sealed_uid" != "$source_uid" ] || [ "$sealed_size" != "$source_size" ] || + [ "$sealed_sha256" != "$source_sha256" ]; then + echo "seal-homebrew-formula-checker: detached checker seal is invalid" >&2 + exit 2 +fi + +mv -f -- "$sealed" "$CHECKER" +trap - EXIT +final_mode="$(stat -c '%a' "$CHECKER" 2>/dev/null || true)" +final_links="$(stat -c '%h' "$CHECKER" 2>/dev/null || true)" +final_uid="$(stat -c '%u' "$CHECKER" 2>/dev/null || true)" +final_size="$(stat -c '%s' "$CHECKER" 2>/dev/null || true)" +final_sha256="$(sha256sum "$CHECKER" 2>/dev/null || true)" +final_sha256="${final_sha256%% *}" +if [ ! -f "$CHECKER" ] || [ -L "$CHECKER" ] || [ ! -x "$CHECKER" ] || + [ "$(realpath -- "$CHECKER" 2>/dev/null || true)" != "$CHECKER" ] || + [ "$final_mode" != "555" ] || [ "$final_links" != "1" ] || + [ "$final_uid" != "$source_uid" ] || [ "$final_size" != "$source_size" ] || + [ "$final_sha256" != "$source_sha256" ]; then + echo "seal-homebrew-formula-checker: installed checker seal is invalid" >&2 + exit 2 +fi + +printf '%s\n' "$CHECKER" diff --git a/scripts/test-homebrew-patched-launcher.sh b/scripts/test-homebrew-patched-launcher.sh index 97c4a17899..91a0a0f419 100755 --- a/scripts/test-homebrew-patched-launcher.sh +++ b/scripts/test-homebrew-patched-launcher.sh @@ -36,6 +36,122 @@ fail() { exit 1 } +assert_real_relocated_xtask_uses_source_alias() { + if [ "$#" -ne 1 ]; then + fail "real relocated xtask regression expects one isolated build user" + fi + local build_user="$1" + local build_group host_target release_xtask regression_root protected_root + local protected_xtask source_alias runner_source runner unit + build_group="$(id -gn "$build_user")" + host_target="$(rustc -vV | sed -n 's/^host: //p')" + release_xtask="$REPO_ROOT/target/$host_target/release/xtask" + [ -n "$host_target" ] && [ -f "$release_xtask" ] && \ + [ ! -L "$release_xtask" ] && [ -x "$release_xtask" ] || + fail "real relocated xtask regression requires the prebuilt host release checker" + + regression_root="$ISOLATION_ROOT/real-relocated-xtask" + case "$regression_root/" in + "$ISOLATION_ROOT/"*) ;; + *) fail "real relocated xtask regression escaped its isolated root" ;; + esac + protected_root="$regression_root/protected" + protected_xtask="$protected_root/xtask" + source_alias="$regression_root/source/kandelo" + runner_source="$ISOLATION_ROOT/verify-relocated-xtask-$$-${RANDOM}.source" + runner="$protected_root/verify-relocated-xtask" + /usr/bin/sudo -n -- /usr/bin/install -d -o root -g root -m 0555 \ + "$protected_root" "${source_alias%/*}" "$source_alias" + /usr/bin/sudo -n -- /usr/bin/install -o root -g root -m 0555 -- \ + "$release_xtask" "$protected_xtask" + /usr/bin/sudo -n -- /usr/bin/cmp -s -- "$release_xtask" "$protected_xtask" || + fail "real relocated xtask regression did not stage the exact release bytes" + + cat >"$runner_source" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +original_root="$1" +source_alias="$2" +checker="$3" +host_target="$4" +expected_registry="$source_alias/packages/registry" + +# The negative control must reproduce the publisher failure: the checker was +# compiled in original_root, but that checkout is deliberately inaccessible. +if [ -r "$original_root" ] || [ -w "$original_root" ] || \ + [ -x "$original_root" ] || ls "$original_root" >/dev/null 2>&1; then + echo "real relocated xtask regression can still access the compile checkout" >&2 + exit 1 +fi +[ -r "$source_alias/Cargo.toml" ] && + [ -r "$expected_registry/program-packages.json" ] || + { echo "real relocated xtask regression cannot read the source alias" >&2; exit 1; } +[ "$checker" = "$source_alias/target/$host_target/release/xtask" ] +[ -f "$checker" ] && [ ! -L "$checker" ] && [ -r "$checker" ] && + [ -x "$checker" ] && [ ! -w "$checker" ] +[ "$(/usr/bin/realpath -- "$checker")" = "$checker" ] +[ "$(/usr/bin/stat -c '%u:%g:%a:%h' "$checker")" = "0:0:555:1" ] +for read_only_path in "$source_alias" "$checker"; do + mount_options="$( + /usr/bin/findmnt --noheadings --output VFS-OPTIONS --target "$read_only_path" + )" + case ",${mount_options// /}," in + *,ro,*) ;; + *) + echo "real relocated xtask regression found a writable bind: $read_only_path" >&2 + exit 1 + ;; + esac +done + +export WASM_POSIX_DEPS_REGISTRY="$expected_registry" +if negative_output="$( + "$checker" build-deps program-index-context-check 2>&1 + )"; then + echo "relocated checker unexpectedly used its inaccessible compile checkout" >&2 + exit 1 +fi +case "$negative_output" in + *"global package build input \"flake.nix\" not found at $original_root/flake.nix"*) ;; + *) + echo "relocated checker negative control failed for the wrong reason:" >&2 + echo "$negative_output" >&2 + exit 1 + ;; +esac + +"$checker" build-deps program-index-context-check \ + --source-repo-root "$source_alias" +EOF + chmod 0555 "$runner_source" + /usr/bin/sudo -n -- /usr/bin/install -o root -g root -m 0555 -- \ + "$runner_source" "$runner" + rm -f "$runner_source" + + unit="kandelo-real-relocated-xtask-$$-${RANDOM}.service" + /usr/bin/sudo -n -- /usr/bin/systemd-run \ + --quiet --wait --collect --pipe \ + --unit="$unit" \ + --uid="$build_user" --gid="$build_group" \ + --property=KillMode=control-group \ + --property=SendSIGKILL=yes \ + --property=TimeoutStopSec=10s \ + --property=NoNewPrivileges=yes \ + "--property=BindReadOnlyPaths=$REPO_ROOT:$source_alias" \ + "--property=BindReadOnlyPaths=$protected_xtask:$source_alias/target/$host_target/release/xtask" \ + "--property=InaccessiblePaths=$REPO_ROOT" \ + --service-type=exec \ + --expand-environment=no \ + --working-directory="$source_alias" \ + -- /usr/bin/env -i \ + "HOME=/home/$build_user" "USER=$build_user" "LOGNAME=$build_user" \ + "PATH=$PATH" \ + "$runner" "$REPO_ROOT" "$source_alias" \ + "$source_alias/target/$host_target/release/xtask" "$host_target" + + /usr/bin/sudo -n -- rm -rf -- "$regression_root" +} + prefix="$TMPDIR/prefix" patch_file="$TMPDIR/marker.patch" publisher_patch_file="$TMPDIR/publisher-marker.patch" @@ -154,7 +270,7 @@ case "${1:-}" in HOMEBREW_KANDELO_ABI HOMEBREW_KANDELO_ARCH HOMEBREW_KANDELO_LLVM_BIN \ HOMEBREW_KANDELO_GNU_TAR HOMEBREW_KANDELO_NODE HOMEBREW_KANDELO_NODE_RECEIPT_PATH \ HOMEBREW_KANDELO_PRIMARY_TAP_ROOT HOMEBREW_KANDELO_ROOT \ - HOMEBREW_KANDELO_SYSROOT LLVM_BIN \ + HOMEBREW_KANDELO_SYSROOT HOMEBREW_KANDELO_XTASK_BIN LLVM_BIN \ PLAYWRIGHT_BROWSERS_PATH WASM_POSIX_LLVM_DIR WASM_POSIX_SYSROOT; do [ -z "${!target_only+x}" ] || exit 1 done @@ -404,15 +520,30 @@ case "${1:-}" in printf 'mutation\n' >>"${XDG_CONFIG_HOME:?}/homebrew/trust.json" ;; assert-source-aliases) - [ "$#" -eq 9 ] + [ "$#" -eq 11 ] [ "${HOMEBREW_KANDELO_ROOT:-}" = "$2" ] [ "${KANDELO_HOMEBREW_KANDELO_ROOT:-}" = "$2" ] [ "${HOMEBREW_KANDELO_SYSROOT:-}" = "$4" ] [ "${WASM_POSIX_SYSROOT:-}" = "$4" ] + [ "${HOMEBREW_KANDELO_XTASK_BIN:-}" = "$5" ] + [ "${WASM_POSIX_XTASK_BIN:-}" = "$5" ] + [ -f "$5" ] && [ ! -L "$5" ] && [ -r "$5" ] && [ -x "$5" ] && [ ! -w "$5" ] + [ "$(/usr/bin/realpath -- "$5")" = "$5" ] + [ "$(/usr/bin/stat -c '%u:%g:%a:%h' "$5")" = "0:0:555:1" ] + actual_xtask_sha256="$(/usr/bin/sha256sum "$5")" + actual_xtask_sha256="${actual_xtask_sha256%% *}" + [ "$actual_xtask_sha256" = "$6" ] + [ "$("$5" build-deps program-index-context-check \ + --source-repo-root "$2")" = "checked source projection" ] [ -r "$2/source-marker" ] [ -r "$3/tap-marker" ] [ "$(cat "$4/lib/libc.a")" = "reviewed sysroot" ] - for hidden_root in "$5" "$6" "$7" "$8" "$9"; do + if printf 'changed\n' >>"$5" 2>/dev/null; then exit 1; fi + if chmod u+w "$5" 2>/dev/null; then exit 1; fi + if rm -f "$5" 2>/dev/null; then exit 1; fi + if mv "$5" "$5-replaced" 2>/dev/null; then exit 1; fi + if ln -snf /tmp/changed "$5" 2>/dev/null; then exit 1; fi + for hidden_root in "$7" "$8" "$9" "${10}" "${11}"; do if [ -r "$hidden_root" ] || [ -w "$hidden_root" ] || [ -x "$hidden_root" ]; then exit 1 fi @@ -1192,6 +1323,8 @@ if [ "$(uname -s)" = "Linux" ] && [ -x /usr/bin/sudo ] && \ isolated_sysroot_private_parent="$ISOLATION_ROOT/private-sysroot-owner" isolated_sysroot_owner="$isolated_sysroot_private_parent/sysroot-build" isolated_sysroot="$isolated_sysroot_owner/sysroot" + isolated_xtask_dir="$isolated_kandelo/target/x86_64-unknown-linux-gnu/release" + isolated_xtask="$isolated_xtask_dir/xtask" isolated_dependency_plan="$isolated_output/host-dependencies.json" isolated_tier2_attestation="$isolated_output/tier2-attestation.json" isolated_home="/home/$ISOLATION_BUILD_USER" @@ -1205,7 +1338,8 @@ if [ "$(uname -s)" = "Linux" ] && [ -x /usr/bin/sudo ] && \ "$isolated_cache" "$isolated_temp" "$isolated_kandelo" "$isolated_tap" \ "$isolated_dependency_tap" "$isolated_output" "$isolated_native_base" \ "$external_cellar" "$external_opt" \ - "$isolated_private_bottle_dir" "$isolated_shared_temp" "$isolated_sysroot/lib" + "$isolated_private_bottle_dir" "$isolated_shared_temp" "$isolated_sysroot/lib" \ + "$isolated_xtask_dir" chmod 0711 "$isolated_native_base" chmod 0700 "$isolated_private_bottle_dir" chmod 0700 "$isolated_sysroot_private_parent" @@ -1219,6 +1353,18 @@ if [ "$(uname -s)" = "Linux" ] && [ -x /usr/bin/sudo ] && \ printf 'reviewed tap\n' >"$isolated_tap/tap-marker" printf 'reviewed dependency tap\n' >"$isolated_dependency_tap/tap-marker" printf 'reviewed sysroot\n' >"$isolated_sysroot/lib/libc.a" + cat >"$isolated_xtask" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +[ "$#" -eq 4 ] +[ "$1" = "build-deps" ] +[ "$2" = "program-index-context-check" ] +[ "$3" = "--source-repo-root" ] +[ "$4" = "${HOMEBREW_KANDELO_ROOT:?}" ] +[ -r "$4/source-marker" ] +printf 'checked source projection\n' +EOF + chmod 0555 "$isolated_xtask" printf 'target work\n' >"$isolated_work/target-work-marker" printf 'external target untouched\n' >"$external_cellar/sentinel" printf 'external target untouched\n' >"$external_opt/sentinel" @@ -1230,9 +1376,10 @@ if [ "$(uname -s)" = "Linux" ] && [ -x /usr/bin/sudo ] && \ chmod 0600 "$isolated_tier2_attestation" mkdir "$isolated_kandelo/runner-control" chmod 0700 "$isolated_kandelo/runner-control" - # Keep the parent traversable so only systemd's InaccessiblePaths protects - # these roots. A mode-000 mountpoint can still exist and stat successfully. - chmod 0755 "$isolated_source_parent" + # Model GitHub's workflow-private home: the Formula identity cannot traverse + # the original checkout. The launcher must expose only its root-created, + # read-only bind aliases inside the isolated service. + 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" @@ -1246,6 +1393,7 @@ if [ "$(uname -s)" = "Linux" ] && [ -x /usr/bin/sudo ] && \ /usr/bin/sudo -n -- /usr/sbin/useradd --system --user-group --create-home \ --home-dir "$isolated_home" --shell /usr/sbin/nologin "$ISOLATION_BUILD_USER" + assert_real_relocated_xtask_uses_source_alias "$ISOLATION_BUILD_USER" /usr/bin/sudo -n -- chown -R \ "$ISOLATION_BUILD_USER:$(id -gn "$ISOLATION_BUILD_USER")" \ "$external_cellar" "$external_opt" @@ -1257,6 +1405,10 @@ if [ "$(uname -s)" = "Linux" ] && [ -x /usr/bin/sudo ] && \ /usr/bin/test -x "$isolated_sysroot_owner"; then fail "sysroot fixture does not model a workflow-private owner path" fi + if /usr/bin/sudo -n -H -u "$ISOLATION_BUILD_USER" -- \ + /usr/bin/test -r "$isolated_xtask"; then + fail "program-index checker fixture does not model a workflow-private checkout" + fi export HOMEBREW_CACHE="$isolated_cache" export HOMEBREW_TEMP="$isolated_temp" export XDG_CONFIG_HOME="$isolated_work/xdg-config" @@ -1318,6 +1470,7 @@ if [ "$(uname -s)" = "Linux" ] && [ -x /usr/bin/sudo ] && \ done printf 'native boundary marker\n' >"$isolated_native_prefix/boundary-marker" export KANDELO_HOMEBREW_ARCH=wasm64 + export WASM_POSIX_XTASK_BIN="$isolated_xtask" if homebrew_patched_launcher_isolate \ "$ISOLATION_BUILD_USER" "$isolated_work" "$isolated_kandelo" "$isolated_tap" \ "$isolated_output" "$isolated_sysroot_owner" >/dev/null 2>&1; then @@ -1331,6 +1484,101 @@ if [ "$(uname -s)" = "Linux" ] && [ -x /usr/bin/sudo ] && \ fi export KANDELO_HOMEBREW_ARCH=wasm32 + assert_xtask_rejected() { + local candidate="$1" expected_error="$2" label="$3" + local error_file="$ISOLATION_ROOT/rejected-xtask.err" + local saved_xtask="$WASM_POSIX_XTASK_BIN" + if [ -n "$candidate" ]; then + export WASM_POSIX_XTASK_BIN="$candidate" + else + unset WASM_POSIX_XTASK_BIN + fi + if homebrew_patched_launcher_isolate \ + "$ISOLATION_BUILD_USER" "$isolated_work" "$isolated_kandelo" "$isolated_tap" \ + "$isolated_output" "$isolated_sysroot_owner" > /dev/null 2>"$error_file"; then + fail "Formula isolation accepted $label" + fi + grep -F "$expected_error" "$error_file" >/dev/null || + fail "Formula isolation did not explain rejected $label" + export WASM_POSIX_XTASK_BIN="$saved_xtask" + } + + assert_xtask_rejected "" \ + "prepared program-index checker must be one exact regular executable" \ + "a missing program-index checker" + + isolated_xtask_link="$isolated_xtask_dir/xtask-link" + ln -s xtask "$isolated_xtask_link" + assert_xtask_rejected "$isolated_xtask_link" \ + "prepared program-index checker must be one exact regular executable" \ + "a symlinked program-index checker" + rm "$isolated_xtask_link" + + outside_xtask="$isolated_output/xtask" + cp "$isolated_xtask" "$outside_xtask" + assert_xtask_rejected "$outside_xtask" \ + "prepared program-index checker is outside the exact Kandelo root" \ + "a program-index checker outside Kandelo" + rm "$outside_xtask" + + misplaced_xtask="$isolated_kandelo/xtask" + cp "$isolated_xtask" "$misplaced_xtask" + assert_xtask_rejected "$misplaced_xtask" \ + "program-index checker is not the prepared release xtask" \ + "a non-release program-index checker" + rm "$misplaced_xtask" + + inaccessible_xtask="$isolated_kandelo/target/inaccessible/release/xtask" + mkdir -p "${inaccessible_xtask%/*}" + cp "$isolated_xtask" "$inaccessible_xtask" + chmod 0700 "$inaccessible_xtask" + assert_xtask_rejected "$inaccessible_xtask" \ + "prepared program-index checker has an unsafe mode" \ + "an inaccessible program-index checker" + rm -rf "$isolated_kandelo/target/inaccessible" + + writable_xtask="$isolated_kandelo/target/writable/release/xtask" + mkdir -p "${writable_xtask%/*}" + cp "$isolated_xtask" "$writable_xtask" + chmod 0777 "$writable_xtask" + assert_xtask_rejected "$writable_xtask" \ + "prepared program-index checker has an unsafe mode" \ + "a build-user-writable program-index checker" + rm -rf "$isolated_kandelo/target/writable" + + hardlinked_xtask="$isolated_kandelo/target/hardlinked/release/xtask" + mkdir -p "${hardlinked_xtask%/*}" + cp "$isolated_xtask" "$hardlinked_xtask" + ln "$hardlinked_xtask" "$hardlinked_xtask.alternate" + assert_xtask_rejected "$hardlinked_xtask" \ + "prepared program-index checker is not single-linked" \ + "a hard-linked program-index checker" + rm -rf "$isolated_kandelo/target/hardlinked" + + owned_xtask="$isolated_kandelo/target/owned/release/xtask" + mkdir -p "${owned_xtask%/*}" + cp "$isolated_xtask" "$owned_xtask" + /usr/bin/sudo -n -- chown "$ISOLATION_BUILD_USER" "$owned_xtask" + assert_xtask_rejected "$owned_xtask" \ + "prepared program-index checker is owned by the Formula user" \ + "a Formula-user-owned program-index checker" + rm -rf "$isolated_kandelo/target/owned" + + replaceable_xtask="$isolated_kandelo/target/replaceable/release/xtask" + mkdir -p "${replaceable_xtask%/*}" + cp "$isolated_xtask" "$replaceable_xtask" + /usr/bin/sudo -n -- chown "$ISOLATION_BUILD_USER" "${replaceable_xtask%/*}" + # This negative case must expose the deliberately replaceable inner + # directory. The positive production-shaped case below restores the private + # runner-home boundary before exercising the read-only source alias. + chmod 0711 "$isolated_source_parent" + assert_xtask_rejected "$replaceable_xtask" \ + "build user can replace protected source" \ + "a build-user-replaceable program-index checker" + chmod 0700 "$isolated_source_parent" + /usr/bin/sudo -n -- chown "$(id -u):$(id -g)" "${replaceable_xtask%/*}" + rm -rf "$isolated_kandelo/target/replaceable" + assert_primary_tap_rejected() { local candidate="$1" expected_error="$2" label="$3" local error_file="$ISOLATION_ROOT/rejected-primary-tap.err" @@ -1404,6 +1652,13 @@ if [ "$(uname -s)" = "Linux" ] && [ -x /usr/bin/sudo ] && \ homebrew_patched_launcher_isolate \ "$ISOLATION_BUILD_USER" "$isolated_work" "$isolated_kandelo" "$isolated_tap" \ "$isolated_output" "$isolated_sysroot_owner" "$isolated_dependency_tap" + protected_dir="$HOMEBREW_PATCHED_PROTECTED_DIR" + source_alias_dir="$HOMEBREW_PATCHED_SOURCE_ALIAS_DIR" + protected_xtask="$HOMEBREW_PATCHED_PROTECTED_DIR/xtask" + [ "$(/usr/bin/sudo -n -- /usr/bin/stat -c '%u:%g:%a:%h' "$protected_xtask")" = \ + "0:0:555:1" ] && + /usr/bin/sudo -n -- /usr/bin/cmp -s -- "$isolated_xtask" "$protected_xtask" || + fail "isolated launcher did not stage one exact root-owned checker inode" /usr/bin/sudo -n -H -u "$ISOLATION_BUILD_USER" -- \ test -x "$isolated_native_base" || fail "build identity cannot traverse the workflow-owned native parent" @@ -1477,10 +1732,16 @@ if [ "$(uname -s)" = "Linux" ] && [ -x /usr/bin/sudo ] && \ if "$HOMEBREW_PATCHED_BREW_BIN" trust >/dev/null 2>&1; then fail "explicit trust mutation succeeded against the sealed store" fi + isolated_xtask_sha256="$(/usr/bin/sha256sum "$isolated_xtask")" + isolated_xtask_sha256="${isolated_xtask_sha256%% *}" + HOMEBREW_KANDELO_XTASK_BIN=caller-poison \ + WASM_POSIX_XTASK_BIN=caller-poison \ "$HOMEBREW_PATCHED_BREW_BIN" assert-source-aliases \ "$HOMEBREW_PATCHED_SOURCE_ALIAS_DIR/kandelo" \ "$HOMEBREW_PATCHED_SOURCE_ALIAS_DIR/tap" \ "$HOMEBREW_PATCHED_SOURCE_ALIAS_DIR/sysroot" \ + "$HOMEBREW_PATCHED_SOURCE_ALIAS_DIR/kandelo/target/x86_64-unknown-linux-gnu/release/xtask" \ + "$isolated_xtask_sha256" \ "$isolated_kandelo" "$isolated_tap" "$isolated_output" "$isolated_sysroot_owner" \ "$isolated_dependency_tap" HOMEBREW_KANDELO_PRIMARY_TAP_ROOT=caller-poison \ @@ -1610,6 +1871,47 @@ if [ "$(uname -s)" = "Linux" ] && [ -x /usr/bin/sudo ] && \ [ -z "$($HOMEBREW_PATCHED_BREW_BIN list --formula cmake)" ] || fail "isolated target Homebrew rejected the native Formula proxy keg" "$HOMEBREW_PATCHED_BREW_BIN" assert-no-new-privileges + cp "$isolated_xtask" "$isolated_xtask.backup" + # WHY: production checkers are sealed 0555. Only the private fixture owner + # may unseal this copy, and every launcher invocation must see it resealed. + chmod 0755 "$isolated_xtask" + printf 'stale replacement\n' >>"$isolated_xtask" + chmod 0555 "$isolated_xtask" + if "$HOMEBREW_PATCHED_BREW_BIN" assert-no-new-privileges \ + >/dev/null 2>"$ISOLATION_ROOT/stale-xtask.err"; then + fail "isolated launcher accepted changed program-index checker bytes" + fi + grep -F "prepared program-index checker changed after isolation" \ + "$ISOLATION_ROOT/stale-xtask.err" >/dev/null || + fail "isolated launcher did not explain stale program-index checker bytes" + chmod 0755 "$isolated_xtask" + cp "$isolated_xtask.backup" "$isolated_xtask" + chmod 0555 "$isolated_xtask" + rm "$isolated_xtask.backup" + "$HOMEBREW_PATCHED_BREW_BIN" assert-no-new-privileges + /usr/bin/sudo -n -- chmod 0755 "$protected_xtask" + printf 'stale root copy\n' | + /usr/bin/sudo -n -- tee -a "$protected_xtask" >/dev/null + /usr/bin/sudo -n -- chmod 0555 "$protected_xtask" + if "$HOMEBREW_PATCHED_BREW_BIN" assert-no-new-privileges \ + >/dev/null 2>"$ISOLATION_ROOT/stale-protected-xtask.err"; then + fail "isolated launcher accepted changed root-owned checker bytes" + fi + grep -F "root-owned program-index checker changed after isolation" \ + "$ISOLATION_ROOT/stale-protected-xtask.err" >/dev/null || + fail "isolated launcher did not explain changed root-owned checker bytes" + if homebrew_patched_launcher_verify_isolation \ + >/dev/null 2>"$ISOLATION_ROOT/stale-protected-xtask-verify.err"; then + fail "isolation verification accepted changed root-owned checker bytes" + fi + grep -F "root-owned program-index checker changed after isolation" \ + "$ISOLATION_ROOT/stale-protected-xtask-verify.err" >/dev/null || + fail "isolation verification did not explain changed root-owned checker bytes" + /usr/bin/sudo -n -- chmod 0755 "$protected_xtask" + /usr/bin/sudo -n -- cp "$isolated_xtask" "$protected_xtask" + /usr/bin/sudo -n -- chmod 0555 "$protected_xtask" + "$HOMEBREW_PATCHED_BREW_BIN" assert-no-new-privileges + homebrew_patched_launcher_verify_isolation "$HOMEBREW_PATCHED_BREW_BIN" spawn-daemon "$daemon_marker" "$daemon_started" [ -e "$daemon_started" ] || fail "detached Formula process never started" sleep 3 @@ -1620,6 +1922,27 @@ if [ "$(uname -s)" = "Linux" ] && [ -x /usr/bin/sudo ] && \ pgrep_status="$?" set -e [ "$pgrep_status" -eq 1 ] || fail "Formula process check did not prove an empty UID" + /usr/bin/sudo -n -- mv -- "$protected_xtask" "$protected_xtask.original" + /usr/bin/sudo -n -- /usr/bin/install -o root -g root -m 0555 -- \ + "$isolated_xtask" "$protected_xtask" + if "$HOMEBREW_PATCHED_BREW_BIN" assert-no-new-privileges \ + >/dev/null 2>"$ISOLATION_ROOT/replaced-protected-xtask.err"; then + fail "isolated launcher accepted a replaced root-owned checker inode" + fi + grep -F "root-owned program-index checker changed after isolation" \ + "$ISOLATION_ROOT/replaced-protected-xtask.err" >/dev/null || + fail "isolated launcher did not explain the replaced root-owned checker inode" + if homebrew_patched_launcher_verify_isolation \ + >/dev/null 2>"$ISOLATION_ROOT/replaced-protected-xtask-verify.err"; then + fail "isolation verification accepted a replaced root-owned checker inode" + fi + grep -F "root-owned program-index checker changed after isolation" \ + "$ISOLATION_ROOT/replaced-protected-xtask-verify.err" >/dev/null || + fail "isolation verification did not explain the replaced root-owned checker inode" + /usr/bin/sudo -n -- rm -f -- "$protected_xtask" + /usr/bin/sudo -n -- mv -- "$protected_xtask.original" "$protected_xtask" + "$HOMEBREW_PATCHED_BREW_BIN" assert-no-new-privileges + homebrew_patched_launcher_verify_isolation homebrew_patched_launcher_teardown "$ISOLATION_BUILD_USER" /usr/bin/sudo -n -- chmod 0755 "$target_proxy_rack" if homebrew_patched_launcher_verify_isolation >/dev/null 2>&1; then @@ -1648,6 +1971,13 @@ if [ "$(uname -s)" = "Linux" ] && [ -x /usr/bin/sudo ] && \ fail "isolated cleanup left the publisher dependency plan" [ ! -e "$isolated_prefix/.kandelo-publisher-tier2-attestation.json" ] || fail "isolated cleanup left the publisher Tier-2 attestation" + [ ! -e "$protected_dir" ] && [ ! -e "$source_alias_dir" ] && \ + [ -z "$HOMEBREW_PATCHED_PROTECTED_DIR" ] && \ + [ -z "$HOMEBREW_PATCHED_SOURCE_ALIAS_DIR" ] && \ + [ -z "$HOMEBREW_PATCHED_PROTECTED_XTASK" ] && \ + [ -z "$HOMEBREW_PATCHED_PROTECTED_XTASK_STATE" ] && \ + [ -z "$HOMEBREW_PATCHED_PROTECTED_XTASK_SHA256" ] || + fail "isolated cleanup left the protected checker or source aliases" [ ! -e "$protected_bottle" ] && [ ! -e "$protected_bottle_dir" ] && \ [ -z "$(find "$isolated_shared_temp" -mindepth 1 -print -quit)" ] && \ [ -z "$HOMEBREW_PATCHED_STAGED_INPUT_SHARED_TEMP" ] && \ diff --git a/scripts/test-homebrew-publish-workflow.sh b/scripts/test-homebrew-publish-workflow.sh index 1731cb23f2..940710d5cf 100755 --- a/scripts/test-homebrew-publish-workflow.sh +++ b/scripts/test-homebrew-publish-workflow.sh @@ -3070,6 +3070,9 @@ assert_bottle_build_trusts_selected_tap() { local log="$TMPDIR/bottle-trust.log" local lifecycle_log="$TMPDIR/bottle-trust-lifecycle.log" local ci_err="$TMPDIR/bottle-trust-ci.err" + local checker_err="$TMPDIR/bottle-trust-checker.err" + local shared_temp="$TMPDIR/bottle-trust-shared-temp" + local different_checker local caller_config="$TMPDIR/caller-homebrew-config" local symlink_target="$TMPDIR/runner-write-target" local ci_tapped="$TMPDIR/bottle-trust-ci-tapped" @@ -3082,7 +3085,7 @@ assert_bottle_build_trusts_selected_tap() { class Zlib < Formula end EOF - mkdir -p "$brew_repo" "$brew_prefix" "$caller_config" + mkdir -p "$brew_repo" "$brew_prefix" "$caller_config" "$shared_temp" printf 'sentinel\n' >"$symlink_target" cat >"$fake_brew" <<'EOF' @@ -3170,6 +3173,35 @@ EOF fail "CI bottle build did not explain its isolated-identity requirement" : >"$log" + different_checker="$FORMULA_RUNNER_FIXTURE_ROOT/target/different-safe-host/release/xtask" + mkdir -p "${different_checker%/*}" + cp "$FORMULA_RUNNER_FIXTURE_ROOT/target/$(rustc -vV | sed -n 's/^host: //p')/release/xtask" \ + "$different_checker" + if FAKE_BREW_LOG="$log" \ + FAKE_REALM_LIFECYCLE_LOG="$lifecycle_log" \ + FAKE_BREW_PREFIX="$brew_prefix" \ + FAKE_BREW_REPOSITORY="$brew_repo" \ + FAKE_TAP_ROOT="$ci_tapped" \ + FAKE_SYMLINK_TARGET="$symlink_target" \ + HOMEBREW_BREW_FILE="$fake_brew" \ + KANDELO_HOMEBREW_BUILD_USER=fixture-build-user \ + KANDELO_HOMEBREW_SHARED_TEMP="$shared_temp" \ + WASM_POSIX_XTASK_BIN="$different_checker" \ + GITHUB_ACTIONS=true \ + bash "$FORMULA_RUNNER_FIXTURE_ROOT/scripts/homebrew-bottle-build.sh" \ + --tap-root "$tap" \ + --tap-repository kandelo-dev/homebrew-tap-core \ + --formula hello \ + --arch wasm32 \ + --out "$out" \ + --bottle-root-url https://ghcr.io/v2/kandelo-dev/homebrew-tap-core \ + >/dev/null 2>"$checker_err"; then + fail "isolated bottle build accepted another safe target-triple checker" + fi + grep -F "scoped program-index checker differs from the exact host xtask" \ + "$checker_err" >/dev/null || + fail "isolated bottle build did not explain the mismatched checker authority" + prepare_formula_runner_tapped_clone \ "$tap" "$tapped" "$KANDELO_HOMEBREW_RESOLVED_TAPS_FILE" if FAKE_BREW_LOG="$log" \ @@ -4168,6 +4200,8 @@ assert_bottle_verifier_installs_test_dependencies() { local shared_temp="$root/shared-temp" local renamed_err="$root/renamed-bottle.err" local nested_target_err="$root/nested-target.err" + local checker_err="$root/checker.err" + local exact_checker different_checker local bottle_sha bottle_bytes tap_commit native_prefix real_python3 real_rm host_git_bin local KANDELO_HOMEBREW_RESOLVED_TAPS_FILE @@ -4469,6 +4503,7 @@ EOF run_bottle_verifier_fixture() { local evidence_out="$1" + local checker="${2:-$exact_checker}" PATH="$fake_bin:$PATH" \ REAL_PYTHON3="$real_python3" \ REAL_RM="$real_rm" \ @@ -4496,6 +4531,7 @@ EOF KANDELO_HOMEBREW_BUILD_USER=fixture-build-user \ KANDELO_HOMEBREW_SHARED_TEMP="$shared_temp" \ KANDELO_HOMEBREW_SUDO_BIN="$fake_bin/sudo" \ + WASM_POSIX_XTASK_BIN="$checker" \ HOMEBREW_KANDELO_BOTTLE_TAG=caller-poison \ KANDELO_HOMEBREW_BOTTLE_TAG=caller-poison \ HOMEBREW_RELOCATE_BUILD_PREFIX=caller-poison \ @@ -4521,6 +4557,18 @@ EOF --out "$evidence_out" } + exact_checker="$FORMULA_RUNNER_FIXTURE_ROOT/target/$(rustc -vV | sed -n 's/^host: //p')/release/xtask" + different_checker="$FORMULA_RUNNER_FIXTURE_ROOT/target/verifier-different-safe-host/release/xtask" + mkdir -p "${different_checker%/*}" + cp "$exact_checker" "$different_checker" + if run_bottle_verifier_fixture "$root/checker-runtime-evidence.json" \ + "$different_checker" >/dev/null 2>"$checker_err"; then + fail "isolated bottle verifier accepted another safe target-triple checker" + fi + grep -F "scoped program-index checker differs from the exact host xtask" \ + "$checker_err" >/dev/null || + fail "isolated bottle verifier did not explain the mismatched checker authority" + rm "$target_opt_prefix" ln -s ../Cellar/hello/nested/Cellar/hello/1.0 "$target_opt_prefix" if run_bottle_verifier_fixture "$root/nested-runtime-evidence.json" \ @@ -6958,8 +7006,26 @@ EOF fail "under-lock publisher does not revalidate the Formula source closure" } +assert_exact_source_program_projection_is_fresh() { + local host_target xtask_bin + host_target="$(rustc -vV | sed -n 's/^host: //p')" + xtask_bin="$REPO_ROOT/target/$host_target/release/xtask" + [ -n "$host_target" ] && [ -f "$xtask_bin" ] && [ ! -L "$xtask_bin" ] && + [ -x "$xtask_bin" ] || + fail "exact-source projection regression lacks the prebuilt host xtask" + + # WHY: dev-shell.sh is a global package-toolchain input. Passing the scoped + # Formula checker through the workflow command argv must not edit that file + # or silently invalidate every committed program package cache key. + WASM_POSIX_DEPS_REGISTRY="$REPO_ROOT/packages/registry" \ + "$xtask_bin" build-deps program-index-context-check \ + --source-repo-root "$REPO_ROOT" || + fail "Formula checker handoff made the exact-source program projection stale" +} + assert_canonical_formula_support_is_load_order_independent make_formula_runner_fixture +assert_exact_source_program_projection_is_fresh assert_formula_support_test_pruning_is_bounded assert_local_root_spill_uses_caller_work_root assert_ghcr_auth_env_does_not_cross_dev_shell @@ -6980,6 +7046,7 @@ assert_bottle_verifier_installs_test_dependencies bash "$REPO_ROOT/scripts/test-homebrew-provision-formula-browser.sh" bash "$REPO_ROOT/scripts/test-materialize-resolver-binaries.sh" bash "$REPO_ROOT/scripts/test-install-local-binary-sealed.sh" +bash "$REPO_ROOT/scripts/test-seal-homebrew-formula-checker.sh" assert_dependency_pour_provenance_is_bounded assert_static_formula_closure_is_fail_closed assert_generator_validates_homebrew_commit_as_data @@ -7007,6 +7074,8 @@ bash "$REPO_ROOT/scripts/test-homebrew-formula-runtime-closure.sh" bash "$REPO_ROOT/scripts/test-homebrew-validate-host-dependency-plan.sh" bash "$REPO_ROOT/scripts/test-homebrew-bottle-runtime-evidence.sh" bash "$REPO_ROOT/scripts/test-publish-immutable-github-release.sh" +bash "$REPO_ROOT/.github/scripts/test-validate-staging-release.sh" +bash "$REPO_ROOT/.github/scripts/test-freeze-homebrew-prepublication-generation.sh" bash "$REPO_ROOT/scripts/test-homebrew-vfs-release.sh" bash "$REPO_ROOT/scripts/test-homebrew-main-shell-closure.sh" assert_formula_composition_is_static_and_lossless diff --git a/scripts/test-homebrew-publisher-real-lifecycle.sh b/scripts/test-homebrew-publisher-real-lifecycle.sh index d2ec42659b..32af4f4c78 100755 --- a/scripts/test-homebrew-publisher-real-lifecycle.sh +++ b/scripts/test-homebrew-publisher-real-lifecycle.sh @@ -114,6 +114,9 @@ fi mkdir -p "$BREW_ROOT/.tmp" "$BREW_ROOT/.cache" "$BREW_ROOT/.config" \ "$BREW_ROOT/.home" +SEALED_XTASK="$TMP_ROOT/sealed-xtask" +printf '#!/bin/sh\nexit 0\n' >"$SEALED_XTASK" +chmod 0555 "$SEALED_XTASK" BREW_ENV=( HOME="$BREW_ROOT/.home" PATH="$PATH" @@ -124,7 +127,9 @@ BREW_ENV=( HOMEBREW_NO_INSTALL_CLEANUP=1 HOMEBREW_NO_INSTALL_FROM_API=1 HOMEBREW_KANDELO_HERMETIC_LIFECYCLE_TEST=1 + HOMEBREW_KANDELO_XTASK_BIN="$SEALED_XTASK" HOMEBREW_TEMP="$BREW_ROOT/.tmp" + WASM_POSIX_XTASK_BIN=caller-poison XDG_CONFIG_HOME="$BREW_ROOT/.config" ) @@ -321,6 +326,13 @@ class Fixture < Formula end test do + # WHY: Homebrew rebuilds the Formula-test environment. It preserves the + # publisher's HOMEBREW_* transport value but must not leak the ordinary + # caller-selected resolver alias; tap support translates the frozen value. + raise "sealed checker transport was not preserved" unless + ENV["HOMEBREW_KANDELO_XTASK_BIN"] == "$SEALED_XTASK" + raise "ordinary checker alias leaked into Formula test" if + ENV.key?("WASM_POSIX_XTASK_BIN") system "wasm-validate", "--kandelo-test-probe" end end diff --git a/scripts/test-seal-homebrew-formula-checker.sh b/scripts/test-seal-homebrew-formula-checker.sh new file mode 100755 index 0000000000..66ef4eebff --- /dev/null +++ b/scripts/test-seal-homebrew-formula-checker.sh @@ -0,0 +1,77 @@ +#!/usr/bin/env bash +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" +TMP_ROOT="$(mktemp -d)" +TMP_ROOT="$(cd "$TMP_ROOT" && pwd -P)" +cleanup() { + rm -rf "$TMP_ROOT" +} +trap cleanup EXIT + +fail() { + echo "test-seal-homebrew-formula-checker: $*" >&2 + exit 1 +} + +root="$TMP_ROOT/kandelo" +release="$root/target/x86_64-unknown-linux-gnu/release" +deps="$release/deps" +mkdir -p "$deps" +artifact="$deps/xtask-0123456789abcdef" +checker="$release/xtask" +cat >"$artifact" <<'EOF' +#!/usr/bin/env bash +printf 'sealed checker\n' +EOF +chmod 0755 "$artifact" +ln "$artifact" "$checker" +[ "$(stat -c '%h' "$artifact")" = "2" ] || + fail "fixture does not model Cargo's Linux hardlink" +source_sha256="$(sha256sum "$artifact" | awk '{print $1}')" + +reported="$( + bash "$REPO_ROOT/scripts/seal-homebrew-formula-checker.sh" \ + --root "$root" \ + --checker "$checker" +)" +[ "$reported" = "$checker" ] || + fail "sealer did not report the exact checker" +[ "$(stat -c '%h:%a' "$checker")" = "1:555" ] || + fail "sealed checker is not one read-only inode" +[ "$(stat -c '%h' "$artifact")" = "1" ] || + fail "Cargo artifact retained the sealed checker inode" +[ "$(stat -c '%d:%i' "$artifact")" != "$(stat -c '%d:%i' "$checker")" ] || + fail "sealed checker still aliases Cargo's deps artifact" +[ "$(sha256sum "$checker" | awk '{print $1}')" = "$source_sha256" ] || + fail "sealed checker bytes differ from Cargo's output" +printf 'changed deps artifact\n' >"$artifact" +[ "$(sha256sum "$checker" | awk '{print $1}')" = "$source_sha256" ] || + fail "Cargo's alternate path can mutate the sealed checker" + +unsafe="$root/target/unsafe/release/xtask" +mkdir -p "${unsafe%/*}" +cp "$checker" "$unsafe" +chmod 0777 "$unsafe" +if bash "$REPO_ROOT/scripts/seal-homebrew-formula-checker.sh" \ + --root "$root" --checker "$unsafe" >/dev/null 2>&1; then + fail "sealer accepted a writable source checker" +fi + +misplaced="$root/target/x86_64-unknown-linux-gnu/xtask" +cp "$checker" "$misplaced" +if bash "$REPO_ROOT/scripts/seal-homebrew-formula-checker.sh" \ + --root "$root" --checker "$misplaced" >/dev/null 2>&1; then + fail "sealer accepted a checker outside the exact release path" +fi + +occupied="$root/target/occupied/release/xtask" +mkdir -p "${occupied%/*}" +cp "$checker" "$occupied" +printf 'occupied\n' >"$occupied.formula-seal" +if bash "$REPO_ROOT/scripts/seal-homebrew-formula-checker.sh" \ + --root "$root" --checker "$occupied" >/dev/null 2>&1; then + fail "sealer overwrote an occupied seal destination" +fi + +echo "test-seal-homebrew-formula-checker.sh: ok" diff --git a/tools/xtask/src/build_deps.rs b/tools/xtask/src/build_deps.rs index 0988c5d057..84152966f9 100644 --- a/tools/xtask/src/build_deps.rs +++ b/tools/xtask/src/build_deps.rs @@ -1551,32 +1551,58 @@ const FORK_INSTRUMENT_TOOL_INPUTS: &[&str] = &[ "scripts/run-wasm-fork-instrument.sh", ]; -static GLOBAL_PACKAGE_TOOLCHAIN_DIGESTS: OnceLock, String>> = - OnceLock::new(); -static FORK_INSTRUMENT_TOOL_DIGESTS: OnceLock, String>> = - OnceLock::new(); +type RootDigestCache = + OnceLock, String>>>>; -fn global_package_toolchain_digests() -> Result, String> { - GLOBAL_PACKAGE_TOOLCHAIN_DIGESTS - .get_or_init(|| { - global_package_build_input_digests_for(&repo_root(), GLOBAL_PACKAGE_TOOLCHAIN_INPUTS) - }) +static GLOBAL_PACKAGE_TOOLCHAIN_DIGESTS: RootDigestCache = OnceLock::new(); +static FORK_INSTRUMENT_TOOL_DIGESTS: RootDigestCache = OnceLock::new(); + +fn root_scoped_build_input_digests( + cache: &RootDigestCache, + root: &Path, + compute: impl FnOnce(&Path) -> Result, String>, +) -> Result, String> { + // WHY: a rootless OnceLock could reuse compile-checkout digests after the + // command selects a different protected alias. Same-path memoization is + // safe at the publisher boundary because the alias is read-only and each + // checker invocation is a fresh process. + let cached = cache.get_or_init(|| Mutex::new(BTreeMap::new())); + if let Some(result) = cached + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .get(root) + .cloned() + { + return result; + } + + let computed = compute(root); + cached + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .entry(root.to_path_buf()) + .or_insert_with(|| computed.clone()) .clone() } +fn global_package_toolchain_digests() -> Result, String> { + let root = repo_root(); + root_scoped_build_input_digests(&GLOBAL_PACKAGE_TOOLCHAIN_DIGESTS, &root, |root| { + global_package_build_input_digests_for(root, GLOBAL_PACKAGE_TOOLCHAIN_INPUTS) + }) +} + fn fork_instrument_tool_digests() -> Result, String> { - FORK_INSTRUMENT_TOOL_DIGESTS - .get_or_init(|| { - let root = repo_root(); - let mut digests = - global_package_build_input_digests_for(&root, FORK_INSTRUMENT_TOOL_INPUTS)?; - digests.push(BuildInputDigest { - label: "cargo-metadata:fork-instrument-build-deps".to_string(), - digest: fork_instrument_cargo_dependency_digest(&root)?, - }); - Ok(digests) - }) - .clone() + let root = repo_root(); + root_scoped_build_input_digests(&FORK_INSTRUMENT_TOOL_DIGESTS, &root, |root| { + let mut digests = + global_package_build_input_digests_for(root, FORK_INSTRUMENT_TOOL_INPUTS)?; + digests.push(BuildInputDigest { + label: "cargo-metadata:fork-instrument-build-deps".to_string(), + digest: fork_instrument_cargo_dependency_digest(root)?, + }); + Ok(digests) + }) } fn package_uses_fork_instrument_tool(target: &DepsManifest) -> bool { @@ -5347,8 +5373,108 @@ fn extract_fetch_only_flag(args: Vec) -> (bool, Vec) { (fetch_only, rest) } +/// Extract the source checkout identity used by the public program-index +/// freshness boundary. Unlike resolver/cache configuration, this is command +/// authority and therefore travels in argv rather than mutable ambient state. +fn extract_source_repo_root_flag( + args: Vec, +) -> Result<(Option, Vec), String> { + let mut source_repo_root: Option = None; + let mut rest = Vec::with_capacity(args.len()); + let mut it = args.into_iter(); + while let Some(arg) = it.next() { + let value = if let Some(value) = arg.strip_prefix("--source-repo-root=") { + Some(value.to_string()) + } else if arg == "--source-repo-root" { + Some( + it.next() + .ok_or_else(|| "--source-repo-root requires a path".to_string())?, + ) + } else { + None + }; + if let Some(value) = value { + if source_repo_root.is_some() { + return Err("--source-repo-root given more than once".to_string()); + } + if value.is_empty() { + return Err("--source-repo-root requires a path".to_string()); + } + source_repo_root = Some(PathBuf::from(value)); + } else { + rest.push(arg); + } + } + Ok((source_repo_root, rest)) +} + +fn validate_source_repo_root_scope( + source_repo_root: Option<&Path>, + subcommand: &str, +) -> Result<(), String> { + if source_repo_root.is_some() && subcommand != "program-index-context-check" { + return Err(format!( + "build-deps {subcommand}: --source-repo-root is only valid for \ + `program-index-context-check`" + )); + } + Ok(()) +} + +fn validate_source_repo_root(path: &Path) -> Result { + if !path.is_absolute() { + return Err(format!( + "--source-repo-root must be an absolute path: {}", + path.display() + )); + } + let canonical = std::fs::canonicalize(path).map_err(|error| { + format!( + "--source-repo-root is not an accessible directory {}: {error}", + path.display() + ) + })?; + if canonical != path { + return Err(format!( + "--source-repo-root must be canonical; received {}, canonical path is {}", + path.display(), + canonical.display() + )); + } + if !canonical.is_dir() { + return Err(format!( + "--source-repo-root is not a directory: {}", + canonical.display() + )); + } + for marker in [ + "Cargo.toml", + "package.json", + "tools/xtask/Cargo.toml", + "scripts/dev-shell.sh", + ] { + let marker_path = canonical.join(marker); + let metadata = std::fs::symlink_metadata(&marker_path).map_err(|_| { + format!( + "--source-repo-root is not a complete Kandelo checkout; \ + missing regular file {}", + marker_path.display() + ) + })?; + if !metadata.file_type().is_file() { + return Err(format!( + "--source-repo-root is not a complete Kandelo checkout; \ + expected a regular file at {}", + marker_path.display() + )); + } + } + Ok(canonical) +} + pub fn run(args: Vec) -> Result<(), String> { - let (arch_flag, rest) = extract_arch_flag(args)?; + let (source_repo_root, rest) = extract_source_repo_root_flag(args)?; + let (arch_flag, rest) = extract_arch_flag(rest)?; let arch = match arch_flag { Some(a) => a, None => default_target_arch()?, @@ -5361,6 +5487,7 @@ pub fn run(args: Vec) -> Result<(), String> { let mut it = rest.into_iter(); let sub = it.next().ok_or( "usage: xtask build-deps [--arch=wasm32|wasm64] [--binaries-dir ] [--fetch-only] \ + [--source-repo-root ] \ \ [ []]", )?; @@ -5374,6 +5501,19 @@ pub fn run(args: Vec) -> Result<(), String> { return Err(format!("build-deps {sub}: unexpected extra args")); } + validate_source_repo_root_scope(source_repo_root.as_deref(), &sub)?; + let source_repo_root = source_repo_root + .as_deref() + .map(validate_source_repo_root) + .transpose()?; + // WHY: install the explicit identity before Registry::from_env or any + // global input digest can consult crate::repo_root(). The guard makes + // toolchain files, fork-tool Cargo metadata, and repo-relative declared + // inputs one coherent source snapshot, then restores in-process callers. + let _repo_root_override = source_repo_root + .map(crate::install_repo_root_override) + .transpose()?; + let repo = repo_root(); let registry = Registry::from_env(&repo); @@ -17412,6 +17552,114 @@ libs = ["lib/libF3b.a"] // Phase C Task 2: --binaries-dir flag (resolver places symlinks) // --------------------------------------------------------------- + #[test] + fn extract_source_repo_root_flag_accepts_both_forms_and_preserves_position() { + let (separated, rest) = extract_source_repo_root_flag(vec![ + "program-index-context-check".into(), + "--source-repo-root".into(), + "/reviewed/kandelo".into(), + ]) + .unwrap(); + assert_eq!(separated, Some(PathBuf::from("/reviewed/kandelo"))); + assert_eq!(rest, vec!["program-index-context-check".to_string()]); + + let (equals, rest) = extract_source_repo_root_flag(vec![ + "--source-repo-root=/reviewed/kandelo".into(), + "program-index-context-check".into(), + ]) + .unwrap(); + assert_eq!(equals, Some(PathBuf::from("/reviewed/kandelo"))); + assert_eq!(rest, vec!["program-index-context-check".to_string()]); + } + + #[test] + fn extract_source_repo_root_flag_rejects_missing_or_duplicate_values() { + assert!(extract_source_repo_root_flag(vec!["--source-repo-root".into()]) + .unwrap_err() + .contains("requires a path")); + assert!(extract_source_repo_root_flag(vec![ + "--source-repo-root=/a".into(), + "--source-repo-root".into(), + "/b".into(), + ]) + .unwrap_err() + .contains("more than once")); + } + + #[test] + fn source_repo_root_override_is_bounded_to_context_check() { + validate_source_repo_root_scope( + Some(Path::new("/reviewed/kandelo")), + "program-index-context-check", + ) + .unwrap(); + for other in ["check", "resolve", "program-index-check", "cache-root"] { + let error = + validate_source_repo_root_scope(Some(Path::new("/reviewed/kandelo")), other) + .unwrap_err(); + assert!( + error.contains("only valid for `program-index-context-check`"), + "unexpected scope error for {other}: {error}" + ); + } + } + + #[test] + fn source_repo_root_must_be_absolute_canonical_and_complete() { + let relative_error = validate_source_repo_root(Path::new("relative/kandelo")).unwrap_err(); + assert!(relative_error.contains("must be an absolute path")); + + let current = crate::repo_root(); + let canonical = std::fs::canonicalize(¤t).unwrap(); + validate_source_repo_root(&canonical).unwrap(); + #[cfg(unix)] + { + let alias_parent = tempdir("source-repo-root-alias"); + let noncanonical = alias_parent.join("kandelo"); + std::os::unix::fs::symlink(&canonical, &noncanonical).unwrap(); + let noncanonical_error = validate_source_repo_root(&noncanonical).unwrap_err(); + assert!(noncanonical_error.contains("must be canonical")); + } + + let incomplete = tempdir("source-repo-root-incomplete"); + let incomplete = std::fs::canonicalize(incomplete).unwrap(); + let incomplete_error = validate_source_repo_root(&incomplete).unwrap_err(); + assert!(incomplete_error.contains("not a complete Kandelo checkout")); + } + + #[test] + fn scoped_repo_root_override_is_restored_after_the_command() { + let original = crate::repo_root(); + let replacement = std::fs::canonicalize(tempdir("scoped-source-repo-root")).unwrap(); + let guard = crate::install_repo_root_override(replacement.clone()).unwrap(); + assert_eq!(crate::repo_root(), replacement); + drop(guard); + assert_eq!(crate::repo_root(), original); + } + + #[test] + fn build_input_digest_cache_is_keyed_by_source_repo_root() { + let first = tempdir("root-digest-cache-first"); + let second = tempdir("root-digest-cache-second"); + fs::write(first.join("identity.txt"), "first source projection").unwrap(); + fs::write(second.join("identity.txt"), "second source projection").unwrap(); + let cache: RootDigestCache = OnceLock::new(); + let compute = |root: &Path| { + global_package_build_input_digests_for(root, &["identity.txt"]) + }; + + let first_digest = + root_scoped_build_input_digests(&cache, &first, compute).unwrap(); + let second_digest = + root_scoped_build_input_digests(&cache, &second, compute).unwrap(); + assert_ne!(first_digest[0].digest, second_digest[0].digest); + + fs::write(first.join("identity.txt"), "changed after memoization").unwrap(); + let first_cached = + root_scoped_build_input_digests(&cache, &first, compute).unwrap(); + assert_eq!(first_cached[0].digest, first_digest[0].digest); + } + #[test] fn extract_binaries_dir_flag_separated_form() { let (got, rest) = extract_binaries_dir_flag(vec![ diff --git a/tools/xtask/src/main.rs b/tools/xtask/src/main.rs index 5f7ab860b1..b2c5f9370a 100644 --- a/tools/xtask/src/main.rs +++ b/tools/xtask/src/main.rs @@ -50,9 +50,12 @@ //! authoritative in-tree registry recipe. //! homebrew-validate Validate Kandelo/Homebrew tap sidecar metadata. +use std::cell::RefCell; use std::collections::BTreeMap; +use std::marker::PhantomData; use std::path::{Path, PathBuf}; use std::process::ExitCode; +use std::rc::Rc; mod archive_stage; mod archive_stage_cli; @@ -131,7 +134,45 @@ fn main() -> ExitCode { } } +thread_local! { + static REPO_ROOT_OVERRIDE: RefCell> = const { RefCell::new(None) }; +} + +pub(crate) struct RepoRootOverrideGuard { + // The guard resets thread-local state and therefore must be dropped on the + // same thread that installed it. + _not_send: PhantomData>, +} + +impl Drop for RepoRootOverrideGuard { + fn drop(&mut self) { + REPO_ROOT_OVERRIDE.with(|slot| { + *slot.borrow_mut() = None; + }); + } +} + +pub(crate) fn install_repo_root_override(root: PathBuf) -> Result { + REPO_ROOT_OVERRIDE.with(|slot| { + let mut slot = slot.borrow_mut(); + if slot.is_some() { + return Err("xtask repository-root override is already installed".to_string()); + } + *slot = Some(root); + Ok(()) + })?; + // WHY: the override belongs to one build-deps command, not ambient process + // state. Restoring it on every return (including unwinding) keeps unit tests + // and future in-process callers from inheriting another command's identity. + Ok(RepoRootOverrideGuard { + _not_send: PhantomData, + }) +} + pub fn repo_root() -> PathBuf { + if let Some(root) = REPO_ROOT_OVERRIDE.with(|slot| slot.borrow().clone()) { + return root; + } // CARGO_MANIFEST_DIR points to tools/xtask/; go up two levels. let manifest = env!("CARGO_MANIFEST_DIR"); Path::new(manifest)