* * For the full copyright and license information, please view * the LICENSE file that was distributed with this source code. */ namespace ps_metrics_module_v4_0_5\Composer\XdebugHandler; use ps_metrics_module_v4_0_5\Composer\Pcre\Preg; use Psr\Log\LoggerInterface; /** * @author John Stevenson * * @phpstan-import-type restartData from PhpConfig */ class XdebugHandler { const SUFFIX_ALLOW = '_ALLOW_XDEBUG'; const SUFFIX_INIS = '_ORIGINAL_INIS'; const RESTART_ID = 'internal'; const RESTART_SETTINGS = 'XDEBUG_HANDLER_SETTINGS'; const DEBUG = 'XDEBUG_HANDLER_DEBUG'; /** @var string|null */ protected $tmpIni; /** @var bool */ private static $inRestart; /** @var string */ private static $name; /** @var string|null */ private static $skipped; /** @var bool */ private static $xdebugActive; /** @var string|null */ private static $xdebugMode; /** @var string|null */ private static $xdebugVersion; /** @var bool */ private $cli; /** @var string|null */ private $debug; /** @var string */ private $envAllowXdebug; /** @var string */ private $envOriginalInis; /** @var bool */ private $persistent; /** @var string|null */ private $script; /** @var Status */ private $statusWriter; /** * Constructor * * The $envPrefix is used to create distinct environment variables. It is * uppercased and prepended to the default base values. For example 'myapp' * would result in MYAPP_ALLOW_XDEBUG and MYAPP_ORIGINAL_INIS. * * @param string $envPrefix Value used in environment variables * @throws \RuntimeException If the parameter is invalid */ public function __construct($envPrefix) { if (!\is_string($envPrefix) || $envPrefix === '') { throw new \RuntimeException('Invalid constructor parameter'); } self::$name = \strtoupper($envPrefix); $this->envAllowXdebug = self::$name . self::SUFFIX_ALLOW; $this->envOriginalInis = self::$name . self::SUFFIX_INIS; self::setXdebugDetails(); self::$inRestart = \false; if ($this->cli = \PHP_SAPI === 'cli') { $this->debug = (string) \getenv(self::DEBUG); } $this->statusWriter = new Status($this->envAllowXdebug, (bool) $this->debug); } /** * Activates status message output to a PSR3 logger * * @param LoggerInterface $logger * * @return $this */ public function setLogger(LoggerInterface $logger) { $this->statusWriter->setLogger($logger); return $this; } /** * Sets the main script location if it cannot be called from argv * * @param string $script * * @return $this */ public function setMainScript($script) { $this->script = $script; return $this; } /** * Persist the settings to keep Xdebug out of sub-processes * * @return $this */ public function setPersistent() { $this->persistent = \true; return $this; } /** * Checks if Xdebug is loaded and the process needs to be restarted * * This behaviour can be disabled by setting the MYAPP_ALLOW_XDEBUG * environment variable to 1. This variable is used internally so that * the restarted process is created only once. * * @return void */ public function check() { $this->notify(Status::CHECK, self::$xdebugVersion . '|' . self::$xdebugMode); $envArgs = \explode('|', (string) \getenv($this->envAllowXdebug)); if (!(bool) $envArgs[0] && $this->requiresRestart(self::$xdebugActive)) { // Restart required $this->notify(Status::RESTART); if ($this->prepareRestart()) { $command = $this->getCommand(); $this->restart($command); } return; } if (self::RESTART_ID === $envArgs[0] && \count($envArgs) === 5) { // Restarted, so unset environment variable and use saved values $this->notify(Status::RESTARTED); Process::setEnv($this->envAllowXdebug); self::$inRestart = \true; if (self::$xdebugVersion === null) { // Skipped version is only set if Xdebug is not loaded self::$skipped = $envArgs[1]; } $this->tryEnableSignals(); // Put restart settings in the environment $this->setEnvRestartSettings($envArgs); return; } $this->notify(Status::NORESTART); $settings = self::getRestartSettings(); if ($settings !== null) { // Called with existing settings, so sync our settings $this->syncSettings($settings); } } /** * Returns an array of php.ini locations with at least one entry * * The equivalent of calling php_ini_loaded_file then php_ini_scanned_files. * The loaded ini location is the first entry and may be empty. * * @return string[] */ public static function getAllIniFiles() { if (self::$name !== null) { $env = \getenv(self::$name . self::SUFFIX_INIS); if (\false !== $env) { return \explode(\PATH_SEPARATOR, $env); } } $paths = array((string) \php_ini_loaded_file()); $scanned = \php_ini_scanned_files(); if ($scanned !== \false) { $paths = \array_merge($paths, \array_map('trim', \explode(',', $scanned))); } return $paths; } /** * Returns an array of restart settings or null * * Settings will be available if the current process was restarted, or * called with the settings from an existing restart. * * @return array|null * @phpstan-return restartData|null */ public static function getRestartSettings() { $envArgs = \explode('|', (string) \getenv(self::RESTART_SETTINGS)); if (\count($envArgs) !== 6 || !self::$inRestart && \php_ini_loaded_file() !== $envArgs[0]) { return null; } return array('tmpIni' => $envArgs[0], 'scannedInis' => (bool) $envArgs[1], 'scanDir' => '*' === $envArgs[2] ? \false : $envArgs[2], 'phprc' => '*' === $envArgs[3] ? \false : $envArgs[3], 'inis' => \explode(\PATH_SEPARATOR, $envArgs[4]), 'skipped' => $envArgs[5]); } /** * Returns the Xdebug version that triggered a successful restart * * @return string */ public static function getSkippedVersion() { return (string) self::$skipped; } /** * Returns whether Xdebug is loaded and active * * true: if Xdebug is loaded and is running in an active mode. * false: if Xdebug is not loaded, or it is running with xdebug.mode=off. * * @return bool */ public static function isXdebugActive() { self::setXdebugDetails(); return self::$xdebugActive; } /** * Allows an extending class to decide if there should be a restart * * The default is to restart if Xdebug is loaded and its mode is not "off". * Do not typehint for 1.x compatibility. * * @param bool $default The default behaviour * * @return bool Whether the process should restart */ protected function requiresRestart($default) { return $default; } /** * Allows an extending class to access the tmpIni * * Do not typehint for 1.x compatibility * * @param string[] $command * * @return void */ protected function restart($command) { $this->doRestart($command); } /** * Executes the restarted command then deletes the tmp ini * * @param string[] $command * * @return void * @phpstan-return never */ private function doRestart(array $command) { $this->tryEnableSignals(); $this->notify(Status::RESTARTING, \implode(' ', $command)); if (\PHP_VERSION_ID >= 70400) { $cmd = $command; } else { $cmd = Process::escapeShellCommand($command); if (\defined('PHP_WINDOWS_VERSION_BUILD')) { // Outer quotes required on cmd string below PHP 8 $cmd = '"' . $cmd . '"'; } } $process = \proc_open($cmd, array(), $pipes); if (\is_resource($process)) { $exitCode = \proc_close($process); } if (!isset($exitCode)) { // Unlikely that php or the default shell cannot be invoked $this->notify(Status::ERROR, 'Unable to restart process'); $exitCode = -1; } else { $this->notify(Status::INFO, 'Restarted process exited ' . $exitCode); } if ($this->debug === '2') { $this->notify(Status::INFO, 'Temp ini saved: ' . $this->tmpIni); } else { @\unlink((string) $this->tmpIni); } exit($exitCode); } /** * Returns true if everything was written for the restart * * If any of the following fails (however unlikely) we must return false to * stop potential recursion: * - tmp ini file creation * - environment variable creation * * @return bool */ private function prepareRestart() { $error = null; $iniFiles = self::getAllIniFiles(); $scannedInis = \count($iniFiles) > 1; $tmpDir = \sys_get_temp_dir(); if (!$this->cli) { $error = 'Unsupported SAPI: ' . \PHP_SAPI; } elseif (!\defined('PHP_BINARY')) { $error = 'PHP version is too old: ' . \PHP_VERSION; } elseif (!$this->checkConfiguration($info)) { $error = $info; } elseif (!$this->checkScanDirConfig()) { $error = 'PHP version does not report scanned inis: ' . \PHP_VERSION; } elseif (!$this->checkMainScript()) { $error = 'Unable to access main script: ' . $this->script; } elseif (!$this->writeTmpIni($iniFiles, $tmpDir, $error)) { $error = $error !== null ? $error : 'Unable to create temp ini file at: ' . $tmpDir; } elseif (!$this->setEnvironment($scannedInis, $iniFiles)) { $error = 'Unable to set environment variables'; } if ($error !== null) { $this->notify(Status::ERROR, $error); } return $error === null; } /** * Returns true if the tmp ini file was written * * @param string[] $iniFiles All ini files used in the current process * @param string $tmpDir The system temporary directory * @param null|string $error Set by method if ini file cannot be read * * @return bool */ private function writeTmpIni(array $iniFiles, $tmpDir, &$error) { if (($tmpfile = @\tempnam($tmpDir, '')) === \false) { return \false; } $this->tmpIni = $tmpfile; // $iniFiles has at least one item and it may be empty if ($iniFiles[0] === '') { \array_shift($iniFiles); } $content = ''; $sectionRegex = '/^\\s*\\[(?:PATH|HOST)\\s*=/mi'; $xdebugRegex = '/^\\s*(zend_extension\\s*=.*xdebug.*)$/mi'; foreach ($iniFiles as $file) { // Check for inaccessible ini files if (($data = @\file_get_contents($file)) === \false) { $error = 'Unable to read ini: ' . $file; return \false; } // Check and remove directives after HOST and PATH sections if (Preg::isMatchWithOffsets($sectionRegex, $data, $matches, \PREG_OFFSET_CAPTURE)) { $data = \substr($data, 0, $matches[0][1]); } $content .= Preg::replace($xdebugRegex, ';$1', $data) . \PHP_EOL; } // Merge loaded settings into our ini content, if it is valid $config = \parse_ini_string($content); $loaded = \ini_get_all(null, \false); if (\false === $config || \false === $loaded) { $error = 'Unable to parse ini data'; return \false; } $content .= $this->mergeLoadedConfig($loaded, $config); // Work-around for https://bugs.php.net/bug.php?id=75932 $content .= 'opcache.enable_cli=0' . \PHP_EOL; return (bool) @\file_put_contents($this->tmpIni, $content); } /** * Returns the command line arguments for the restart * * @return string[] */ private function getCommand() { $php = array(\PHP_BINARY); $args = \array_slice($_SERVER['argv'], 1); if (!$this->persistent) { // Use command-line options \array_push($php, '-n', '-c', $this->tmpIni); } return \array_merge($php, array($this->script), $args); } /** * Returns true if the restart environment variables were set * * No need to update $_SERVER since this is set in the restarted process. * * @param bool $scannedInis Whether there were scanned ini files * @param string[] $iniFiles All ini files used in the current process * * @return bool */ private function setEnvironment($scannedInis, array $iniFiles) { $scanDir = \getenv('PHP_INI_SCAN_DIR'); $phprc = \getenv('PHPRC'); // Make original inis available to restarted process if (!\putenv($this->envOriginalInis . '=' . \implode(\PATH_SEPARATOR, $iniFiles))) { return \false; } if ($this->persistent) { // Use the environment to persist the settings if (!\putenv('PHP_INI_SCAN_DIR=') || !\putenv('PHPRC=' . $this->tmpIni)) { return \false; } } // Flag restarted process and save values for it to use $envArgs = array(self::RESTART_ID, self::$xdebugVersion, (int) $scannedInis, \false === $scanDir ? '*' : $scanDir, \false === $phprc ? '*' : $phprc); return \putenv($this->envAllowXdebug . '=' . \implode('|', $envArgs)); } /** * Logs status messages * * @param string $op Status handler constant * @param null|string $data Optional data * * @return void */ private function notify($op, $data = null) { $this->statusWriter->report($op, $data); } /** * Returns default, changed and command-line ini settings * * @param mixed[] $loadedConfig All current ini settings * @param mixed[] $iniConfig Settings from user ini files * * @return string */ private function mergeLoadedConfig(array $loadedConfig, array $iniConfig) { $content = ''; foreach ($loadedConfig as $name => $value) { // Value will either be null, string or array (HHVM only) if (!\is_string($value) || \strpos($name, 'xdebug') === 0 || $name === 'apc.mmap_file_mask') { continue; } if (!isset($iniConfig[$name]) || $iniConfig[$name] !== $value) { // Double-quote escape each value $content .= $name . '="' . \addcslashes($value, '\\"') . '"' . \PHP_EOL; } } return $content; } /** * Returns true if the script name can be used * * @return bool */ private function checkMainScript() { if (null !== $this->script) { // Allow an application to set -- for standard input return \file_exists($this->script) || '--' === $this->script; } if (\file_exists($this->script = $_SERVER['argv'][0])) { return \true; } // Use a backtrace to resolve Phar and chdir issues. $trace = \debug_backtrace(\DEBUG_BACKTRACE_IGNORE_ARGS); $main = \end($trace); if ($main !== \false && isset($main['file'])) { return \file_exists($this->script = $main['file']); } return \false; } /** * Adds restart settings to the environment * * @param string[] $envArgs * * @return void */ private function setEnvRestartSettings($envArgs) { $settings = array(\php_ini_loaded_file(), $envArgs[2], $envArgs[3], $envArgs[4], \getenv($this->envOriginalInis), self::$skipped); Process::setEnv(self::RESTART_SETTINGS, \implode('|', $settings)); } /** * Syncs settings and the environment if called with existing settings * * @param array $settings * @phpstan-param restartData $settings * * @return void */ private function syncSettings(array $settings) { if (\false === \getenv($this->envOriginalInis)) { // Called by another app, so make original inis available Process::setEnv($this->envOriginalInis, \implode(\PATH_SEPARATOR, $settings['inis'])); } self::$skipped = $settings['skipped']; $this->notify(Status::INFO, 'Process called with existing restart settings'); } /** * Returns true if there are scanned inis and PHP is able to report them * * php_ini_scanned_files will fail when PHP_CONFIG_FILE_SCAN_DIR is empty. * Fixed in 7.1.13 and 7.2.1 * * @return bool */ private function checkScanDirConfig() { if (\PHP_VERSION_ID >= 70113 && \PHP_VERSION_ID !== 70200) { return \true; } return (string) \getenv('PHP_INI_SCAN_DIR') === '' || \PHP_CONFIG_FILE_SCAN_DIR !== ''; } /** * Returns true if there are no known configuration issues * * @param string $info Set by method * @return bool */ private function checkConfiguration(&$info) { if (!\function_exists('proc_open')) { $info = 'proc_open function is disabled'; return \false; } if (\extension_loaded('uopz') && !(bool) \ini_get('uopz.disable')) { // uopz works at opcode level and disables exit calls if (\function_exists('uopz_allow_exit')) { @\uopz_allow_exit(\true); } else { $info = 'uopz extension is not compatible'; return \false; } } // Check UNC paths when using cmd.exe if (\defined('PHP_WINDOWS_VERSION_BUILD') && \PHP_VERSION_ID < 70400) { $workingDir = \getcwd(); if ($workingDir === \false) { $info = 'unable to determine working directory'; return \false; } if (0 === \strpos($workingDir, '\\\\')) { $info = 'cmd.exe does not support UNC paths: ' . $workingDir; return \false; } } return \true; } /** * Enables async signals and control interrupts in the restarted process * * Available on Unix PHP 7.1+ with the pcntl extension and Windows PHP 7.4+. * * @return void */ private function tryEnableSignals() { if (\function_exists('pcntl_async_signals') && \function_exists('pcntl_signal')) { \pcntl_async_signals(\true); $message = 'Async signals enabled'; if (!self::$inRestart) { // Restarting, so ignore SIGINT in parent \pcntl_signal(\SIGINT, \SIG_IGN); } elseif (\is_int(\pcntl_signal_get_handler(\SIGINT))) { // Restarted, no handler set so force default action \pcntl_signal(\SIGINT, \SIG_DFL); } } if (!self::$inRestart && \function_exists('sapi_windows_set_ctrl_handler')) { // Restarting, so set a handler to ignore CTRL events in the parent. // This ensures that CTRL+C events will be available in the child // process without having to enable them there, which is unreliable. \sapi_windows_set_ctrl_handler(function ($evt) { }); } } /** * Sets static properties $xdebugActive, $xdebugVersion and $xdebugMode * * @return void */ private static function setXdebugDetails() { if (self::$xdebugActive !== null) { return; } self::$xdebugActive = \false; if (!\extension_loaded('xdebug')) { return; } $version = \phpversion('xdebug'); self::$xdebugVersion = $version !== \false ? $version : 'unknown'; if (\version_compare(self::$xdebugVersion, '3.1', '>=')) { $modes = \xdebug_info('mode'); self::$xdebugMode = \count($modes) === 0 ? 'off' : \implode(',', $modes); self::$xdebugActive = self::$xdebugMode !== 'off'; return; } // See if xdebug.mode is supported in this version $iniMode = \ini_get('xdebug.mode'); if ($iniMode === \false) { self::$xdebugActive = \true; return; } // Environment value wins but cannot be empty $envMode = (string) \getenv('XDEBUG_MODE'); if ($envMode !== '') { self::$xdebugMode = $envMode; } else { self::$xdebugMode = $iniMode !== '' ? $iniMode : 'off'; } // An empty comma-separated list is treated as mode 'off' if (Preg::isMatch('/^,+$/', \str_replace(' ', '', self::$xdebugMode))) { self::$xdebugMode = 'off'; } self::$xdebugActive = self::$xdebugMode !== 'off'; } }