Files
cloud-apache-container/ext/cac-path-parity/tests/fpm-parity-check.sh
T
shadowdaoandClaude Opus 5 e28c3dcce4 fix(lsphp): correct the SIGPIPE explanation, drop the temp-dir boot precondition, close two scanner blind spots
Three non-blocking findings from the review of 8790b02. The fix itself is
unchanged in intent; this makes the reasoning around it true, removes a
regression the fix introduced on the boot path, and stops the new test from
under-reporting.

F1 — the shipped comments explained the bug wrongly, and a wrong rule is what
the next maintainer reasons from. entrypoint-lsphp.sh and
render-shared-ols-config.sh both said the race is decided by PIPE CAPACITY:
"while the output fits the pipe the writer always wins; once it doesn't, SIGPIPE
is guaranteed." Both halves are refuted by measurement against a default
65536-byte pipe (F_GETPIPE_SZ):

    41144 bytes -> 141 in  32/300 runs (11%)  — well UNDER capacity
    65012 bytes -> 141 in  25/30  runs        — not certain even AT capacity
    500 KB into a 1 MiB pipe -> 200/200 with 4096-byte writes, 0/200 with one
                                500 KB write

and strace caught printf dying having written 12086 of 40406 bytes into a
65536-byte pipe. The mechanism is a race on whether the reader closes before the
writer's final write() returns; capacity only modulates how many syscalls the
writer needs. What actually separated whp02 (5/5 failures) from a dev container
(10/10 clean) is the WRITER's syscall size: bash <= 5.2.15 writes ~37 KB at a
time, bash >= 5.2.21 writes 80-160 bytes. The fs.pipe-user-pages-soft aside was
also wrong: it clamps to two pages not one, needs one uid holding >1024 pipes,
and is skipped for CAP_SYS_RESOURCE.

Both blocks now state the rule that is actually true — any
`writer | early-exiting-reader` under pipefail is a latent 141; payload size is
not a safety argument; the only sound reasons a call site is safe are structural
(no pipefail, reader provably reads to EOF, or the status is discarded) — and
the same correction is applied to the three other comments that leaned on size
(`ols_running` x2, fpm-parity-check.sh's pre-flight). Nor is the reader's
implementation a defence: at 248 KB, mawk, gawk, `grep -q` and `head -1` all
gave 141 on 10/10, and these images already differ (mawk 1.3.4 vs gawk 5.2.1).
Comment-only; the test file's own section-4 output no longer contradicts the
prose next to it.

F2 — `<<<` added a writable-temp-dir precondition to the boot path. Above a
build-dependent size bash materialises a here-string as /tmp/sh-thd.XXXXXX
(measured switch: 65536 in this image's bash 5.2.21, and Debian's 5.2.15
switches between 4096 and 16384, where a ~40 KB `lsphp -i` WOULD spill). On a
bare assignment a temp file it cannot create is `set -e` killing PID 1 — the
exact failure this branch exists to remove, re-acquired from a different
direction and gated on which bash the base image ships. In cac-lsphp:f1f2f3
under `docker run --read-only`, same payload, same statement shape:

    OLD (here-string) : bash: cannot create temp file for here-document
                        -> exit 1, script dead
    NEW (pure bash)   : REACHED NEXT STATEMENT, SCAN=[…/mods-available/], exit 0

So the boot-critical sites — the three probe helpers in entrypoint-lsphp.sh and
the SCAN_DIR extraction in entrypoint-litespeed.sh — now match with `[[ ]]` and
parameter expansion, which allocate nothing. The non-boot sites keep their
here-strings and say why at the call site: `ols_running` in both OLS entrypoints
(`lswsctrl status` is under 100 bytes, orders below any spill threshold) and
fpm-parity-check.sh's `php-fpm -m` pre-flight (~1 KB, in a harness that has
already written a docroot and a pool config).

Matching semantics are preserved, not approximated: the anchored whole-line
grep becomes a glob over a subject wrapped in newlines at BOTH ends (so first
and unterminated-last lines still match), and awk's `-F'=> ' {print $2; exit}`
becomes first-matching-line then the text between the FIRST and SECOND
separator. Section 6 of the test asserts that against the original grep/awk
patterns reading a FILE — 24 cases incl. trailing-space, prefix decoys, CRLF,
a second separator, an empty value, two candidate lines, glob metacharacters in
the body, and the full 40 KB fixture. Mutations verify the assertions bite:
dropping the trailing-newline wrap fails 3 cases, taking the whole rest of the
line fails "second separator", `##` instead of `#` fails "first of two wins",
dropping the `^` anchor on the banner fails "banner not at line start".

F3 — the structural scan missed shapes it implied it caught, and the extractor
was unbounded.

  * `grep -l`/`-L`/`--quiet`/`--files-with-matches`, `-im1`-style clusters, a
    bare `head` before `;`, and `sed q` / `sed 'q'` / `sed 2q` / `sed '$q'` were
    all invisible. grep is now walked option by option the way grep reads them
    (so `grep -eq foo` stays the pattern "q", not --quiet), and the sed test
    reads the script with quote characters stripped but their contents kept.
    Replaying the old regexes against the new fixtures: 10 shapes missed and 2
    false positives (`sed s/a/q/`, `grep -eq foo`) — both now correct.
  * new section 7 pins that coverage from both sides: 19 early-exit shapes must
    be reported, 18 read-to-EOF / quoted / non-pipeline forms must not. Without
    it the scan's regexes are unfalsified and can quietly stop matching, which
    is precisely how `grep -l` and `sed q` stayed missing.
  * the helper extraction is bounded. It buffers and emits nothing until it has
    seen the END marker (exit 4 = BEGIN without END, exit 3 = no markers), so a
    half-deleted pair is a marker error instead of a slurp. Measured on this
    entrypoint with the END marker removed: the old extractor produced 301 lines
    including `mkdir -p "$SCAN_DIR"` and three `rm -f "$SCAN_DIR/…"` — which the
    harness then sourced and ran. It failed loudly last time only because `set
    -u` happened to trip two statements in. The new one emits 0 bytes and says
    what is wrong.
  * the stated scope limits now include what remains: the reader list is an
    enumeration, not a proof (nothing knows about `perl -ne … last`, `jq`,
    `head -c`), and only the first word after a pipe is inspected.

Verified: PHP 8.3 `--no-cache` build exit 0, 10/10 .phpt; cac-lsphp boots and
logs `path parity = extension` with `Rewriting => active` and .from/.to
populated from the rendered ini; cac-litespeed boots, resolves SCAN_DIR and
writes 99-user-error-log.ini, OLS reports "running with PID", /healthz 200. The
FPM parity harness — never executed by the previous review because no cac-fpm
image existed locally — was built (Dockerfile.fpm, PHPVER=83), the extension
compiled inside it, and it reports 9/9 ALL PASS, exit 0. The new test exits 0
here and exits 1 against a `git archive 9343a56` export naming all 9 offending
lines. `bash -n` clean repo-wide; `shellcheck -S warning` clean on the CI set;
`-S style` is byte-identical to before this commit (5 pre-existing info-level
findings, 0 added — the earlier report's claim of `-S style` clean was wrong).
No `.c`/`.h` file touched and the C fail-open invariant grep is still empty.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 16:03:08 -07:00

257 lines
11 KiB
Bash
Executable File

#!/usr/bin/env bash
## fpm-parity-check.sh — end-to-end proof under a REAL web SAPI.
##
## WHY NOT .phpt: the CLI SAPI overwrites DOCUMENT_ROOT / SCRIPT_FILENAME /
## PATH_TRANSLATED after importing the environment, and the cli-server SAPI does
## not process .user.ini at all — so neither can exercise the two things that
## actually matter here.
##
## WHY PHP-FPM: php-fpm takes DOCUMENT_ROOT and SCRIPT_FILENAME as caller-
## supplied FastCGI params and honours .user.ini — structurally the same shape as
## OpenLiteSpeed handing a detached lsphp its LSAPI params. It is the closest
## analogue available without an OLS runtime.
##
## Asserts:
## 1. CONTROL — no mapping => PHP reports the raw /mnt/users paths, i.e. the
## test reproduces the bug before claiming to fix it.
## 2. FIX — mapping => both keys read /home/<user>/... .
## 3. WORDFENCE — mapping AND a customer .user.ini auto_prepend_file (the state
## 7 live shared_ols sites are in): paths are STILL corrected
## AND the customer's prepend STILL runs. This is the case the
## old auto_prepend_file normaliser silently lost.
## 4. OLD — for the record: the previous auto_prepend mechanism, with the
## same customer .user.ini, does NOT run. This is the evidence
## that hardening the prepend hook could not have worked.
##
## Exit codes: 0 = all assertions passed, 1 = an assertion FAILED, 2 = the
## harness could not run (missing binary, php-fpm refused to start, .so would not
## load). 2 is deliberately distinct from 1: a startup problem previously
## surfaced as all nine assertions failing with an empty `got:`, which reads like
## nine parity bugs and is the opposite of the truth.
##
## Usage: ./fpm-parity-check.sh [ROOT] [PHP_FPM_BIN] [EXT_SO]
## ROOT defaults to /mnt/users (falls back to a temp dir if not creatable).
## PHP_FPM_BIN is auto-detected; every packaging of php-fpm this repo touches
## uses a different name (`php-fpm` in the official docker images,
## `php-fpm8.N` on Debian/Ubuntu, /usr/sbin/... unlinked from PATH), so a
## single hardcoded default is guaranteed to be wrong somewhere and its only
## symptom was a silent `SKIP`.
set -uo pipefail
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
find_fpm() {
local c
for c in php-fpm php-fpm8.5 php-fpm8.4 php-fpm8.3 php-fpm8.2 php-fpm8.1; do
if command -v "$c" >/dev/null 2>&1; then command -v "$c"; return 0; fi
done
for c in /usr/local/sbin/php-fpm /usr/sbin/php-fpm /usr/sbin/php-fpm8.*; do
if [ -x "$c" ]; then echo "$c"; return 0; fi
done
return 1
}
ROOT="${1:-/mnt/users}"
FPM_BIN="${2:-$(find_fpm || true)}"
EXT_SO="${3:-$HERE/../modules/cac_path_parity.so}"
PORT="${PORT:-9001}"
command -v cgi-fcgi >/dev/null || { echo "SKIP: cgi-fcgi not installed (apt install libfcgi-bin)"; exit 0; }
[ -n "$FPM_BIN" ] && [ -x "$FPM_BIN" ] || { echo "SKIP: php-fpm not found (pass it as \$2)"; exit 0; }
[ -f "$EXT_SO" ] || { echo "SKIP: $EXT_SO not built (run phpize && ./configure && make)"; exit 0; }
## `${VAR%%$'\n'*}` rather than `| head -1`: same first line, no pipeline, so
## nothing here can be decided by a SIGPIPE race under the pipefail on line 39.
## This one only ever fed an echo, so it could not have misled anyone — it is
## changed so that "no pipefail script in this repo pipes into an early-exit
## reader" stays a rule with no exceptions to remember.
FPM_VERSION=$("$FPM_BIN" -n -v 2>/dev/null || true)
echo "php-fpm: $FPM_BIN (${FPM_VERSION%%$'\n'*})"
echo "extension: $EXT_SO"
## Pre-flight. If the .so will not load into THIS php-fpm (PHP API mismatch is
## the usual cause) every assertion below would fail identically and blame the
## extension's logic. Say what actually happened instead.
## Captured into a variable and matched with a here-string, not piped into
## `grep -qx`. `grep -q` exits on its first match, and with `set -o pipefail`
## (line 39) a writer still writing at that moment dies 141 and the pipeline
## reads FALSE — announcing "cannot load the extension" *because* the extension
## was listed. The reason to change it is structural, not that `php-fpm -m` is
## small: there is no payload size that makes this shape safe (41 KB SIGPIPEs
## about 11% of the time into a 64 KB pipe — see the note over the probe helpers
## in scripts/entrypoint-lsphp.sh), and this pre-flight exists precisely to stop
## a harness malfunction being reported as an extension fault, so it must not
## have one of its own. (The same construct on 40 KB of `lsphp -i` is what broke
## entrypoint-lsphp.sh in production.)
##
## A here-string, not the `[[ ]]` form those helpers use, on purpose: `<<<`
## spills to /tmp/sh-thd.XXXXXX above ~4-64 KB depending on the bash build, so
## it is a writable-temp-dir precondition, which is unacceptable on a boot path
## and irrelevant here — `php-fpm -m` is ~1 KB, and this harness has already
## created a docroot and a pool config by the time it runs.
FPM_MODULES=$("$FPM_BIN" -n -d "extension=$EXT_SO" -m 2>/dev/null || true)
if ! grep -qx 'cac_path_parity' <<<"$FPM_MODULES"; then
echo "HARNESS FAILURE: $FPM_BIN cannot load $EXT_SO" >&2
"$FPM_BIN" -n -d "extension=$EXT_SO" -m 2>&1 | grep -i 'unable\|warning\|error' >&2
echo " The .so must be built against the same PHP as this php-fpm binary." >&2
exit 2
fi
mkdir -p "$ROOT" 2>/dev/null || ROOT="$(mktemp -d)/mnt/users"
USER_NAME=bob
SITE="$ROOT/$USER_NAME/site.com"
DOCROOT="$SITE/public_html"
HOME_PATH="/home/$USER_NAME"
TMP="$(mktemp -d)"
fail=0
## php-fpm REFUSES to start as root unless the pool names a non-root user/group,
## and the pool this script generates had neither — so as shipped it never got
## past startup in any root context (which is every container in this repo).
## Resolve a real unprivileged account rather than assuming www-data exists.
POOL_USER=""
POOL_GROUP=""
if [ "$(id -u)" -eq 0 ]; then
for u in www-data nobody daemon; do
if id -u "$u" >/dev/null 2>&1; then POOL_USER="$u"; break; fi
done
for g in www-data nogroup nobody daemon; do
if getent group "$g" >/dev/null 2>&1; then POOL_GROUP="$g"; break; fi
done
[ -n "$POOL_USER" ] && [ -n "$POOL_GROUP" ] || {
echo "HARNESS FAILURE: running as root but found no unprivileged user/group for the pool" >&2
exit 2
}
fi
mkdir -p "$DOCROOT" || { echo "cannot create $DOCROOT"; exit 1; }
## The pool worker is not root: it has to be able to read the fixtures under
## $TMP (mktemp -d is 0700) and walk down to $DOCROOT.
chmod 755 "$TMP"
trap 'rm -rf "$TMP"; rm -f "$DOCROOT/.user.ini"' EXIT
cat > "$DOCROOT/probe.php" <<'PHP'
<?php
echo "DOCUMENT_ROOT=" . $_SERVER['DOCUMENT_ROOT'] . "\n";
echo "SCRIPT_FILENAME=" . $_SERVER['SCRIPT_FILENAME'] . "\n";
echo "PREPEND_RAN=" . (defined('CUSTOMER_PREPEND_RAN') ? 'yes' : 'no') . "\n";
PHP
## Stand-in for the customer's wordfence-waf.php.
cat > "$SITE/customer-waf.php" <<'PHP'
<?php
define('CUSTOMER_PREPEND_RAN', 1);
PHP
## Stand-in for the OLD mechanism (scripts/cac-lsphp-normalize.php).
cat > "$TMP/old-normalize.php" <<'PHP'
<?php
foreach (array('DOCUMENT_ROOT', 'SCRIPT_FILENAME') as $k) {
if (!empty($_SERVER[$k]) && strncmp($_SERVER[$k], '/mnt/users/', 11) === 0) {
$r = realpath($_SERVER[$k]);
if ($r !== false) { $_SERVER[$k] = $r; }
}
}
PHP
{
echo "[global]"
echo "error_log = $TMP/fpm-error.log"
echo "daemonize = no"
echo "[www]"
echo "listen = 127.0.0.1:$PORT"
echo "pm = static"
echo "pm.max_children = 2"
## Only when we are root: php-fpm hard-errors on a root pool, and warns
## (harmlessly, but noisily) if a non-root master names a user at all.
if [ -n "$POOL_USER" ]; then
echo "user = $POOL_USER"
echo "group = $POOL_GROUP"
fi
} > "$TMP/fpm.conf"
## Returns non-zero when php-fpm never answered. Callers MUST distinguish that
## from an assertion failure — an unstarted php-fpm makes every expect() below
## fail with an empty `got:`, which looks like nine parity bugs.
run_case() {
: > "$TMP/fpm.out"
"$FPM_BIN" -n -y "$TMP/fpm.conf" -F -d user_ini.cache_ttl=0 "$@" \
>"$TMP/fpm.out" 2>&1 &
local pid=$! out=""
for _ in $(seq 1 40); do
sleep 0.15
## SC1007: `QUERY_STRING=` IS the intent — an empty FastCGI param in the
## per-command environment prefix, exactly as a webserver sends it for a
## URL with no query string. Not a truncated assignment.
# shellcheck disable=SC1007
out=$(SCRIPT_FILENAME="$DOCROOT/probe.php" DOCUMENT_ROOT="$DOCROOT" \
SCRIPT_NAME=/probe.php REQUEST_METHOD=GET QUERY_STRING= \
cgi-fcgi -bind -connect "127.0.0.1:$PORT" 2>/dev/null)
[ -n "$out" ] && break
## Master already gone => it will never answer; stop waiting 6s for it.
kill -0 "$pid" 2>/dev/null || break
done
kill "$pid" 2>/dev/null; wait "$pid" 2>/dev/null
printf '%s' "$out"
[ -n "$out" ]
}
die_startup() {
echo
echo "HARNESS FAILURE: php-fpm never answered for case '$1'." >&2
echo " This is a STARTUP/environment failure, NOT a parity assertion failure." >&2
echo " php-fpm: $FPM_BIN" >&2
echo " pool user/group: ${POOL_USER:-<none, master is not root>}/${POOL_GROUP:-}" >&2
echo " --- php-fpm output ---" >&2
sed 's/^/ /' "$TMP/fpm.out" >&2
echo " --- pool error_log ---" >&2
[ -s "$TMP/fpm-error.log" ] && sed 's/^/ /' "$TMP/fpm-error.log" >&2
echo " ----------------------" >&2
exit 2
}
expect() {
local label="$1" got="$2" want="$3"
if [ "$got" = "$want" ]; then
echo " PASS $label"
else
echo " FAIL $label"
echo " want: $want"
echo " got: $got"
fail=1
fi
}
field() { printf '%s' "$1" | sed -n "s/^$2=//p"; }
EXT=( -d "extension=$EXT_SO" )
MAP=( -d "cac_path_parity.from=$SITE" -d "cac_path_parity.to=$HOME_PATH" )
USERINI_LINE="auto_prepend_file = $SITE/customer-waf.php"
echo "== 1. CONTROL: extension loaded, no mapping (reproduces the bug) =="
rm -f "$DOCROOT/.user.ini"
out=$(run_case "${EXT[@]}") || die_startup "1. CONTROL"
expect "DOCUMENT_ROOT is the raw OLS path" "$(field "$out" DOCUMENT_ROOT)" "$DOCROOT"
expect "SCRIPT_FILENAME is the raw OLS path" "$(field "$out" SCRIPT_FILENAME)" "$DOCROOT/probe.php"
echo "== 2. FIX: mapping configured =="
out=$(run_case "${EXT[@]}" "${MAP[@]}") || die_startup "2. FIX"
expect "DOCUMENT_ROOT == cac-fpm value" "$(field "$out" DOCUMENT_ROOT)" "$HOME_PATH/public_html"
expect "SCRIPT_FILENAME == cac-fpm value" "$(field "$out" SCRIPT_FILENAME)" "$HOME_PATH/public_html/probe.php"
echo "== 3. WORDFENCE: customer .user.ini auto_prepend_file present =="
printf '%s\n' "$USERINI_LINE" > "$DOCROOT/.user.ini"
out=$(run_case "${EXT[@]}" "${MAP[@]}") || die_startup "3. WORDFENCE"
expect "DOCUMENT_ROOT still corrected" "$(field "$out" DOCUMENT_ROOT)" "$HOME_PATH/public_html"
expect "SCRIPT_FILENAME still corrected" "$(field "$out" SCRIPT_FILENAME)" "$HOME_PATH/public_html/probe.php"
expect "customer auto_prepend_file still ran" "$(field "$out" PREPEND_RAN)" "yes"
echo "== 4. OLD MECHANISM (why the prepend hook could not be hardened) =="
out=$(run_case -d "auto_prepend_file=$TMP/old-normalize.php") || die_startup "4. OLD MECHANISM"
expect "auto_prepend normaliser is displaced by the customer's .user.ini" \
"$(field "$out" DOCUMENT_ROOT)" "$DOCROOT"
expect "customer's prepend is the one that ran" "$(field "$out" PREPEND_RAN)" "yes"
rm -f "$DOCROOT/.user.ini"
if [ "$fail" -eq 0 ]; then echo "ALL PASS"; else echo "FAILURES"; fi
exit "$fail"