From bd867157f74c531aa0a089f1568d0f347ac04d4e Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Thu, 29 Oct 2026 06:26:49 +0530 Subject: [PATCH 1/3] fix(cli): check the PHP extensions and functions ee needs before a command runs On PHP 8 a function listed in disable_functions is removed, so calling one is a fatal "Call to undefined function". A host whose CLI php.ini disables functions ee calls (HestiaCP disables pcntl_*; other panels disable exec, proc_open, chown, symlink, curl_exec, ...) or whose PHP lacks an extension got a PHP fatal in the middle of a command instead of an error saying what to fix (EasyEngine/easyengine#1956). EE\Requirements lists the extensions and functions ee calls without a guard and checks them with function_exists()/extension_loaded() only, so the check itself can't hit a disabled function. Runner::check_requirements() runs it first, for every command it already covers: a missing requirement is an error naming the extensions or functions, the ini files whose disable_functions lists them, and a fix (remove them for the PHP CLI, with a conf.d override when one would be read last; the Debian/Ubuntu package for a missing extension). Optional functions (pcntl_signal, pcntl_signal_dispatch, shell_exec) only log a debug line. ee cli info shows a "PHP requirements" row and the error as a warning, and skips the migration until the requirements are met. ee cli update only requires what it needs to run the new phar and warns about the rest, so a host can still update to a fixed release. ee cli self-uninstall only requires the process functions and pdo_sqlite, and works before any migration created the sites table. Two boot-time calls that ran before any check are gone: init-ee.php uses PHP_OS instead of php_uname(), and the first-run mkdir no longer shells out. A failed requirement now exits 1 instead of 0. --- php/EE/Requirements.php | 355 +++++++++++++++++++++++++++++++ php/EE/Runner.php | 23 +- php/commands/src/CLI_Command.php | 46 +++- php/init-ee.php | 3 +- 4 files changed, 415 insertions(+), 12 deletions(-) create mode 100644 php/EE/Requirements.php diff --git a/php/EE/Requirements.php b/php/EE/Requirements.php new file mode 100644 index 000000000..f6331d873 --- /dev/null +++ b/php/EE/Requirements.php @@ -0,0 +1,355 @@ + [ 'chgrp', 'chown', 'disk_free_space', 'escapeshellarg', 'exec', 'gethostname', 'getmypid', 'parse_ini_string', 'php_uname', 'proc_close', 'proc_open', 'stream_select', 'stream_set_blocking', 'symlink' ], + 'curl' => [ 'curl_close', 'curl_errno', 'curl_error', 'curl_exec', 'curl_getinfo', 'curl_init', 'curl_setopt', 'curl_version' ], + 'openssl' => [ 'openssl_pkey_get_private', 'openssl_x509_check_private_key', 'openssl_x509_parse' ], + 'posix' => [ 'posix_getegid', 'posix_geteuid', 'posix_getpwuid' ], + 'zlib' => [ 'gzclose', 'gzeof', 'gzgets', 'gzopen', 'gzwrite' ], + ]; + + /** + * Extensions used through classes (PDO, ZipArchive) rather than functions. + */ + const EXTENSIONS = [ 'pdo_sqlite', 'zip' ]; + + /** + * What running external commands (EE::exec(), EE::launch()) needs. + */ + const PROCESS_FUNCTIONS = [ 'proc_close', 'proc_open', 'stream_select', 'stream_set_blocking' ]; + + /** + * Functions EasyEngine uses only when available, with what is lost without them. + */ + const OPTIONAL_FUNCTIONS = [ + 'pcntl_signal' => 'site commands interrupted by a signal (e.g. Ctrl+C) are not rolled back', + 'pcntl_signal_dispatch' => 'Ctrl+C while waiting for another SSL operation takes effect only after the wait', + 'shell_exec' => '`ee site share` does not work', + ]; + + /** + * Debian/Ubuntu package suffixes (php-) of extensions that aren't built into php-cli there. + */ + const DEB_PACKAGES = [ + 'curl' => 'curl', + 'mbstring' => 'mbstring', + 'pdo_sqlite' => 'sqlite3', + 'posix' => 'common', + 'zip' => 'zip', + ]; + + /** + * Find the required extensions and functions this PHP lacks. + * + * @param string[]|null $only Extension or function names to check, null for everything EasyEngine needs. + * + * @return array { + * @type string[] $extensions Extensions that aren't loaded. + * @type string[] $disabled Functions listed in `disable_functions`. + * @type string[] $missing Functions missing for another reason (e.g. a PHP build without them). + * } + */ + public static function get_missing( $only = null ) { + + $missing = [ + 'extensions' => [], + 'disabled' => [], + 'missing' => [], + ]; + $wanted = function ( $name ) use ( $only ) { + return null === $only || in_array( $name, $only, true ); + }; + + foreach ( self::FUNCTIONS as $extension => $functions ) { + if ( ! extension_loaded( $extension ) ) { + if ( $wanted( $extension ) || array_filter( $functions, $wanted ) ) { + $missing['extensions'][] = $extension; + } + continue; + } + foreach ( $functions as $function ) { + if ( ( $wanted( $function ) || $wanted( $extension ) ) && ! function_exists( $function ) ) { + $missing[ self::is_disabled( $function ) ? 'disabled' : 'missing' ][] = $function; + } + } + } + + foreach ( self::EXTENSIONS as $extension ) { + if ( $wanted( $extension ) && ! extension_loaded( $extension ) ) { + $missing['extensions'][] = $extension; + } + } + + // Symfony's mbstring polyfill, used for all table output, needs iconv. + if ( $wanted( 'mbstring' ) && ! extension_loaded( 'mbstring' ) && ! extension_loaded( 'iconv' ) ) { + $missing['extensions'][] = 'mbstring'; + } + + // Names this class doesn't list are checked as functions, so a caller can't get a false OK. + $known = array_merge( array_keys( self::FUNCTIONS ), self::EXTENSIONS, [ 'mbstring' ], call_user_func_array( 'array_merge', array_values( self::FUNCTIONS ) ) ); + foreach ( array_diff( (array) $only, $known ) as $function ) { + if ( ! function_exists( $function ) ) { + $missing[ self::is_disabled( $function ) ? 'disabled' : 'missing' ][] = $function; + } + } + + return $missing; + } + + /** + * Whether get_missing() found anything. + * + * @param array $missing Result of get_missing(). + * + * @return bool + */ + public static function has_missing( $missing ) { + return ! empty( $missing['extensions'] ) || ! empty( $missing['disabled'] ) || ! empty( $missing['missing'] ); + } + + /** + * Summarize what get_missing() found in one line, e.g. for `ee cli info`. + * + * @param array $missing Result of get_missing(). + * + * @return string Empty when nothing is missing. + */ + public static function get_summary( $missing ) { + $labels = [ + 'extensions' => 'missing extensions', + 'disabled' => 'disabled functions', + 'missing' => 'missing functions', + ]; + $parts = []; + foreach ( $labels as $key => $label ) { + if ( ! empty( $missing[ $key ] ) ) { + $parts[] = $label . ': ' . implode( ', ', $missing[ $key ] ); + } + } + + return ucfirst( implode( '; ', $parts ) ); + } + + /** + * Find the optional functions this PHP lacks. + * + * @return string[] What is lost without each, keyed by function name. + */ + public static function get_missing_optional() { + $missing = []; + foreach ( self::OPTIONAL_FUNCTIONS as $function => $impact ) { + // Without pcntl_signal() no handler waits to be dispatched, so Ctrl+C ends the wait at once. + if ( 'pcntl_signal_dispatch' === $function && ! function_exists( 'pcntl_signal' ) ) { + continue; + } + if ( ! function_exists( $function ) ) { + $missing[ $function ] = $impact; + } + } + + return $missing; + } + + /** + * Get the functions listed in `disable_functions`. + * + * @return string[] + */ + public static function get_disabled_functions() { + if ( function_exists( 'ini_get' ) ) { + $value = ini_get( 'disable_functions' ); + } elseif ( function_exists( 'get_cfg_var' ) ) { + $value = get_cfg_var( 'disable_functions' ); + } else { + return []; + } + + return array_values( array_filter( array_map( 'trim', explode( ',', (string) $value ) ) ) ); + } + + /** + * Whether a function is listed in `disable_functions`. + * + * @param string $function Function name. + * + * @return bool + */ + private static function is_disabled( $function ) { + return in_array( strtolower( $function ), array_map( 'strtolower', self::get_disabled_functions() ), true ); + } + + /** + * Get the loaded ini files, in the order PHP reads them. + * + * @return string[] + */ + private static function get_ini_files() { + $files = []; + if ( function_exists( 'php_ini_loaded_file' ) ) { + $files[] = (string) php_ini_loaded_file(); + } + if ( function_exists( 'php_ini_scanned_files' ) ) { + $files = array_merge( $files, explode( ',', (string) php_ini_scanned_files() ) ); + } + + return array_values( array_filter( array_map( 'trim', $files ) ) ); + } + + /** + * Get the loaded ini files whose `disable_functions` lists any of the given functions. + * + * @param string[] $functions Function names. + * + * @return string[] + */ + public static function get_disabling_ini_files( $functions ) { + $functions = array_map( 'strtolower', $functions ); + $disabling = []; + foreach ( self::get_ini_files() as $file ) { + $content = function_exists( 'file_get_contents' ) && is_readable( $file ) ? (string) file_get_contents( $file ) : ''; + if ( ! preg_match_all( '/^[ \t]*disable_functions[ \t]*=(.*)$/m', $content, $matches ) ) { + continue; + } + // The last directive in a file is the one that counts; `;` starts a comment. + $value = preg_replace( '/;.*$/', '', end( $matches[1] ) ); + $listed = array_map( 'strtolower', array_map( 'trim', explode( ',', trim( $value, " \t\r\"'" ) ) ) ); + if ( array_intersect( $functions, $listed ) ) { + $disabling[] = $file; + } + } + + return $disabling; + } + + /** + * Build the error message for what get_missing() found, with how to fix it. + * + * @param array $missing Result of get_missing(). + * + * @return string + */ + public static function get_error_message( $missing ) { + + $php = sprintf( 'PHP %s (%s)', PHP_VERSION, PHP_BINARY ); + $messages = []; + + if ( ! empty( $missing['disabled'] ) ) { + $files = self::get_disabling_ini_files( $missing['disabled'] ); + $messages[] = sprintf( + 'EasyEngine needs the PHP %s %s, which %s disables with disable_functions in %s.', + self::plural( $missing['disabled'], 'function', 'functions' ), + implode( ', ', $missing['disabled'] ), + $php, + empty( $files ) ? 'its configuration (see `php --ini`)' : implode( ', ', $files ) + ); + $messages[] = sprintf( 'Remove %s from disable_functions for the PHP CLI (if the web server\'s PHP has its own configuration, that can stay as it is).', self::plural( $missing['disabled'], 'it', 'them' ) ) . self::get_override_hint( $missing['disabled'], $files ); + } + + if ( ! empty( $missing['extensions'] ) ) { + $messages[] = sprintf( + 'EasyEngine needs the PHP %s %s, which %s does not load. %s', + self::plural( $missing['extensions'], 'extension', 'extensions' ), + implode( ', ', $missing['extensions'] ), + $php, + self::get_extension_hint( $missing['extensions'] ) + ); + } + + if ( ! empty( $missing['missing'] ) ) { + $messages[] = sprintf( + 'EasyEngine needs the PHP %s %s, which %s lacks. Use a PHP build that includes %s.', + self::plural( $missing['missing'], 'function', 'functions' ), + implode( ', ', $missing['missing'] ), + $php, + self::plural( $missing['missing'], 'it', 'them' ) + ); + } + + return implode( PHP_EOL, $messages ); + } + + /** + * Suggest an override file in the scan directory, which survives tools that rewrite php.ini. + * + * @param string[] $disabled Required functions that are disabled. + * @param string[] $files Ini files that disable them. + * + * @return string Empty when no override file would be read last. + */ + private static function get_override_hint( $disabled, $files ) { + $scanned = array_slice( self::get_ini_files(), function_exists( 'php_ini_loaded_file' ) && php_ini_loaded_file() ? 1 : 0 ); + $scan_dir = empty( $scanned ) ? ( defined( 'PHP_CONFIG_FILE_SCAN_DIR' ) ? PHP_CONFIG_FILE_SCAN_DIR : '' ) : dirname( end( $scanned ) ); + // Without a disabling ini file the value comes from `php -d`, which no ini file overrides. + if ( '' === $scan_dir || empty( $files ) ) { + return ''; + } + + $name = '99-easyengine.ini'; + foreach ( $files as $file ) { + // A disabling file read after the override would win, so it has to be edited instead. + if ( in_array( $file, $scanned, true ) && strcmp( basename( $file ), $name ) > 0 ) { + return ''; + } + } + $keep = array_diff( self::get_disabled_functions(), $disabled ); + + return sprintf( + ' Tools that manage php.ini may rewrite it, so an override read after it keeps the change, e.g. %s/%s containing: disable_functions = %s', + rtrim( $scan_dir, '/' ), + $name, + empty( $keep ) ? '""' : implode( ',', $keep ) + ); + } + + /** + * Get a hint on how to install missing extensions. + * + * @param string[] $extensions Missing extensions. + * + * @return string + */ + private static function get_extension_hint( $extensions ) { + $it = self::plural( $extensions, 'it', 'them' ); + if ( 'Darwin' === PHP_OS ) { + return sprintf( 'Install or enable %s, or use a PHP that includes %s, e.g. Homebrew\'s (`brew install php`).', $it, $it ); + } + + $packages = []; + foreach ( $extensions as $extension ) { + if ( isset( self::DEB_PACKAGES[ $extension ] ) ) { + $packages[] = 'php' . PHP_MAJOR_VERSION . '.' . PHP_MINOR_VERSION . '-' . self::DEB_PACKAGES[ $extension ]; + } + } + + return sprintf( 'Install or enable %s for this PHP%s.', $it, empty( $packages ) ? '' : ', e.g. on Debian/Ubuntu: `apt-get install ' . implode( ' ', $packages ) . '`' ); + } + + /** + * Pick the singular or plural word for a list. + * + * @param array $items List. + * @param string $singular Word for one item. + * @param string $plural Word for more. + * + * @return string + */ + private static function plural( $items, $singular, $plural ) { + return 1 === count( $items ) ? $singular : $plural; + } +} diff --git a/php/EE/Runner.php b/php/EE/Runner.php index 2b96b1c73..a3ea143a4 100644 --- a/php/EE/Runner.php +++ b/php/EE/Runner.php @@ -84,14 +84,29 @@ private function init_ee() { /** * Check EE requirements for required commands. * - * @param bool $show_error To display error or to retutn status. + * @param bool $show_error To display error or to return status. + * @param string[]|null $php_requirements PHP extensions or functions to require, null for all EasyEngine needs. */ - public function check_requirements( $show_error = true ) { + public function check_requirements( $show_error = true, $php_requirements = null ) { $docker_running = true; $status = true; $error = []; + // Before the Docker checks, which already need proc_open() and the stream functions. + $missing_php = Requirements::get_missing( $php_requirements ); + if ( Requirements::has_missing( $missing_php ) ) { + if ( $show_error ) { + EE::error( Requirements::get_error_message( $missing_php ) ); + } + return false; + } + if ( null === $php_requirements ) { + foreach ( Requirements::get_missing_optional() as $function => $impact ) { + EE::debug( sprintf( 'PHP function %s() is unavailable, so %s.', $function, $impact ), 'bootstrap' ); + } + } + // Retry logic for Docker availability check to handle transient failures under system load. $docker_running_cmd = 'docker ps > /dev/null 2>&1'; $max_retries = 4; @@ -137,7 +152,7 @@ public function check_requirements( $show_error = true ) { if ( IS_DARWIN && ! $docker_running ) { EE::log( 'For macOS docker can be installed using: `brew cask install docker`' ); } - die; + EE::halt( 1 ); } return $status; @@ -600,7 +615,7 @@ public function init_logger() { // Create the config directory if not exist for file logger to initialize. if ( ! is_dir( EE_ROOT_DIR ) ) { - shell_exec('mkdir -p ' . EE_ROOT_DIR); + @mkdir( EE_ROOT_DIR, 0777, true ); } if ( ! is_writable( EE_ROOT_DIR ) ) { diff --git a/php/commands/src/CLI_Command.php b/php/commands/src/CLI_Command.php index 433821cdc..a7a2af1a5 100644 --- a/php/commands/src/CLI_Command.php +++ b/php/commands/src/CLI_Command.php @@ -2,6 +2,7 @@ use Composer\Semver\Comparator; use EE\Model\Site; +use EE\Requirements; use EE\Utils; use Symfony\Component\Filesystem\Filesystem; @@ -105,7 +106,7 @@ public function version() { public function info( $_, $assoc_args ) { $php_bin = Utils\get_php_binary(); - $system_os = sprintf( '%s %s %s %s', php_uname( 's' ), php_uname( 'r' ), php_uname( 'v' ), php_uname( 'm' ) ); + $system_os = function_exists( 'php_uname' ) ? sprintf( '%s %s %s %s', php_uname( 's' ), php_uname( 'r' ), php_uname( 'v' ), php_uname( 'm' ) ) : PHP_OS; $shell = getenv( 'SHELL' ); if ( ! $shell && Utils\is_windows() ) { $shell = getenv( 'ComSpec' ); @@ -116,6 +117,15 @@ public function info( $_, $assoc_args ) { if ( ! is_dir( $packages_dir ) ) { $packages_dir = null; } + + // Informational only, so a host missing something can still diagnose and update. + $missing_php = Requirements::get_missing(); + $missing_optional = array_keys( Requirements::get_missing_optional() ); + $php_requirements = Requirements::has_missing( $missing_php ) ? Requirements::get_summary( $missing_php ) : 'OK'; + if ( ! empty( $missing_optional ) ) { + $php_requirements .= ' (optional, unavailable: ' . implode( ', ', $missing_optional ) . ')'; + } + if ( \EE\Utils\get_flag_value( $assoc_args, 'format' ) === 'json' ) { $info = array( 'php_binary_path' => $php_bin, @@ -126,6 +136,7 @@ public function info( $_, $assoc_args ) { 'ee_version' => EE_VERSION, 'system_os' => $system_os, 'shell' => $shell, + 'php_requirements' => $php_requirements, ); EE::line( json_encode( $info ) ); @@ -136,7 +147,8 @@ public function info( $_, $assoc_args ) { array( 'Shell', $shell ), array( 'PHP binary', $php_bin ), array( 'PHP version', PHP_VERSION ), - array( 'php.ini used', get_cfg_var( 'cfg_file_path' ) ), + array( 'php.ini used', function_exists( 'get_cfg_var' ) ? get_cfg_var( 'cfg_file_path' ) : '' ), + array( 'PHP requirements', $php_requirements ), array( 'EE root dir', EE_ROOT ), array( 'EE vendor dir', EE_VENDOR_DIR ), array( 'EE phar path', ( defined( 'EE_PHAR_PATH' ) ? EE_PHAR_PATH : '' ) ), @@ -154,6 +166,10 @@ public function info( $_, $assoc_args ) { \EE::line( $line ); } } + + if ( Requirements::has_missing( $missing_php ) ) { + EE::warning( Requirements::get_error_message( $missing_php ) ); + } } /** @@ -290,7 +306,12 @@ public function update( $_, $assoc_args ) { $download_url = $newest['package_url']; $md5_url = str_replace( '.phar', '.phar.md5', $download_url ); } - EE::get_runner()->check_requirements(); + // Updating only needs to run the new phar; blocking it on the rest would keep a host off fixed releases. + EE::get_runner()->check_requirements( true, Requirements::PROCESS_FUNCTIONS ); + $missing_php = Requirements::get_missing(); + if ( Requirements::has_missing( $missing_php ) ) { + EE::warning( 'The update can go ahead, but other ee commands will not run until this is fixed:' . PHP_EOL . Requirements::get_error_message( $missing_php ) ); + } EE::log( sprintf( 'Downloading from %s...', $download_url ) ); $temp = \EE\Utils\get_temp_dir() . uniqid( 'ee_', true ) . '.phar'; $headers = array(); @@ -500,9 +521,11 @@ public function cmd_dump() { */ public function self_uninstall( $args, $assoc_args ) { - if ( ! EE::get_runner()->check_requirements( false ) ) { - EE::error( 'Unable to proceed with uninstallation. Seems there is a dependency down.', false ); - die; + // Uninstalling only runs docker and reads the site list. + $php_requirements = array_merge( Requirements::PROCESS_FUNCTIONS, [ 'pdo_sqlite' ] ); + if ( ! EE::get_runner()->check_requirements( false, $php_requirements ) ) { + $missing_php = Requirements::get_missing( $php_requirements ); + EE::error( 'Unable to proceed with uninstallation. Seems there is a dependency down.' . ( Requirements::has_missing( $missing_php ) ? PHP_EOL . Requirements::get_error_message( $missing_php ) : '' ) ); } EE::confirm( "Are you sure you want to remove EasyEngine and all its sites(along with their data)?\nThis is an irreversible action. No backup will be kept.", $assoc_args ); @@ -512,7 +535,16 @@ public function self_uninstall( $args, $assoc_args ) { EE::exec( 'docker volume rm -f $(docker volume ls -f "label=org.label-schema.vendor=EasyEngine" -q)' ); EE::exec( 'docker image rm $(docker image ls -f "label=org.label-schema.vendor=EasyEngine" -q)' ); - $records = Site::all( [ 'site_fs_path' ] ); + try { + $records = Site::all( [ 'site_fs_path' ] ); + } catch ( \PDOException $e ) { + // No sites table yet: no command ran its migrations, e.g. while a PHP requirement was missing. + if ( false === stripos( $e->getMessage(), 'no such table' ) ) { + throw $e; + } + EE::debug( 'Could not read the site list: ' . $e->getMessage() ); + $records = []; + } $fs = new Filesystem(); if ( ! empty( $records ) ) { diff --git a/php/init-ee.php b/php/init-ee.php index f5574d9b3..5c82b1746 100644 --- a/php/init-ee.php +++ b/php/init-ee.php @@ -4,7 +4,8 @@ define( 'EE', true ); define( 'EE_VERSION', trim( file_get_contents( EE_ROOT . '/VERSION' ) ) ); define( 'EE_START_MICROTIME', microtime( true ) ); -define( 'IS_DARWIN', 'Darwin' === php_uname( 's' ) ); +// PHP_OS, not php_uname(): that can be disabled, and this runs before the requirements check. +define( 'IS_DARWIN', 'Darwin' === PHP_OS ); if ( IS_DARWIN ) { define( 'EE_ROOT_DIR', rtrim( getenv( 'HOME' ), '/\\' ) . '/easyengine' ); From 8c3b212270651e959b5a29af0193ac914dfe3821 Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Sun, 1 Nov 2026 06:05:50 +0530 Subject: [PATCH 2/3] fix(requirements): read disable_functions and the ini scan directory as PHP does PHP splits disable_functions on spaces as well as commas, so a space-separated list was read as one name: the functions were reported as missing from the build instead of disabled, and the ini file and override were not named. When no ini file is scanned, the override hint now follows PHP_INI_SCAN_DIR (empty: no hint) instead of the compiled-in directory. --- php/EE/Requirements.php | 25 ++++++++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/php/EE/Requirements.php b/php/EE/Requirements.php index f6331d873..8031c0b47 100644 --- a/php/EE/Requirements.php +++ b/php/EE/Requirements.php @@ -180,7 +180,8 @@ public static function get_disabled_functions() { return []; } - return array_values( array_filter( array_map( 'trim', explode( ',', (string) $value ) ) ) ); + // PHP splits the list on spaces and commas only. + return array_values( array_filter( preg_split( '/[ ,]+/', (string) $value ) ) ); } /** @@ -228,7 +229,7 @@ public static function get_disabling_ini_files( $functions ) { } // The last directive in a file is the one that counts; `;` starts a comment. $value = preg_replace( '/;.*$/', '', end( $matches[1] ) ); - $listed = array_map( 'strtolower', array_map( 'trim', explode( ',', trim( $value, " \t\r\"'" ) ) ) ); + $listed = array_map( 'strtolower', preg_split( '/[ ,]+/', trim( $value, " \t\r\"'" ) ) ); if ( array_intersect( $functions, $listed ) ) { $disabling[] = $file; } @@ -294,7 +295,7 @@ public static function get_error_message( $missing ) { */ private static function get_override_hint( $disabled, $files ) { $scanned = array_slice( self::get_ini_files(), function_exists( 'php_ini_loaded_file' ) && php_ini_loaded_file() ? 1 : 0 ); - $scan_dir = empty( $scanned ) ? ( defined( 'PHP_CONFIG_FILE_SCAN_DIR' ) ? PHP_CONFIG_FILE_SCAN_DIR : '' ) : dirname( end( $scanned ) ); + $scan_dir = empty( $scanned ) ? self::get_scan_dir() : dirname( end( $scanned ) ); // Without a disabling ini file the value comes from `php -d`, which no ini file overrides. if ( '' === $scan_dir || empty( $files ) ) { return ''; @@ -317,6 +318,24 @@ private static function get_override_hint( $disabled, $files ) { ); } + /** + * Get the directory PHP scans last for ini files. + * + * @return string Empty when PHP scans none. + */ + private static function get_scan_dir() { + $builtin = defined( 'PHP_CONFIG_FILE_SCAN_DIR' ) ? PHP_CONFIG_FILE_SCAN_DIR : ''; + $env = function_exists( 'getenv' ) ? getenv( 'PHP_INI_SCAN_DIR' ) : ( isset( $_SERVER['PHP_INI_SCAN_DIR'] ) ? $_SERVER['PHP_INI_SCAN_DIR'] : false ); + // PHP_INI_SCAN_DIR replaces the built-in directory: empty turns scanning off, an empty entry in a list stands for the built-in one. + if ( false === $env || '' === $env ) { + return false === $env ? $builtin : ''; + } + $dirs = explode( PATH_SEPARATOR, $env ); + $last = end( $dirs ); + + return '' === $last ? $builtin : $last; + } + /** * Get a hint on how to install missing extensions. * From 160ab728f123c6d8132fe680a12e9a24baf66cb5 Mon Sep 17 00:00:00 2001 From: Riddhesh Sanghvi Date: Sun, 1 Nov 2026 06:29:19 +0530 Subject: [PATCH 3/3] fix(http): use the fsockopen transport when a curl function is unavailable Requests selects its curl transport when curl_init() and curl_exec() exist, then calls curl_setopt() and curl_version() unchecked, so a PHP that disables one of those died with a fatal error in ee cli update (release lookup and downloads) instead of downloading. --- php/utils.php | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/php/utils.php b/php/utils.php index 120d50050..2ca999efd 100644 --- a/php/utils.php +++ b/php/utils.php @@ -521,6 +521,11 @@ function http_request( $method, $url, $data = null, $headers = array(), $options } } + // Requests picks curl when curl_init() and curl_exec() exist, then calls the other curl functions unchecked. + if ( ! isset( $options['transport'] ) && count( array_filter( \EE\Requirements::FUNCTIONS['curl'], 'function_exists' ) ) < count( \EE\Requirements::FUNCTIONS['curl'] ) ) { + $options['transport'] = \WpOrg\Requests\Transport\Fsockopen::class; + } + try { // Updated class name: \WpOrg\Requests\Requests $response = \WpOrg\Requests\Requests::request( $url, $headers, $data, $method, $options );