set -euo pipefail
die() { echo "ERROR: $*" >&2; exit 1; }
critical() {
echo "CRITICAL non-convergent: $*" >&2
echo "Rescue paths: PHP_INI=${PHP_INI:-unknown} TX_DIR=${TX_DIR:-unknown} BACKUP=${BACKUP:-unknown} STATE=${STATE:-unknown}" >&2
exit 1
}
hash_file() { sha256sum -- "$1" | awk '{print $1}'; }
[[ "$EUID" -eq 0 ]] || die 'Run the reviewed script as root'
for COMMAND in base64 busctl flock getent jq numfmt sha256sum systemctl; do command -v "$COMMAND" >/dev/null || die "Missing command: $COMMAND"; done
NGINX_UNIT='nginx.service'
NGINX_CONFIG='/REPLACE/exact/config-containing-fastcgi-pass.conf'
FASTCGI_PASS='unix:/REPLACE/exact/php-fpm.sock'
PHP_FPM_UNIT='REPLACE_EXACT_FPM_UNIT.service'
FPM_CONFIG='/REPLACE/exact/php-fpm.conf'
POOL_CONFIG='/REPLACE/exact/pool.conf'
PHP_INI='/REPLACE/exact/fpm/php.ini'
EXPECTED_UPLOAD='60M'
EXPECTED_POST='64M'
MARKER='; TERAMONT-PHP-413-LIMIT'
LOCK_DIR='/run/teramont-php-413'
LOCKFILE="${LOCK_DIR}/transaction.lock"
CONFIG_MANAGER_STATE='REPLACE_PAUSED_OR_NONE'
CGI_FCGI_BIN='/usr/bin/cgi-fcgi'
verify_lock_dir() {
[[ -d "$LOCK_DIR" && ! -L "$LOCK_DIR" ]] || return 1
[[ "$(stat -Lc '%u:%a' -- "$LOCK_DIR")" == '0:700' ]] || return 1
[[ -n "${LOCK_DIR_ID:-}" && "$(stat -Lc '%d:%i' -- "$LOCK_DIR")" == "$LOCK_DIR_ID" ]]
}
verify_lock() {
local FD_PATH="/proc/$$/fd/${LOCK_FD}" FD_ID PATH_ID FD_META PATH_META
verify_lock_dir || return 1
[[ -f "$FD_PATH" && -f "$LOCKFILE" && ! -L "$LOCKFILE" ]] || return 1
FD_ID="$(stat -Lc '%d:%i' -- "$FD_PATH")" || return 1; PATH_ID="$(stat -Lc '%d:%i' -- "$LOCKFILE")" || return 1
FD_META="$(stat -Lc '%u:%a:%h' -- "$FD_PATH")" || return 1; PATH_META="$(stat -Lc '%u:%a:%h' -- "$LOCKFILE")" || return 1
[[ "$FD_ID" == "$PATH_ID" && "$FD_META" == '0:600:1' && "$PATH_META" == "$FD_META" ]]
}
prepare_lock_dir() {
umask 077
if [[ -e "$LOCK_DIR" || -L "$LOCK_DIR" ]]; then
[[ -d "$LOCK_DIR" && ! -L "$LOCK_DIR" && "$(stat -Lc '%u:%a' -- "$LOCK_DIR")" == '0:700' ]] || die 'Unsafe dedicated FPM lock directory'
else
mkdir --mode=700 -- "$LOCK_DIR" || die 'Cannot create the dedicated FPM lock directory'
fi
[[ -d "$LOCK_DIR" && ! -L "$LOCK_DIR" && "$(stat -Lc '%u:%a' -- "$LOCK_DIR")" == '0:700' ]] || die 'Dedicated FPM lock directory failed validation'
LOCK_DIR_ID="$(stat -Lc '%d:%i' -- "$LOCK_DIR")" || die 'Cannot identify the dedicated FPM lock directory'
verify_lock_dir || die 'Dedicated FPM lock directory identity changed'
}
acquire_lock() {
local FD_PATH FD_ID PATH_ID FD_META PATH_META
prepare_lock_dir
if [[ -e "$LOCKFILE" || -L "$LOCKFILE" ]]; then
[[ -f "$LOCKFILE" && ! -L "$LOCKFILE" ]] || die 'Unsafe cooperative FPM lock path'
[[ "$(stat -Lc '%u:%h' -- "$LOCKFILE")" == '0:1' ]] || die 'Existing FPM lock must be root-owned with one link'
fi
exec {LOCK_FD}>>"$LOCKFILE" || die 'Cannot open the cooperative FPM lock without truncation'
FD_PATH="/proc/$$/fd/${LOCK_FD}"
[[ -f "$FD_PATH" && -f "$LOCKFILE" && ! -L "$LOCKFILE" ]] || die 'FPM lock type changed during open'
FD_ID="$(stat -Lc '%d:%i' -- "$FD_PATH")"; PATH_ID="$(stat -Lc '%d:%i' -- "$LOCKFILE")"
FD_META="$(stat -Lc '%u:%h' -- "$FD_PATH")"; PATH_META="$(stat -Lc '%u:%h' -- "$LOCKFILE")"
[[ "$FD_ID" == "$PATH_ID" && "$FD_META" == '0:1' && "$PATH_META" == "$FD_META" ]] || die 'FPM lock FD/path identity, owner, or link count is unsafe before chmod'
chmod 600 -- "$FD_PATH" || die 'Cannot set FPM lock mode through its open descriptor'
verify_lock || die 'FPM lock FD/path identity is unsafe after descriptor chmod'
flock --exclusive --nonblock "$LOCK_FD" || die 'Another cooperating FPM transaction holds the lock'
verify_lock || die 'FPM lock FD/path identity changed after flock'
}
lock_self_test() {
local TEST_ROOT TEST_DIR TEST_LOCK VICTIM BEFORE_HASH BEFORE_MODE AFTER_HASH AFTER_MODE
TEST_ROOT="$(mktemp -d /tmp/teramont-php-lock-test.XXXXXXXX)" || die 'Cannot create FPM lock self-test directory'
chmod 700 -- "$TEST_ROOT"; TEST_DIR="${TEST_ROOT}/lockdir"; TEST_LOCK="${TEST_DIR}/transaction.lock"; VICTIM="${TEST_ROOT}/victim"
mkdir --mode=700 -- "$TEST_DIR"; printf 'DO-NOT-CHANGE\n' >"$VICTIM"; chmod 640 -- "$VICTIM"
BEFORE_HASH="$(hash_file "$VICTIM")"; BEFORE_MODE="$(stat -Lc '%a' -- "$VICTIM")"; ln -s -- "$VICTIM" "$TEST_LOCK"
if (LOCK_DIR="$TEST_DIR"; LOCKFILE="$TEST_LOCK"; unset LOCK_DIR_ID LOCK_FD; acquire_lock) >/dev/null 2>&1; then die 'LOCK_SELF_TEST failed: a symlink lock was accepted'; fi
AFTER_HASH="$(hash_file "$VICTIM")"; AFTER_MODE="$(stat -Lc '%a' -- "$VICTIM")"
[[ "$AFTER_HASH" == "$BEFORE_HASH" && "$AFTER_MODE" == "$BEFORE_MODE" ]] || die 'LOCK_SELF_TEST failed: victim bytes or mode changed'
unlink -- "$TEST_LOCK"; unlink -- "$VICTIM"; rmdir -- "$TEST_DIR" "$TEST_ROOT"
printf 'LOCK_SELF_TEST=PASS script_aborted=1 victim_hash_before=%s victim_hash_after=%s victim_mode_before=%s victim_mode_after=%s\n' "$BEFORE_HASH" "$AFTER_HASH" "$BEFORE_MODE" "$AFTER_MODE"
}
if [[ "${1:-}" == '--lock-self-test' ]]; then lock_self_test; exit 0; fi
for FILE in "$NGINX_CONFIG" "$FPM_CONFIG" "$POOL_CONFIG" "$PHP_INI"; do
[[ "$FILE" == /* && "$FILE" != /REPLACE* && -f "$FILE" && ! -L "$FILE" ]] || die "Replace with an exact regular, non-symlink path: $FILE"
done
[[ "$FASTCGI_PASS" == unix:/* && "$FASTCGI_PASS" != unix:/REPLACE* ]] || die 'Replace FASTCGI_PASS with the effective Unix socket'
[[ "$PHP_FPM_UNIT" != REPLACE* && "$PHP_FPM_UNIT" =~ ^[a-zA-Z0-9_.@-]+\.service$ ]] || die 'Replace PHP_FPM_UNIT with one exact unit; never use a service glob'
case "$CONFIG_MANAGER_STATE" in PAUSED|NONE_CONFIRMED) ;; *) die 'Pause the config manager/deployer, or set NONE_CONFIRMED after proving none controls these files' ;; esac
[[ -x "$CGI_FCGI_BIN" ]] || die 'Install cgi-fcgi or stop and have an administrator design an equally restricted probe'
[[ "$EXPECTED_UPLOAD" =~ ^[1-9][0-9]*[KMG]$ && "$EXPECTED_POST" =~ ^[1-9][0-9]*[KMG]$ ]] || die 'Expected PHP limits must be finite shorthand values'
UPLOAD_BYTES="$(numfmt --from=iec "$EXPECTED_UPLOAD")"; POST_BYTES="$(numfmt --from=iec "$EXPECTED_POST")"
(( POST_BYTES > UPLOAD_BYTES )) || die 'EXPECTED_POST must exceed EXPECTED_UPLOAD'
NGINX_CONFIG="$(readlink -f -- "$NGINX_CONFIG")"; FPM_CONFIG="$(readlink -f -- "$FPM_CONFIG")"; POOL_CONFIG="$(readlink -f -- "$POOL_CONFIG")"; PHP_INI="$(readlink -f -- "$PHP_INI")"
FASTCGI_SOCKET="${FASTCGI_PASS#unix:}"
[[ "$FASTCGI_SOCKET" == /* && -S "$FASTCGI_SOCKET" && ! -L "$FASTCGI_SOCKET" ]] || die 'Effective FastCGI socket must exist as a non-symlink Unix socket'
get_unit_path() {
local UNIT="$1" REPLY
REPLY="$(busctl --json=short call org.freedesktop.systemd1 /org/freedesktop/systemd1 org.freedesktop.systemd1.Manager GetUnit s "$UNIT")" || die "D-Bus GetUnit failed for $UNIT"
UNIT_PATH="$(jq -er 'select(.type=="o") | if (.data|type)=="string" then .data elif ((.data|type)=="array" and (.data|length)==1 and (.data[0]|type)=="string") then .data[0] else error("ambiguous") end' <<<"$REPLY")" || die "Cannot decode unit path for $UNIT; stop and consult the administrator"
}
capture_nginx() {
local EXEC_PATH ARGV0 PROC_BIN
get_unit_path "$NGINX_UNIT"; NGINX_UNIT_PATH="$UNIT_PATH"
NGINX_EXEC_JSON="$(busctl --json=short get-property org.freedesktop.systemd1 "$NGINX_UNIT_PATH" org.freedesktop.systemd1.Service ExecStart)" || die 'Cannot read structured Nginx ExecStart'
jq -e '.type=="a(sasbttttuii)" and (.data|type)=="array" and (.data|length)==1 and (.data[0]|length)==10 and (.data[0][1]|type)=="array" and (.data[0][1]|length)>=1 and all(.data[0][1][];type=="string")' <<<"$NGINX_EXEC_JSON" >/dev/null || die 'Nginx ExecStart is ambiguous; stop and consult the administrator'
mapfile -d '' -t NGINX_ARGV < <(jq -j '.data[0][1][] | ., "\u0000"' <<<"$NGINX_EXEC_JSON")
EXEC_PATH="$(jq -er '.data[0][0]' <<<"$NGINX_EXEC_JSON")"; NGINX_BIN="$(readlink -f -- "$EXEC_PATH")"; ARGV0="$(readlink -f -- "${NGINX_ARGV[0]}")"
NGINX_PID="$(systemctl show --property=MainPID --value "$NGINX_UNIT")"; [[ "$NGINX_PID" =~ ^[1-9][0-9]*$ ]] || die 'Nginx MainPID is unavailable'
PROC_BIN="$(readlink -f -- "/proc/${NGINX_PID}/exe")"
[[ "$NGINX_BIN" == "$ARGV0" && "$NGINX_BIN" == "$PROC_BIN" ]] || die 'Nginx structured ExecStart and process binary disagree'
NGINX_CONTEXT=("${NGINX_ARGV[@]:1}")
for ARG in "${NGINX_CONTEXT[@]}"; do case "$ARG" in -s|-s*|-t|-T|-q|-v|-V|-h|-\?) die 'Nginx ExecStart contains an action flag unsafe to reuse' ;; esac; done
NGINX_EXEC_HASH="$(jq -cS . <<<"$NGINX_EXEC_JSON" | sha256sum | awk '{print $1}')"
NGINX_ARGV_HASH="$({ printf '%s\0' "${NGINX_ARGV[@]}"; } | sha256sum | awk '{print $1}')"
}
capture_nginx
NGINX_DUMP="$(mktemp --tmpdir=/root .nginx-php-link.XXXXXXXX)"; NGINX_SECTION="${NGINX_DUMP}.section"
"$NGINX_BIN" -T "${NGINX_CONTEXT[@]}" >"$NGINX_DUMP" || die 'Nginx -T failed with the full structured context'
NGINX_HEADER="# configuration file ${NGINX_CONFIG}:"
awk -v h="$NGINX_HEADER" '$0==h{seen++;active=1;next} /^# configuration file /&&active{active=0} active{print} END{if(seen!=1)exit 42}' "$NGINX_DUMP" >"$NGINX_SECTION" || die 'NGINX_CONFIG header is missing or ambiguous'
FASTCGI_COUNT="$(awk -v expected="fastcgi_pass ${FASTCGI_PASS};" '{v=$0;gsub(/^[[:space:]]+|[[:space:]]+$/,"",v);gsub(/[[:space:]]+/," ",v);if(v==expected)c++}END{print c+0}' "$NGINX_SECTION")"
[[ "$FASTCGI_COUNT" == 1 ]] || die 'Confirmed Nginx section must contain one normalized exact fastcgi_pass'
awk -v expected="fastcgi_pass ${FASTCGI_PASS};" '{v=$0;gsub(/^[[:space:]]+|[[:space:]]+$/,"",v);gsub(/[[:space:]]+/," ",v)} /^[[:space:]]*server[[:space:]]*\{/ {server=$0;location=""} /^[[:space:]]*location[[:space:]]/ {location=$0} v==expected {print "Server seen: " server;print "Location seen: " location;print "fastcgi_pass line: " NR ":" $0}' "$NGINX_SECTION"
echo 'Confirm manually that this is the location selected by the real upload URL; an exact match does not solve location precedence.' >&2
read -r -p 'Type CONFIRM-FASTCGI-PATH after tracing the request path: ' CONFIRM
[[ "$CONFIRM" == 'CONFIRM-FASTCGI-PATH' ]] || die 'FastCGI path was not confirmed'
read_pool_file() {
mapfile -t POOL_NAMES < <(awk '/^[[:space:]]*\[[^]]+\][[:space:]]*$/ {v=$0;gsub(/^[[:space:]]*\[|\][[:space:]]*$/,"",v);print v}' "$POOL_CONFIG")
mapfile -t POOL_LISTENS < <(awk -F= '/^[[:space:]]*(;|#)/{next} $1~/^[[:space:]]*listen[[:space:]]*$/{v=$2;gsub(/^[[:space:]]+|[[:space:]]+$/,"",v);print v}' "$POOL_CONFIG")
mapfile -t POOL_USERS < <(awk -F= '/^[[:space:]]*(;|#)/{next} $1~/^[[:space:]]*user[[:space:]]*$/{v=$2;gsub(/^[[:space:]]+|[[:space:]]+$/,"",v);print v}' "$POOL_CONFIG")
mapfile -t POOL_GROUPS < <(awk -F= '/^[[:space:]]*(;|#)/{next} $1~/^[[:space:]]*group[[:space:]]*$/{v=$2;gsub(/^[[:space:]]+|[[:space:]]+$/,"",v);print v}' "$POOL_CONFIG")
[[ ${#POOL_NAMES[@]} == 1 && ${#POOL_LISTENS[@]} == 1 && ${#POOL_USERS[@]} == 1 && ${#POOL_GROUPS[@]} == 1 ]] || die 'POOL_CONFIG must define one unambiguous pool/name/listen/user/group'
POOL_NAME="${POOL_NAMES[0]}"; POOL_USER="${POOL_USERS[0]}"; POOL_GROUP="${POOL_GROUPS[0]}"
[[ "${POOL_LISTENS[0]}" == "$FASTCGI_SOCKET" ]] || die 'POOL_CONFIG listen does not match effective fastcgi_pass'
id "$POOL_USER" >/dev/null || die 'Pool user does not exist'; getent group "$POOL_GROUP" >/dev/null || die 'Pool group does not exist'
}
read_pool_file
capture_fpm() {
local EXEC_PATH ARGV0 PROC_BIN MASTER_CGROUP
get_unit_path "$PHP_FPM_UNIT"; FPM_UNIT_PATH="$UNIT_PATH"
FPM_EXEC_JSON="$(busctl --json=short get-property org.freedesktop.systemd1 "$FPM_UNIT_PATH" org.freedesktop.systemd1.Service ExecStart)" || die 'Cannot read structured FPM ExecStart'
jq -e '.type=="a(sasbttttuii)" and (.data|type)=="array" and (.data|length)==1 and (.data[0]|length)==10 and (.data[0][1]|type)=="array" and (.data[0][1]|length)>=1 and all(.data[0][1][];type=="string")' <<<"$FPM_EXEC_JSON" >/dev/null || die 'FPM ExecStart is not one unambiguous structured command; stop and consult the administrator'
mapfile -d '' -t FPM_ARGV < <(jq -j '.data[0][1][] | ., "\u0000"' <<<"$FPM_EXEC_JSON")
EXEC_PATH="$(jq -er '.data[0][0]' <<<"$FPM_EXEC_JSON")"; FPM_BIN="$(readlink -f -- "$EXEC_PATH")"; ARGV0="$(readlink -f -- "${FPM_ARGV[0]}")"
FPM_PID="$(systemctl show --property=MainPID --value "$PHP_FPM_UNIT")"; [[ "$FPM_PID" =~ ^[1-9][0-9]*$ ]] || die 'FPM MainPID is unavailable'
PROC_BIN="$(readlink -f -- "/proc/${FPM_PID}/exe")"
[[ -x "$FPM_BIN" && "$FPM_BIN" == "$ARGV0" && "$FPM_BIN" == "$PROC_BIN" ]] || die 'FPM structured ExecStart, argv[0], and process binary disagree'
FPM_CONTEXT=("${FPM_ARGV[@]:1}")
FPM_CONFIG_ARG=''; FPM_CONFIG_COUNT=0
for ((I=0; I<${#FPM_CONTEXT[@]}; I++)); do
case "${FPM_CONTEXT[I]}" in
-t|-tt|-i|-m|-v|--test|--phpinfo|--modules|--version) die 'FPM ExecStart contains an action flag unsafe to reuse' ;;
-c|--php-ini|-d|--define|-y|--fpm-config|-p|--prefix)
((I+1<${#FPM_CONTEXT[@]})) || die "FPM option ${FPM_CONTEXT[I]} lacks a value"
case "${FPM_CONTEXT[I]}" in
-y|--fpm-config) FPM_CONFIG_ARG="${FPM_CONTEXT[I+1]}"; ((FPM_CONFIG_COUNT+=1)) ;;
-c|--php-ini|-p|--prefix) [[ "${FPM_CONTEXT[I+1]}" == /* ]] || die "FPM path for ${FPM_CONTEXT[I]} must be absolute" ;;
esac
((I+=1)) ;;
-y?*) FPM_CONFIG_ARG="${FPM_CONTEXT[I]#-y}"; ((FPM_CONFIG_COUNT+=1)) ;;
--fpm-config=*) FPM_CONFIG_ARG="${FPM_CONTEXT[I]#*=}"; ((FPM_CONFIG_COUNT+=1)) ;;
-c?*|--php-ini=*|-p?*|--prefix=*)
VALUE="${FPM_CONTEXT[I]}"; VALUE="${VALUE#-c}"; VALUE="${VALUE#-p}"; VALUE="${VALUE#*=}"; [[ "$VALUE" == /* ]] || die 'Compact FPM config/prefix paths must be absolute' ;;
-d?*|--define=*|-n|--no-php-ini|--nodaemonize|--force-stderr) ;;
esac
done
(( FPM_CONFIG_COUNT <= 1 )) || die 'FPM config argument is ambiguous'
if (( FPM_CONFIG_COUNT == 1 )); then [[ "$FPM_CONFIG_ARG" == /* && "$(readlink -f -- "$FPM_CONFIG_ARG")" == "$FPM_CONFIG" ]] || die 'Structured -y/--fpm-config does not identify FPM_CONFIG'; fi
FPM_EXEC_HASH="$(jq -cS . <<<"$FPM_EXEC_JSON" | sha256sum | awk '{print $1}')"
FPM_ARGV_HASH="$({ printf '%s\0' "${FPM_ARGV[@]}"; } | sha256sum | awk '{print $1}')"
FPM_CWD="$(readlink -f -- "/proc/${FPM_PID}/cwd")"; [[ "$FPM_CWD" == /* && -d "$FPM_CWD" ]] || die 'Cannot identify the FPM master cwd'
FPM_CGROUP="$(systemctl show --property=ControlGroup --value "$PHP_FPM_UNIT")"; [[ "$FPM_CGROUP" == /* ]] || die 'Cannot identify the FPM ControlGroup'
MASTER_CGROUP="$(awk -F: '$1=="0"{print $3}' "/proc/${FPM_PID}/cgroup")"
[[ "$MASTER_CGROUP" == "$FPM_CGROUP" || "$MASTER_CGROUP" == "$FPM_CGROUP"/* ]] || die 'FPM MainPID is outside the unit ControlGroup'
}
systemctl is-active --quiet "$PHP_FPM_UNIT" || die 'The exact FPM unit is not active'
capture_fpm
START_FPM_EXEC_HASH="$FPM_EXEC_HASH"; START_FPM_ARGV_HASH="$FPM_ARGV_HASH"; START_FPM_BIN="$FPM_BIN"; START_FPM_CWD="$FPM_CWD"; START_FPM_CGROUP="$FPM_CGROUP"
START_NGINX_EXEC_HASH="$NGINX_EXEC_HASH"; START_NGINX_ARGV_HASH="$NGINX_ARGV_HASH"
acquire_lock
BASE_HASH="$(hash_file "$PHP_INI")"; BASE_UID="$(stat -Lc '%u' "$PHP_INI")"; BASE_GID="$(stat -Lc '%g' "$PHP_INI")"; BASE_MODE="$(stat -Lc '%a' "$PHP_INI")"
[[ "$BASE_UID" == 0 ]] || die 'PHP_INI and transaction artifacts must be root-owned'
INI_DIR="$(dirname -- "$PHP_INI")"; TX_DIR="$(mktemp -d --tmpdir="$INI_DIR" .php-413.XXXXXXXX)"; chmod 700 "$TX_DIR"
[[ "$(stat -Lc '%d:%u:%a' "$TX_DIR")" == "$(stat -Lc '%d' "$INI_DIR"):0:700" ]] || critical 'Transaction directory is not protected on PHP_INI filesystem'
BACKUP="${TX_DIR}/php.ini.before"; BACKUP_SHA="${TX_DIR}/php.ini.before.sha256"; CANDIDATE="${TX_DIR}/php.ini.candidate"; STATE="${TX_DIR}/transaction.json"
cp --archive -- "$PHP_INI" "$BACKUP"; cp --archive -- "$PHP_INI" "$CANDIDATE"
[[ "$(hash_file "$BACKUP")" == "$BASE_HASH" ]] || critical 'PHP backup hash differs from base'
printf '%s %s\n' "$BASE_HASH" "$BACKUP" >"$BACKUP_SHA"; chmod 600 "$BACKUP_SHA"; sha256sum --check "$BACKUP_SHA" || critical 'Persistent PHP backup SHA failed'
ACTIVE_ENV_FILE="${TX_DIR}/fpm.environment.before.nul"; cp -- "/proc/${FPM_PID}/environ" "$ACTIVE_ENV_FILE"; chmod 600 "$ACTIVE_ENV_FILE"
ACTIVE_ENV_HASH="$(hash_file "$ACTIVE_ENV_FILE")"; mapfile -d '' -t MASTER_ENV <"$ACTIVE_ENV_FILE"
for ENTRY in "${MASTER_ENV[@]}"; do NAME="${ENTRY%%=*}"; [[ "$ENTRY" == *=* && "$NAME" =~ ^[a-zA-Z_][a-zA-Z0-9_]*$ ]] || die 'FPM master environment contains an entry unsafe for env -i'; done
run_fpm() { (cd "$FPM_CWD" && env -i "${MASTER_ENV[@]}" "$FPM_BIN" "${FPM_CONTEXT[@]}" "$@"); }
collect_fpm_graph() {
local PREFIX="$1" CURRENT PATTERN MATCH; declare -A SEEN=(); local -a QUEUE=("$FPM_CONFIG") MATCHES=()
: >"${PREFIX}.paths"
while ((${#QUEUE[@]})); do
CURRENT="${QUEUE[0]}"; QUEUE=("${QUEUE[@]:1}")
[[ "$CURRENT" == /* && -f "$CURRENT" && ! -L "$CURRENT" ]] || return 1
CURRENT="$(readlink -f -- "$CURRENT")"; [[ -z "${SEEN[$CURRENT]:-}" ]] || continue; SEEN[$CURRENT]=1; printf '%s\n' "$CURRENT" >>"${PREFIX}.paths"
while IFS= read -r PATTERN; do
[[ "$PATTERN" == /* && "$PATTERN" != *$'\t'* && "$PATTERN" != *' '* ]] || return 1
mapfile -t MATCHES < <(compgen -G "$PATTERN" | LC_ALL=C sort)
((${#MATCHES[@]})) || return 1
for MATCH in "${MATCHES[@]}"; do QUEUE+=("$MATCH"); done
done < <(awk -F= '/^[[:space:]]*include[[:space:]]*=/{v=$0;sub(/^[^=]*=/,"",v);gsub(/^[[:space:]]+|[[:space:]]+$/,"",v);print v}' "$CURRENT")
done
LC_ALL=C sort -u -o "${PREFIX}.paths" "${PREFIX}.paths"; : >"${PREFIX}.graph.tsv"
while IFS= read -r CURRENT; do printf '%s\t%s\n' "$CURRENT" "$(hash_file "$CURRENT")" >>"${PREFIX}.graph.tsv"; done <"${PREFIX}.paths"
chmod 600 "${PREFIX}.paths" "${PREFIX}.graph.tsv"
}
normalize_ini_list() {
local RAW="$1" DEST="$2" ITEM
: >"$DEST"
while IFS= read -r ITEM; do
ITEM="$(sed 's/^[[:space:]]*//;s/[[:space:]]*$//' <<<"$ITEM")"
[[ -z "$ITEM" || "$ITEM" == '(none)' ]] && continue
[[ "$ITEM" == /* && -f "$ITEM" && ! -L "$ITEM" ]] || return 1
readlink -f -- "$ITEM" >>"$DEST"
done < <(printf '%s\n' "$RAW" | tr ',' '\n')
chmod 600 "$DEST"
}
capture_fpm_info() {
local PREFIX="$1" RAW LOADED
run_fpm -i >"${PREFIX}.info" || return 1; chmod 600 "${PREFIX}.info"
grep -Fq 'Server API => FPM/FastCGI' "${PREFIX}.info" || return 1
mapfile -t LOADED_VALUES < <(awk -F ' => ' '$1=="Loaded Configuration File"{print $2}' "${PREFIX}.info")
[[ ${#LOADED_VALUES[@]} == 1 ]] || return 1
LOADED="$(readlink -f -- "${LOADED_VALUES[0]}")"; [[ "$LOADED" == "$PHP_INI" ]] || return 1
RAW="$(awk '/^Additional .ini files parsed => /{active=1;sub(/^Additional .ini files parsed => /,"");print;next} active&&/^[^[:space:]].* => /{exit} active{print}' "${PREFIX}.info")"
normalize_ini_list "$RAW" "${PREFIX}.scanned" || return 1
{ printf '%s\n' "$PHP_INI"; cat "${PREFIX}.scanned"; } | LC_ALL=C sort -u >"${PREFIX}.php.paths"
: >"${PREFIX}.php.graph.tsv"; while IFS= read -r FILE; do printf '%s\t%s\n' "$FILE" "$(hash_file "$FILE")" >>"${PREFIX}.php.graph.tsv"; done <"${PREFIX}.php.paths"
chmod 600 "${PREFIX}.php.paths" "${PREFIX}.php.graph.tsv"
}
validate_tt_pool() {
local FILE="$1" NORMAL="${1}.normalized"
sed -E 's/^\[[^]]+\][[:space:]]+NOTICE:[[:space:]]*//' "$FILE" >"$NORMAL"; chmod 600 "$NORMAL"
[[ "$(grep -Fxc -- "[$POOL_NAME]" "$NORMAL")" == 1 ]] || return 1
mapfile -t TT_LISTENS < <(awk -F= -v p="$POOL_NAME" '$0=="["p"]"{seen++;active=1;next} /^\[[^]]+\]$/{active=0} active&&$1~/^[[:space:]]*listen[[:space:]]*$/{v=$2;gsub(/^[[:space:]]+|[[:space:]]+$/,"",v);print v} END{if(seen!=1)exit 42}' "$NORMAL") || return 1
mapfile -t TT_USERS < <(awk -F= -v p="$POOL_NAME" '$0=="["p"]"{active=1;next} /^\[[^]]+\]$/{active=0} active&&$1~/^[[:space:]]*user[[:space:]]*$/{v=$2;gsub(/^[[:space:]]+|[[:space:]]+$/,"",v);print v}' "$NORMAL")
mapfile -t TT_GROUPS < <(awk -F= -v p="$POOL_NAME" '$0=="["p"]"{active=1;next} /^\[[^]]+\]$/{active=0} active&&$1~/^[[:space:]]*group[[:space:]]*$/{v=$2;gsub(/^[[:space:]]+|[[:space:]]+$/,"",v);print v}' "$NORMAL")
[[ ${#TT_LISTENS[@]} == 1 && "${TT_LISTENS[0]}" == "$FASTCGI_SOCKET" && ${#TT_USERS[@]} == 1 && "${TT_USERS[0]}" == "$POOL_USER" && ${#TT_GROUPS[@]} == 1 && "${TT_GROUPS[0]}" == "$POOL_GROUP" ]]
}
collect_fpm_graph "${TX_DIR}/fpm-base" || die 'Cannot resolve the FPM include graph; require absolute, unambiguous include patterns'
grep -Fqx "$POOL_CONFIG" "${TX_DIR}/fpm-base.paths" || die 'POOL_CONFIG is not proven to be included from FPM_CONFIG'
FPM_GRAPH_HASH="$(hash_file "${TX_DIR}/fpm-base.graph.tsv")"
run_fpm -tt >"${TX_DIR}/fpm-base.tt" 2>&1 || die 'Starting FPM -tt failed with full structured argv, clean master env, and master cwd'
chmod 600 "${TX_DIR}/fpm-base.tt"; validate_tt_pool "${TX_DIR}/fpm-base.tt" || die 'FPM -tt does not show one exact pool/listen/user/group'
if (( FPM_CONFIG_COUNT == 0 )); then
[[ "$(grep -Foc -- "$FPM_CONFIG" "${TX_DIR}/fpm-base.tt")" == 1 ]] || die 'No -y was configured and -tt did not uniquely identify the reviewed default FPM_CONFIG; stop and consult the administrator'
fi
capture_fpm_info "${TX_DIR}/php-base" || die 'FPM -i did not identify FPM/FastCGI, PHP_INI, and scanned INIs unambiguously'
PHP_GRAPH_BASE_HASH="$(hash_file "${TX_DIR}/php-base.php.graph.tsv")"
PROBE_DIR=''
cleanup_probe() { local RC=0; if [[ -n "$PROBE_DIR" && -d "$PROBE_DIR" && ! -L "$PROBE_DIR" ]]; then [[ "$(stat -Lc '%u' "$PROBE_DIR")" == 0 ]] || RC=1; find "$PROBE_DIR" -maxdepth 1 -type f -delete || RC=1; rmdir -- "$PROBE_DIR" || RC=1; PROBE_DIR=''; fi; return "$RC"; }
trap 'if ! cleanup_probe; then echo "CRITICAL non-convergent: temporary probe cleanup failed" >&2; fi' EXIT
probe_fpm() {
local DEST="$1" RAW="${1}.raw" PROBE PID WORKER_BIN WORKER_CGROUP
PROBE_DIR="$(mktemp -d --tmpdir=/run .teramont-fpm-probe.XXXXXXXX)"; chown "root:$POOL_GROUP" "$PROBE_DIR"; chmod 710 "$PROBE_DIR"
PROBE="${PROBE_DIR}/probe.php"
cat >"$PROBE" <<'PHP'
<?php
header('Content-Type: application/json');
echo json_encode([
'pid' => getmypid(),
'sapi' => PHP_SAPI,
'loaded_ini' => php_ini_loaded_file(),
'scanned_ini' => php_ini_scanned_files(),
'upload_max_filesize' => ini_get('upload_max_filesize'),
'post_max_size' => ini_get('post_max_size'),
], JSON_THROW_ON_ERROR);
PHP
chown "$POOL_USER:$POOL_GROUP" "$PROBE"; chmod 400 "$PROBE"
env -i SCRIPT_FILENAME="$PROBE" SCRIPT_NAME='/restricted-local-probe.php' REQUEST_METHOD='GET' SERVER_PROTOCOL='HTTP/1.1' GATEWAY_INTERFACE='CGI/1.1' REDIRECT_STATUS='200' "$CGI_FCGI_BIN" -bind -connect "$FASTCGI_SOCKET" >"$RAW" || { cleanup_probe || critical 'Probe failure plus cleanup failure'; return 1; }
cleanup_probe || critical 'Probe completed but temporary file removal failed'
sed '1,/^\r$/d' "$RAW" >"$DEST"; chmod 600 "$RAW" "$DEST"; jq empty "$DEST" || return 1
PID="$(jq -er '.pid|select(type=="number" and .>1)|floor' "$DEST")" || return 1
[[ -e "/proc/${PID}/exe" ]] || return 1; WORKER_BIN="$(readlink -f -- "/proc/${PID}/exe")"; [[ "$WORKER_BIN" == "$FPM_BIN" ]] || return 1
WORKER_CGROUP="$(awk -F: '$1=="0"{print $3}' "/proc/${PID}/cgroup")"; [[ "$WORKER_CGROUP" == "$FPM_CGROUP" || "$WORKER_CGROUP" == "$FPM_CGROUP"/* ]] || return 1
jq -e --arg ini "$PHP_INI" '.sapi=="fpm-fcgi" and .loaded_ini==$ini and (.scanned_ini|type)=="string" and (.upload_max_filesize|type)=="string" and (.post_max_size|type)=="string"' "$DEST" >/dev/null || return 1
normalize_ini_list "$(jq -r '.scanned_ini' "$DEST")" "${DEST}.scanned" || return 1
}
SOCKET_BEFORE="$(stat -Lc '%d:%i:%u:%g:%a:%F' "$FASTCGI_SOCKET")"
BEFORE_PROBE="${TX_DIR}/probe.before.json"; probe_fpm "$BEFORE_PROBE" || die 'Restricted local probe did not tie SAPI/PID/exe/cgroup/INI to the selected unit'
cmp -s "${TX_DIR}/php-base.scanned" "${BEFORE_PROBE}.scanned" || die 'FPM -i scanned INIs differ from the live worker probe'
echo "Edit only this candidate: $CANDIDATE" >&2
printf '%s\nupload_max_filesize = %s\npost_max_size = %s\n' "$MARKER" "$EXPECTED_UPLOAD" "$EXPECTED_POST" >&2
[[ -x /usr/bin/vi ]] || die '/usr/bin/vi is unavailable'; /usr/bin/vi "$CANDIDATE"
[[ -f "$CANDIDATE" && ! -L "$CANDIDATE" ]] || die 'PHP candidate changed type'; chown "$BASE_UID:$BASE_GID" "$CANDIDATE"; chmod "$BASE_MODE" "$CANDIDATE"
PHP_MARK_COUNT="$(awk -v m="$MARKER" '$0==m{c++}END{print c+0}' "$CANDIDATE")"
PHP_PAIR_COUNT="$(awk -v m="$MARKER" -v u="upload_max_filesize = $EXPECTED_UPLOAD" -v p="post_max_size = $EXPECTED_POST" '$0==m{getline a;getline b;if(a==u&&b==p)c++}END{print c+0}' "$CANDIDATE")"
[[ "$PHP_MARK_COUNT" == 1 && "$PHP_PAIR_COUNT" == 1 ]] || die 'PHP candidate needs one exact marker/upload/post triple'
DIFF_RC=0; diff -u -- "$BACKUP" "$CANDIDATE" || DIFF_RC=$?; [[ "$DIFF_RC" == 1 ]] || die 'PHP candidate is unchanged or diff failed'
read -r -p 'Type APPLY-PHP-413 after reviewing diff, unit, argv, environment, pool, socket, and INI graph: ' CONFIRM
[[ "$CONFIRM" == 'APPLY-PHP-413' ]] || die 'Cancelled before installing candidate'
CANDIDATE_HASH="$(hash_file "$CANDIDATE")"
verify_lock || critical 'Lock identity changed before PHP installation'
[[ "$(hash_file "$PHP_INI")" == "$BASE_HASH" && "$(hash_file "${TX_DIR}/fpm-base.graph.tsv")" == "$FPM_GRAPH_HASH" && "$(hash_file "${TX_DIR}/php-base.php.graph.tsv")" == "$PHP_GRAPH_BASE_HASH" ]] || critical 'A frozen config changed before installation'
collect_fpm_graph "${TX_DIR}/fpm-base-recheck" || critical 'FPM graph recheck failed'; cmp -s "${TX_DIR}/fpm-base.graph.tsv" "${TX_DIR}/fpm-base-recheck.graph.tsv" || critical 'FPM include graph changed before installation'
capture_fpm_info "${TX_DIR}/php-base-recheck" || critical 'PHP graph recheck failed'; cmp -s "${TX_DIR}/php-base.php.graph.tsv" "${TX_DIR}/php-base-recheck.php.graph.tsv" || critical 'PHP INI graph changed before installation'
unexpected_after_mutation() { local RC=$?; trap - ERR; critical "unexpected command failure after mutation (rc=${RC}, line=${BASH_LINENO[0]:-unknown}); state was not assumed"; }
trap unexpected_after_mutation ERR
mv -T -- "$CANDIDATE" "$PHP_INI" || critical 'Atomic PHP candidate rename failed'
[[ "$(hash_file "$PHP_INI")" == "$CANDIDATE_HASH" && "$(stat -Lc '%u:%g:%a' "$PHP_INI")" == "${BASE_UID}:${BASE_GID}:${BASE_MODE}" ]] || critical 'Installed PHP candidate hash or attributes mismatch'
recover_php_after_failure() {
local REASON="$1" CURRENT_COPY="${TX_DIR}/php.candidate.rescue" STAGE="${TX_DIR}/php.base.restore.stage"
[[ "$(hash_file "$PHP_INI")" == "$CANDIDATE_HASH" ]] || critical "$REASON; active bytes changed, so no automatic overwrite was attempted"
cp --archive -- "$PHP_INI" "$CURRENT_COPY" || critical "$REASON; could not preserve current bytes"; [[ "$(hash_file "$CURRENT_COPY")" == "$CANDIDATE_HASH" ]] || critical "$REASON; current rescue hash mismatch"
cp --archive -- "$BACKUP" "$STAGE" || critical "$REASON; could not stage backup"; chown "$BASE_UID:$BASE_GID" "$STAGE"; chmod "$BASE_MODE" "$STAGE"
[[ "$(hash_file "$STAGE")" == "$BASE_HASH" && "$(stat -Lc '%d' "$STAGE")" == "$(stat -Lc '%d' "$PHP_INI")" ]] || critical "$REASON; base stage mismatch"
mv -T -- "$STAGE" "$PHP_INI" || critical "$REASON; atomic base rename failed"; [[ "$(hash_file "$PHP_INI")" == "$BASE_HASH" ]] || critical "$REASON; base hash is not on disk"
if ! run_fpm -tt >/dev/null 2>&1; then
STAGE="${TX_DIR}/php.current.restore.stage"; cp --archive -- "$CURRENT_COPY" "$STAGE" || critical "$REASON; base test failed and current bytes could not be staged"
chown "$BASE_UID:$BASE_GID" "$STAGE"; chmod "$BASE_MODE" "$STAGE"; mv -T -- "$STAGE" "$PHP_INI" || critical "$REASON; current atomic rename failed"
[[ "$(hash_file "$PHP_INI")" == "$CANDIDATE_HASH" ]] || critical "$REASON; current hash was not recovered"
run_fpm -tt >/dev/null 2>&1 || critical "$REASON; base and repeated current tests failed; no reload occurred"
critical "$REASON; base test failed, current hash/tests are recorded, and no reload occurred"
fi
critical "$REASON; no reload occurred; inspect the recorded disk hash and rescue paths before any next action"
}
[[ "$(hash_file "$PHP_INI")" == "$CANDIDATE_HASH" ]] || critical 'Candidate changed immediately before -tt'
run_fpm -tt >"${TX_DIR}/fpm-candidate.tt" 2>&1 || recover_php_after_failure 'Candidate FPM -tt failed'; chmod 600 "${TX_DIR}/fpm-candidate.tt"
validate_tt_pool "${TX_DIR}/fpm-candidate.tt" || recover_php_after_failure 'Candidate -tt pool/listen/user/group identity failed'
collect_fpm_graph "${TX_DIR}/fpm-candidate" || recover_php_after_failure 'Candidate FPM graph failed'; cmp -s "${TX_DIR}/fpm-base.graph.tsv" "${TX_DIR}/fpm-candidate.graph.tsv" || recover_php_after_failure 'FPM config/pool graph changed outside PHP_INI'
capture_fpm_info "${TX_DIR}/php-candidate" || recover_php_after_failure 'Candidate FPM -i identity failed'
awk -F '\t' -v ini="$PHP_INI" -v h="$CANDIDATE_HASH" 'BEGIN{OFS="\t"}$1==ini{$2=h}{print}' "${TX_DIR}/php-base.php.graph.tsv" >"${TX_DIR}/php-candidate.expected.graph.tsv"; chmod 600 "${TX_DIR}/php-candidate.expected.graph.tsv"
cmp -s "${TX_DIR}/php-candidate.expected.graph.tsv" "${TX_DIR}/php-candidate.php.graph.tsv" || recover_php_after_failure 'PHP graph changed beyond reviewed PHP_INI bytes'
PHP_GRAPH_CANDIDATE_HASH="$(hash_file "${TX_DIR}/php-candidate.php.graph.tsv")"
FPM_ARGV_B64="$({ printf '%s\0' "${FPM_ARGV[@]}"; } | base64 -w0)"
jq -nS --arg unit "$PHP_FPM_UNIT" --arg fpm_config "$FPM_CONFIG" --arg pool_config "$POOL_CONFIG" --arg pool_name "$POOL_NAME" --arg pool_user "$POOL_USER" --arg pool_group "$POOL_GROUP" \
--arg socket "$FASTCGI_SOCKET" --arg socket_before "$SOCKET_BEFORE" --arg php_ini "$PHP_INI" --arg fpm_bin "$FPM_BIN" --arg exec_hash "$START_FPM_EXEC_HASH" --arg argv_hash "$START_FPM_ARGV_HASH" --arg argv_b64 "$FPM_ARGV_B64" \
--arg env_hash "$ACTIVE_ENV_HASH" --arg cwd "$FPM_CWD" --arg cgroup "$FPM_CGROUP" --arg base_hash "$BASE_HASH" --arg candidate_hash "$CANDIDATE_HASH" \
--arg fpm_graph_hash "$FPM_GRAPH_HASH" --arg php_base_graph_hash "$PHP_GRAPH_BASE_HASH" --arg php_candidate_graph_hash "$PHP_GRAPH_CANDIDATE_HASH" \
--arg backup "$BACKUP" --arg backup_sha "$BACKUP_SHA" --arg lockdir "$LOCK_DIR" --arg lockfile "$LOCKFILE" --arg manager_state "$CONFIG_MANAGER_STATE" --arg before_probe "$BEFORE_PROBE" --arg before_probe_sha256 "$(hash_file "$BEFORE_PROBE")" --arg cgi "$CGI_FCGI_BIN" \
--argjson uid "$BASE_UID" --argjson gid "$BASE_GID" --arg mode "$BASE_MODE" \
'{unit:$unit,fpm_config:$fpm_config,pool_config:$pool_config,pool_name:$pool_name,pool_user:$pool_user,pool_group:$pool_group,socket:$socket,socket_before:$socket_before,php_ini:$php_ini,fpm_bin:$fpm_bin,execstart_sha256:$exec_hash,argv_sha256:$argv_hash,argv_b64:$argv_b64,environment_sha256:$env_hash,cwd:$cwd,cgroup:$cgroup,base_hash:$base_hash,candidate_hash:$candidate_hash,fpm_graph_hash:$fpm_graph_hash,php_base_graph_hash:$php_base_graph_hash,php_candidate_graph_hash:$php_candidate_graph_hash,backup:$backup,backup_sha:$backup_sha,lock_dir:$lockdir,lockfile:$lockfile,config_manager_state:$manager_state,before_probe:$before_probe,before_probe_sha256:$before_probe_sha256,cgi_fcgi:$cgi,base_uid:$uid,base_gid:$gid,base_mode:$mode}' >"${STATE}.tmp"
chmod 600 "${STATE}.tmp"; mv -T -- "${STATE}.tmp" "$STATE"
recheck_fpm_identity() {
local ENV_DEST="$1"
capture_fpm
[[ "$FPM_EXEC_HASH" == "$START_FPM_EXEC_HASH" && "$FPM_ARGV_HASH" == "$START_FPM_ARGV_HASH" && "$FPM_BIN" == "$START_FPM_BIN" && "$FPM_CWD" == "$START_FPM_CWD" && "$FPM_CGROUP" == "$START_FPM_CGROUP" ]] || return 1
cp -- "/proc/${FPM_PID}/environ" "$ENV_DEST"; chmod 600 "$ENV_DEST"; [[ "$(hash_file "$ENV_DEST")" == "$ACTIVE_ENV_HASH" ]] || return 1
}
verify_lock || critical 'Lock identity changed immediately before reload'
[[ "$(hash_file "$PHP_INI")" == "$CANDIDATE_HASH" && "$(stat -Lc '%d:%i:%u:%g:%a:%F' "$FASTCGI_SOCKET")" == "$SOCKET_BEFORE" ]] || critical 'PHP_INI or socket identity changed immediately before reload'
recheck_fpm_identity "${TX_DIR}/fpm.environment.pre-reload.nul" || critical 'FPM binary/argv/env/cwd/cgroup changed before reload'
collect_fpm_graph "${TX_DIR}/fpm-pre-reload" || critical 'FPM graph recheck failed'; cmp -s "${TX_DIR}/fpm-candidate.graph.tsv" "${TX_DIR}/fpm-pre-reload.graph.tsv" || critical 'FPM graph changed before reload'
capture_fpm_info "${TX_DIR}/php-pre-reload" || critical 'PHP graph recheck failed'; cmp -s "${TX_DIR}/php-candidate.php.graph.tsv" "${TX_DIR}/php-pre-reload.php.graph.tsv" || critical 'PHP graph changed before reload'
run_fpm -tt >/dev/null 2>&1 || recover_php_after_failure 'Final FPM -tt failed'
verify_lock || critical 'Lock identity changed after the final FPM test; no reload occurred'
[[ "$(hash_file "$PHP_INI")" == "$CANDIDATE_HASH" && "$(stat -Lc '%d:%i:%u:%g:%a:%F' "$FASTCGI_SOCKET")" == "$SOCKET_BEFORE" ]] || critical 'PHP_INI or socket changed after the final FPM test; no reload occurred'
recheck_fpm_identity "${TX_DIR}/fpm.environment.reload-gate.nul" || critical 'FPM binary/argv/env/cwd/cgroup changed after the final test; no reload occurred'
mapfile -d '' -t MASTER_ENV <"${TX_DIR}/fpm.environment.reload-gate.nul"
collect_fpm_graph "${TX_DIR}/fpm-reload-gate" || critical 'Reload-gate FPM graph failed; no reload occurred'; cmp -s "${TX_DIR}/fpm-candidate.graph.tsv" "${TX_DIR}/fpm-reload-gate.graph.tsv" || critical 'FPM graph changed after the final test; no reload occurred'
capture_fpm_info "${TX_DIR}/php-reload-gate" || critical 'Reload-gate PHP graph failed; no reload occurred'; cmp -s "${TX_DIR}/php-candidate.php.graph.tsv" "${TX_DIR}/php-reload-gate.php.graph.tsv" || critical 'PHP graph changed after the final test; no reload occurred'
if ! systemctl reload "$PHP_FPM_UNIT"; then
echo "Reload failed: disk hash was ${CANDIDATE_HASH}; FPM master/worker memory is unknown and may still use prior settings." >&2
systemctl status "$PHP_FPM_UNIT" --no-pager || true; journalctl --unit="$PHP_FPM_UNIT" --since='-10 minutes' --no-pager || true
critical 'Keep SSH and rescue artifacts; do not restart blindly. Inspect logs, then use the reviewed rollback with this TX_DIR or retry only after an exact successful test'
fi
systemctl is-active --quiet "$PHP_FPM_UNIT" || critical 'Reload returned success but the exact FPM unit is not active'
recheck_fpm_identity "${TX_DIR}/fpm.environment.after-reload.nul" || critical 'FPM binary/argv/env/cwd/cgroup changed after reload'
mapfile -d '' -t MASTER_ENV <"${TX_DIR}/fpm.environment.after-reload.nul"
[[ "$(hash_file "$PHP_INI")" == "$CANDIDATE_HASH" ]] || critical 'PHP_INI changed after reload'
collect_fpm_graph "${TX_DIR}/fpm-after" || critical 'Post-reload FPM graph failed'; cmp -s "${TX_DIR}/fpm-candidate.graph.tsv" "${TX_DIR}/fpm-after.graph.tsv" || critical 'Post-reload FPM graph differs'
capture_fpm_info "${TX_DIR}/php-after" || critical 'Post-reload FPM -i identity failed'; cmp -s "${TX_DIR}/php-candidate.php.graph.tsv" "${TX_DIR}/php-after.php.graph.tsv" || critical 'Post-reload PHP graph differs'
SOCKET_AFTER="$(stat -Lc '%d:%i:%u:%g:%a:%F' "$FASTCGI_SOCKET")"
AFTER_PROBE="${TX_DIR}/probe.after.json"; probe_fpm "$AFTER_PROBE" || critical 'Post-reload restricted probe did not tie PID/exe/cgroup/INI to the selected unit'
cmp -s "${TX_DIR}/php-after.scanned" "${AFTER_PROBE}.scanned" || critical 'Post-reload FPM -i scanned files differ from live probe'
jq -e --arg upload "$EXPECTED_UPLOAD" --arg post "$EXPECTED_POST" '.upload_max_filesize==$upload and .post_max_size==$post' "$AFTER_PROBE" >/dev/null || critical 'Live worker limits do not match the reviewed values'
jq -S --arg socket_after "$SOCKET_AFTER" --arg after_probe "$AFTER_PROBE" --arg after_probe_sha256 "$(hash_file "$AFTER_PROBE")" \
--arg fpm_after_graph_sha256 "$(hash_file "${TX_DIR}/fpm-after.graph.tsv")" --arg php_after_graph_sha256 "$(hash_file "${TX_DIR}/php-after.php.graph.tsv")" \
--arg environment_after_sha256 "$(hash_file "${TX_DIR}/fpm.environment.after-reload.nul")" \
'. + {socket_after:$socket_after,after_probe:$after_probe,after_probe_sha256:$after_probe_sha256,fpm_after_graph_sha256:$fpm_after_graph_sha256,php_after_graph_sha256:$php_after_graph_sha256,environment_after_sha256:$environment_after_sha256}' \
"$STATE" >"${STATE}.tmp"; chmod 600 "${STATE}.tmp"; mv -T -- "${STATE}.tmp" "$STATE"
printf 'Reload command succeeded; disk/config graphs and restricted worker probe match pool=%s socket=%s INI=%s. Transaction=%s\n' "$POOL_NAME" "$FASTCGI_SOCKET" "$PHP_INI" "$TX_DIR"