fix(cac-path-parity): make the FPM proof harness actually runnable as shipped

The artifact cited as the web-SAPI evidence could not have been run as it stood.
Measured in an official php:8.3-fpm container with the extension built in place:

  - as shipped, no args:            "SKIP: php-fpm not found", exit 0.
    The default was `php-fpm8.3`, which matches neither the official images
    (`php-fpm`) nor this repo's images.
  - with the binary supplied by hand: 9 FAIL, every one with an empty `got:`.
    The generated pool had no user/group, so php-fpm refused to start as root
    ("please specify user and group other than root"). A startup failure was
    wearing the costume of nine parity bugs.

Changes:
  - auto-detect the binary (php-fpm, php-fpm8.N, /usr/local/sbin, /usr/sbin) and
    print which one was chosen plus its version;
  - pre-flight the extension with `php-fpm -m`, so a .so that will not load into
    THIS php-fpm reports as a harness failure naming the ABI mismatch rather
    than as nine wrong paths;
  - emit user/group in the pool when running as root, resolved from accounts
    that actually exist (www-data / nobody / daemon), and chmod the fixture tmpdir
    so the non-root worker can read it;
  - run_case() now returns non-zero when php-fpm never answered, and every call
    site routes that to die_startup(), which prints the php-fpm output and the
    pool error_log and exits 2 — an exit code deliberately distinct from 1
    (assertion failure).

After: 9/9 ALL PASS from a clean checkout with no arguments and no environment
fixing, running as root in php:8.3-fpm. Mutation-tested both new paths: a pool
user that does not exist reports "HARNESS FAILURE ... STARTUP/environment
failure" with the real php-fpm error and exit 2; an EXT_SO that is not a loadable
extension is caught by the pre-flight, also exit 2.

Also fixes doc drift: 001-rewrite.phpt pointed at tests/web-sapi-parity-check.sh,
which has never existed. The file it means is tests/fpm-parity-check.sh.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-05 13:06:10 -07:00
co-authored by Claude Opus 5
parent 690ff8738d
commit fb4946641a
2 changed files with 102 additions and 17 deletions
+1 -1
View File
@@ -15,7 +15,7 @@ HTTP_HOST=site.com
// PATH_TRANSLATED (to the script path) AFTER the env import, so those three // PATH_TRANSLATED (to the script path) AFTER the env import, so those three
// cannot be driven from --ENV-- here. They go through the identical code path // cannot be driven from --ENV-- here. They go through the identical code path
// as CONTEXT_DOCUMENT_ROOT (one loop over one key table); the real web-SAPI // as CONTEXT_DOCUMENT_ROOT (one loop over one key table); the real web-SAPI
// proof for them is tests/web-sapi-parity-check.sh. // proof for them is tests/fpm-parity-check.sh.
var_dump($_SERVER['CONTEXT_DOCUMENT_ROOT']); var_dump($_SERVER['CONTEXT_DOCUMENT_ROOT']);
// Non-path vars must be untouched. // Non-path vars must be untouched.
var_dump($_SERVER['HTTP_HOST']); var_dump($_SERVER['HTTP_HOST']);
+100 -15
View File
@@ -23,20 +23,56 @@
## same customer .user.ini, does NOT run. This is the evidence ## same customer .user.ini, does NOT run. This is the evidence
## that hardening the prepend hook could not have worked. ## 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] ## 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). ## 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 set -uo pipefail
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" 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}" ROOT="${1:-/mnt/users}"
FPM_BIN="${2:-$(command -v php-fpm8.3 || echo /usr/sbin/php-fpm8.3)}" FPM_BIN="${2:-$(find_fpm || true)}"
EXT_SO="${3:-$HERE/../modules/cac_path_parity.so}" EXT_SO="${3:-$HERE/../modules/cac_path_parity.so}"
PORT="${PORT:-9001}" PORT="${PORT:-9001}"
command -v cgi-fcgi >/dev/null || { echo "SKIP: cgi-fcgi not installed (apt install libfcgi-bin)"; exit 0; } command -v cgi-fcgi >/dev/null || { echo "SKIP: cgi-fcgi not installed (apt install libfcgi-bin)"; exit 0; }
[ -x "$FPM_BIN" ] || { echo "SKIP: php-fpm not found"; 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; } [ -f "$EXT_SO" ] || { echo "SKIP: $EXT_SO not built (run phpize && ./configure && make)"; exit 0; }
echo "php-fpm: $FPM_BIN ($("$FPM_BIN" -n -v 2>/dev/null | head -1))"
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.
if ! "$FPM_BIN" -n -d "extension=$EXT_SO" -m 2>/dev/null | grep -qx 'cac_path_parity'; 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" mkdir -p "$ROOT" 2>/dev/null || ROOT="$(mktemp -d)/mnt/users"
USER_NAME=bob USER_NAME=bob
SITE="$ROOT/$USER_NAME/site.com" SITE="$ROOT/$USER_NAME/site.com"
@@ -45,7 +81,29 @@ HOME_PATH="/home/$USER_NAME"
TMP="$(mktemp -d)" TMP="$(mktemp -d)"
fail=0 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; } 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 trap 'rm -rf "$TMP"; rm -f "$DOCROOT/.user.ini"' EXIT
cat > "$DOCROOT/probe.php" <<'PHP' cat > "$DOCROOT/probe.php" <<'PHP'
@@ -72,17 +130,27 @@ foreach (array('DOCUMENT_ROOT', 'SCRIPT_FILENAME') as $k) {
} }
PHP PHP
cat > "$TMP/fpm.conf" <<EOF {
[global] echo "[global]"
error_log = $TMP/fpm-error.log echo "error_log = $TMP/fpm-error.log"
daemonize = no echo "daemonize = no"
[www] echo "[www]"
listen = 127.0.0.1:$PORT echo "listen = 127.0.0.1:$PORT"
pm = static echo "pm = static"
pm.max_children = 2 echo "pm.max_children = 2"
EOF ## 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() { run_case() {
: > "$TMP/fpm.out"
"$FPM_BIN" -n -y "$TMP/fpm.conf" -F -d user_ini.cache_ttl=0 "$@" \ "$FPM_BIN" -n -y "$TMP/fpm.conf" -F -d user_ini.cache_ttl=0 "$@" \
>"$TMP/fpm.out" 2>&1 & >"$TMP/fpm.out" 2>&1 &
local pid=$! out="" local pid=$! out=""
@@ -92,9 +160,26 @@ run_case() {
SCRIPT_NAME=/probe.php REQUEST_METHOD=GET QUERY_STRING= \ SCRIPT_NAME=/probe.php REQUEST_METHOD=GET QUERY_STRING= \
cgi-fcgi -bind -connect "127.0.0.1:$PORT" 2>/dev/null) cgi-fcgi -bind -connect "127.0.0.1:$PORT" 2>/dev/null)
[ -n "$out" ] && break [ -n "$out" ] && break
## Master already gone => it will never answer; stop waiting 6s for it.
kill -0 "$pid" 2>/dev/null || break
done done
kill "$pid" 2>/dev/null; wait "$pid" 2>/dev/null kill "$pid" 2>/dev/null; wait "$pid" 2>/dev/null
printf '%s' "$out" 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() { expect() {
@@ -116,24 +201,24 @@ USERINI_LINE="auto_prepend_file = $SITE/customer-waf.php"
echo "== 1. CONTROL: extension loaded, no mapping (reproduces the bug) ==" echo "== 1. CONTROL: extension loaded, no mapping (reproduces the bug) =="
rm -f "$DOCROOT/.user.ini" rm -f "$DOCROOT/.user.ini"
out=$(run_case "${EXT[@]}") out=$(run_case "${EXT[@]}") || die_startup "1. CONTROL"
expect "DOCUMENT_ROOT is the raw OLS path" "$(field "$out" DOCUMENT_ROOT)" "$DOCROOT" 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" expect "SCRIPT_FILENAME is the raw OLS path" "$(field "$out" SCRIPT_FILENAME)" "$DOCROOT/probe.php"
echo "== 2. FIX: mapping configured ==" echo "== 2. FIX: mapping configured =="
out=$(run_case "${EXT[@]}" "${MAP[@]}") out=$(run_case "${EXT[@]}" "${MAP[@]}") || die_startup "2. FIX"
expect "DOCUMENT_ROOT == cac-fpm value" "$(field "$out" DOCUMENT_ROOT)" "$HOME_PATH/public_html" 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" 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 ==" echo "== 3. WORDFENCE: customer .user.ini auto_prepend_file present =="
printf '%s\n' "$USERINI_LINE" > "$DOCROOT/.user.ini" printf '%s\n' "$USERINI_LINE" > "$DOCROOT/.user.ini"
out=$(run_case "${EXT[@]}" "${MAP[@]}") out=$(run_case "${EXT[@]}" "${MAP[@]}") || die_startup "3. WORDFENCE"
expect "DOCUMENT_ROOT still corrected" "$(field "$out" DOCUMENT_ROOT)" "$HOME_PATH/public_html" 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 "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" expect "customer auto_prepend_file still ran" "$(field "$out" PREPEND_RAN)" "yes"
echo "== 4. OLD MECHANISM (why the prepend hook could not be hardened) ==" echo "== 4. OLD MECHANISM (why the prepend hook could not be hardened) =="
out=$(run_case -d "auto_prepend_file=$TMP/old-normalize.php") 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" \ expect "auto_prepend normaliser is displaced by the customer's .user.ini" \
"$(field "$out" DOCUMENT_ROOT)" "$DOCROOT" "$(field "$out" DOCUMENT_ROOT)" "$DOCROOT"
expect "customer's prepend is the one that ran" "$(field "$out" PREPEND_RAN)" "yes" expect "customer's prepend is the one that ran" "$(field "$out" PREPEND_RAN)" "yes"