From abaca702a0a08e69c05eb8aede1449873064ae39 Mon Sep 17 00:00:00 2001 From: sepehr-safari Date: Fri, 25 Sep 2026 12:35:06 +0300 Subject: [PATCH] fix: the macOS installer verifies against the checksum published beside the zip Releases now carry Plaza-vX.Y.Z-macos.zip.sha256, and v0.24.0 is the first. The installer fetches it by the zip's own name, as the Linux installer does with its tarball, instead of picking a digest out of the release JSON; that parsing is gone, and with it the one way this check has failed before, a Linux asset's digest taken for the zip's. It also refuses to install without a verified checksum. When no digest was found it used to say it was skipping verification and install anyway; now a missing, empty or malformed .sha256, or a mismatch, stops it before anything is installed, which is what the Linux installer already did. check-macos-installer.sh existed to test the JSON parsing and goes with it. Its CI step becomes a check that both installers, which are served over curl | bash, stay pure ASCII and parse. --- .github/workflows/ci.yml | 30 ++++++++------ scripts/check-macos-installer.sh | 70 -------------------------------- scripts/install-macos.sh | 57 ++++++++++---------------- 3 files changed, 40 insertions(+), 117 deletions(-) delete mode 100755 scripts/check-macos-installer.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b2ef5d6..b44c5bc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -152,19 +152,25 @@ jobs: echo "::warning::Plaza pins Notary $pinned; the latest release is $latest. A keyholder fix only reaches a reader through a Plaza release, so move .github/notary-ref and cut one when it is wanted." - # The installer verifies the file it downloaded, not whatever is listed - # first. This fired for real on v0.19.0: the script took the first - # `sha256:` in the release JSON, which was correct while a release carried - # one asset and quietly stopped being correct when Linux tarballs were - # added. GitHub lists assets in upload order, so the first digest belongs - # to whichever packaging job won the race. macOS won through v0.18.x and - # lost at v0.19.0, and every macOS install then failed with "checksum - # mismatch" on a download that was perfectly fine. + # Both installers are served over `curl | bash`, and macOS ships bash 3.2, + # where a multibyte character next to a $variable under `set -u` aborts the + # script before it has said anything. So they stay pure ASCII, and they + # have to parse. `tr` rather than a grep class, because BSD grep has no -P + # and the two greps disagree on classes. # - # The checks live in the script so they can be run by hand, and so this - # file does not carry shell that only ever runs on a runner. - - name: The macOS installer verifies the macOS download - run: scripts/check-macos-installer.sh ${{ github.repository }} . + # This replaces a check that tested how the macOS installer picked its + # digest out of the release JSON. It no longer does that: it reads the + # `.sha256` published beside the zip, which the release refuses to publish + # without, so there is nothing left to pick from. + - name: The installers are ASCII and parse + run: | + set -eu + for f in scripts/install-macos.sh scripts/install-linux.sh; do + n="$(tr -d '\000-\177' < "$f" | wc -c | tr -d ' ')" + [ "$n" = "0" ] || { echo "$f carries $n bytes outside ASCII" >&2; exit 1; } + bash -n "$f" + done + echo "the installers are ASCII and parse" # The release's publish job refuses to lift the draft until this says every # artifact is up. Its self-test covers a missing file, an upload that never diff --git a/scripts/check-macos-installer.sh b/scripts/check-macos-installer.sh deleted file mode 100755 index 3c79edd..0000000 --- a/scripts/check-macos-installer.sh +++ /dev/null @@ -1,70 +0,0 @@ -# The installer verifies the file it downloaded, not whatever is listed first. -# -# Two checks, because they catch different things. -set -eu -repo="$1" -prefix="$2" - -extract() { printf '%s' "$1" | tr -d '\n' | tr '{' '\n' | grep 'macos\.zip' | grep -o 'sha256:[0-9a-f]\{64\}' | head -1 | cut -d: -f2 || true; } - -# 1. A FIXTURE with a Linux asset first. This is the case that broke, and it -# breaks deterministically whether or not the live release happens to be -# ordered that way today. Pretty-printed and carrying an `uploader` object, -# because the real response is both and a flat fixture would pass against -# code that cannot read the real thing. -fixture='{ - "tag_name": "v9.9.9", - "assets": [ - { - "name": "plaza-9.9.9-linux-aarch64.tar.gz", - "uploader": { "login": "github-actions[bot]", "id": 41898282 }, - "digest": "sha256:1111111111111111111111111111111111111111111111111111111111111111", - "browser_download_url": "https://example.com/plaza-9.9.9-linux-aarch64.tar.gz" - }, - { - "name": "Plaza-v9.9.9-macos.zip", - "uploader": { "login": "github-actions[bot]", "id": 41898282 }, - "digest": "sha256:2222222222222222222222222222222222222222222222222222222222222222", - "browser_download_url": "https://example.com/Plaza-v9.9.9-macos.zip" - } - ] -}' -got="$(extract "$fixture")" -if [ "$got" != "2222222222222222222222222222222222222222222222222222222222222222" ]; then - echo "With a Linux asset listed first, the installer picks the wrong digest." - echo " picked: ${got:-nothing}" - echo " wanted: 2222... (the macOS zip)" - echo "That is the v0.19.0 failure: a correct download reported as corrupt." - exit 1 -fi -echo "ok: a Linux asset listed first does not steal the macOS digest" - -# 2. The LIVE release, because a fixture only encodes what I think the response -# looks like, and what can break this again is GitHub changing exactly that. -json="$(curl -fsSL "https://api.github.com/repos/$repo/releases/latest")" -want="$(printf '%s' "$json" | python3 -c 'import json,sys -d=json.load(sys.stdin) -for a in d.get("assets",[]): - if a["name"].endswith("macos.zip"): - print((a.get("digest") or "").replace("sha256:","")) - break')" -if [ -z "$want" ]; then - echo "ok: the latest release publishes no macOS digest, nothing to compare" -else - got="$(extract "$json")" - if [ "$got" != "$want" ]; then - echo "Against the live release, the installer picks the wrong digest." - echo " picked: ${got:-nothing}" - echo " wanted: $want" - exit 1 - fi - echo "ok: against the live release the installer picks the macOS digest ($want)" -fi - -# 3. And the script that ships is the one these checks describe. -grep -q "tr -d '\\\\n' | tr '{' " "$prefix/scripts/install-macos.sh" || { - echo "$prefix/scripts/install-macos.sh no longer flattens before splitting." - exit 1 -} -bash -n "$prefix/scripts/install-macos.sh" -echo "ok: the shipped script is the one that was checked" diff --git a/scripts/install-macos.sh b/scripts/install-macos.sh index 36d9773..8867535 100755 --- a/scripts/install-macos.sh +++ b/scripts/install-macos.sh @@ -60,35 +60,9 @@ main() { # whole script on the spot with no message at all. The explicit check below, # which exists to explain precisely that case, would never be reached: the # reader would see the "Finding the latest release" line and then silence. - local tag url digest + local tag url tag="$(printf '%s' "$json" | grep -o '"tag_name":[[:space:]]*"[^"]*"' | head -1 | sed -E 's/.*"([^"]+)".*/\1/' || true)" url="$(printf '%s' "$json" | grep -o '"browser_download_url":[[:space:]]*"[^"]*macos\.zip"' | head -1 | sed -E 's/.*"(https[^"]+)".*/\1/' || true)" - # The digest OF THE FILE BEING DOWNLOADED, which is not the same thing as the - # first digest in the release. - # - # This used to be `grep -o 'sha256:...' | head -1` over the whole response. - # Correct while a release carried one asset, and silently wrong once Linux - # tarballs were added: GitHub lists assets in upload order, so the first digest - # belongs to whichever packaging job finished first. macOS won that race - # through v0.18.x and lost it at v0.19.0, and every macOS install then died - # with "checksum mismatch" on a download that was perfectly fine. Nothing in - # this script changed between those two releases. - # - # That is the worst way for a checksum to fail. The bytes were right and the - # comparison was against a different file, so the tool told people their - # download was corrupt and refused to continue. A check that cries wolf - # teaches people to skip checks. - # - # So the digest is read from the asset that names the file. The response is - # pretty-printed, so it is FLATTENED FIRST and only then split on the `{` that - # starts each object: without the flatten every field is already on its own - # line and the name and the digest can never meet. The segment that names the - # macOS zip is bounded by the next asset's `{`, so it cannot reach a - # neighbour's digest. - # - # No jq. This runs before anything is installed and uses only what macOS - # already ships. - digest="$(printf '%s' "$json" | tr -d '\n' | tr '{' '\n' | grep 'macos\.zip' | grep -o 'sha256:[0-9a-f]\{64\}' | head -1 | cut -d: -f2 || true)" [ -n "$url" ] || die "release ${tag:-unknown} has no macOS build attached. Try https://github.com/$repo/releases" say "Latest release: ${tag:-unknown}" @@ -104,14 +78,27 @@ main() { say "Downloading $(basename "$url")..." curl -fSL --progress-bar -o "$zip" "$url" || die "download failed." - if [ -n "$digest" ]; then - local got - got="$(shasum -a 256 "$zip" | awk '{print $1}')" - [ "$got" = "$digest" ] || die "checksum mismatch (expected $digest, got $got). Aborting." - say "SHA-256 verified." - else - say "No published checksum for this release; skipping verification." - fi + # The digest is the file published beside the zip, `.sha256`, fetched by + # the zip's own name, exactly as the Linux installer reads its tarball's. It + # used to be picked out of the release JSON, which went wrong once already: + # the first digest in the response belonged to whichever asset uploaded + # first, and at v0.19.0 that was a Linux tarball, so every macOS install + # reported a perfectly good download as corrupt. There is no list to pick + # from any more. + # + # And no checksum means no install. This used to say "skipping verification" + # and carry on, which is the one outcome a checksum exists to prevent. + curl -fsSL --retry 2 --retry-all-errors -o "$zip.sha256" "$url.sha256" 2>/dev/null || + die "could not fetch the published SHA-256 for $(basename "$url"), so the download cannot be verified. Not installing it. Try again, or get it from https://github.com/$repo/releases" + local want got + want="$(awk '{print $1}' "$zip.sha256")" + case "$want" in + "" | *[!0-9a-f]*) die "the published SHA-256 for $(basename "$url") is empty or malformed. Not installing it." ;; + esac + [ "${#want}" -eq 64 ] || die "the published SHA-256 for $(basename "$url") is malformed. Not installing it." + got="$(shasum -a 256 "$zip" | awk '{print $1}')" + [ "$got" = "$want" ] || die "checksum mismatch (expected $want, got $got). Not installing it." + say "SHA-256 verified." # --- unpack -------------------------------------------------------------- say "Unpacking..."