Skip to content

Commit 90f8bee

Browse files
authored
chore(install): fail-closed sha256 verification for the install script (#481)
Verification per #464 found the sha256 gate itself already shipped (PR #219); the missing piece was test coverage. This adds `scripts/install-test.sh` (8 offline cases driving the real `install.sh` with a stubbed `curl`: valid checksum, mismatch, unreachable/missing/duplicate checksums entry, `GPLAY_INSTALL_NO_VERIFY` on/off, missing sha256 tool), wired into the always-on `docs` CI job and `make install-test`. The harness was proven to bite by mutating the mismatch `die` into a `warn`. Two hardenings found while writing the tests: - checksum entry lookup is now an exact filename-field match refusing 0 or multiple entries (was a spoofable line regex); - the sha256 tool is resolved up front with a dedicated error (was fail-closed by accident via an empty digest). `shellcheck` clean on both scripts; full gate green. Closes #464 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: PollyGlot <3662505+PollyGlot@users.noreply.github.com>
1 parent 0de884a commit 90f8bee

6 files changed

Lines changed: 193 additions & 11 deletions

File tree

.github/workflows/ci.yml

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -159,7 +159,12 @@ jobs:
159159
run: bash scripts/dash-gate.sh
160160

161161
- name: shellcheck install.sh
162-
run: shellcheck install.sh
162+
run: shellcheck install.sh scripts/install-test.sh
163+
164+
# Offline: a stub curl serves a fixture release, so this asserts the
165+
# fail-closed sha256 gate without touching the network.
166+
- name: Install script fail-closed checksum gate
167+
run: bash scripts/install-test.sh
163168

164169
- name: Verify required files exist
165170
run: |

Makefile

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
.PHONY: help build test lint verb-gate dash-gate format install-hooks tidy clean release-snapshot discovery-update schema-index-update stats
1+
.PHONY: help build test lint verb-gate dash-gate install-test format install-hooks tidy clean release-snapshot discovery-update schema-index-update stats
22

33
# Project metadata
44
BINARY := gplay
@@ -24,6 +24,9 @@ verb-gate: ## Fail if a pre-rename verb name (ADR-0019) reappears
2424
dash-gate: ## Fail if an em dash reappears in Go source (help text and errors reach users)
2525
@bash scripts/dash-gate.sh
2626

27+
install-test: ## Exercise install.sh offline (fail-closed sha256 gate)
28+
@bash scripts/install-test.sh
29+
2730
format: ## Run gofmt + goimports on the whole tree
2831
gofmt -w .
2932

README.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -73,8 +73,9 @@ curl -fsSL https://gplay.sh/install | sh
7373
```
7474

7575
The install script verifies the archive's SHA-256 against the release
76-
`checksums.txt` and **fails closed**: a missing, incomplete, or mismatched
77-
checksum aborts the install. Set `GPLAY_INSTALL_NO_VERIFY=1` to bypass (prints
76+
`checksums.txt` and **fails closed**: a missing, ambiguous, or mismatched
77+
checksum aborts the install before anything is written, and so does a host with
78+
no sha256 tool. Set `GPLAY_INSTALL_NO_VERIFY=1` to bypass (prints
7879
a warning, greppable in CI). To add cosign and provenance checks on top, see
7980
[Verify a release](#verify-a-release).
8081

install.sh

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -89,12 +89,28 @@ else
8989
if ! curl -fsSL "$checksums_url" -o "$tmp/checksums.txt"; then
9090
die "could not fetch checksums.txt for $VERSION — refusing to install unverified. Set GPLAY_INSTALL_NO_VERIFY=1 to bypass (air-gapped/mirrored installs only)."
9191
fi
92-
expected="$(grep " $archive$" "$tmp/checksums.txt" | awk '{print $1}' || true)"
93-
if [ -z "$expected" ]; then
94-
die "no checksum entry for $archive in checksums.txt — refusing to install unverified. Set GPLAY_INSTALL_NO_VERIFY=1 to bypass (air-gapped/mirrored installs only)."
92+
# Exact field match on the archive name, not a regex over the line: the name
93+
# contains dots, and a loose pattern would let an attacker-supplied
94+
# checksums.txt satisfy the lookup with an entry for a different artifact.
95+
# goreleaser writes "<sha256> <name>"; a leading "*" marks binary mode.
96+
expected="$(awk -v a="$archive" '$2 == a || $2 == "*" a { print $1 }' "$tmp/checksums.txt")"
97+
entries="$(printf '%s\n' "$expected" | grep -c . || true)"
98+
if [ "$entries" != "1" ]; then
99+
die "expected exactly one checksum entry for $archive in checksums.txt, found $entries — refusing to install unverified. Set GPLAY_INSTALL_NO_VERIFY=1 to bypass (air-gapped/mirrored installs only)."
95100
fi
96-
actual="$(shasum -a 256 "$tmp/$archive" 2>/dev/null | awk '{print $1}' \
97-
|| sha256sum "$tmp/$archive" | awk '{print $1}')"
101+
102+
# Resolve the digest tool up front. Piping `shasum | awk` would swallow a
103+
# missing shasum (the pipeline exits on awk's status), leaving an empty digest
104+
# that surfaces as a bogus "mismatch" instead of the real cause.
105+
if command -v shasum >/dev/null 2>&1; then
106+
actual="$(shasum -a 256 "$tmp/$archive")"
107+
elif command -v sha256sum >/dev/null 2>&1; then
108+
actual="$(sha256sum "$tmp/$archive")"
109+
else
110+
die "no sha256 tool found (need shasum or sha256sum) — refusing to install unverified. Set GPLAY_INSTALL_NO_VERIFY=1 to bypass (air-gapped/mirrored installs only)."
111+
fi
112+
actual="$(printf '%s' "$actual" | awk '{print $1}' | tr 'A-F' 'a-f')"
113+
expected="$(printf '%s' "$expected" | tr 'A-F' 'a-f')"
98114
[ "$expected" = "$actual" ] || die "checksum mismatch (expected $expected, got $actual)"
99115
log "checksum OK"
100116
fi

scripts/install-test.sh

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
#!/usr/bin/env bash
2+
# Offline test harness for install.sh — proves the sha256 gate is fail-closed.
3+
#
4+
# Runs install.sh against a fake release built on disk, with a stub `curl` on
5+
# PATH mapping URLs to local fixtures. No network, no real download, so it can
6+
# run in CI on every PR alongside shellcheck.
7+
#
8+
# Usage: bash scripts/install-test.sh
9+
10+
set -euo pipefail
11+
12+
repo_root="$(cd "$(dirname "$0")/.." && pwd)"
13+
script="$repo_root/install.sh"
14+
work="$(mktemp -d)"
15+
trap 'rm -rf "$work"' EXIT
16+
17+
fail=0
18+
pass_count=0
19+
20+
sha256() {
21+
if command -v shasum >/dev/null 2>&1; then shasum -a 256 "$1" | awk '{print $1}'
22+
else sha256sum "$1" | awk '{print $1}'; fi
23+
}
24+
25+
# -- Fixture release ---------------------------------------------------------
26+
# The archive name install.sh builds depends on the host, so mirror its own
27+
# os/arch detection rather than hardcoding a platform.
28+
case "$(uname -s)" in
29+
Linux) os=linux ;;
30+
Darwin) os=darwin ;;
31+
*) echo "install-test: unsupported host OS, skipping" >&2; exit 0 ;;
32+
esac
33+
case "$(uname -m)" in
34+
x86_64|amd64) arch=amd64 ;;
35+
arm64|aarch64) arch=arm64 ;;
36+
*) echo "install-test: unsupported host arch, skipping" >&2; exit 0 ;;
37+
esac
38+
39+
version="v9.9.9"
40+
archive="gplay_9.9.9_${os}_${arch}.tar.gz"
41+
fixtures="$work/fixtures"
42+
mkdir -p "$fixtures/payload"
43+
cat >"$fixtures/payload/gplay" <<'EOF'
44+
#!/bin/sh
45+
echo "gplay 9.9.9 (test fixture)"
46+
EOF
47+
chmod +x "$fixtures/payload/gplay"
48+
tar -C "$fixtures/payload" -czf "$fixtures/$archive" gplay
49+
good_sum="$(sha256 "$fixtures/$archive")"
50+
51+
printf '%s %s\n' "$good_sum" "$archive" >"$fixtures/checksums.good.txt"
52+
printf '%s %s\n' "0000000000000000000000000000000000000000000000000000000000000000" "$archive" \
53+
>"$fixtures/checksums.bad.txt"
54+
printf '%s %s\n' "$good_sum" "gplay_9.9.9_other_platform.tar.gz" >"$fixtures/checksums.missing-entry.txt"
55+
# Two entries for the same archive: ambiguous, so unverifiable.
56+
{ printf '%s %s\n' "$good_sum" "$archive"; printf '%s %s\n' "$good_sum" "$archive"; } \
57+
>"$fixtures/checksums.duplicate.txt"
58+
59+
# -- curl stub ---------------------------------------------------------------
60+
# Serves the archive always; serves whatever CHECKSUMS_FIXTURE names, or 404s
61+
# (curl -f's exit 22) when it is empty.
62+
mkdir -p "$work/bin"
63+
cat >"$work/bin/curl" <<'EOF'
64+
#!/usr/bin/env bash
65+
set -euo pipefail
66+
url=""; out=""
67+
while [ $# -gt 0 ]; do
68+
case "$1" in
69+
-o) out="$2"; shift 2 ;;
70+
-*) shift ;;
71+
*) url="$1"; shift ;;
72+
esac
73+
done
74+
case "$url" in
75+
*checksums.txt)
76+
[ -n "${CHECKSUMS_FIXTURE:-}" ] || exit 22
77+
src="$FIXTURES/$CHECKSUMS_FIXTURE" ;;
78+
*.tar.gz) src="$FIXTURES/$ARCHIVE" ;;
79+
*) exit 22 ;;
80+
esac
81+
[ -f "$src" ] || exit 22
82+
if [ -n "$out" ]; then cp "$src" "$out"; else cat "$src"; fi
83+
EOF
84+
chmod +x "$work/bin/curl"
85+
86+
# -- Runner ------------------------------------------------------------------
87+
88+
# run <name> <expected-exit> <expected-stderr-substring> <checksums-fixture> [env=val ...]
89+
run() {
90+
local name="$1" want_exit="$2" want_msg="$3" fixture="$4"; shift 4
91+
local dest="$work/dest/$name" out="$work/$name.log" rc=0
92+
rm -rf "$dest"; mkdir -p "$dest"
93+
env PATH="$work/bin:$PATH" \
94+
FIXTURES="$fixtures" ARCHIVE="$archive" CHECKSUMS_FIXTURE="$fixture" \
95+
GPLAY_INSTALL_DIR="$dest" GPLAY_VERSION="$version" \
96+
"$@" sh "$script" >"$out" 2>&1 || rc=$?
97+
98+
local problems=""
99+
[ "$rc" = "$want_exit" ] || problems="exit $rc (want $want_exit)"
100+
if [ -n "$want_msg" ] && ! grep -qF "$want_msg" "$out"; then
101+
problems="$problems; stderr missing '$want_msg'"
102+
fi
103+
if [ "$want_exit" = "0" ]; then
104+
[ -x "$dest/gplay" ] || problems="$problems; binary not installed"
105+
else
106+
[ ! -e "$dest/gplay" ] || problems="$problems; binary installed despite failure"
107+
fi
108+
109+
if [ -n "$problems" ]; then
110+
printf 'FAIL %s: %s\n' "$name" "${problems#; }"
111+
sed 's/^/ | /' "$out"
112+
fail=1
113+
else
114+
printf 'ok %s\n' "$name"
115+
pass_count=$((pass_count + 1))
116+
fi
117+
}
118+
119+
run valid-checksum 0 "checksum OK" checksums.good.txt
120+
run checksum-mismatch 1 "checksum mismatch" checksums.bad.txt
121+
run checksums-unavailable 1 "could not fetch checksums.txt" ""
122+
run no-entry-for-archive 1 "found 0" checksums.missing-entry.txt
123+
run duplicate-entries 1 "found 2" checksums.duplicate.txt
124+
run bypass-installs-anyway 0 "SKIPPING checksum verification" checksums.bad.txt GPLAY_INSTALL_NO_VERIFY=1
125+
run bypass-off-still-checks 1 "checksum mismatch" checksums.bad.txt GPLAY_INSTALL_NO_VERIFY=0
126+
127+
# A host with no sha256 tool must abort, not install unverified. `command -v`
128+
# has to come up empty, so run against a PATH built from an explicit tool list
129+
# that omits shasum and sha256sum.
130+
hidden="$work/hidden"
131+
mkdir -p "$hidden"
132+
for tool in sh bash uname mktemp tar unzip grep awk sed head printf mkdir install rm cp tr; do
133+
p="$(command -v "$tool" 2>/dev/null || true)"
134+
[ -n "$p" ] && ln -sf "$p" "$hidden/$tool"
135+
done
136+
ln -sf "$work/bin/curl" "$hidden/curl"
137+
rc=0
138+
env -i PATH="$hidden" HOME="$work" \
139+
FIXTURES="$fixtures" ARCHIVE="$archive" CHECKSUMS_FIXTURE="checksums.good.txt" \
140+
GPLAY_INSTALL_DIR="$work/dest/no-sha-tool" GPLAY_VERSION="$version" \
141+
sh "$script" >"$work/no-sha-tool.log" 2>&1 || rc=$?
142+
if [ "$rc" != "1" ] || ! grep -qF "no sha256 tool found" "$work/no-sha-tool.log" \
143+
|| [ -e "$work/dest/no-sha-tool/gplay" ]; then
144+
printf 'FAIL no-sha256-tool: exit %s\n' "$rc"
145+
sed 's/^/ | /' "$work/no-sha-tool.log"
146+
fail=1
147+
else
148+
printf 'ok no-sha256-tool\n'
149+
pass_count=$((pass_count + 1))
150+
fi
151+
152+
if [ "$fail" -ne 0 ]; then
153+
printf '\ninstall-test: FAILED\n'
154+
exit 1
155+
fi
156+
printf '\ninstall-test: %d checks passed\n' "$pass_count"

website/src/content/docs/docs/getting-started/installation.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,9 @@ curl -fsSL https://gplay.sh/install | sh
2323
```
2424

2525
The script **verifies the downloaded archive's SHA-256 against the release
26-
`checksums.txt` and fails closed**: a missing checksum file, no entry for
27-
your platform, or a mismatch all abort the install. For air-gapped or
26+
`checksums.txt` and fails closed**: a missing checksum file, no entry (or an
27+
ambiguous one) for your platform, a mismatch, or no sha256 tool on the host all
28+
abort the install before anything is written. For air-gapped or
2829
mirrored installs where the checksum file is unreachable, set
2930
`GPLAY_INSTALL_NO_VERIFY=1` to bypass (it prints a warning and stays greppable
3031
in your CI config).

0 commit comments

Comments
 (0)