2026-08-05 15:23:56 -07:00
#!/usr/bin/env bash
## lsphp-info-probe.test.sh — regression test for the SIGPIPE-under-pipefail
## class of bug that shipped in cac-lsphp (trunk 9343a56) and silently turned
## $_SERVER path parity off on every shared-ols site whose host lost the race.
##
## THE BUG. entrypoint-lsphp.sh asked "is cac_path_parity loaded?" with
##
## printf '%s\n' "$LSPHP_INFO" | grep -q '^cac_path_parity support => enabled$'
##
## under `set -euo pipefail`. `grep -q` exits the instant it matches; printf is
2026-08-05 16:03:08 -07:00
## still pushing the rest of `lsphp -i` into the pipe, takes SIGPIPE, and exits
## 141; pipefail prefers that over grep's 0. So the test read FALSE **because
## the extension was present** — present early enough in the output to stop the
## reader — and the container fell back to the degraded auto_prepend normaliser
## the extension exists to replace, while telling the operator the extension was
## "not loadable in this image".
##
## THE RULE THIS FILE ENFORCES, stated so nobody re-derives a wrong one: ANY
## `writer | early-exiting-reader` under pipefail is a latent 141. PAYLOAD SIZE
## IS NOT A SAFETY ARGUMENT — each run is a race on whether the reader's close
## lands before the writer's final write() returns, and size only sets how many
## write() syscalls the writer must survive. Measured against a default
## 65536-byte pipe: 41144 bytes SIGPIPEd on 32/300 runs (11%, well UNDER
## capacity) and 65012 bytes on 25/30 (not certain even AT capacity); 500 KB
## into a 1 MiB pipe was 200/200 when written 4096 bytes at a time and 0/200 as
## a single write. A call site is sound only for a STRUCTURAL reason: the file
## does not set pipefail, or the reader provably consumes to EOF (no `q`, `-q`,
## `-l`, `-m`, `exit`, `break`), or the pipeline's status is discarded. Section 4
## below prints this race happening at ~41 KB; if the numbers there ever read as
## "small payloads are fine", the numbers are right and the reading is wrong.
2026-08-05 15:23:56 -07:00
##
## WHY THE EXISTING SUITE DID NOT CATCH IT. The .phpt suite and
## fpm-parity-check.sh both test the EXTENSION; nothing executed the
## ENTRYPOINT's branch logic, and the Dockerfile's own `lsphp -i | grep -q`
## build gate runs under `bash -c 'set -e'` with NO pipefail, so it reported the
## extension present in the very image whose entrypoint declared it missing.
##
## WHAT THIS ASSERTS.
## 1. behaviour — the SHIPPED probe helpers (extracted verbatim from
## entrypoint-lsphp.sh, never copied, so they cannot drift) return the
## right answer AND a clean exit status under `set -euo pipefail` with a
## realistic ~40 KB phpinfo body.
## 2. structure — no script in this repo that enables pipefail pipes into a
## reader that can exit before its writer finishes. This is the check that
## fails deterministically against trunk; assertion 1's *old* form is a
## RACE (measured 141 on 3 of 5 runs here, 5 of 5 on whp02, and 0 of 5
## under a different Docker daemon), so no behavioural assertion about the
## broken code could be trusted to fail on every machine. Section 4 runs
## the old form anyway and prints what it did, for the record.
2026-08-05 16:03:08 -07:00
## 3. equivalence — the helpers' pure-bash matching answers exactly what the
## grep/awk patterns they replaced answer, checked case by case against
## those same patterns reading a FILE (a file, so the reference itself
## cannot SIGPIPE). Section 6.
## 4. the scan's own coverage — every reader shape section 5 claims to catch
## is caught, and a matched set of safe shapes is NOT flagged. Section 7.
## Without this the scan's regexes are unfalsified and can quietly stop
## matching; `grep -l` and `sed q` were both missed until section 7 existed.
2026-08-05 15:23:56 -07:00
##
## SCOPE LIMITS, stated rather than hidden. The structural scan is a text scan,
2026-08-05 16:03:08 -07:00
## so it under-reports in known ways:
2026-08-05 15:23:56 -07:00
## - it reads one line at a time. The repo's only multi-line pipeline
## (ols-htaccess-watcher.sh's `inotifywait … |` / `while read`) is invisible
## to it and was reviewed by hand: that reader loops until EOF, i.e. until
## the writer has already gone, so it cannot produce this failure.
## - its quote stripping is flat, so a pipe nested inside a command
## substitution inside a quoted string (`echo "x ($(a | head -1))"`) is read
## as quoted text and skipped.
2026-08-05 16:03:08 -07:00
## - the reader list is an ENUMERATION, not a proof. It knows `grep`
## (-q/-l/-L/-m and their long forms), `head`, `read`, `awk … exit` and
## `sed` with a q/Q command; it does not know an early exit hidden in
## `perl -ne '… last'`, `python -c`, `jq`, `head -c`, or any project-local
## program that stops reading. A reader not on the list is not thereby safe.
## - it only inspects the FIRST word after a pipe, so `foo | LC_ALL=C grep -q`
## or `foo | { grep -q x; }` reads as an unknown reader and is skipped.
2026-08-05 15:23:56 -07:00
## It is a guard against reintroducing the shape, not a proof of its absence.
##
## Usage: scripts/tests/lsphp-info-probe.test.sh [REPO_ROOT]
## Exit: 0 all assertions passed, 1 an assertion FAILED, 2 the test could not run.
set -uo pipefail
HERE = " $( cd " $( dirname " ${ BASH_SOURCE [0] } " ) " && pwd ) "
ROOT = " ${ 1 :-$( cd " $HERE /../.." && pwd ) } "
ENTRYPOINT = " $ROOT /scripts/entrypoint-lsphp.sh"
TMP = " $( mktemp -d) "
trap 'rm -rf "$TMP"' EXIT
pass = 0; fail = 0
ok() { pass = $(( pass+1)) ; echo " ok — $1 " ; }
bad() { fail = $(( fail+1)) ; echo " FAIL — $1 " >& 2; }
die() { echo "HARNESS FAILURE: $* " >& 2; exit 2; }
[ -f " $ENTRYPOINT " ] || die "no entrypoint at $ENTRYPOINT (pass REPO_ROOT as \$1)"
## ---------------------------------------------------------------------------
## 1. Extract the real probe helpers. Not a copy: whatever the shipped
## entrypoint says between the markers is what runs below, so a future edit
## that reintroduces a pipeline is tested, not narrated.
##
## A missing block is an assertion FAILURE, not a harness error, and it does
## not stop the run: against a pre-fix checkout the probes are still inline
## pipelines, and section 5 below is what names them. Bailing out here would
## have replaced that report with "could not run".
2026-08-05 16:03:08 -07:00
##
## BOUNDED ON PURPOSE. The extraction buffers and emits NOTHING until it has
## seen the END marker, so deleting or mistyping that one line is a marker
## error (exit 4) rather than a slurp of every line after BEGIN into a file
## this harness then `source`s. That is not hypothetical: it happened during
## development and only failed loudly by luck — `set -u` tripped over an
## unbound variable two statements into the entrypoint's real boot code. An
## extractor whose failure mode is "execute arbitrary parts of the program
## under test" is not a safe thing to leave lying around, however careful the
## markers are today.
## exit 0 = both markers, block on stdout
## exit 3 = no BEGIN marker at all (pre-fix checkout, or block removed)
## exit 4 = BEGIN seen, END missing — refuse to emit, refuse to source
2026-08-05 15:23:56 -07:00
## ---------------------------------------------------------------------------
HAVE_HELPERS = yes
2026-08-05 16:03:08 -07:00
XRC = 0
awk '
/^## ---- CAC-TEST: probe helpers BEGIN ----$/ { f = 1 }
f { buf = buf $0 ORS }
f && /^## ---- CAC-TEST: probe helpers END ----$/ { printf "%s", buf; found = 1; exit 0 }
END { if (found) exit 0; else if (f) exit 4; else exit 3 }
' " $ENTRYPOINT " > " $TMP /helpers.sh" || XRC = $?
case " $XRC " in
0)
for fn in lsphp_info_has_parity_ext lsphp_info_scan_dir lsphp_info_is_usable; do
if ! grep -q "^ ${ fn } ()" " $TMP /helpers.sh" ; then
HAVE_HELPERS = no
bad "the probe-helper block in ${ ENTRYPOINT # " $ROOT " / } defines no ${ fn } ()"
fi
done
;;
4)
HAVE_HELPERS = no
: > " $TMP /helpers.sh"
bad " ${ ENTRYPOINT # " $ROOT " / } has a 'CAC-TEST: probe helpers BEGIN' marker with no matching END marker. Nothing was extracted — an unterminated block would otherwise have pulled the whole rest of the entrypoint into a file this test sources and runs."
;;
3)
HAVE_HELPERS = no
bad "no probe-helper markers in ${ ENTRYPOINT # " $ROOT " / } — either they were removed, or this is a pre-fix checkout where the probes are still inline pipelines (trunk 9343a56). Section 5 says which lines."
;;
*)
HAVE_HELPERS = no
bad "extracting the probe-helper block from ${ ENTRYPOINT # " $ROOT " / } failed (awk exit $XRC )"
;;
esac
2026-08-05 15:23:56 -07:00
## ---------------------------------------------------------------------------
## 2. Fixtures. Shaped like real `lsphp -i`: the two lines the probes look for
## sit near the TOP (that is what lets a reader stop early), with tens of KB
## of body after them. A fixture whose marker is near the end could not
## SIGPIPE at all and would make this whole test vacuous, so both properties
## are asserted before anything else runs.
## ---------------------------------------------------------------------------
SCAN_PATH = '/usr/local/lsws/lsphp83/etc/php/8.3/mods-available'
make_fixture() { # $1=outfile $2=yes|no (include the cac_path_parity line)
{
printf 'phpinfo()\nPHP Version => 8.3.27\n\n'
printf 'System => Linux 6ecb4e0a1c1f 5.15.0 #1 SMP x86_64\n'
printf 'Server API => LiteSpeed V8.3\n'
printf 'Configuration File (php.ini) Path => /usr/local/lsws/lsphp83/etc/php/8.3/litespeed\n'
printf 'Scan this dir for additional .ini files => %s\n' " $SCAN_PATH "
printf 'PHP API => 20230831\nDebug Build => no\nThread Safety => disabled\n\n'
printf 'bcmath\n\nBCMath support => enabled\n\n'
printf 'calendar\n\nCalendar support => enabled\n\n'
[ " $2 " = yes ] && printf 'cac_path_parity\n\ncac_path_parity support => enabled\nRewriting => active\n\n'
printf 'Core\n\nPHP Version => 8.3.27\n\n'
## ~40 KB of directive rows, exactly the shape phpinfo() prints them in.
local i = 0
while [ " $i " -lt 700 ] ; do
printf 'some.directive_%03d => local_value_%03d => master_value_%03d\n' " $i " " $i " " $i "
i = $(( i+1))
done
} > " $1 "
}
make_fixture " $TMP /info-present.txt" yes
make_fixture " $TMP /info-absent.txt" no
: > " $TMP /info-empty.txt"
echo "== fixture sanity =="
FIX_BYTES = $( wc -c < " $TMP /info-present.txt" )
MATCH_OFF = $( grep -b -m1 '^cac_path_parity support => enabled$' " $TMP /info-present.txt" | cut -d: -f1)
if [ " $FIX_BYTES " -ge 32768 ] ; then
ok "fixture is ${ FIX_BYTES } bytes (realistic 'lsphp -i' is ~40 KB)"
else
bad "fixture is only ${ FIX_BYTES } bytes — too small to reproduce the failure"
fi
if [ " $MATCH_OFF " -lt $(( FIX_BYTES / 4 )) ] ; then
ok "match sits at byte ${ MATCH_OFF } of ${ FIX_BYTES } — most of the body is still unwritten when a reader could stop"
else
bad "match at byte ${ MATCH_OFF } of ${ FIX_BYTES } leaves too small a tail; the test would be vacuous"
fi
## ---------------------------------------------------------------------------
## 3. Behaviour, under the REAL option set (`set -euo pipefail`, as line 34 of
## the entrypoint sets it). Each case runs in its own bash process so the
## options and the exit status are the genuine article, not something this
## harness simulated.
## ---------------------------------------------------------------------------
cat > " $TMP /runner.sh" <<'RUNNER'
#!/usr/bin/env bash
set -euo pipefail
# shellcheck disable=SC1091
source "$1" # the extracted probe helpers
LSPHP_INFO=$(cat "$2")
case "$3" in
has_ext) lsphp_info_has_parity_ext "$LSPHP_INFO" ;;
scan_dir) lsphp_info_scan_dir "$LSPHP_INFO" ;;
usable) lsphp_info_is_usable "$LSPHP_INFO" ;;
legacy) printf '%s\n' "$LSPHP_INFO" | grep -q '^cac_path_parity support => enabled$' ;;
*) echo "unknown case $3" >&2; exit 99 ;;
esac
RUNNER
## NOT `out=$(run …)`: command substitution runs the function in a subshell,
## where an exit status assigned to RC would be thrown away with it. Run in this
## shell, capture stdout through a file, and RC is the real thing.
RC = 0; out = ""
run() { # $1=fixture $2=case -> sets RC and out
RC = 0
bash " $TMP /runner.sh" " $TMP /helpers.sh" " $1 " " $2 " >" $TMP /out" 2>& 1 || RC = $?
out = $( cat " $TMP /out" )
}
echo "== the shipped probes under set -euo pipefail =="
if [ " $HAVE_HELPERS " = no ] ; then
echo " (skipped — no probe helpers to run; see the failure above)"
fi
if [ " $HAVE_HELPERS " = yes ] ; then
run " $TMP /info-present.txt" has_ext
if [ " $RC " -eq 0 ] ; then
ok "extension present => branch TAKEN (exit 0)"
elif [ " $RC " -eq 141 ] ; then
bad "exit 141 (SIGPIPE): the probe is a pipeline again — this is the original bug"
else
bad "extension present => exit $RC (expected 0). Output: $out "
fi
run " $TMP /info-absent.txt" has_ext
if [ " $RC " -eq 1 ] ; then
ok "extension genuinely absent => branch NOT taken (exit 1, a clean 'no')"
else
bad "extension absent => exit $RC (expected 1). Output: $out "
fi
run " $TMP /info-present.txt" scan_dir
if [ " $RC " -eq 0 ] && [ " $out " = " $SCAN_PATH " ] ; then
ok "scan-dir probe returns ' $out ' (exit 0)"
elif [ " $RC " -eq 141 ] ; then
bad "scan-dir probe exit 141 (SIGPIPE) — as a bare assignment under set -e that KILLS PID 1"
else
bad "scan-dir probe => exit $RC , got ' $out ', expected ' $SCAN_PATH '"
fi
run " $TMP /info-present.txt" usable
if [ " $RC " -eq 0 ] ; then ok "usability probe: real phpinfo body => usable (exit 0)"
else bad "usability probe on real body => exit $RC (expected 0)" ; fi
run " $TMP /info-empty.txt" usable
if [ " $RC " -eq 1 ] ; then ok "usability probe: empty body => NOT usable (exit 1)"
else bad "usability probe on empty body => exit $RC (expected 1)" ; fi
## The two failure causes must be reported as the different things they are.
## A probe that cannot answer has established nothing about the image, and the
## old message asserted the opposite of that for every reason it fired.
## SC2016: these patterns are the entrypoint's literal text, `$` and all.
# shellcheck disable=SC2016
if grep -q 'if lsphp_info_is_usable "$LSPHP_INFO"; then' " $ENTRYPOINT " &&
grep -q 'This is a PROBE failure and establishes nothing' " $ENTRYPOINT " ; then
ok "a probe that produced nothing is reported as OUR failure, not as a verdict on the image"
else
bad "entrypoint no longer reports an unusable 'lsphp -i' as a probe failure"
fi
# shellcheck disable=SC2016
if grep -q 'not loadable in this image' " $ENTRYPOINT " &&
grep -q 'answered (${#LSPHP_INFO} bytes, scan dir ${SCAN_DIR}) and does not list it' " $ENTRYPOINT " ; then
ok "the 'extension not loadable' verdict now ships the evidence it rests on"
else
bad "the 'extension not loadable' message no longer states what it observed"
fi
fi # HAVE_HELPERS
## ---------------------------------------------------------------------------
## 4. The old form, for the record. NOT asserted: it is a race, and asserting a
## race would make this suite flap on whichever machine happens to win it.
## ---------------------------------------------------------------------------
echo "== the pre-fix pipeline form on the same input (informational) =="
if [ " $HAVE_HELPERS " = no ] ; then
echo " (skipped — the runner needs the helper block to source)"
fi
legacy_rcs = ""
if [ " $HAVE_HELPERS " = yes ] ; then
for _ in 1 2 3 4 5 6 7 8 9 10; do
run " $TMP /info-present.txt" legacy
legacy_rcs = " $legacy_rcs $RC "
done
echo " 10 runs of 'printf | grep -q' under pipefail: ${ legacy_rcs } "
echo " (0 = happened to finish writing, 141 = writer SIGPIPEd and pipefail reported the"
echo " extension MISSING because it was present. Either value is expected here.)"
fi
## ---------------------------------------------------------------------------
## 5. Structural scan — the deterministic guard, and the part of this file that
## fails against trunk on EVERY machine. Any script that turns on pipefail
## and pipes into a reader that can stop early has this bug latent in it
## whether or not today's buffer sizes expose it, so the shape is what gets
## outlawed, not the symptom.
##
## It is a text scan, so it is honest about its limits: it strips quoted
## spans and comments (that is what keeps `case a|b)`, `sed "s|x|y|"` and
## `echo "a | b"` from being reported), it requires the reader to be the
## FIRST word after a pipe, and it reads one line at a time. This file is
## skipped because section 4 runs the broken form on purpose.
## ---------------------------------------------------------------------------
echo "== structural scan: early-exit readers in pipefail scripts =="
mapfile -t PIPEFAIL_FILES < <( grep -rl --include= '*.sh' -E '^[[:space:]]*set[[:space:]]+-[a-zA-Z]*[[:space:]]*o?[[:space:]]*pipefail|^[[:space:]]*set[[:space:]]+-o[[:space:]]+pipefail' " $ROOT /scripts" " $ROOT /ext" 2>/dev/null | sort)
[ " ${# PIPEFAIL_FILES [@] } " -gt 0 ] || die "found no pipefail-enabled scripts under $ROOT — the scan would be vacuous"
cat > " $TMP /scan.awk" <<'AWKPROG'
2026-08-05 16:03:08 -07:00
# Does this `grep …` invocation stop reading before EOF? -q/-l/-L/-m do; -c,
# -i, -v, -o, -n and the rest read the whole input and are none of our business.
# Walked option by option rather than pattern-matched in one go, because the
# letters have to be read the way grep reads them: a cluster like -im1 stops
# early, -e/-f/-A/-B/-C/-d/-D swallow the rest of their token as an ARGUMENT
# (so `grep -eq` is the pattern "q", not --quiet), and the first non-option word
# is the pattern, after which nothing is a flag any more.
function grep_stops_early(s, a, k, j, t, c, m) {
if (s !~ /^[[:space:]]*grep([[:space:];&)]|$)/) return 0
sub(/^[[:space:]]*grep([[:space:]]+|$)/, "", s)
m = split(s, a, /[[:space:]]+/)
for (k = 1; k <= m; k++) {
t = a[k]
if (t == "") continue
if (t == "--") return 0
if (t ~ /^--/) {
if (t ~ /^--(quiet|silent|max-count|files-with-match|files-without-match)/) return 1
continue
}
if (t !~ /^-/) return 0 # the pattern; options are over
sub(/^-/, "", t)
for (j = 1; j <= length(t); j++) {
c = substr(t, j, 1)
if (c == "q" || c == "l" || c == "L" || c == "m") return 1
if (c ~ /[efABCdD]/) break # rest of the token is its argument
}
}
return 0
}
2026-08-05 15:23:56 -07:00
{
raw = $0
l = raw
gsub(/\\"/, "X", l); gsub(/\\'/, "X", l) # escaped quotes are not delimiters
while (match(l, /'[^']*'/)) l = substr(l, 1, RSTART-1) "SQ" substr(l, RSTART+RLENGTH)
while (match(l, /"[^"]*"/)) l = substr(l, 1, RSTART-1) "DQ" substr(l, RSTART+RLENGTH)
if (l ~ /^[[:space:]]*#/) next # whole-line comment
sub(/[[:space:]]#.*/, "", l) # trailing comment
2026-08-05 16:03:08 -07:00
# `u` = the line with quote CHARACTERS dropped but their contents KEPT, used
# only to read a command's script argument. `l` cannot serve for that: it
# replaces a whole quoted span with a placeholder, so `sed 'q'` and
# `awk '{exit}'` lose the very token that makes them early-exit readers.
u = raw
gsub(/["']/, "", u)
sub(/[[:space:]]#.*/, "", u)
2026-08-05 15:23:56 -07:00
gsub(/\|\|/, " ", l) # || is not a pipeline
if (l !~ /\|/) next
n = split(l, seg, "|")
for (i = 2; i <= n; i++) {
r = seg[i]
2026-08-05 16:03:08 -07:00
if (grep_stops_early(r)) { print NR ": " raw; next }
# The `[[:space:];&)]|$` tail rather than a bare `[[:space:]]|$`: a reader
# can be the last word of a compound command (`… | head; }`), which the
# whitespace-only form silently skipped.
if (r ~ /^[[:space:]]*head([[:space:];&)]|$)/) { print NR ": " raw; next }
if (r ~ /^[[:space:]]*(while[[:space:]]+)?read([[:space:];&)]|$)/) { print NR ": " raw; next }
if (r ~ /^[[:space:]]*awk([[:space:];&)]|$)/ && u ~ /exit/) { print NR ": " raw; next }
# sed: a q/Q command anywhere in the script — `/re/q`, `2q`, `$q`, `1p;q`,
# `{…;q}`, and the bare `sed q` / `sed 'q'` that the earlier pattern missed
# (it required a `;`, `{` or `/` in front of the q, which a lone script has
# none of). Anchored on what may PRECEDE the q and what may FOLLOW it, so a
# q inside a replacement — `sed s/a/q/` — is not read as the command (which
# is what the trailing `/` exclusion buys: a command q is never followed by
# another delimiter, a replacement q always is).
if (r ~ /^[[:space:]]*sed([[:space:];&)]|$)/ &&
(u ~ /[;{\/][[:space:]]*[qQ]([^[:alnum:]\/]|$)/ ||
u ~ /[[:space:]]([0-9]+|\$)?[qQ]([[:space:];}]|$)/)) { print NR ": " raw; next }
2026-08-05 15:23:56 -07:00
}
}
AWKPROG
offenders = 0
for f in " ${ PIPEFAIL_FILES [@] } " ; do
[ " $( basename " $f " ) " = " $( basename " ${ BASH_SOURCE [0] } " ) " ] && continue
while IFS = read -r hit; do
offenders = $(( offenders+1))
echo " FAIL — ${ f # " $ROOT " / } : ${ hit } " >& 2
done < <( awk -f " $TMP /scan.awk" " $f " )
done
if [ " $offenders " -eq 0 ] ; then
ok " ${# PIPEFAIL_FILES [@] } pipefail-enabled scripts, no pipeline whose reader can outrun its writer"
else
bad " $offenders pipeline(s) above pipe into an early-exit reader under pipefail."
2026-08-05 16:03:08 -07:00
echo " Read the value into a variable and match it in the shell (see" >& 2
2026-08-05 15:23:56 -07:00
echo " lsphp_info_has_parity_ext in scripts/entrypoint-lsphp.sh), or give the" >& 2
2026-08-05 16:03:08 -07:00
echo " reader the file directly. Neither is a pipeline, so there is no second" >& 2
echo " exit status for pipefail to prefer. A here-string also works, but it" >& 2
echo " spills to a temp file above a build-dependent size, so it is not the" >& 2
echo " right shape on a boot path." >& 2
fi
## ---------------------------------------------------------------------------
## 6. Equivalence. The helpers answer with `[[ ]]` and `${…}` what they used to
## answer with grep and awk, and "same patterns, different plumbing" is a
## claim that has to be checked rather than asserted — an anchored line match
## re-expressed as a glob is exactly where an off-by-one lives.
##
## The reference runs the ORIGINAL grep/awk patterns over a FILE, so the
## reference itself cannot SIGPIPE and cannot be accused of the bug it is
## refereeing. The file is written the way the old pipeline fed them,
## `printf '%s\n' "$SUBJECT"`, so the comparison is against the pre-fix
## behaviour byte for byte and not against a tidier reading of it.
## ---------------------------------------------------------------------------
echo "== pure-bash matching vs the grep/awk patterns it replaced =="
if [ " $HAVE_HELPERS " = no ] ; then
echo " (skipped — no probe helpers to compare)"
else
# shellcheck disable=SC1091
source " $TMP /helpers.sh"
NL = $'\n'
eq_fail = 0
eq_case() { # $1 = label, $2 = subject
local label = " $1 " subj = " $2 " f = " $TMP /eq.txt"
local ref_has ref_use new_has new_use ref_scan new_scan
printf '%s\n' " $subj " > " $f "
ref_has = 0; grep -q '^cac_path_parity support => enabled$' " $f " || ref_has = $?
new_has = 0; lsphp_info_has_parity_ext " $subj " || new_has = $?
ref_use = 0; grep -q '^PHP Version => ' " $f " || ref_use = $?
new_use = 0; lsphp_info_is_usable " $subj " || new_use = $?
ref_scan = $( awk -F'=> ' '/^Scan this dir/ {print $2; exit}' " $f " )
new_scan = $( lsphp_info_scan_dir " $subj " )
if [ " $ref_has " = " $new_has " ] && [ " $ref_use " = " $new_use " ] && [ " $ref_scan " = " $new_scan " ] ; then
ok "equivalent on: $label "
else
eq_fail = $(( eq_fail+1))
bad "NOT equivalent on: $label — has_ext grep= $ref_has bash= $new_has ; usable grep= $ref_use bash= $new_use ; scan_dir awk=' $ref_scan ' bash=' $new_scan '"
fi
}
SD = 'Scan this dir for additional .ini files'
PARITY = 'cac_path_parity support => enabled'
eq_case "empty subject" ""
eq_case "match on the first line" " ${ PARITY }${ NL } tail line"
eq_case "match on the last line" "head line ${ NL }${ PARITY } "
eq_case "match is the only line" " ${ PARITY } "
eq_case "match sandwiched" "a ${ NL }${ PARITY }${ NL } b"
eq_case "not at line start (decoy)" "x ${ PARITY }${ NL } b"
eq_case "trailing space defeats the \$" " ${ PARITY } ${ NL } b"
eq_case "prefix-only line (decoy)" "cac_path_parity support => enabled but no ${ NL } b"
eq_case "genuinely absent" "a ${ NL } b ${ NL } c"
eq_case "embedded blank lines" "a ${ NL }${ NL }${ PARITY }${ NL }${ NL } b"
eq_case "banner first, scan dir second" "PHP Version => 8.3.27 ${ NL }${ SD } => /a/b"
eq_case "scan dir on the first line" " ${ SD } => /a/b ${ NL } PHP Version => 8.3.27"
eq_case "scan dir on the last line" "PHP Version => 8.3.27 ${ NL }${ SD } => /a/b"
eq_case "scan dir, second separator" " ${ SD } => /a => /b ${ NL } x"
eq_case "scan dir, empty value" " ${ SD } => ${ NL } x"
eq_case "scan dir, no separator" "Scan this dir is broken ${ NL } x"
eq_case "scan dir, first of two wins" " ${ SD } => /first ${ NL }${ SD } => /second"
eq_case "scan dir, value has spaces" " ${ SD } => /a b/c ${ NL } x"
eq_case "scan-dir line is a prefix" "Scan this directory => /a/b ${ NL } x"
eq_case "banner not at line start" "x PHP Version => 8.3.27 ${ NL } b"
eq_case "banner without trailing space" "PHP Version => ${ NL } b"
eq_case "CR-terminated lines" $'PHP Version => 8.3.27\r' " ${ NL }${ PARITY } " $'\r'
eq_case "glob metacharacters in body" "* ${ NL } ? ${ NL } [a-z] ${ NL }${ PARITY }${ NL } ]["
eq_case "realistic 40 KB body" " $( cat " $TMP /info-present.txt" ) "
[ " $eq_fail " -eq 0 ] || echo " (an inequivalence here means the shipped probe now answers something the old grep/awk did not)" >& 2
fi
## ---------------------------------------------------------------------------
## 7. The scan's own coverage. Section 5 only proves something if its regexes
## actually match the shapes it claims to outlaw — an unfalsified scanner
## reports "clean" just as loudly when it has stopped matching anything.
## `grep -l foo` and `sed q` were both silently missed until this section
## existed; both are asserted below, alongside the safe forms that must NOT
## be reported, because a scanner that flags everything is no better.
## ---------------------------------------------------------------------------
echo "== the structural scan catches what it claims to =="
cat > " $TMP /scan-bad.sh" <<'BADSH'
set -euo pipefail
a() { producer | grep -q needle; }
b() { producer | grep -qx needle; }
c() { producer | grep -m1 needle; }
d() { producer | grep -im1 needle; }
e() { producer | grep -l foo; }
f() { producer | grep -L foo; }
g() { producer | grep --quiet foo; }
h() { producer | grep --files-with-matches foo; }
i() { producer | head -1; }
j() { producer | head; }
k() { producer | read -r x; }
l() { producer | while read -r x; do :; done; }
m() { producer | awk '/x/ {print; exit}'; }
n() { producer | sed q; }
o() { producer | sed 'q'; }
p() { producer | sed 2q; }
q() { producer | sed -n '1p;q'; }
r() { producer | sed -n '/x/{p;q}'; }
s() { producer | sed '$q'; }
BADSH
cat > " $TMP /scan-good.sh" <<'GOODSH'
set -euo pipefail
a() { producer | grep -c needle; }
b() { producer | grep -i needle; }
c() { producer | grep -v needle; }
d() { producer | grep -o needle; }
e() { producer | awk '{print $1}'; }
f() { producer | sed -n 's/^K=//p'; }
g() { producer | sed 's/a/q/'; }
h() { producer | sed -e 'y/abc/xqz/'; }
i() { producer | wc -l; }
j() { producer | sort -u; }
k() { producer | tail -1; }
l() { case $x in a|b) : ;; esac; }
m() { echo "a | head -1"; }
n() { echo 'x | grep -q y'; }
o() { grep -q needle <<<"$1"; }
p() { grep -q needle "$file"; }
# q() { producer | grep -q commented-out; }
r() { producer | grep -eq foo; }
GOODSH
missed = "" ; falsely = "" ; hits_bad = ""
while IFS = read -r line; do
fn = ${ line #*: } ; fn = ${ fn %%(* }
hits_bad = " $hits_bad $fn "
done < <( awk -f " $TMP /scan.awk" " $TMP /scan-bad.sh" )
for want in a b c d e f g h i j k l m n o p q r s; do
case " ${ hits_bad :- } " in *" $want " *) ;; *) missed = " $missed $want " ;; esac
done
mapfile -t good_hits < <( awk -f " $TMP /scan.awk" " $TMP /scan-good.sh" )
if [ -z " $missed " ] ; then
ok "all 19 early-exit reader shapes are reported (incl. grep -l/-L/--quiet and bare 'sed q')"
else
bad "the scan misses these shapes in scan-bad.sh: $missed "
awk -f " $TMP /scan.awk" " $TMP /scan-bad.sh" >& 2
fi
if [ " ${# good_hits [@] } " -eq 0 ] ; then
ok "18 read-to-EOF / quoted / non-pipeline forms are not reported"
else
falsely = $( printf '%s; ' " ${ good_hits [@] } " )
bad "the scan false-positives on: $falsely "
2026-08-05 15:23:56 -07:00
fi
echo
echo "passed: $pass failed: $fail "
[ " $fail " -eq 0 ] || exit 1
exit 0