diff --git a/functions b/functions index c20697e..2298efd 100644 --- a/functions +++ b/functions @@ -411,6 +411,78 @@ function setup_php_extensions() { fi } +# Functions ee calls unguarded that hardened php.ini files (e.g. HestiaCP's CLI one) often list in disable_functions. +EE_PHP_REQUIRED_FUNCTIONS="curl_exec disk_free_space escapeshellarg exec getmypid ini_set pcntl_signal pcntl_signal_dispatch php_uname posix_getegid posix_geteuid posix_getpwuid proc_close proc_open" + +function check_php_functions() { + ee_log_info1 "Checking required PHP functions" + if ! command -v php >/dev/null 2>&1; then + return 0 + fi + + local report kind name disabled="" missing="" degraded="" ini_files="" scan_dir="" where + # PHP 7 only warns when a disabled function is called, so disabled pcntl_* just loses rollback on signals there. + # shellcheck disable=SC2016,SC2086 # PHP code, not shell; one argument per function + if ! report="$(php -r ' + $off = function_exists("ini_get") ? preg_split("/[\s,]+/", (string) ini_get("disable_functions")) : array(); + foreach (array_slice($argv, 1) as $f) { + if (function_exists($f)) { continue; } + if (!in_array($f, $off, true)) { echo "missing ", $f, "\n"; } + elseif (PHP_MAJOR_VERSION < 8 && strpos($f, "pcntl_") === 0) { echo "degraded ", $f, "\n"; } + else { echo "disabled ", $f, "\n"; } + } + if (function_exists("php_ini_loaded_file") && function_exists("php_ini_scanned_files") && function_exists("file_get_contents")) { + $files = array_merge(array((string) php_ini_loaded_file()), explode(",", (string) php_ini_scanned_files())); + foreach (array_filter(array_map("trim", $files)) as $file) { + if (preg_match("/^[ \t]*disable_functions[ \t]*=[ \t]*[\"\x27]?[ \t]*[A-Za-z_]/m", (string) @file_get_contents($file))) { echo "ini ", $file, "\n"; } + } + } + echo "scandir ", PHP_CONFIG_FILE_SCAN_DIR, "\n";' -- $EE_PHP_REQUIRED_FUNCTIONS)"; then + ee_log_fail "ERROR: Could not run the PHP CLI ($(command -v php)) to check the functions EasyEngine needs." + fi + + while read -r kind name; do + case "$kind" in + disabled) disabled+="${disabled:+, }$name" ;; + missing) missing+="${missing:+, }$name" ;; + degraded) degraded+="${degraded:+, }$name" ;; + ini) ini_files+="${ini_files:+, }$name" ;; + scandir) scan_dir="$name" ;; + esac + done <<< "$report" + + if [ -n "$degraded" ]; then + ee_log_warn "The PHP CLI disables ${degraded}; EasyEngine can't roll back a site operation interrupted by a signal (on PHP 8 this is a fatal error)." + fi + if [ -z "$disabled" ] && [ -z "$missing" ]; then + return 0 + fi + + if [ -n "$disabled" ]; then + echo "ERROR: The PHP CLI ($(command -v php)) disables functions EasyEngine needs: ${disabled}." >&2 + { + if [ -n "$ini_files" ]; then + ee_log_verbose "disable_functions is set in: ${ini_files}" + else + ee_log_verbose "disable_functions is set in one of the files that php --ini lists." + fi + # A conf.d override only wins over files that load before it, so not when conf.d itself disables them. + if [ -n "$scan_dir" ] && [[ ", $ini_files" != *", $scan_dir/"* ]]; then + ee_log_verbose "To fix it, create ${scan_dir}/99-easyengine.ini that sets disable_functions to the same list without these functions, or edit php.ini directly." + else + if [ -n "$ini_files" ]; then where="the file(s) above"; else where="that file"; fi + ee_log_verbose "To fix it, remove them from disable_functions in ${where}." + fi + ee_log_verbose "Only the PHP CLI configuration needs the change, not PHP-FPM. Then re-run the installer." + } >&2 + fi + if [ -n "$missing" ]; then + echo "ERROR: The PHP CLI ($(command -v php)) lacks functions EasyEngine needs: ${missing}." >&2 + ee_log_verbose "To fix it, install or enable the PHP extensions that provide them (e.g. pcntl, posix, curl) for the PHP CLI. Then re-run the installer." >&2 + fi + exit 1 +} + function create_swap() { ee_log_info2 "Enabling 1GiB swap" EE_SWAP_FILE="/ee-swapfile" @@ -467,9 +539,11 @@ function check_dependencies() { fi setup_host_dependencies - setup_docker setup_php setup_php_extensions + # Before Docker, so a refused host is left without it. + check_php_functions + setup_docker check_swap } diff --git a/tests/README.md b/tests/README.md index 6b0d752..29766d3 100644 --- a/tests/README.md +++ b/tests/README.md @@ -19,7 +19,7 @@ bounded **before** the phar download/pull and stubs `docker`, exercising only th OS-version–dependent surface that has actually regressed in the past: ``` -check_dependencies → setup_host_dependencies → setup_php → setup_php_extensions +check_dependencies → setup_host_dependencies → setup_php → setup_php_extensions → check_php_functions ``` PHP selection in particular can only be validated against real distro repos + @@ -74,8 +74,8 @@ functions to drive the algorithm through every branch deterministically. - `ee_select_and_install_php` — native vs PPA, version-first ordering, no-candidate. - `download_and_install_easyengine` — checksum match / mismatch / empty phar / empty checksum / download failure. -- Host-prep integration — php + the three required extensions install across - Ubuntu 22.04/24.04/26.04/rolling/devel and Debian 12/13. +- `check_php_functions` — functions disabled via `disable_functions` (comma- or space-separated) or missing from the build, PHP 7's warn-only pcntl case, the messages, and its place in `check_dependencies` (after PHP setup, before Docker). +- Host-prep integration — php + the three required extensions install across Ubuntu 22.04/24.04/26.04/rolling/devel and Debian 12/13, the functions ee calls are usable, and `check_php_functions` refuses a HestiaCP-style CLI `disable_functions`. ## Not yet covered (candidate follow-ups) diff --git a/tests/integration/host-prep.sh b/tests/integration/host-prep.sh index 90cae88..7b4aeb9 100755 --- a/tests/integration/host-prep.sh +++ b/tests/integration/host-prep.sh @@ -74,6 +74,27 @@ for ext in curl sqlite3 zip; do check "php extension loaded: $ext" bash -c "php -m | grep -qiE '^${ext}\$'" done +# The functions ee calls are usable on a stock distro PHP; check_php_functions exits on failure, hence the subshell. +in_subshell() { ( "$@" ); } +check "required PHP functions are available" in_subshell check_php_functions + +# A HestiaCP-style CLI disable_functions must be refused with the file named; a scan-dir file overrides php.ini. +scan_dir="$(php -r 'echo PHP_CONFIG_FILE_SCAN_DIR;')" +if [ -n "$scan_dir" ] && [ -d "$scan_dir" ]; then + hardened_ini="$scan_dir/99-ee-test-hardening.ini" + echo "disable_functions = pcntl_alarm,pcntl_fork,pcntl_signal,pcntl_signal_dispatch" > "$hardened_ini" + hardened_out="$( (check_php_functions) 2>&1 )" + hardened_rc=$? + rm -f "$hardened_ini" + check "disabled PHP functions are refused" test "$hardened_rc" -ne 0 + check "the error names the disabled functions" grep -qF "disables functions EasyEngine needs: pcntl_signal, pcntl_signal_dispatch." <<< "$hardened_out" + check "the error names the ini file" grep -qF "$hardened_ini" <<< "$hardened_out" + check "the error says to edit that file" grep -qF "remove them from disable_functions in the file(s) above." <<< "$hardened_out" + check "the error is at most 4 lines" test "$(sed -n '/^ERROR: /,$p' <<< "$hardened_out" | wc -l)" -le 4 +else + fail "PHP CLI has no config scan dir to test disable_functions with" +fi + # Base host dependency installed earlier in check_dependencies(). if ! command -v sqlite3 >/dev/null 2>&1; then apt-get install -y sqlite3 >/dev/null 2>&1 || true diff --git a/tests/unit/php_functions.bats b/tests/unit/php_functions.bats new file mode 100644 index 0000000..a203e3d --- /dev/null +++ b/tests/unit/php_functions.bats @@ -0,0 +1,144 @@ +#!/usr/bin/env bats +# +# check_php_functions() — refuses a PHP CLI that disables or lacks functions ee calls (e.g. HestiaCP's pcntl_* list), which otherwise only fails on the first `ee site` command. + +setup() { + source "${BATS_TEST_DIRNAME}/../helpers/common.bash" + # Resolve a real php before the stub dir shadows it (empty when none). + REAL_PHP="$(command -v php || true)" + export REAL_PHP + stub_init + export EE_QUIET_OUTPUT="" + load_functions +} + +teardown() { + stub_cleanup +} + +# php stub that prints the report lines from $PHP_REPORT and exits $PHP_EXIT. +stub_php_report() { + make_stub php <<'EOF' +#!/usr/bin/env bash +printf '%b' "${PHP_REPORT:-}" +exit "${PHP_EXIT:-0}" +EOF +} + +# The error, from its ERROR: line on, spans exactly $1 lines. +assert_error_lines() { + local n + n="$(sed -n '/^ERROR: /,$p' <<< "$output" | wc -l)" + [ "$n" -eq "$1" ] +} + +@test "passes when every required function is available" { + stub_php_report + PHP_REPORT="" run check_php_functions + [ "$status" -eq 0 ] + [[ "$output" != *ERROR* ]] +} + +@test "passes when only an unrelated ini line is reported" { + stub_php_report + PHP_REPORT='ini /etc/php/8.3/cli/php.ini\n' run check_php_functions + [ "$status" -eq 0 ] +} + +@test "fails and names disabled functions, the ini file and a conf.d override" { + stub_php_report + PHP_REPORT='disabled pcntl_signal\ndisabled pcntl_signal_dispatch\nini /etc/php/8.3/cli/php.ini\nscandir /etc/php/8.3/cli/conf.d\n' run check_php_functions + [ "$status" -eq 1 ] + [[ "$output" == *"ERROR: The PHP CLI ("*") disables functions EasyEngine needs: pcntl_signal, pcntl_signal_dispatch."* ]] + [[ "$output" == *" disable_functions is set in: /etc/php/8.3/cli/php.ini"* ]] + [[ "$output" == *"create /etc/php/8.3/cli/conf.d/99-easyengine.ini that sets disable_functions to the same list without these functions, or edit php.ini directly."* ]] + [[ "$output" == *"Only the PHP CLI configuration needs the change, not PHP-FPM. Then re-run the installer."* ]] + assert_error_lines 4 +} + +@test "suggests editing the files when a conf.d file disables the functions" { + stub_php_report + PHP_REPORT='disabled exec\nini /etc/php/8.3/cli/php.ini\nini /etc/php/8.3/cli/conf.d/99-zz-hardening.ini\nscandir /etc/php/8.3/cli/conf.d\n' run check_php_functions + [ "$status" -eq 1 ] + [[ "$output" == *"disable_functions is set in: /etc/php/8.3/cli/php.ini, /etc/php/8.3/cli/conf.d/99-zz-hardening.ini"* ]] + [[ "$output" == *"remove them from disable_functions in the file(s) above."* ]] + [[ "$output" != *99-easyengine.ini* ]] + assert_error_lines 4 +} + +@test "only warns about pcntl functions PHP 7 disables" { + stub_php_report + PHP_REPORT='degraded pcntl_signal\n' run check_php_functions + [ "$status" -eq 0 ] + [[ "$output" == *"disables pcntl_signal; EasyEngine can't roll back"* ]] + [[ "$output" != *ERROR* ]] +} + +@test "points at php --ini when no file could be identified" { + stub_php_report + PHP_REPORT='disabled exec\n' run check_php_functions + [ "$status" -eq 1 ] + [[ "$output" == *"disable_functions is set in one of the files that php --ini lists."* ]] + [[ "$output" == *"remove them from disable_functions in that file."* ]] + assert_error_lines 4 +} + +@test "fails and names functions missing from the PHP build" { + stub_php_report + PHP_REPORT='missing pcntl_signal\n' run check_php_functions + [ "$status" -eq 1 ] + [[ "$output" == *"lacks functions EasyEngine needs: pcntl_signal."* ]] + [[ "$output" == *"To fix it, install or enable the PHP extensions that provide them"* ]] + [[ "$output" == *"Then re-run the installer."* ]] + [[ "$output" != *"disables functions"* ]] + assert_error_lines 2 +} + +@test "fails when the PHP CLI cannot run" { + stub_php_report + PHP_EXIT=255 run check_php_functions + [ "$status" -eq 1 ] + [[ "$output" == *"Could not run the PHP CLI"* ]] +} + +@test "skips the check when no php is installed" { + PATH="$STUB_DIR:/usr/bin/missing" run check_php_functions + [ "$status" -eq 0 ] +} + +# The rest run the real PHP code against a real php, when one is available. +stub_real_php() { # stub_real_php + if [ -z "$REAL_PHP" ]; then + skip "no php on this host" + fi + printf '#!/bin/sh\nexec %s -d "disable_functions=%s" "$@"\n' "$REAL_PHP" "$1" | make_stub php +} + +@test "real php: detects functions disabled via disable_functions" { + # PHP splits the list on commas and whitespace. + stub_real_php "pcntl_alarm, curl_exec exec" + run check_php_functions + [ "$status" -eq 1 ] + [[ "$output" == *"disables functions EasyEngine needs: curl_exec, exec."* ]] +} + +@test "real php: passes when nothing required is disabled" { + stub_real_php "" + # shellcheck disable=SC2016,SC2086 + if ! php -r 'foreach (array_slice($argv, 1) as $f) { if (!function_exists($f)) { exit(1); } }' -- $EE_PHP_REQUIRED_FUNCTIONS; then + skip "this php lacks an extension ee needs (e.g. curl, pcntl, posix)" + fi + run check_php_functions + [ "$status" -eq 0 ] +} + +@test "check_dependencies checks PHP functions after PHP setup and before Docker" { + CALLS="" + for fn in ee_apt_update setup_host_dependencies setup_php setup_php_extensions check_php_functions setup_docker check_swap; do + eval "$fn() { CALLS+=\" $fn\"; }" + done + export EE_LINUX_DISTRO=Debian + printf '#!/bin/sh\nexit 0\n' | make_stub sqlite3 + check_dependencies >/dev/null + [[ "$CALLS" == *"setup_php_extensions check_php_functions setup_docker"* ]] +}