#!/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 ## 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. ## ## 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. ## 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. ## ## SCOPE LIMITS, stated rather than hidden. The structural scan is a text scan, ## so it under-reports in known ways: ## - 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. ## - 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. ## 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". ## ## 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 ## --------------------------------------------------------------------------- HAVE_HELPERS=yes 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 ## --------------------------------------------------------------------------- ## 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' # 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 } { 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 # `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) gsub(/\|\|/, " ", l) # || is not a pipeline if (l !~ /\|/) next n = split(l, seg, "|") for (i = 2; i <= n; i++) { r = seg[i] 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 } } } 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." echo " Read the value into a variable and match it in the shell (see" >&2 echo " lsphp_info_has_parity_ext in scripts/entrypoint-lsphp.sh), or give the" >&2 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" fi echo echo "passed: $pass failed: $fail" [ "$fail" -eq 0 ] || exit 1 exit 0