Files
ersatztv/scripts/jq-preflight.sh
T
timothy 807ebbd38e fix(648): round 4 — the round-1 fail-open was still reachable, via an over-long number
Round 4 found the round-1 MECHANISM alive in round 3's regex. The pattern guaranteed the
operands were digits but not that they fit `test`'s integer range, so a 23-digit major made
`[ "$major" -lt "$min_major" ]` error with "integer expression expected" — and `set -e`
exempts a failing command in an `if` condition, so the conditional read false and the floor
was never asserted. Exit 0. That is precisely what the empty string did in round 1: same
shape, third occurrence, same predicate.

Bounding the runs with {1,9} was not sufficient on its own. The pattern is unanchored at the
end, so `jq-1.99999999999999999999999` simply matched the first 9 digits of the minor and
compared THAT — a mis-parse that passes the floor rather than an error that skips it. The
trailing `([^0-9]|$)` is what actually closes it.

Second hole: `[[:space:]]` matches NEWLINES, so round 3's "anchor" still scanned the whole
output. `jq\n2.34: cannot load shared library` matched `jq`, crossed the newline as
separator, and parsed 2.34. Now the first line only, with `[[:blank:]]`.

Third: the separator class `[-[:blank:]]{1,4}` could be walked across filler —
`jq -- 2.34 (real jq-1.6)` parsed as 2.34, `jq<TAB><TAB>9.9` as 9.9. It is now one of the two
forms real jq emits: `jq-1.6` or `jq version 1.6` (a blank separator REQUIRES the literal
word `version`).

Verified across a 20-case matrix: every legitimate form still parses to the right numbers
(jq-1.6, jq version 1.6, jq-1.7.1, jq-1.6-dirty, jq-1.10 numerically, jq-1.6 (Debian 1.6-2.1),
jq-v1.6, JQ-1.6, jq-1.6.0, CRLF), and every constructed attack fails closed. Four mutations,
each reddening exactly its own tests. The real jq 1.8.2 on this machine still reports cleanly.

Also: the log line now interpolates the first line, so a multi-line --version cannot split the
single grep-able line the no-arg mode exists to emit.

None of these are reachable from a real jq build. They are recorded and fixed because the
guard's own stated invariant — never assert a floor against something it did not parse — was
still violable three rounds in, and the follow-up PR moves this exact code into the
branch-protection-required check.
2026-07-26 22:21:07 +02:00

174 lines
11 KiB
Bash
Executable File

#!/usr/bin/env bash
# Make the jq version a job's shell gates run under OBSERVABLE, and any drift LOUD.
#
# ersatztv#648. Every shell gate in this repo is authored and tested on a developer Mac shipping
# jq 1.8.x. The CI runner ships jq 1.6. Nothing pinned or checked that, and until ersatztv#631 the one
# thing that could have noticed (scripts/tests/) never ran on the runner. Three independent divergences
# surfaced in a single day:
#
# ersatztv#643 `jq -e` over EMPTY input -> exit 4 on 1.8, exit 0 on 1.6 (a transport failure
# passed the docs-only pagination guard)
# ersatztv#647 contains("<NUL>") -> false on 1.8, TRUE for every string on 1.6
# (the H10 verdict classifier was entirely inert)
# ersatztv#647 parse-error exit code -> 5 on 1.8, 4 on 1.6 — same as "no output"
# (garbage API response read as "no comments")
#
# All three are fixed with version-stable constructs, but patching constructs one at a time does not
# scale: the failures share one shape — a shell gate's behaviour is a function of its interpreter's
# version, and that version was an UNTESTED AXIS. This script makes the axis explicit.
#
# WHY A FLOOR AND NOT A PIN EVERYWHERE. The obvious fix — bake a pinned jq into the CI toolchain image
# (docker/ci/Dockerfile) — provably does NOT cover the gate that actually broke. `.gitea/workflows/
# review-verdict.yml` is `runs-on: small`, carries no toolchain-image pin, and per `ci.small-lane-git-only`
# the small lane is git-only. It therefore gets the HOST's jq 1.6 no matter what the image contains.
# That was checked, not assumed (ersatztv#648's first Done-when box).
#
# So the contract is the other way round: 1.6 is the FLOOR every gate must work on, and it is the
# runner's own jq that provides the 1.6 coverage `scripts/tests/` runs under.
#
# TWO MODES, deliberately asymmetric:
#
# (no --expect) Print the version and assert it is >= MIN_VERSION. Used by jobs on the merge
# path, including review-verdict.yml. There is NO upper bound here on purpose:
# review-verdict.yml writes `review-verdict/h10`, a REQUIRED status check on
# `main`, so a hard pin there would turn any jq upgrade on the runner into a
# repo-wide merge deadlock. Observability without a deadlock risk.
#
# --expect X.Y Additionally assert the version is exactly X.Y, and FAIL if not. Used by the
# `script-tests` job. This is the tripwire: `scripts/tests/` currently exercises
# the 1.6 path only because the runner happens to ship 1.6. If the runner were
# upgraded, that coverage would vanish SILENTLY and the whole class of bug above
# would go untested again. Going red forces a human to decide — re-pin, or add a
# real 1.6 matrix leg — rather than letting the coverage evaporate unnoticed.
#
# Usage: jq-preflight.sh [--expect <major.minor>]
set -euo pipefail
# The lowest jq every shell gate in this repo must run correctly on. Do not raise this without
# confirming the CI runner has actually been upgraded first — the runner, not the dev Mac, is the
# binding constraint.
MIN_VERSION="1.6"
expect=""
while [ "$#" -gt 0 ]; do
case "$1" in
--expect)
# `shift 2` with a missing value fails under `set -e` and exits 1 with NOTHING on either
# stream — a CI step dying with an empty log is exactly the diagnostic hole this script exists
# to remove. Check explicitly instead.
if [ "$#" -lt 2 ] || [ -z "${2:-}" ]; then
echo "jq-preflight: --expect requires a <major.minor> value" >&2
exit 2
fi
expect="$2"; shift 2 ;;
*) echo "jq-preflight: unknown argument '$1'" >&2; exit 2 ;;
esac
done
if ! command -v jq >/dev/null 2>&1; then
echo "jq-preflight: jq is not on PATH. The shell gates in scripts/ and .gitea/workflows/ shell out to jq; without it they fail as a pile of opaque assertion errors instead of one clear message." >&2
exit 1
fi
# Take jq's EXIT STATUS seriously, and keep stderr OUT of the parse input.
#
# This was `raw=$(jq --version 2>&1 || true)`, which did neither — and that combination turned the
# guard fail-OPEN on the case it most needs to catch. A jq that cannot start (the canonical one is a
# glibc mismatch after a base-image change) exits 127 and writes something like
# `jq: /lib/x86_64-linux-gnu/libc.so.6: version 'GLIBC_2.34' not found` to stderr. Folded into `raw`,
# that string contains `2.34`, which the version pattern happily matched — so the preflight printed
# "parsed 2.34", certified the floor, and exited 0 on a jq that cannot run at all. The strip-based
# parse this replaced failed CLOSED there, so it was a regression introduced by the fix.
# `$?` inside an `if ! cmd; then` block is the NEGATED status (0), not jq's, so capture it explicitly.
set +e
raw=$(jq --version 2>/dev/null)
jq_rc=$?
set -e
if [ "$jq_rc" -ne 0 ]; then
echo "jq-preflight: 'jq --version' failed (exit ${jq_rc}). jq is on PATH but cannot run — a broken build or a missing shared library. Failing closed rather than certifying a version it did not report." >&2
exit 1
fi
# `jq --version` prints e.g. `jq-1.6`, `jq-1.7.1`, or on some builds `jq-1.8.2-dirty`.
# Parse with an explicit regex rather than by stripping around the first `-` and `.`.
#
# The strip approach had a hole that defeated the whole point of this script. It assumed the format
# is exactly `jq-X.Y`, so a build printing anything else — `jq version 1.6` (a distro wrapper),
# `JQ-1.6`, `jq-1.-6` — left ONE of major/minor empty. The old sanity check was
# `case "$major$minor" in *[!a-9]*|"")`, and on `jq version 1.6` that concatenation is "6": non-empty
# and all-digits, so the guard PASSED. The floor comparison then ran `[ "" -lt 1 ]`, which exits 2
# with "integer expression expected" — and `set -e` exempts a failing command in an `if` condition,
# so the whole conditional read false and the script exited 0 having asserted NOTHING, after printing
# a plausible-looking "parsed" line.
#
# That is the silently-untested-axis failure this script was written to eliminate, reproduced inside
# the script itself. Require a real `<digits>.<digits>` match, and fail closed when there isn't one.
# ANCHORED to the leading `jq` token, not "first digits.digits anywhere in the string".
#
# An unanchored match takes whatever number comes first, wherever it is. That accepted a leading
# warning line or a date prefix as the version — `2026.07.26 jq-1.6` parsed as 2026.07, which sails
# over the floor. Anchoring keeps every legitimate form (`jq-1.6`, `jq version 1.6`, `jq-1.7.1`,
# `jq-1.6-dirty`, `jq-1.6 (Debian 1.6-2.1)`) and rejects the rest, which then fails closed below.
# FIRST LINE ONLY, and bounded everywhere. Both bounds are load-bearing; this is the third round on
# this one predicate and each previous version failed for a variant of the same reason.
#
# * First line only. `[[:space:]]` matches NEWLINES, so an "anchored" pattern still scanned the
# whole output: `jq\n2.34: cannot load` matched `jq`, crossed the newline as separator, and
# parsed 2.34 — fail-open, the round-2 bug narrowed but not closed. `[[:blank:]]` (space/tab
# only) plus a first-line slice confines the match to the line that can actually carry a version.
# * Bounded digit runs. This is the round-1 mechanism resurrected. The regex guaranteed the
# operands were digits but not that they fit in `test`'s integer range, so a 23-digit major made
# `[ "$major" -lt "$min_major" ]` error with "integer expression expected" — and `set -e` exempts
# a failing command in an `if` condition, so the conditional read false and THE FLOOR WAS NEVER
# ASSERTED, exit 0. Exactly what the empty-string case did in round 1. `{1,9}` keeps every
# operand inside a 32-bit integer, so the comparison can no longer error.
# * Bounded separator runs, so the pattern cannot be walked across arbitrary filler.
first=${raw%%$'\n'*}
first=${first%$'\r'}
# The separator is one of the two forms real jq actually emits — `jq-1.6` or `jq version 1.6` — not
# "any run of dashes and blanks". A permissive class let the pattern be walked across filler:
# `jq -- 2.34 (real jq-1.6)` parsed as 2.34, and `jq<TAB><TAB>9.9` as 9.9. A blank separator now
# REQUIRES the literal word `version`, which is the only context a real build puts one in.
#
# The trailing `([^0-9]|$)` is what actually bounds the digit runs. `{1,9}` alone does not: the regex
# is unanchored at the end, so `jq-1.99999999999999999999999` simply matched the first 9 digits of
# the minor and compared THAT — a mis-parse that passes the floor. Requiring a non-digit (or
# end-of-string) after the minor makes an over-long run fail to match at all, so it fails closed.
if [[ "$first" =~ ^[[:blank:]]*[Jj][Qq](-v?|[[:blank:]]+version[[:blank:]]+v?)([0-9]{1,9})\.([0-9]{1,9})([^0-9]|$) ]]; then
major="${BASH_REMATCH[2]}"
minor="${BASH_REMATCH[3]}"
else
echo "jq-preflight: could not parse a major.minor version out of '${first}'. Refusing to assert a floor against an unparsed version — that would silently pass." >&2
exit 1
fi
# THIS LINE IS THE POINT of the no-arg mode: the jq version CI actually used is in the job log, so a
# future divergence can be diagnosed from the log alone rather than by guessing at the runner image.
# `$first`, not `$raw`: a multi-line `--version` would split this across lines, breaking the single
# grep-able log line that is the entire point of the no-arg mode.
echo "jq-preflight: jq version in use = ${first} (parsed ${major}.${minor}; floor ${MIN_VERSION})"
min_major=${MIN_VERSION%%.*}
min_minor=${MIN_VERSION#*.}
if [ "$major" -lt "$min_major" ] || { [ "$major" -eq "$min_major" ] && [ "$minor" -lt "$min_minor" ]; }; then
echo "jq-preflight: jq ${major}.${minor} is BELOW the supported floor ${MIN_VERSION}. The gates in scripts/ and .gitea/workflows/ are written against ${MIN_VERSION}+ semantics and will misbehave silently on older builds." >&2
exit 1
fi
if [ -n "$expect" ]; then
if [ "${major}.${minor}" != "$expect" ]; then
echo "jq-preflight: expected jq ${expect}, found ${major}.${minor}." >&2
echo "" >&2
echo "This is a TRIPWIRE, not a defect in your change (ersatztv#648). scripts/tests/ was pinned to" >&2
echo "jq ${expect} because that is what this runner shipped; it now reports ${major}.${minor}. The ${expect}" >&2
echo "coverage the suite assumed has therefore just disappeared, silently — and jq 1.7 altered NUL" >&2
echo "handling, exit codes, @base64d and number precision, every one of which a gate here depends on." >&2
echo "" >&2
echo "Decide explicitly, then update the --expect value in .gitea/workflows/pr-checks.yml:" >&2
echo " * re-pin to the new version after re-reading docs/ci-cd.md -> 'The jq contract', or" >&2
echo " * add a real matrix leg that runs the suite under ${MIN_VERSION} as well." >&2
exit 1
fi
echo "jq-preflight: version matches the expected pin (${expect})."
fi