From 7b10725c1cbf2a286b200087c6161c5543dcc3fa Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Sun, 2 Aug 2026 15:30:35 -0700 Subject: [PATCH 01/19] Add bin/ci with gh signoff, matching our other repos MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit No cloud CI here, so bin/ci runs the checks locally and signs the commit off on success, the same shape as bcx and highrise. Bash rather than their Ruby CI class, deliberately: this repo exists because a working Ruby is precisely what you don't have yet, so its own CI must not need one. house-skills/bin/ci sets the in-house precedent. Only a full run signs off. Passing a platform or version narrows the matrix and explicitly declines to sign — a green tick covering one platform is worse than no tick. The lint pass costs nothing and catches the two mistakes that otherwise surface ten minutes into a Docker build: a syntax error in a definition, since ruby-build sources these, and an install_package URL with no #sha256, since ruby-build silently skips verification when the checksum is absent. Shellcheck runs when present and says so out loud when it isn't, rather than passing silently. It's gated at warning severity because the info tier here is all intentional. Fixing what it did flag: two declare-and-assign warnings, and a note on the one deliberate unquoted expansion, which holds two words and must split — an array would be tidier but expanding an empty one under set -u breaks on macOS's Bash 3.2. --- README.md | 29 +++++++++++- bin/ci | 137 +++++++++++++++++++++++++++++++++++++++++++++++++++++ test/build | 11 ++++- 3 files changed, 174 insertions(+), 3 deletions(-) create mode 100755 bin/ci diff --git a/README.md b/README.md index 333bce0..32153e2 100644 --- a/README.md +++ b/README.md @@ -153,10 +153,37 @@ Whichever tool you use, the definitions are only found if ruby-build can actuall If a build fails in a way that looks nothing like the notes in this repo, check that the definition was actually picked up before debugging the compiler error. +### CI + +There's no cloud CI here. Run `bin/ci` before merging, and it signs off the commit for you +on success: + +```bash +bin/ci # lint + the full build matrix, then gh signoff +bin/ci --lint # lint only, no Docker — seconds, good for a quick check +bin/ci arch # lint + Arch only +bin/ci arch 2.7.8 # lint + a single build +``` + +Only a full run signs off. Anything narrower reports its results and explicitly declines to +sign, because a green tick that covered one platform is worse than no tick. + +The lint pass is cheap and catches the two mistakes that otherwise surface ten minutes into +a Docker build: a syntax error in a definition (ruby-build sources these, so a stray quote +is a build failure), and an `install_package` URL with no `#sha256` (ruby-build silently +skips verification when the checksum is missing). + +Sign-off needs the extension: + +```bash +gh extension install basecamp/gh-signoff +``` + ### Testing `test/build` builds definitions in throwaway Docker containers, so a clean-machine build -is checked without touching your own toolchain. +is checked without touching your own toolchain. `bin/ci` runs it for you; use it directly +when you want a specific slice. ```bash test/build arch 1.8.7-p374 # one version on one platform diff --git a/bin/ci b/bin/ci new file mode 100755 index 0000000..0efafbd --- /dev/null +++ b/bin/ci @@ -0,0 +1,137 @@ +#!/usr/bin/env bash +# Local CI. There is no cloud CI for this repo — run this, then `gh signoff` +# marks the commit green so the PR can merge. +# +# Bash rather than the Ruby CI class the app repos use, deliberately: this repo +# exists because a working Ruby is the thing you don't have yet. Its own CI must +# not need one. +# +# bin/ci # lint + full build matrix, then sign off +# bin/ci arch # lint + Arch only (no signoff — partial run) +# bin/ci arch 2.7.8 # lint + one build (no signoff — partial run) +# bin/ci --lint # lint only, no Docker (no signoff — partial run) +# +# Only a full run signs off. A partial run deliberately won't: signing off on a +# subset is worse than not signing off at all. +set -euo pipefail + +cd "$(dirname "$0")/.." + +BANNER=$'\033[1;32m'; TITLE=$'\033[1;35m'; SUBTITLE=$'\033[1;90m' +ERROR=$'\033[1;31m'; SUCCESS=$'\033[1;32m'; RESET=$'\033[0m' + +failures=() + +echo "${BANNER}🚀 Local CI for ruby-dev${RESET}" + +heading() { printf '\n%s%s%s\n' "$TITLE" "$1" "$RESET"; [ $# -gt 1 ] && printf '%s%s%s\n' "$SUBTITLE" "$2" "$RESET"; return 0; } +pass() { printf '%s ✓ %s%s\n' "$SUCCESS" "$1" "$RESET"; } +fail() { printf '%s ✗ %s%s\n' "$ERROR" "$1" "$RESET"; failures+=("$1"); } + +definitions() { ls -1 [0-9]* 2>/dev/null | sort -V; } + +# --- Shell syntax ----------------------------------------------------------- +# Definitions are sourced by ruby-build, so a syntax error in one is a build +# failure several minutes into a Docker run. Catch it in milliseconds instead. +lint_syntax() { + heading "Syntax" "bash -n over scripts and definitions" + local f + for f in bin/ci test/build $(definitions); do + if bash -n "$f" 2>/dev/null; then pass "$f"; else fail "$f has a syntax error"; bash -n "$f" || true; fi + done +} + +# --- Checksums -------------------------------------------------------------- +# ruby-build only verifies a download when the URL carries a #checksum. Without +# one it fetches and builds whatever it got, silently. A missing checksum is the +# kind of thing that survives review, so assert it here. +lint_checksums() { + heading "Checksums" "every install_package URL carries a #sha256" + local f url ok + for f in $(definitions); do + ok=true + while IFS= read -r url; do + [[ $url =~ \#[0-9a-f]{64}$ ]] || { fail "$f: no sha256 on ${url%%\#*}"; ok=false; } + done < <(grep -oE '"https?://[^"]+"' "$f" | tr -d '"') + # Explicit if, not `$ok && pass`: that leaves the loop's exit status at 1 when + # the *last* definition fails, and set -e then kills the run before the later + # checks and the summary — failures reported, no verdict. + if $ok; then pass "$f"; fi + done +} + +# --- Shellcheck ------------------------------------------------------------- +# Optional: not everywhere, and not worth blocking a build matrix over. Report +# the skip out loud rather than passing silently, so nobody reads a green run as +# "shellcheck is clean" when it never ran. +lint_shellcheck() { + heading "Shellcheck" "optional static analysis" + if ! command -v shellcheck >/dev/null; then + printf '%s – skipped: shellcheck not installed%s\n' "$SUBTITLE" "$RESET" + return 0 + fi + # Scripts only. Definitions are ruby-build fragments, not standalone scripts — + # they call install_package et al from their sourcing shell, so shellcheck reads + # every one of those as an unknown command. + # + # --severity=warning on purpose: the info tier here is all intentional (ls over + # find on version-numbered filenames, deliberate word splitting). Gating on info + # would mean either noisy failures or a scattering of disable comments, and both + # train people to ignore the step. + if shellcheck -s bash --severity=warning bin/ci test/build; then + pass "scripts" + else + fail "shellcheck" + fi +} + +# --- Build matrix ----------------------------------------------------------- +build_matrix() { + heading "Builds" "test/build ${*:-all}" + if ! docker info >/dev/null 2>&1; then + fail "Docker isn't available — the build matrix can't run" + return 0 + fi + if test/build "${@:-all}"; then pass "build matrix"; else fail "build matrix"; fi +} + +# --- Signoff ---------------------------------------------------------------- +signoff() { + heading "📋 Signoff" "gh signoff" + if ! command -v gh >/dev/null || ! gh extension list 2>/dev/null | grep -q gh-signoff; then + printf '%s – skipped: gh signoff not installed (gh extension install basecamp/gh-signoff)%s\n' \ + "$SUBTITLE" "$RESET" + return 0 + fi + gh signoff +} + +started=$SECONDS + +lint_syntax +lint_checksums +lint_shellcheck + +partial=false +case "${1:-}" in + --lint) partial=true ;; + "") build_matrix ;; + *) partial=true; build_matrix "$@" ;; +esac + +elapsed=$(( SECONDS - started )) + +if [ ${#failures[@]} -eq 0 ]; then + printf '\n%s✅ CI passed in %ds%s\n' "$SUCCESS" "$elapsed" "$RESET" + if $partial; then + printf '%s📋 Partial run — not signing off. Run bin/ci with no arguments to sign off.%s\n' \ + "$SUBTITLE" "$RESET" + else + signoff + fi +else + printf '\n%s❌ CI failed in %ds%s\n' "$ERROR" "$elapsed" "$RESET" + for f in "${failures[@]}"; do printf '%s • %s%s\n' "$ERROR" "$f" "$RESET"; done + printf '%s📋 No sign-off. Fix the issues and try again.%s\n' "$SUBTITLE" "$RESET" + exit 1 +fi diff --git a/test/build b/test/build index 79545b2..eeece6d 100755 --- a/test/build +++ b/test/build @@ -58,7 +58,8 @@ build_image() { local platform=$1 local dockerfile="test/${platform}.dockerfile" local image="ruby-build-test:${platform}" - local target_platform=$(platform_for "$platform") + local target_platform + target_platform=$(platform_for "$platform") local build_args=(-f "$dockerfile" -t "$image" --load .) @@ -109,7 +110,8 @@ test_ruby() { local platform=$1 local version=$2 local image="ruby-build-test:${platform}" - local target_platform=$(platform_for "$platform") + local target_platform + target_platform=$(platform_for "$platform") local platform_flag="" [[ -n "$target_platform" ]] && platform_flag="--platform $target_platform" @@ -140,6 +142,11 @@ test_ruby() { " local started=$SECONDS output elapsed + # $platform_flag is deliberately unquoted: it holds two words ("--platform + # linux/amd64") and must split into two arguments. An array would be the tidier + # idiom, but expanding an empty one under `set -u` is an error on macOS's Bash + # 3.2, and this script has to keep working there. + # shellcheck disable=SC2086 if output=$(docker run --rm $platform_flag -e MAKE_OPTS="-j${MAKE_JOBS}" \ "$image" bash -c "$build_script" 2>&1); then elapsed=$(( SECONDS - started )) From 2e130720edbab24a18d5ab145acd9344a62ccd25 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Sun, 2 Aug 2026 15:35:42 -0700 Subject: [PATCH 02/19] Handle bin/ci --help before the lint pass, not after --- bin/ci | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/bin/ci b/bin/ci index 0efafbd..b214709 100755 --- a/bin/ci +++ b/bin/ci @@ -17,6 +17,11 @@ set -euo pipefail cd "$(dirname "$0")/.." +# Before anything else, so --help doesn't sit through a lint pass first. +case "${1:-}" in + -h|--help) sed -n '2,15p' "$0" | sed 's/^#\{1,\} \{0,1\}//'; exit 0 ;; +esac + BANNER=$'\033[1;32m'; TITLE=$'\033[1;35m'; SUBTITLE=$'\033[1;90m' ERROR=$'\033[1;31m'; SUCCESS=$'\033[1;32m'; RESET=$'\033[0m' From d3c229a9daf97d36ba4951b98f2564fd808dd384 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Sun, 2 Aug 2026 16:24:39 -0700 Subject: [PATCH 03/19] Fix two silent false-greens in the lint pass, and stop under-scheduling builds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both lint findings are the same failure mode this repo keeps producing: a check that reports success because it inspected nothing. sort -V is GNU-only, and macOS — which we explicitly support — ships BSD sort. The failure doesn't trip set -e in bin/ci because the call sits in a command substitution inside a `for` list, so definitions() returned empty and both lint passes sailed over zero files and printed "CI passed". Reproduced with a stub sort that rejects -V: six definitions silently unchecked, exit 0. Plain sort here, since ordering is cosmetic when linting, plus a hard guard so an empty list can never be a pass whatever the cause — wrong directory, failed glob, broken sort. test/build had the same -V dependency but failed loudly instead, because a failing command substitution in a direct assignment *does* trip set -e. Still broken on macOS, just noisily, so it now detects -V support and falls back. The checksum lint only matched double-quoted URLs, so a definition written with single quotes — valid shell — would extract nothing and pass with no digest at all. Widening the pattern alone would have flagged 1.8.7's `curl ''` calls, which fetch config.guess and have no checksum to carry, so extraction is now scoped to install_package lines and accepts either quote style. Verified both directions: single-quoted without a digest fails, with one passes, and the savannah URLs stay unflagged. Also fails when a definition yields no install_package URLs at all, since that means the extractor stopped matching rather than that the file is clean. Separately, JOBS was budgeting MAKE_JOBS cores per container. Measured against a real run, each container averages ~1.0 core: these builds are mostly single-threaded — miniruby bootstrapping and generating exts.mk, the dependency-serialized tail of make, gem install bundler — with brief parallel bursts, and 1.9.3 forces make -j1 in its own definition to dodge a race. The old default left ~75% of the machine idle and could queue the long pole (1.9.3, ~190s vs ~110s) behind short builds. Default to cores/2. --- bin/ci | 37 ++++++++++++++++++++++++++++++++++--- test/build | 26 +++++++++++++++++++++----- 2 files changed, 55 insertions(+), 8 deletions(-) diff --git a/bin/ci b/bin/ci index b214709..cc10e00 100755 --- a/bin/ci +++ b/bin/ci @@ -33,7 +33,21 @@ heading() { printf '\n%s%s%s\n' "$TITLE" "$1" "$RESET"; [ $# -gt 1 ] && printf ' pass() { printf '%s ✓ %s%s\n' "$SUCCESS" "$1" "$RESET"; } fail() { printf '%s ✗ %s%s\n' "$ERROR" "$1" "$RESET"; failures+=("$1"); } -definitions() { ls -1 [0-9]* 2>/dev/null | sort -V; } +# Plain `sort`, not `sort -V`: macOS ships BSD sort, which rejects -V. That failure would +# not trip set -e here (it's a command substitution inside a `for` list), so the list would +# come back empty and every check below would silently pass over nothing — a green run that +# linted zero definitions. Ordering is cosmetic for linting, so portability wins. +definitions() { ls -1 [0-9]* 2>/dev/null | sort; } + +# And belt-and-braces: whatever the cause — wrong directory, failed glob, broken sort — an +# empty list must be a hard error, never a quiet pass. +require_definitions() { + if [ -z "$(definitions)" ]; then + printf '%serror: no definition files found in %s — refusing to report success%s\n' \ + "$ERROR" "$PWD" "$RESET" >&2 + exit 1 + fi +} # --- Shell syntax ----------------------------------------------------------- # Definitions are sourced by ruby-build, so a syntax error in one is a build @@ -52,12 +66,28 @@ lint_syntax() { # kind of thing that survives review, so assert it here. lint_checksums() { heading "Checksums" "every install_package URL carries a #sha256" - local f url ok + local f url ok found for f in $(definitions); do ok=true + found=0 + # Scoped to install_package lines, and accepting either quote style. + # + # Both halves matter. Matching only double quotes would silently pass a definition + # written with single quotes — valid shell, and the check would extract nothing rather + # than complain. But widening to single quotes without scoping to install_package would + # start flagging 1.8.7's `curl ''` calls, which fetch config.guess and + # legitimately have no checksum to carry. while IFS= read -r url; do + found=$(( found + 1 )) [[ $url =~ \#[0-9a-f]{64}$ ]] || { fail "$f: no sha256 on ${url%%\#*}"; ok=false; } - done < <(grep -oE '"https?://[^"]+"' "$f" | tr -d '"') + done < <(grep -E '^[[:space:]]*install_package' "$f" \ + | grep -oE "[\"']https?://[^\"']+[\"']" | tr -d "\"'") + # Every definition installs at least Ruby itself from a URL. Finding none means the + # extractor stopped matching, not that the file is clean — don't call that a pass. + if [ "$found" -eq 0 ]; then + fail "$f: no install_package URLs found — checksum lint could not inspect this file" + ok=false + fi # Explicit if, not `$ok && pass`: that leaves the loop's exit status at 1 when # the *last* definition fails, and set -e then kills the run before the later # checks and the summary — failures reported, no verdict. @@ -113,6 +143,7 @@ signoff() { started=$SECONDS +require_definitions lint_syntax lint_checksums lint_shellcheck diff --git a/test/build b/test/build index eeece6d..7defbb6 100755 --- a/test/build +++ b/test/build @@ -4,15 +4,31 @@ set -euo pipefail cd "$(dirname "$0")/.." PLATFORMS="ubuntu-noble arch" -VERSIONS=$(ls -1 [0-9]* 2>/dev/null | sort -rV) + +# macOS ships BSD sort, which rejects -V. Fall back to plain sort there rather than dying +# on an unsupported flag — the order is cosmetic, it only sets which builds start first. +if printf '1\n' | sort -V >/dev/null 2>&1; then + VERSIONS=$(ls -1 [0-9]* 2>/dev/null | sort -rV) +else + VERSIONS=$(ls -1 [0-9]* 2>/dev/null | sort -r) +fi +[ -n "$VERSIONS" ] || { echo "error: no definition files found in $PWD" >&2; exit 1; } # Builds run concurrently. JOBS caps how many containers are in flight; MAKE_JOBS caps -# make parallelism inside each one. The product is what actually hits the CPU, so the -# defaults aim for a mild oversubscribe rather than JOBS x nproc meltdown. +# make parallelism inside each one. +# +# JOBS is deliberately generous relative to core count. Measuring a real run, each +# container averages ~1.0 core, not the MAKE_JOBS it's allowed: these old Ruby builds +# spend most of their wall time single-threaded — miniruby bootstrapping and generating +# exts.mk, the dependency-serialized tail of make, `gem install bundler` — with only brief +# bursts of parallel compilation. 1.9.3 is stricter still, forcing make -j1 in its own +# definition to dodge a parallel-make race. Budgeting MAKE_JOBS cores per container left +# the machine ~75% idle, and the long pole (1.9.3, ~190s vs ~110s for the rest) could sit +# queued behind short builds instead of starting immediately. CORES=$(nproc 2>/dev/null || sysctl -n hw.ncpu 2>/dev/null || echo 4) -JOBS=${JOBS:-$(( CORES / 4 ))} +JOBS=${JOBS:-$(( CORES / 2 ))} (( JOBS < 1 )) && JOBS=1 -(( JOBS > 12 )) && JOBS=12 +(( JOBS > 16 )) && JOBS=16 MAKE_JOBS=${MAKE_JOBS:-4} # Use multi-platform builder if available (faster cross-arch builds) From 2c55da83904751f9570923ca574772d7fc4ac541 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Sun, 2 Aug 2026 16:30:25 -0700 Subject: [PATCH 04/19] Inspect line-continued install_package calls too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scoping checksum extraction to lines starting with install_package missed a call spelled across continued lines: only the first line matched, so a URL on a continuation went uninspected. Latent rather than live — no definition uses continuations today — but it's the same vacuous-pass hole one level down, and the "found no URLs" guard wouldn't have caught it either, since another well-formed call in the same file keeps the count nonzero. Fold continuations before matching. Pure bash rather than sed or awk: the usual line-joining one-liners differ between BSD and GNU, and macOS portability is what this whole section is about. --- bin/ci | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/bin/ci b/bin/ci index cc10e00..12d2df3 100755 --- a/bin/ci +++ b/bin/ci @@ -61,6 +61,27 @@ lint_syntax() { } # --- Checksums -------------------------------------------------------------- +# Fold backslash-continued lines into one, so an install_package spelled across several +# lines is inspected whole. Matching raw lines would see only the first, and a missing +# checksum on a continuation would pass unnoticed — with another well-formed call in the +# file, even the "found nothing" guard below wouldn't fire. +# +# Pure bash rather than sed/awk: the usual line-joining one-liners differ between BSD and +# GNU, and macOS portability is the entire point of this section. +join_continuations() { + local line acc="" + while IFS= read -r line || [ -n "$line" ]; do + if [ "${line%\\}" != "$line" ]; then + acc="${acc}${line%\\} " + else + printf '%s%s\n' "$acc" "$line" + acc="" + fi + done < "$1" + [ -n "$acc" ] && printf '%s\n' "$acc" + return 0 +} + # ruby-build only verifies a download when the URL carries a #checksum. Without # one it fetches and builds whatever it got, silently. A missing checksum is the # kind of thing that survives review, so assert it here. @@ -80,7 +101,7 @@ lint_checksums() { while IFS= read -r url; do found=$(( found + 1 )) [[ $url =~ \#[0-9a-f]{64}$ ]] || { fail "$f: no sha256 on ${url%%\#*}"; ok=false; } - done < <(grep -E '^[[:space:]]*install_package' "$f" \ + done < <(join_continuations "$f" | grep -E '^[[:space:]]*install_package' \ | grep -oE "[\"']https?://[^\"']+[\"']" | tr -d "\"'") # Every definition installs at least Ruby itself from a URL. Finding none means the # extractor stopped matching, not that the file is clean — don't call that a pass. From c6d9d3bd5526c83f2e58979629b0177572bd7b20 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Sun, 2 Aug 2026 16:40:40 -0700 Subject: [PATCH 05/19] Match package URLs regardless of quoting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `install_package "x" https://…` is valid shell, and requiring quotes meant the extractor found nothing there — passing a definition with no digest at all rather than complaining. The "found no URLs" guard doesn't help when another quoted call in the same file keeps the count nonzero. This is the third input shape to slip past this check, after single quotes and line continuations, so stop enumerating quote styles: match the URL itself and let whitespace or either quote terminate it. One pattern now covers double-quoted, single-quoted and bare, and the `tr -d` goes away with it. Scoping to install_package lines stays, and stays load-bearing — over the whole file this pattern would flag 1.8.7's `curl ''` calls, which fetch config.guess and carry no checksum by nature. Verified all four forms with digests pass, each without one fails, and the savannah URLs stay unflagged. --- bin/ci | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/bin/ci b/bin/ci index 12d2df3..946dab5 100755 --- a/bin/ci +++ b/bin/ci @@ -91,18 +91,20 @@ lint_checksums() { for f in $(definitions); do ok=true found=0 - # Scoped to install_package lines, and accepting either quote style. + # Match the URL itself and let whitespace or either quote end it, rather than + # enumerating quote styles. `install_package "x" "url"`, `'url'` and a bare `url` are + # all valid shell, and a pattern that requires quotes silently extracts nothing from + # the form it doesn't know — passing a definition with no digest at all rather than + # complaining. Matching the thing itself has no such blind spot. # - # Both halves matter. Matching only double quotes would silently pass a definition - # written with single quotes — valid shell, and the check would extract nothing rather - # than complain. But widening to single quotes without scoping to install_package would - # start flagging 1.8.7's `curl ''` calls, which fetch config.guess and - # legitimately have no checksum to carry. + # Still scoped to install_package lines, and that scoping is load-bearing: applied to + # the whole file this would flag 1.8.7's `curl ''` calls, which fetch + # config.guess and legitimately carry no checksum. while IFS= read -r url; do found=$(( found + 1 )) [[ $url =~ \#[0-9a-f]{64}$ ]] || { fail "$f: no sha256 on ${url%%\#*}"; ok=false; } done < <(join_continuations "$f" | grep -E '^[[:space:]]*install_package' \ - | grep -oE "[\"']https?://[^\"']+[\"']" | tr -d "\"'") + | grep -oE "https?://[^[:space:]\"']+") # Every definition installs at least Ruby itself from a URL. Finding none means the # extractor stopped matching, not that the file is clean — don't call that a pass. if [ "$found" -eq 0 ]; then From 48374646b9cb70646e51e9f5ed5f1a11d75fa8f3 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Sun, 2 Aug 2026 16:46:32 -0700 Subject: [PATCH 06/19] Check every URL for a digest, not just ones that look like install_package MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fifth report of the same defect, so stop fixing shapes. Matching invocations means matching shell syntax, and shell spells the same call unboundedly many ways — quoted, unquoted, line-continued, after `then` or `;` or `&&`, inside a function. Each form the pattern doesn't know is a download that goes uninspected, and it fails silently: reports success having looked at nothing. Four such forms turned up in a row, each fix addressing the shape rather than the class, so the next one was always waiting. Inverted: every URL in a definition must carry a digest. That fails closed. A download written in a syntax nobody anticipated is flagged rather than skipped, and the only way to exempt one is to say so explicitly — right friction, given an unverified download is what this exists to prevent. One exemption, GNU's git web view for config.guess/config.sub: a moving HEAD with no release tarball and no published digest, fetched only to teach ancient configure scripts about modern architectures, never linked into the built Ruby. Comment lines are skipped so a URL in prose isn't treated as a download. Verified against six forms with no digest — including `&&` chaining and a curl inside a function body, neither of which review had raised — all caught. Real definitions still pass, savannah URLs stay exempt, commented URLs ignored. --- bin/ci | 52 ++++++++++++++++++++++++++++++++++------------------ 1 file changed, 34 insertions(+), 18 deletions(-) diff --git a/bin/ci b/bin/ci index 946dab5..31a7a09 100755 --- a/bin/ci +++ b/bin/ci @@ -27,6 +27,12 @@ ERROR=$'\033[1;31m'; SUCCESS=$'\033[1;32m'; RESET=$'\033[0m' failures=() +# A URL ends at whitespace, a quote, or a shell metacharacter — so `"url"`, `'url'`, a bare +# `url;` and `$(... url)` all yield the same string. Built here rather than inline because +# embedding a single quote inside a single-quoted grep pattern is unreadable. +SQ="'" +URL_RE="https?://[^[:space:];)(&|<>\`\"$SQ]+" + echo "${BANNER}🚀 Local CI for ruby-dev${RESET}" heading() { printf '\n%s%s%s\n' "$TITLE" "$1" "$RESET"; [ $# -gt 1 ] && printf '%s%s%s\n' "$SUBTITLE" "$2" "$RESET"; return 0; } @@ -82,33 +88,43 @@ join_continuations() { return 0 } -# ruby-build only verifies a download when the URL carries a #checksum. Without -# one it fetches and builds whatever it got, silently. A missing checksum is the -# kind of thing that survives review, so assert it here. +# ruby-build only verifies a download when the URL carries a #checksum. Without one it +# fetches and builds whatever it got, silently. A missing checksum is the kind of thing +# that survives review, so assert it here. +# +# This checks EVERY url in the file, not just the ones on lines that look like an +# install_package call. That inversion is the whole point. Matching invocations means +# matching shell syntax, and shell has unboundedly many ways to spell the same call — +# quoted, unquoted, line-continued, after `then` or `;` or `&&`, inside a function. Every +# form the pattern doesn't know is a URL that goes uninspected, and the failure is silent: +# it reports success having looked at nothing. Review found four such forms in a row here, +# each time because the fix addressed the shape rather than the class. +# +# Checking every URL fails closed instead. A download in a syntax nobody anticipated gets +# flagged rather than skipped, and the only way to exempt one is to say so explicitly +# below — which is the right friction, since an unverified download is exactly what this +# check exists to prevent. lint_checksums() { - heading "Checksums" "every install_package URL carries a #sha256" + heading "Checksums" "every download URL carries a #sha256" local f url ok found for f in $(definitions); do ok=true found=0 - # Match the URL itself and let whitespace or either quote end it, rather than - # enumerating quote styles. `install_package "x" "url"`, `'url'` and a bare `url` are - # all valid shell, and a pattern that requires quotes silently extracts nothing from - # the form it doesn't know — passing a definition with no digest at all rather than - # complaining. Matching the thing itself has no such blind spot. - # - # Still scoped to install_package lines, and that scoping is load-bearing: applied to - # the whole file this would flag 1.8.7's `curl ''` calls, which fetch - # config.guess and legitimately carry no checksum. while IFS= read -r url; do + case "$url" in + # The sole exemption. config.guess/config.sub are fetched from GNU's git web view, + # which serves a moving HEAD with no release tarball and no published digest. They + # only teach ancient configure scripts about modern architectures and are never + # linked into the built Ruby. Anything else must carry a digest. + https://git.savannah.gnu.org/*) continue ;; + esac found=$(( found + 1 )) [[ $url =~ \#[0-9a-f]{64}$ ]] || { fail "$f: no sha256 on ${url%%\#*}"; ok=false; } - done < <(join_continuations "$f" | grep -E '^[[:space:]]*install_package' \ - | grep -oE "https?://[^[:space:]\"']+") - # Every definition installs at least Ruby itself from a URL. Finding none means the - # extractor stopped matching, not that the file is clean — don't call that a pass. + done < <(join_continuations "$f" | grep -v '^[[:space:]]*#' | grep -oE "$URL_RE") + # Every definition downloads at least Ruby itself. Finding nothing means the extractor + # stopped matching, not that the file is clean — don't call that a pass. if [ "$found" -eq 0 ]; then - fail "$f: no install_package URLs found — checksum lint could not inspect this file" + fail "$f: no download URLs found — checksum lint could not inspect this file" ok=false fi # Explicit if, not `$ok && pass`: that leaves the loop's exit status at 1 when From fbf7812a683071a6e89ac34dae877a2daf4b713a Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Sun, 2 Aug 2026 16:51:53 -0700 Subject: [PATCH 07/19] Exempt the two config files by name, not the whole savannah domain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The comment claimed a config.guess/config.sub exemption but the pattern was a domain wildcard, so any other unverified download from that host would have been skipped — with the checksummed Ruby URL keeping the count nonzero, silently. Intent and implementation disagreed, and the implementation was the permissive one. Narrowing it first required fixing an extraction bug underneath. Excluding shell metacharacters from the URL pattern also truncated URLs that legitimately contain them: both savannah links were being cut at the first semicolon, down to `?p=config.git`, discarding the `f=config.guess` / `f=config.sub` that says which file is fetched. Nothing to match on. Metacharacters are now allowed inside the match and trimmed from the end instead, which is where they actually signal shell syntax rather than URL content. Verified: real definitions still pass, a different artifact from the same host is now caught, so is the same gitweb config.git path requesting another file, and unquoted URLs trailed by `;` or `&&` are trimmed and still checked. --- bin/ci | 43 ++++++++++++++++++++++++++++++++++--------- 1 file changed, 34 insertions(+), 9 deletions(-) diff --git a/bin/ci b/bin/ci index 31a7a09..176c364 100755 --- a/bin/ci +++ b/bin/ci @@ -27,11 +27,30 @@ ERROR=$'\033[1;31m'; SUCCESS=$'\033[1;32m'; RESET=$'\033[0m' failures=() -# A URL ends at whitespace, a quote, or a shell metacharacter — so `"url"`, `'url'`, a bare -# `url;` and `$(... url)` all yield the same string. Built here rather than inline because -# embedding a single quote inside a single-quoted grep pattern is unreadable. +# A URL ends at whitespace or a quote. Shell metacharacters are deliberately allowed +# *inside* the match and trimmed from the end afterwards (see trim_url): excluding them +# outright truncates legitimate URLs that contain them, and the savannah gitweb links are +# exactly that — `?p=config.git;a=blob_plain;f=config.guess;hb=HEAD` would be cut at the +# first semicolon, discarding the part that says which file it fetches. +# +# Built here rather than inline because embedding a single quote inside a single-quoted +# grep pattern is unreadable. SQ="'" -URL_RE="https?://[^[:space:];)(&|<>\`\"$SQ]+" +URL_RE="https?://[^[:space:]\"$SQ\`]+" + +# Strip trailing shell punctuation left over from an unquoted URL (`…tar.gz; fi`, +# `…tar.gz && echo`). Safe because none of these can legitimately end a URL, unlike the +# semicolons that appear mid-query-string above. +trim_url() { + local u="$1" + while :; do + case "$u" in + *[\;\)\(\&\|\<\>]) u="${u%?}" ;; + *) break ;; + esac + done + printf '%s' "$u" +} echo "${BANNER}🚀 Local CI for ruby-dev${RESET}" @@ -111,12 +130,18 @@ lint_checksums() { ok=true found=0 while IFS= read -r url; do + url=$(trim_url "$url") case "$url" in - # The sole exemption. config.guess/config.sub are fetched from GNU's git web view, - # which serves a moving HEAD with no release tarball and no published digest. They - # only teach ancient configure scripts about modern architectures and are never - # linked into the built Ruby. Anything else must carry a digest. - https://git.savannah.gnu.org/*) continue ;; + # The sole exemption, and deliberately spelled out to the exact file rather than + # by domain: config.guess and config.sub come from GNU's git web view, which serves + # a moving HEAD with no release tarball and no published digest. They only teach + # ancient configure scripts about modern architectures and are never linked into + # the built Ruby. + # + # A domain wildcard here would quietly exempt any other unverified download from + # the same host — re-opening the false-green path this check exists to close. + *git.savannah.gnu.org/gitweb/?p=config.git*f=config.guess*|\ + *git.savannah.gnu.org/gitweb/?p=config.git*f=config.sub*) continue ;; esac found=$(( found + 1 )) [[ $url =~ \#[0-9a-f]{64}$ ]] || { fail "$f: no sha256 on ${url%%\#*}"; ok=false; } From c689796cbd7c6a5588b4335a6b23a6673055adee Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Sun, 2 Aug 2026 16:57:21 -0700 Subject: [PATCH 08/19] Match the exempted config URLs as exact literals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `*f=config.guess*` also matches `f=config.guess.backdoor`, so the exemption still skipped verification on unrelated downloads. Second time a wildcard in this exemption has been wider than intended — a domain glob before, a filename prefix now — so drop wildcards entirely and match the two URLs as literals. There is nothing left to widen: the definitions reference exactly these two fixed strings, anything else is checked. Quoted so `?` and `;` are matched literally rather than as glob and case-clause syntax. If the URLs ever change shape, this list has to be updated by hand, which is the intended cost of skipping verification on a download. Verified the two real URLs still pass and four bypass shapes are caught: config.guess.backdoor, config.subversion, an altered hb parameter, and the same path on another host. --- bin/ci | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/bin/ci b/bin/ci index 176c364..5a20067 100755 --- a/bin/ci +++ b/bin/ci @@ -132,16 +132,20 @@ lint_checksums() { while IFS= read -r url; do url=$(trim_url "$url") case "$url" in - # The sole exemption, and deliberately spelled out to the exact file rather than - # by domain: config.guess and config.sub come from GNU's git web view, which serves - # a moving HEAD with no release tarball and no published digest. They only teach - # ancient configure scripts about modern architectures and are never linked into - # the built Ruby. + # The only exempt downloads, matched as exact literals. config.guess and config.sub + # come from GNU's git web view, which serves a moving HEAD with no release tarball + # and no published digest. They only teach ancient configure scripts about modern + # architectures and are never linked into the built Ruby. # - # A domain wildcard here would quietly exempt any other unverified download from - # the same host — re-opening the false-green path this check exists to close. - *git.savannah.gnu.org/gitweb/?p=config.git*f=config.guess*|\ - *git.savannah.gnu.org/gitweb/?p=config.git*f=config.sub*) continue ;; + # Literals, not patterns, because every wildcard here is a hole: a domain glob + # exempts anything from the host, and `*f=config.guess*` also exempts + # `f=config.guess.anything`. Both were tried, both were wrong. Quoted so `?` and + # `;` are matched literally rather than as glob and case-clause syntax. + # + # If these URLs ever change shape, this list must be updated by hand — that is the + # intended cost of skipping verification on a download. + "https://git.savannah.gnu.org/gitweb/?p=config.git;a=blob_plain;f=config.guess;hb=HEAD"|\ + "https://git.savannah.gnu.org/gitweb/?p=config.git;a=blob_plain;f=config.sub;hb=HEAD") continue ;; esac found=$(( found + 1 )) [[ $url =~ \#[0-9a-f]{64}$ ]] || { fail "$f: no sha256 on ${url%%\#*}"; ok=false; } From ca195bf5ed73112465c4b5cd698d4ba12e03a448 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Sun, 2 Aug 2026 17:03:34 -0700 Subject: [PATCH 09/19] Join continued lines with nothing, as shell does MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A backslash-newline is removed entirely in shell; it does not become whitespace. Joining with a space split tokens that bash keeps together, so `"https\` + `://host/pkg.tar.gz"` — one URL to bash, confirmed by sourcing it — arrived at the extractor as `https ://host/pkg.tar.gz` and matched nothing. Another download skipped silently. Contrived to write by hand, but the joiner was approximating shell semantics rather than following them, and that gap is what the check keeps getting caught by. Ordinary continuations already carry whitespace around the backslash, so nothing else changes. Verified: real definitions pass, the split-scheme URL is now caught, and continuations with digests still pass in all three forms. --- bin/ci | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/bin/ci b/bin/ci index 5a20067..a077c66 100755 --- a/bin/ci +++ b/bin/ci @@ -97,7 +97,12 @@ join_continuations() { local line acc="" while IFS= read -r line || [ -n "$line" ]; do if [ "${line%\\}" != "$line" ]; then - acc="${acc}${line%\\} " + # Join with nothing, matching shell: a backslash-newline is removed entirely, it + # does not become whitespace. Inserting a space would split tokens shell keeps + # together — `"https\` + `://host/x"` is one URL to bash but would arrive here as + # `https ://host/x` and match nothing, silently skipping that download. Ordinary + # continuations already carry their own whitespace around the backslash. + acc="${acc}${line%\\}" else printf '%s%s\n' "$acc" "$line" acc="" From 67ddaf4719b11a1066e28e5150ddff77aaac2f9a Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Sun, 2 Aug 2026 19:36:36 -0700 Subject: [PATCH 10/19] Ignore URLs in shell comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Only full-line comments were stripped, so a reference link in a trailing comment was treated as a download and failed for lacking a digest — blocking CI and signoff over a URL that is never fetched. First false positive here rather than a false negative, and the more disruptive direction: it stops work rather than letting something through. Stripping comments has to leave `pkg.tar.gz#` alone, since cutting at the first `#` would turn every checksummed URL into a failure. The `#` opening a comment is always preceded by whitespace or starts the line; the `#` before a digest never is, it follows the last character of the URL. Keying on that separates them without parsing quotes. Verified: a reference URL in a trailing comment is ignored, the digest on the same line still registers, a full-line comment URL is ignored, and a genuinely undigested download on a commented line is still caught. --- bin/ci | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/bin/ci b/bin/ci index a077c66..726f6f7 100755 --- a/bin/ci +++ b/bin/ci @@ -41,6 +41,15 @@ URL_RE="https?://[^[:space:]\"$SQ\`]+" # Strip trailing shell punctuation left over from an unquoted URL (`…tar.gz; fi`, # `…tar.gz && echo`). Safe because none of these can legitimately end a URL, unlike the # semicolons that appear mid-query-string above. +# Drop shell comments before extracting URLs, so a reference link in a comment isn't +# mistaken for a download and failed for lacking a digest. +# +# The `#` that opens a comment is always preceded by whitespace or starts the line; the `#` +# introducing a digest never is — it follows the last character of the URL. Keying on that +# distinction strips comments without touching `pkg.tar.gz#`, which a naive +# cut-at-first-hash would destroy, turning every checksummed URL into a failure. +strip_comments() { sed -E 's/(^|[[:space:]])#.*$//'; } + trim_url() { local u="$1" while :; do @@ -154,7 +163,7 @@ lint_checksums() { esac found=$(( found + 1 )) [[ $url =~ \#[0-9a-f]{64}$ ]] || { fail "$f: no sha256 on ${url%%\#*}"; ok=false; } - done < <(join_continuations "$f" | grep -v '^[[:space:]]*#' | grep -oE "$URL_RE") + done < <(join_continuations "$f" | strip_comments | grep -oE "$URL_RE") # Every definition downloads at least Ruby itself. Finding nothing means the extractor # stopped matching, not that the file is clean — don't call that a pass. if [ "$found" -eq 0 ]; then From 8a5ad5f1df60afe044a65b048a6eb1a8a01f459d Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Sun, 2 Aug 2026 19:43:30 -0700 Subject: [PATCH 11/19] Recognise comments that open straight after a shell operator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `install_package ...;# note` is a comment to bash — verified, not assumed: `bash -c 'echo one;# echo two'` prints only "one". The previous rule required whitespace or line start before the `#`, so the trailing reference URL was read as a download and failed the lint despite the real package URL being checksummed. Same disruptive direction as the last one: it blocks CI over a URL nothing fetches. A `#` opens a comment when it starts a word, which includes straight after an operator that terminated the previous one. Digests stay safe because a digest's `#` never starts a word — it follows the last character of the URL, and no URL character is in the operator set. Verified against `;#`, `&&#` and a spaced `#`, all ignored, with the digests on those same lines still registering and a genuinely undigested download after a `;#` still caught. --- bin/ci | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/bin/ci b/bin/ci index 726f6f7..b39cb05 100755 --- a/bin/ci +++ b/bin/ci @@ -44,11 +44,13 @@ URL_RE="https?://[^[:space:]\"$SQ\`]+" # Drop shell comments before extracting URLs, so a reference link in a comment isn't # mistaken for a download and failed for lacking a digest. # -# The `#` that opens a comment is always preceded by whitespace or starts the line; the `#` -# introducing a digest never is — it follows the last character of the URL. Keying on that -# distinction strips comments without touching `pkg.tar.gz#`, which a naive -# cut-at-first-hash would destroy, turning every checksummed URL into a failure. -strip_comments() { sed -E 's/(^|[[:space:]])#.*$//'; } +# A `#` opens a comment when it starts a word: at line start, after whitespace, or straight +# after an operator that ends the previous word — `echo one;# two` really is a comment to +# bash, verified rather than assumed. The `#` introducing a digest never starts a word; it +# follows the last character of the URL. Keying on that distinction strips comments without +# touching `pkg.tar.gz#`, which a naive cut-at-first-hash would destroy, turning +# every checksummed URL into a failure. +strip_comments() { sed -E 's/(^|[[:space:];&|()<>])#.*$//'; } trim_url() { local u="$1" From 3ac1420450a524dfe956942e65bb3127b712de16 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Sun, 2 Aug 2026 19:51:47 -0700 Subject: [PATCH 12/19] Track quote state when stripping comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A regex over a line cannot know whether a `#` is inside quotes, so the previous rule fired on the literal hash in `"download #1"` and truncated the rest of the line — dropping a real install_package and its unverified URL. Successive regexes here were each wrong in a new way, in both directions: one blocks CI over a URL nothing fetches, the other skips a genuine unverified download. So scan the line and track quoting instead of guessing. A `#` opens a comment only when it starts a word and is unquoted. Digests are untouched because a digest's `#` follows the last character of the URL, so it never starts a word. Worth keeping this check despite the churn: ruby-build's verify_checksum returns success for an empty checksum, so a missing digest means the download is never verified and the build still passes. Nothing else catches that — not the build matrix, not ruby-build itself. Also splits comment_index's `local` in two. `local line="$1" n=${#line}` leaves n at 0 in bash; it only scanned correctly because dynamic scoping resolved ${#line} to the caller's identically-named variable. Renaming either would have silently disabled stripping. Verified by renaming the caller's variable and confirming comments are still stripped. --- bin/ci | 64 +++++++++++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 57 insertions(+), 7 deletions(-) diff --git a/bin/ci b/bin/ci index b39cb05..efe2585 100755 --- a/bin/ci +++ b/bin/ci @@ -44,13 +44,63 @@ URL_RE="https?://[^[:space:]\"$SQ\`]+" # Drop shell comments before extracting URLs, so a reference link in a comment isn't # mistaken for a download and failed for lacking a digest. # -# A `#` opens a comment when it starts a word: at line start, after whitespace, or straight -# after an operator that ends the previous word — `echo one;# two` really is a comment to -# bash, verified rather than assumed. The `#` introducing a digest never starts a word; it -# follows the last character of the URL. Keying on that distinction strips comments without -# touching `pkg.tar.gz#`, which a naive cut-at-first-hash would destroy, turning -# every checksummed URL into a failure. -strip_comments() { sed -E 's/(^|[[:space:];&|()<>])#.*$//'; } +# Find where a comment starts on a line, tracking quote state rather than pattern-matching +# for it. Sets COMMENT_IDX to the offset, or -1. +# +# A `#` opens a comment only when it starts a word *and* is not inside quotes. Successive +# regexes for this kept being wrong in a new way: they missed `;#`, or they fired on the +# literal hash in `"download #1"` and silently truncated a real install_package off the end +# of the line. Both directions matter — one blocks CI over a URL nothing fetches, the other +# skips a genuine unverified download. +# +# Quoting cannot be expressed as a regex over a line, so scan it. Digests survive because a +# digest's `#` follows the last character of the URL and so never starts a word. +comment_index() { + # Two statements on purpose: in a single `local`, ${#line} would not see the `line` + # assigned beside it. It would instead resolve to the caller's `line` via dynamic + # scoping — which happens to hold the same string today, so this scanned correctly + # by accident and would have silently stopped stripping if either were renamed. + local line="$1" + local n=${#line} i=0 c prev="" insq=0 indq=0 + COMMENT_IDX=-1 + while [ "$i" -lt "$n" ]; do + c="${line:$i:1}" + if [ "$insq" = 1 ]; then + [ "$c" = "'" ] && insq=0 + elif [ "$indq" = 1 ]; then + case "$c" in + '\') i=$(( i + 1 )) ;; # backslash escapes the next char inside "..." + '"') indq=0 ;; + esac + else + case "$c" in + "'") insq=1 ;; + '"') indq=1 ;; + '\') i=$(( i + 1 )) ;; + '#') + case "$prev" in + ''|[[:space:]]|';'|'&'|'|'|'('|')'|'<'|'>') COMMENT_IDX=$i; return 0 ;; + esac + ;; + esac + fi + prev="$c" + i=$(( i + 1 )) + done + return 0 +} + +strip_comments() { + local line + while IFS= read -r line || [ -n "$line" ]; do + comment_index "$line" + if [ "$COMMENT_IDX" -ge 0 ]; then + printf '%s\n' "${line:0:$COMMENT_IDX}" + else + printf '%s\n' "$line" + fi + done +} trim_url() { local u="$1" From f990ce731cbfbb69d60ab27b8d0b2e22fba8284e Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Sun, 2 Aug 2026 19:57:24 -0700 Subject: [PATCH 13/19] Fail safe on quoting forms the scanner doesn't model MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit $'...' has its own escape rules, so an escaped apostrophe inside it looked like the end of a quoted string and the following `#` like a comment — truncating a real install_package off the line and passing its unverified URL. Modelling $'...', then $"...", then heredocs, is writing a shell lexer in bash, and every incomplete version of one is wrong in some new way. This is the fourth report in that sequence, so stop extending the model and bound it instead: the scanner handles '...' and "..." and, on encountering anything else, declines to strip that line at all. The failure then lands in the safe direction by construction. Comments on such a line get scanned for URLs, which can only produce a false positive — a loud complaint about a URL that needed no digest. Guessing risks the silent direction, dropping a real download and passing it unverified. No definition here uses these forms, so the cost is theoretical while the safety isn't. Verified the reported case now fails as it should, and ordinary quoted hashes and trailing comments still strip with no false positives. --- bin/ci | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/bin/ci b/bin/ci index efe2585..8297d89 100755 --- a/bin/ci +++ b/bin/ci @@ -63,6 +63,21 @@ comment_index() { local line="$1" local n=${#line} i=0 c prev="" insq=0 indq=0 COMMENT_IDX=-1 + + # This models ordinary '...' and "..." quoting, and nothing else. Shell has more: + # $'...' has its own escape rules, $"..." is locale translation, and there are + # heredocs. Modelling each in turn is writing a shell lexer in bash, and every + # incomplete version of that is wrong in some new way. + # + # So don't guess at what isn't modelled — refuse to strip that line at all. Its + # comments then get scanned for URLs, which can only produce a false positive: a + # loud, visible complaint about a URL that needed no digest. Guessing instead risks + # the silent direction, dropping a real download from the line and passing it + # unverified. No definition here uses these forms, so the cost is theoretical and + # the safety is not. + case "$line" in + *\$\'*|*\$\"*) return 0 ;; + esac while [ "$i" -lt "$n" ]; do c="${line:$i:1}" if [ "$insq" = 1 ]; then From ff1c335cf96f688fc06eda305f7cae9db9ec0d8f Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Sun, 2 Aug 2026 20:03:11 -0700 Subject: [PATCH 14/19] Carry quote state between lines MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Quoting is not a per-line property — a string can contain a literal newline, and everything up to the closing quote is data. Scanning each line from a clean state made a `#` inside such a string look like a comment, truncating the rest of the line and taking any real download on it along too. Unlike the last few reports this isn't an unmodelled construct; it's ordinary `"..."` quoting that the scanner claimed to handle and got wrong by chunking the file into lines. Thread the state through instead. When a comment is found the line necessarily ends unquoted, since a comment can only open outside quotes. Verified the reported case now fails as it should, with no false positives on trailing comments, `;#` comments, or a quoted hash on the same line as a checksummed download. --- bin/ci | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/bin/ci b/bin/ci index 8297d89..7561947 100755 --- a/bin/ci +++ b/bin/ci @@ -61,8 +61,10 @@ comment_index() { # scoping — which happens to hold the same string today, so this scanned correctly # by accident and would have silently stopped stripping if either were renamed. local line="$1" - local n=${#line} i=0 c prev="" insq=0 indq=0 + local n=${#line} i=0 c prev="" insq="${2:-0}" indq="${3:-0}" COMMENT_IDX=-1 + Q_SQ=$insq + Q_DQ=$indq # This models ordinary '...' and "..." quoting, and nothing else. Shell has more: # $'...' has its own escape rules, $"..." is locale translation, and there are @@ -94,7 +96,9 @@ comment_index() { '\') i=$(( i + 1 )) ;; '#') case "$prev" in - ''|[[:space:]]|';'|'&'|'|'|'('|')'|'<'|'>') COMMENT_IDX=$i; return 0 ;; + ''|[[:space:]]|';'|'&'|'|'|'('|')'|'<'|'>') + # A comment only opens outside quotes, so the line ends unquoted. + COMMENT_IDX=$i; Q_SQ=0; Q_DQ=0; return 0 ;; esac ;; esac @@ -102,13 +106,21 @@ comment_index() { prev="$c" i=$(( i + 1 )) done + Q_SQ=$insq + Q_DQ=$indq return 0 } strip_comments() { - local line + # Quote state is carried between lines, because quoting is not a per-line property: a + # string can hold a literal newline, and everything up to its closing quote is data. + # Restarting unquoted on each line made a `#` inside such a string look like a comment + # and truncated the rest of the line — taking any real download on it along too. + local line insq=0 indq=0 while IFS= read -r line || [ -n "$line" ]; do - comment_index "$line" + comment_index "$line" "$insq" "$indq" + insq=$Q_SQ + indq=$Q_DQ if [ "$COMMENT_IDX" -ge 0 ]; then printf '%s\n' "${line:0:$COMMENT_IDX}" else From 6c788ff749fc25be1463f56764a8135a7c27acd1 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Sun, 2 Aug 2026 20:08:36 -0700 Subject: [PATCH 15/19] Latch closed when quote state becomes unknowable Interaction between my own last two commits: the fallback for unmodelled quoting returned before updating the quote state that cross-line tracking had just started depending on, so state could carry forward desynced and drop a real download on a later line. Fixing the interaction directly would leave the same shape of bug available again. Instead, once a construct we can't track appears, stop stripping for the rest of that file. State can then never be wrong, only absent, and the failure is confined to over-reporting: URLs in comments get flagged loudly rather than real ones dropped quietly. The latch is per file, so one awkward definition can't affect the others. Verified the reported case is caught and a clean definition in the same run still strips its comments normally. --- bin/ci | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/bin/ci b/bin/ci index 7561947..b5f78f4 100755 --- a/bin/ci +++ b/bin/ci @@ -77,8 +77,16 @@ comment_index() { # the silent direction, dropping a real download from the line and passing it # unverified. No definition here uses these forms, so the cost is theoretical and # the safety is not. + # Once a construct we can't track appears, quote state is unknowable from here on — the + # line may open or close quotes in ways this scan would misread. Rather than carry a + # possibly-desynced state forward (which silently drops real downloads on later lines), + # latch into "don't strip anything else in this file". That can only over-report: URLs in + # comments get flagged, loudly, instead of real ones being dropped, quietly. + if [ "${Q_UNKNOWN:-0}" = 1 ]; then + return 0 + fi case "$line" in - *\$\'*|*\$\"*) return 0 ;; + *\$\'*|*\$\"*) Q_UNKNOWN=1; return 0 ;; esac while [ "$i" -lt "$n" ]; do c="${line:$i:1}" @@ -117,6 +125,7 @@ strip_comments() { # Restarting unquoted on each line made a `#` inside such a string look like a comment # and truncated the rest of the line — taking any real download on it along too. local line insq=0 indq=0 + Q_UNKNOWN=0 # reset per file, not per line: the latch is file-scoped while IFS= read -r line || [ -n "$line" ]; do comment_index "$line" "$insq" "$indq" insq=$Q_SQ From 3345ee0de3e100da7a47bfb86d56ebde4d02e87f Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Sun, 2 Aug 2026 20:15:11 -0700 Subject: [PATCH 16/19] Skip heredoc bodies instead of scanning them as shell MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The comment listed heredocs as unmodelled but nothing acted on that, so their bodies were scanned as code: a `#` in one looked like a comment, a quote in one desynced quote state for the rest of the file. Not hypothetical — 1.8.7-p374 contains a heredoc, and further down has a URL inside a comment that depends on stripping still working. Latching off on `<<` would therefore have failed our own definition, so this needs real handling rather than another fail-closed shortcut: track the delimiter and skip the body. Anything in it is patch content, not a download. Openers are looked for in the code part of a line only, so one mentioned in a comment doesn't start swallowing lines. `<<<` is excluded as a herestring with no body, and arithmetic `<< 2` can't match since a delimiter must start with a letter or underscore. Verified: real definitions pass, a heredoc body containing a quote no longer hides a later undigested download, `<<-` with an indented terminator ends correctly, and neither `<<<` nor arithmetic shifts swallow what follows. --- bin/ci | 33 ++++++++++++++++++++++++++------- 1 file changed, 26 insertions(+), 7 deletions(-) diff --git a/bin/ci b/bin/ci index b5f78f4..b357de6 100755 --- a/bin/ci +++ b/bin/ci @@ -66,10 +66,10 @@ comment_index() { Q_SQ=$insq Q_DQ=$indq - # This models ordinary '...' and "..." quoting, and nothing else. Shell has more: - # $'...' has its own escape rules, $"..." is locale translation, and there are - # heredocs. Modelling each in turn is writing a shell lexer in bash, and every - # incomplete version of that is wrong in some new way. + # This models ordinary '...' and "..." quoting. Heredoc bodies are skipped by the + # caller before reaching here. What is left unmodelled is $'...', with its own escape + # rules, and $"..." locale translation — and those latch the scanner off rather than + # being guessed at. # # So don't guess at what isn't modelled — refuse to strip that line at all. Its # comments then get scanned for URLs, which can only produce a false positive: a @@ -124,17 +124,36 @@ strip_comments() { # string can hold a literal newline, and everything up to its closing quote is data. # Restarting unquoted on each line made a `#` inside such a string look like a comment # and truncated the rest of the line — taking any real download on it along too. - local line insq=0 indq=0 + # Heredoc bodies are data, not shell: a `#` in them opens no comment and a quote in them + # closes nothing. Scanning them desynced quote state for the rest of the file, and + # 1.8.7-p374 really does contain one — with a URL inside a comment further down that + # depends on stripping still working. Skip the body outright; any URL in it is patch + # content, not a download. + local line insq=0 indq=0 out hd="" trimmed + local hd_re='<<-?[[:space:]]*["'"'"']?([A-Za-z_][A-Za-z0-9_]*)' Q_UNKNOWN=0 # reset per file, not per line: the latch is file-scoped while IFS= read -r line || [ -n "$line" ]; do + if [ -n "$hd" ]; then + trimmed="${line#"${line%%[![:space:]]*}"}" # <<- allows an indented terminator + if [ "$line" = "$hd" ] || [ "$trimmed" = "$hd" ]; then hd=""; fi + continue + fi + comment_index "$line" "$insq" "$indq" insq=$Q_SQ indq=$Q_DQ if [ "$COMMENT_IDX" -ge 0 ]; then - printf '%s\n' "${line:0:$COMMENT_IDX}" + out="${line:0:$COMMENT_IDX}" else - printf '%s\n' "$line" + out="$line" + fi + # Look for a heredoc opener in the code part only, so one mentioned in a comment + # doesn't start swallowing lines. `<<<` is a herestring with no body; arithmetic + # `<< 2` can't match because a delimiter has to start with a letter or underscore. + if [ "${out#*<<<}" = "$out" ] && [[ $out =~ $hd_re ]]; then + hd="${BASH_REMATCH[1]}" fi + printf '%s\n' "$out" done } From 9ede8ff56d771c5ffbffe0eb71e6d75db5b731d2 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Sun, 2 Aug 2026 20:22:36 -0700 Subject: [PATCH 17/19] Identify downloads by shape, deleting the shell scanner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both reports are interactions with the heredoc handling added two commits ago: an opener detected inside a quoted string, and join_continuations merging a delimiter line because it runs before heredocs are recognised. Fixing them means more lexer, and more lexer has produced more findings every time — quoting, continuations, `;#`, ANSI-C strings, cross-line state, heredocs, now these. The premise was wrong. All that machinery existed to answer "is this URL an argument, a comment, or data", which needs a shell tokeniser. But every download here is a release tarball and every reference URL is an issue or a repo, so the question answers itself from the URL: check the ones ending in an archive extension, ignore the rest. Position stops mattering, and 113 lines of scanner go with it — comment_index, strip_comments, the unknown-state latch and the heredoc tracking, along with the bug surface that kept generating reports. The savannah config.guess/config.sub fetches no longer need an exemption list either; they aren't archives, so they fall outside the rule naturally. Cost, documented in place: a tarball URL written in a comment is flagged. Rare, self-explanatory, and loud rather than silent. No definition has one. Verified every undigested form from the whole review sequence is still caught — double and single quoted, unquoted, continued, split mid-scheme, after `if`, after `&&`, before `;`, after ANSI-C quoting, after a string spanning a newline, after a heredoc, and after a quoted `<') - # A comment only opens outside quotes, so the line ends unquoted. - COMMENT_IDX=$i; Q_SQ=0; Q_DQ=0; return 0 ;; - esac - ;; - esac - fi - prev="$c" - i=$(( i + 1 )) - done - Q_SQ=$insq - Q_DQ=$indq - return 0 -} - -strip_comments() { - # Quote state is carried between lines, because quoting is not a per-line property: a - # string can hold a literal newline, and everything up to its closing quote is data. - # Restarting unquoted on each line made a `#` inside such a string look like a comment - # and truncated the rest of the line — taking any real download on it along too. - # Heredoc bodies are data, not shell: a `#` in them opens no comment and a quote in them - # closes nothing. Scanning them desynced quote state for the rest of the file, and - # 1.8.7-p374 really does contain one — with a URL inside a comment further down that - # depends on stripping still working. Skip the body outright; any URL in it is patch - # content, not a download. - local line insq=0 indq=0 out hd="" trimmed - local hd_re='<<-?[[:space:]]*["'"'"']?([A-Za-z_][A-Za-z0-9_]*)' - Q_UNKNOWN=0 # reset per file, not per line: the latch is file-scoped - while IFS= read -r line || [ -n "$line" ]; do - if [ -n "$hd" ]; then - trimmed="${line#"${line%%[![:space:]]*}"}" # <<- allows an indented terminator - if [ "$line" = "$hd" ] || [ "$trimmed" = "$hd" ]; then hd=""; fi - continue - fi - - comment_index "$line" "$insq" "$indq" - insq=$Q_SQ - indq=$Q_DQ - if [ "$COMMENT_IDX" -ge 0 ]; then - out="${line:0:$COMMENT_IDX}" - else - out="$line" - fi - # Look for a heredoc opener in the code part only, so one mentioned in a comment - # doesn't start swallowing lines. `<<<` is a herestring with no body; arithmetic - # `<< 2` can't match because a delimiter has to start with a letter or underscore. - if [ "${out#*<<<}" = "$out" ] && [[ $out =~ $hd_re ]]; then - hd="${BASH_REMATCH[1]}" - fi - printf '%s\n' "$out" - done -} - trim_url() { local u="$1" while :; do @@ -232,49 +116,48 @@ join_continuations() { # fetches and builds whatever it got, silently. A missing checksum is the kind of thing # that survives review, so assert it here. # -# This checks EVERY url in the file, not just the ones on lines that look like an -# install_package call. That inversion is the whole point. Matching invocations means -# matching shell syntax, and shell has unboundedly many ways to spell the same call — -# quoted, unquoted, line-continued, after `then` or `;` or `&&`, inside a function. Every -# form the pattern doesn't know is a URL that goes uninspected, and the failure is silent: -# it reports success having looked at nothing. Review found four such forms in a row here, -# each time because the fix addressed the shape rather than the class. +# Every URL in the file is considered, wherever it appears, and the ones that look like +# source archives must carry a digest. No attempt is made to work out which are arguments +# and which are prose — that question is what made earlier versions of this wrong. +# +# The cost is that a tarball URL written in a comment gets flagged too. That is rare, it +# says exactly what to do about it, and it errs loud rather than quiet. Reference links in +# these definitions point at issues and repos, not tarballs, so it doesn't arise today. # -# Checking every URL fails closed instead. A download in a syntax nobody anticipated gets -# flagged rather than skipped, and the only way to exempt one is to say so explicitly -# below — which is the right friction, since an unverified download is exactly what this -# check exists to prevent. +# Not checked, deliberately: the config.guess and config.sub fetches in 1.8.7 and 1.9.3. +# They come from GNU's git web view, which serves a moving HEAD with no release tarball +# and no digest to cite. They aren't archives, so they fall outside this rule naturally +# rather than needing an exemption list. The same is true of any future non-archive +# download, which is the honest limitation of matching on shape. lint_checksums() { - heading "Checksums" "every download URL carries a #sha256" - local f url ok found + heading "Checksums" "every source archive carries a #sha256" + local f url base ok found for f in $(definitions); do ok=true found=0 while IFS= read -r url; do url=$(trim_url "$url") - case "$url" in - # The only exempt downloads, matched as exact literals. config.guess and config.sub - # come from GNU's git web view, which serves a moving HEAD with no release tarball - # and no published digest. They only teach ancient configure scripts about modern - # architectures and are never linked into the built Ruby. - # - # Literals, not patterns, because every wildcard here is a hole: a domain glob - # exempts anything from the host, and `*f=config.guess*` also exempts - # `f=config.guess.anything`. Both were tried, both were wrong. Quoted so `?` and - # `;` are matched literally rather than as glob and case-clause syntax. - # - # If these URLs ever change shape, this list must be updated by hand — that is the - # intended cost of skipping verification on a download. - "https://git.savannah.gnu.org/gitweb/?p=config.git;a=blob_plain;f=config.guess;hb=HEAD"|\ - "https://git.savannah.gnu.org/gitweb/?p=config.git;a=blob_plain;f=config.sub;hb=HEAD") continue ;; + base=${url%%#*} + # Only archives are checked, and that is what removes the need to understand the + # shell around them. install_package fetches release tarballs, so a URL ending in + # an archive extension is a download; anything else is a reference. + # + # The alternative was deciding by position — is this URL an argument, or inside a + # comment, or inside a heredoc — which means tokenising shell, and that is where + # every bug in this check came from. Quoting, continuations, `;#`, ANSI-C strings, + # heredocs: each one handled, each one exposing the next. Matching on what the URL + # *is* needs none of it. + case "$base" in + *.tar.gz|*.tar.bz2|*.tar.xz|*.tgz|*.tbz2|*.tar.Z|*.zip) ;; + *) continue ;; esac found=$(( found + 1 )) - [[ $url =~ \#[0-9a-f]{64}$ ]] || { fail "$f: no sha256 on ${url%%\#*}"; ok=false; } - done < <(join_continuations "$f" | strip_comments | grep -oE "$URL_RE") - # Every definition downloads at least Ruby itself. Finding nothing means the extractor - # stopped matching, not that the file is clean — don't call that a pass. + [[ $url =~ \#[0-9a-f]{64}$ ]] || { fail "$f: no sha256 on $base"; ok=false; } + done < <(join_continuations "$f" | grep -oE "$URL_RE") + # Every definition fetches at least Ruby itself as a tarball. Finding none means the + # extractor stopped matching, not that the file is clean — don't call that a pass. if [ "$found" -eq 0 ]; then - fail "$f: no download URLs found — checksum lint could not inspect this file" + fail "$f: no source archives found — checksum lint could not inspect this file" ok=false fi # Explicit if, not `$ok && pass`: that leaves the loop's exit status at 1 when From 98a62f9dd38af64fdbda140e9a32fc9f1525004d Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Sun, 2 Aug 2026 20:28:08 -0700 Subject: [PATCH 18/19] Cut unquoted URLs at the first operator, not just trailing ones MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit grep stops at whitespace, so `…tar.gz;echo done` arrived with `;echo` attached. Trimming only trailing punctuation left it there, the archive extension went unrecognised, and the download was skipped — silently, the direction that matters. Cutting at the first operator rather than the last is safe now that archive shape decides what gets checked. The savannah gitweb links were the reason for preserving mid-string semicolons, and they aren't archives, so nothing depends on that any more. Deleting the exemption made this fix a one-liner. Verified `;`, `&&`, `|` and `)` attached with no space are all caught, real definitions still pass, and every form from the earlier rounds still fails as it should. --- bin/ci | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/bin/ci b/bin/ci index def82f4..bf52732 100755 --- a/bin/ci +++ b/bin/ci @@ -38,18 +38,16 @@ failures=() SQ="'" URL_RE="https?://[^[:space:]\"$SQ\`]+" -# Strip trailing shell punctuation left over from an unquoted URL (`…tar.gz; fi`, -# `…tar.gz && echo`). Safe because none of these can legitimately end a URL, unlike the -# semicolons that appear mid-query-string above. +# Cut an unquoted URL where the shell command continues. grep stops at whitespace, so +# `…tar.gz;echo done` arrives with `;echo` still attached — and trimming only trailing +# punctuation left it there, hiding the archive extension and skipping the download. +# +# Cutting at the *first* operator rather than the last is safe now that archive shape +# decides what gets checked: the savannah gitweb links are the only URLs here with +# mid-string semicolons, and they aren't archives either way. trim_url() { local u="$1" - while :; do - case "$u" in - *[\;\)\(\&\|\<\>]) u="${u%?}" ;; - *) break ;; - esac - done - printf '%s' "$u" + printf '%s' "${u%%[;\)\(\&\|\<\>]*}" } echo "${BANNER}🚀 Local CI for ruby-dev${RESET}" From f0314e6db145a2a856532b613e2d2892e7d23dcf Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Sun, 2 Aug 2026 20:34:24 -0700 Subject: [PATCH 19/19] Classify on the URL path, ignoring the query string MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `https://host/pkg.tar.gz?download=1` didn't end in an archive extension, so it was skipped and its missing digest went unreported — silently. Classification now uses the path with the query removed. Fixing that surfaced a hazard in the previous commit: trimming at the first shell operator cuts a legitimate `?a=1&b=2` query too, taking the digest after it and reporting a checksummed URL as unchecksummed. `&` is both a shell operator and query syntax, and no amount of trimming distinguishes them. So the two questions are now asked of different strings. Whether a digest is present is asked of the untrimmed URL, where `&` is harmless. What kind of URL it is gets asked of the trimmed, query-stripped path, where an attached command or query can't hide the extension. Neither answer depends on the other. Verified a query-with-ampersand URL carrying a digest passes, query-string and attached-command URLs without one fail, and every form from the earlier rounds still fails as it should. --- bin/ci | 33 ++++++++++++++++++++++----------- 1 file changed, 22 insertions(+), 11 deletions(-) diff --git a/bin/ci b/bin/ci index bf52732..13b25c6 100755 --- a/bin/ci +++ b/bin/ci @@ -27,11 +27,10 @@ ERROR=$'\033[1;31m'; SUCCESS=$'\033[1;32m'; RESET=$'\033[0m' failures=() -# A URL ends at whitespace or a quote. Shell metacharacters are deliberately allowed -# *inside* the match and trimmed from the end afterwards (see trim_url): excluding them -# outright truncates legitimate URLs that contain them, and the savannah gitweb links are -# exactly that — `?p=config.git;a=blob_plain;f=config.guess;hb=HEAD` would be cut at the -# first semicolon, discarding the part that says which file it fetches. +# A URL runs to the next whitespace or quote. Shell metacharacters are captured here and +# cut afterwards by trim_url, rather than excluded outright, so that an unquoted URL with +# a command attached (`…tar.gz;echo`) is seen whole before being trimmed — grep alone +# can't tell where the URL ends and the next command begins. # # Built here rather than inline because embedding a single quote inside a single-quoted # grep pattern is unreadable. @@ -129,13 +128,23 @@ join_continuations() { # download, which is the honest limitation of matching on shape. lint_checksums() { heading "Checksums" "every source archive carries a #sha256" - local f url base ok found + local f raw url base path ok found for f in $(definitions); do ok=true found=0 - while IFS= read -r url; do - url=$(trim_url "$url") - base=${url%%#*} + while IFS= read -r raw; do + # Two questions, deliberately answered from different strings. + # + # Whether a digest is present is asked of the untrimmed URL, because `&` is both a + # shell operator and ordinary query syntax. Trimming first would cut + # `…tar.gz?a=1&b=2#` at the ampersand and report a missing digest that is + # right there. + # + # What kind of URL it is gets asked of the trimmed path, so an attached command + # (`…tar.gz;echo`) or a query string (`…tar.gz?download=1`) can't hide the extension. + url=$(trim_url "$raw") + base=${url%%#*} # up to the digest — what to report + path=${base%%\?*} # and without the query — what to classify on # Only archives are checked, and that is what removes the need to understand the # shell around them. install_package fetches release tarballs, so a URL ending in # an archive extension is a download; anything else is a reference. @@ -145,12 +154,14 @@ lint_checksums() { # every bug in this check came from. Quoting, continuations, `;#`, ANSI-C strings, # heredocs: each one handled, each one exposing the next. Matching on what the URL # *is* needs none of it. - case "$base" in + case "$path" in *.tar.gz|*.tar.bz2|*.tar.xz|*.tgz|*.tbz2|*.tar.Z|*.zip) ;; *) continue ;; esac found=$(( found + 1 )) - [[ $url =~ \#[0-9a-f]{64}$ ]] || { fail "$f: no sha256 on $base"; ok=false; } + # Unanchored, with a boundary, since the digest may be followed by a query + # remnant or an attached command rather than ending the string. + [[ $raw =~ \#[0-9a-f]{64}([^0-9a-f]|$) ]] || { fail "$f: no sha256 on $base"; ok=false; } done < <(join_continuations "$f" | grep -oE "$URL_RE") # Every definition fetches at least Ruby itself as a tarball. Finding none means the # extractor stopped matching, not that the file is clean — don't call that a pass.