diff --git a/ext/cac-path-parity/tests/001-rewrite.phpt b/ext/cac-path-parity/tests/001-rewrite.phpt index cf38688..e7f641c 100644 --- a/ext/cac-path-parity/tests/001-rewrite.phpt +++ b/ext/cac-path-parity/tests/001-rewrite.phpt @@ -15,7 +15,7 @@ HTTP_HOST=site.com // 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 // 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']); // Non-path vars must be untouched. var_dump($_SERVER['HTTP_HOST']); diff --git a/ext/cac-path-parity/tests/fpm-parity-check.sh b/ext/cac-path-parity/tests/fpm-parity-check.sh index cde05ec..ae27361 100755 --- a/ext/cac-path-parity/tests/fpm-parity-check.sh +++ b/ext/cac-path-parity/tests/fpm-parity-check.sh @@ -23,20 +23,56 @@ ## 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:-$(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}" PORT="${PORT:-9001}" 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; } +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" USER_NAME=bob SITE="$ROOT/$USER_NAME/site.com" @@ -45,7 +81,29 @@ 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' @@ -72,17 +130,27 @@ foreach (array('DOCUMENT_ROOT', 'SCRIPT_FILENAME') as $k) { } PHP -cat > "$TMP/fpm.conf" < "$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="" @@ -92,9 +160,26 @@ run_case() { 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:-}/${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() { @@ -116,24 +201,24 @@ 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[@]}") +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[@]}") +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[@]}") -expect "DOCUMENT_ROOT still corrected" "$(field "$out" DOCUMENT_ROOT)" "$HOME_PATH/public_html" +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") +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"