XdebugHandler.php 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722
  1. <?php
  2. /*
  3. * This file is part of composer/xdebug-handler.
  4. *
  5. * (c) Composer <https://github.com/composer>
  6. *
  7. * For the full copyright and license information, please view
  8. * the LICENSE file that was distributed with this source code.
  9. */
  10. declare(strict_types=1);
  11. namespace Composer\XdebugHandler;
  12. use Composer\Pcre\Preg;
  13. use Psr\Log\LoggerInterface;
  14. /**
  15. * @author John Stevenson <john-stevenson@blueyonder.co.uk>
  16. *
  17. * @phpstan-import-type restartData from PhpConfig
  18. */
  19. class XdebugHandler
  20. {
  21. const SUFFIX_ALLOW = '_ALLOW_XDEBUG';
  22. const SUFFIX_INIS = '_ORIGINAL_INIS';
  23. const RESTART_ID = 'internal';
  24. const RESTART_SETTINGS = 'XDEBUG_HANDLER_SETTINGS';
  25. const DEBUG = 'XDEBUG_HANDLER_DEBUG';
  26. /** @var string|null */
  27. protected $tmpIni;
  28. /** @var bool */
  29. private static $inRestart;
  30. /** @var string */
  31. private static $name;
  32. /** @var string|null */
  33. private static $skipped;
  34. /** @var bool */
  35. private static $xdebugActive;
  36. /** @var string|null */
  37. private static $xdebugMode;
  38. /** @var string|null */
  39. private static $xdebugVersion;
  40. /** @var bool */
  41. private $cli;
  42. /** @var string|null */
  43. private $debug;
  44. /** @var string */
  45. private $envAllowXdebug;
  46. /** @var string */
  47. private $envOriginalInis;
  48. /** @var bool */
  49. private $persistent;
  50. /** @var string|null */
  51. private $script;
  52. /** @var Status */
  53. private $statusWriter;
  54. /**
  55. * Constructor
  56. *
  57. * The $envPrefix is used to create distinct environment variables. It is
  58. * uppercased and prepended to the default base values. For example 'myapp'
  59. * would result in MYAPP_ALLOW_XDEBUG and MYAPP_ORIGINAL_INIS.
  60. *
  61. * @param string $envPrefix Value used in environment variables
  62. * @throws \RuntimeException If the parameter is invalid
  63. */
  64. public function __construct(string $envPrefix)
  65. {
  66. if ($envPrefix === '') {
  67. throw new \RuntimeException('Invalid constructor parameter');
  68. }
  69. self::$name = strtoupper($envPrefix);
  70. $this->envAllowXdebug = self::$name.self::SUFFIX_ALLOW;
  71. $this->envOriginalInis = self::$name.self::SUFFIX_INIS;
  72. self::setXdebugDetails();
  73. self::$inRestart = false;
  74. if ($this->cli = PHP_SAPI === 'cli') {
  75. $this->debug = (string) getenv(self::DEBUG);
  76. }
  77. $this->statusWriter = new Status($this->envAllowXdebug, (bool) $this->debug);
  78. }
  79. /**
  80. * Activates status message output to a PSR3 logger
  81. */
  82. public function setLogger(LoggerInterface $logger): self
  83. {
  84. $this->statusWriter->setLogger($logger);
  85. return $this;
  86. }
  87. /**
  88. * Sets the main script location if it cannot be called from argv
  89. */
  90. public function setMainScript(string $script): self
  91. {
  92. $this->script = $script;
  93. return $this;
  94. }
  95. /**
  96. * Persist the settings to keep Xdebug out of sub-processes
  97. */
  98. public function setPersistent(): self
  99. {
  100. $this->persistent = true;
  101. return $this;
  102. }
  103. /**
  104. * Checks if Xdebug is loaded and the process needs to be restarted
  105. *
  106. * This behaviour can be disabled by setting the MYAPP_ALLOW_XDEBUG
  107. * environment variable to 1. This variable is used internally so that
  108. * the restarted process is created only once.
  109. */
  110. public function check(): void
  111. {
  112. $this->notify(Status::CHECK, self::$xdebugVersion.'|'.self::$xdebugMode);
  113. $envArgs = explode('|', (string) getenv($this->envAllowXdebug));
  114. if (!((bool) $envArgs[0]) && $this->requiresRestart(self::$xdebugActive)) {
  115. // Restart required
  116. $this->notify(Status::RESTART);
  117. $command = $this->prepareRestart();
  118. if ($command !== null) {
  119. $this->restart($command);
  120. }
  121. return;
  122. }
  123. if (self::RESTART_ID === $envArgs[0] && count($envArgs) === 5) {
  124. // Restarted, so unset environment variable and use saved values
  125. $this->notify(Status::RESTARTED);
  126. Process::setEnv($this->envAllowXdebug);
  127. self::$inRestart = true;
  128. if (self::$xdebugVersion === null) {
  129. // Skipped version is only set if Xdebug is not loaded
  130. self::$skipped = $envArgs[1];
  131. }
  132. $this->tryEnableSignals();
  133. // Put restart settings in the environment
  134. $this->setEnvRestartSettings($envArgs);
  135. return;
  136. }
  137. $this->notify(Status::NORESTART);
  138. $settings = self::getRestartSettings();
  139. if ($settings !== null) {
  140. // Called with existing settings, so sync our settings
  141. $this->syncSettings($settings);
  142. }
  143. }
  144. /**
  145. * Returns an array of php.ini locations with at least one entry
  146. *
  147. * The equivalent of calling php_ini_loaded_file then php_ini_scanned_files.
  148. * The loaded ini location is the first entry and may be an empty string.
  149. *
  150. * @return non-empty-list<string>
  151. */
  152. public static function getAllIniFiles(): array
  153. {
  154. if (self::$name !== null) {
  155. $env = getenv(self::$name.self::SUFFIX_INIS);
  156. if (false !== $env) {
  157. return explode(PATH_SEPARATOR, $env);
  158. }
  159. }
  160. $paths = [(string) php_ini_loaded_file()];
  161. $scanned = php_ini_scanned_files();
  162. if ($scanned !== false) {
  163. $paths = array_merge($paths, array_map('trim', explode(',', $scanned)));
  164. }
  165. return $paths;
  166. }
  167. /**
  168. * Returns an array of restart settings or null
  169. *
  170. * Settings will be available if the current process was restarted, or
  171. * called with the settings from an existing restart.
  172. *
  173. * @phpstan-return restartData|null
  174. */
  175. public static function getRestartSettings(): ?array
  176. {
  177. $envArgs = explode('|', (string) getenv(self::RESTART_SETTINGS));
  178. if (count($envArgs) !== 6
  179. || (!self::$inRestart && php_ini_loaded_file() !== $envArgs[0])) {
  180. return null;
  181. }
  182. return [
  183. 'tmpIni' => $envArgs[0],
  184. 'scannedInis' => (bool) $envArgs[1],
  185. 'scanDir' => '*' === $envArgs[2] ? false : $envArgs[2],
  186. 'phprc' => '*' === $envArgs[3] ? false : $envArgs[3],
  187. 'inis' => explode(PATH_SEPARATOR, $envArgs[4]),
  188. 'skipped' => $envArgs[5],
  189. ];
  190. }
  191. /**
  192. * Returns the Xdebug version that triggered a successful restart
  193. */
  194. public static function getSkippedVersion(): string
  195. {
  196. return (string) self::$skipped;
  197. }
  198. /**
  199. * Returns whether Xdebug is loaded and active
  200. *
  201. * true: if Xdebug is loaded and is running in an active mode.
  202. * false: if Xdebug is not loaded, or it is running with xdebug.mode=off.
  203. */
  204. public static function isXdebugActive(): bool
  205. {
  206. self::setXdebugDetails();
  207. return self::$xdebugActive;
  208. }
  209. /**
  210. * Allows an extending class to decide if there should be a restart
  211. *
  212. * The default is to restart if Xdebug is loaded and its mode is not "off".
  213. */
  214. protected function requiresRestart(bool $default): bool
  215. {
  216. return $default;
  217. }
  218. /**
  219. * Allows an extending class to access the tmpIni
  220. *
  221. * @param non-empty-list<string> $command
  222. */
  223. protected function restart(array $command): void
  224. {
  225. $this->doRestart($command);
  226. }
  227. /**
  228. * Executes the restarted command then deletes the tmp ini
  229. *
  230. * @param non-empty-list<string> $command
  231. * @phpstan-return never
  232. */
  233. private function doRestart(array $command): void
  234. {
  235. if (PHP_VERSION_ID >= 70400) {
  236. $cmd = $command;
  237. $displayCmd = sprintf('[%s]', implode(', ', $cmd));
  238. } else {
  239. $cmd = Process::escapeShellCommand($command);
  240. if (defined('PHP_WINDOWS_VERSION_BUILD')) {
  241. // Outer quotes required on cmd string below PHP 8
  242. $cmd = '"'.$cmd.'"';
  243. }
  244. $displayCmd = $cmd;
  245. }
  246. $this->tryEnableSignals();
  247. $this->notify(Status::RESTARTING, $displayCmd);
  248. $process = proc_open($cmd, [], $pipes);
  249. if (is_resource($process)) {
  250. $exitCode = proc_close($process);
  251. }
  252. if (!isset($exitCode)) {
  253. // Unlikely that php or the default shell cannot be invoked
  254. $this->notify(Status::ERROR, 'Unable to restart process');
  255. $exitCode = -1;
  256. } else {
  257. $this->notify(Status::INFO, 'Restarted process exited '.$exitCode);
  258. }
  259. if ($this->debug === '2') {
  260. $this->notify(Status::INFO, 'Temp ini saved: '.$this->tmpIni);
  261. } else {
  262. @unlink((string) $this->tmpIni);
  263. }
  264. exit($exitCode);
  265. }
  266. /**
  267. * Returns the command line array if everything was written for the restart
  268. *
  269. * If any of the following fails (however unlikely) we must return false to
  270. * stop potential recursion:
  271. * - tmp ini file creation
  272. * - environment variable creation
  273. *
  274. * @return non-empty-list<string>|null
  275. */
  276. private function prepareRestart(): ?array
  277. {
  278. if (!$this->cli) {
  279. $this->notify(Status::ERROR, 'Unsupported SAPI: '.PHP_SAPI);
  280. return null;
  281. }
  282. if (($argv = $this->checkServerArgv()) === null) {
  283. $this->notify(Status::ERROR, '$_SERVER[argv] is not as expected');
  284. return null;
  285. }
  286. if (!$this->checkConfiguration($info)) {
  287. $this->notify(Status::ERROR, $info);
  288. return null;
  289. }
  290. $mainScript = (string) $this->script;
  291. if (!$this->checkMainScript($mainScript, $argv)) {
  292. $this->notify(Status::ERROR, 'Unable to access main script: '.$mainScript);
  293. return null;
  294. }
  295. $tmpDir = sys_get_temp_dir();
  296. $iniError = 'Unable to create temp ini file at: '.$tmpDir;
  297. if (($tmpfile = @tempnam($tmpDir, '')) === false) {
  298. $this->notify(Status::ERROR, $iniError);
  299. return null;
  300. }
  301. $error = null;
  302. $iniFiles = self::getAllIniFiles();
  303. $scannedInis = count($iniFiles) > 1;
  304. if (!$this->writeTmpIni($tmpfile, $iniFiles, $error)) {
  305. $this->notify(Status::ERROR, $error ?? $iniError);
  306. @unlink($tmpfile);
  307. return null;
  308. }
  309. if (!$this->setEnvironment($scannedInis, $iniFiles, $tmpfile)) {
  310. $this->notify(Status::ERROR, 'Unable to set environment variables');
  311. @unlink($tmpfile);
  312. return null;
  313. }
  314. $this->tmpIni = $tmpfile;
  315. return $this->getCommand($argv, $tmpfile, $mainScript);
  316. }
  317. /**
  318. * Returns true if the tmp ini file was written
  319. *
  320. * @param non-empty-list<string> $iniFiles All ini files used in the current process
  321. */
  322. private function writeTmpIni(string $tmpFile, array $iniFiles, ?string &$error): bool
  323. {
  324. // $iniFiles has at least one item and it may be empty
  325. if ($iniFiles[0] === '') {
  326. array_shift($iniFiles);
  327. }
  328. $content = '';
  329. $sectionRegex = '/^\s*\[(?:PATH|HOST)\s*=/mi';
  330. $xdebugRegex = '/^\s*(zend_extension\s*=.*xdebug.*)$/mi';
  331. foreach ($iniFiles as $file) {
  332. // Check for inaccessible ini files
  333. if (($data = @file_get_contents($file)) === false) {
  334. $error = 'Unable to read ini: '.$file;
  335. return false;
  336. }
  337. // Check and remove directives after HOST and PATH sections
  338. if (Preg::isMatchWithOffsets($sectionRegex, $data, $matches)) {
  339. $data = substr($data, 0, $matches[0][1]);
  340. }
  341. $content .= Preg::replace($xdebugRegex, ';$1', $data).PHP_EOL;
  342. }
  343. // Merge loaded settings into our ini content, if it is valid
  344. $config = parse_ini_string($content);
  345. $loaded = ini_get_all(null, false);
  346. if (false === $config || false === $loaded) {
  347. $error = 'Unable to parse ini data';
  348. return false;
  349. }
  350. $content .= $this->mergeLoadedConfig($loaded, $config);
  351. // Work-around for https://bugs.php.net/bug.php?id=75932
  352. $content .= 'opcache.enable_cli=0'.PHP_EOL;
  353. return (bool) @file_put_contents($tmpFile, $content);
  354. }
  355. /**
  356. * Returns the command line arguments for the restart
  357. *
  358. * @param non-empty-list<string> $argv
  359. * @return non-empty-list<string>
  360. */
  361. private function getCommand(array $argv, string $tmpIni, string $mainScript): array
  362. {
  363. $php = [PHP_BINARY];
  364. $args = array_slice($argv, 1);
  365. if (!$this->persistent) {
  366. // Use command-line options
  367. array_push($php, '-n', '-c', $tmpIni);
  368. }
  369. return array_merge($php, [$mainScript], $args);
  370. }
  371. /**
  372. * Returns true if the restart environment variables were set
  373. *
  374. * No need to update $_SERVER since this is set in the restarted process.
  375. *
  376. * @param non-empty-list<string> $iniFiles All ini files used in the current process
  377. */
  378. private function setEnvironment(bool $scannedInis, array $iniFiles, string $tmpIni): bool
  379. {
  380. $scanDir = getenv('PHP_INI_SCAN_DIR');
  381. $phprc = getenv('PHPRC');
  382. // Make original inis available to restarted process
  383. if (!putenv($this->envOriginalInis.'='.implode(PATH_SEPARATOR, $iniFiles))) {
  384. return false;
  385. }
  386. if ($this->persistent) {
  387. // Use the environment to persist the settings
  388. if (!putenv('PHP_INI_SCAN_DIR=') || !putenv('PHPRC='.$tmpIni)) {
  389. return false;
  390. }
  391. }
  392. // Flag restarted process and save values for it to use
  393. $envArgs = [
  394. self::RESTART_ID,
  395. self::$xdebugVersion,
  396. (int) $scannedInis,
  397. false === $scanDir ? '*' : $scanDir,
  398. false === $phprc ? '*' : $phprc,
  399. ];
  400. return putenv($this->envAllowXdebug.'='.implode('|', $envArgs));
  401. }
  402. /**
  403. * Logs status messages
  404. */
  405. private function notify(string $op, ?string $data = null): void
  406. {
  407. $this->statusWriter->report($op, $data);
  408. }
  409. /**
  410. * Returns default, changed and command-line ini settings
  411. *
  412. * @param mixed[] $loadedConfig All current ini settings
  413. * @param mixed[] $iniConfig Settings from user ini files
  414. *
  415. */
  416. private function mergeLoadedConfig(array $loadedConfig, array $iniConfig): string
  417. {
  418. $content = '';
  419. foreach ($loadedConfig as $name => $value) {
  420. // Value will either be null, string or array (HHVM only)
  421. if (!is_string($value)
  422. || strpos($name, 'xdebug') === 0
  423. || $name === 'apc.mmap_file_mask') {
  424. continue;
  425. }
  426. if (!isset($iniConfig[$name]) || $iniConfig[$name] !== $value) {
  427. // Double-quote escape each value
  428. $content .= $name.'="'.addcslashes($value, '\\"').'"'.PHP_EOL;
  429. }
  430. }
  431. return $content;
  432. }
  433. /**
  434. * Returns true if the script name can be used
  435. *
  436. * @param non-empty-list<string> $argv
  437. */
  438. private function checkMainScript(string &$mainScript, array $argv): bool
  439. {
  440. if ($mainScript !== '') {
  441. // Allow an application to set -- for standard input
  442. return file_exists($mainScript) || '--' === $mainScript;
  443. }
  444. if (file_exists($mainScript = $argv[0])) {
  445. return true;
  446. }
  447. // Use a backtrace to resolve Phar and chdir issues.
  448. $trace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS);
  449. $main = end($trace);
  450. if ($main !== false && isset($main['file'])) {
  451. return file_exists($mainScript = $main['file']);
  452. }
  453. return false;
  454. }
  455. /**
  456. * Adds restart settings to the environment
  457. *
  458. * @param non-empty-list<string> $envArgs
  459. */
  460. private function setEnvRestartSettings(array $envArgs): void
  461. {
  462. $settings = [
  463. php_ini_loaded_file(),
  464. $envArgs[2],
  465. $envArgs[3],
  466. $envArgs[4],
  467. getenv($this->envOriginalInis),
  468. self::$skipped,
  469. ];
  470. Process::setEnv(self::RESTART_SETTINGS, implode('|', $settings));
  471. }
  472. /**
  473. * Syncs settings and the environment if called with existing settings
  474. *
  475. * @phpstan-param restartData $settings
  476. */
  477. private function syncSettings(array $settings): void
  478. {
  479. if (false === getenv($this->envOriginalInis)) {
  480. // Called by another app, so make original inis available
  481. Process::setEnv($this->envOriginalInis, implode(PATH_SEPARATOR, $settings['inis']));
  482. }
  483. self::$skipped = $settings['skipped'];
  484. $this->notify(Status::INFO, 'Process called with existing restart settings');
  485. }
  486. /**
  487. * Returns true if there are no known configuration issues
  488. */
  489. private function checkConfiguration(?string &$info): bool
  490. {
  491. if (!function_exists('proc_open')) {
  492. $info = 'proc_open function is disabled';
  493. return false;
  494. }
  495. if (!file_exists(PHP_BINARY)) {
  496. $info = 'PHP_BINARY is not available';
  497. return false;
  498. }
  499. if (extension_loaded('uopz') && !((bool) ini_get('uopz.disable'))) {
  500. // uopz works at opcode level and disables exit calls
  501. if (function_exists('uopz_allow_exit')) {
  502. @uopz_allow_exit(true);
  503. } else {
  504. $info = 'uopz extension is not compatible';
  505. return false;
  506. }
  507. }
  508. // Check UNC paths when using cmd.exe
  509. if (defined('PHP_WINDOWS_VERSION_BUILD') && PHP_VERSION_ID < 70400) {
  510. $workingDir = getcwd();
  511. if ($workingDir === false) {
  512. $info = 'unable to determine working directory';
  513. return false;
  514. }
  515. if (0 === strpos($workingDir, '\\\\')) {
  516. $info = 'cmd.exe does not support UNC paths: '.$workingDir;
  517. return false;
  518. }
  519. }
  520. return true;
  521. }
  522. /**
  523. * Enables async signals and control interrupts in the restarted process
  524. *
  525. * Available on Unix PHP 7.1+ with the pcntl extension and Windows PHP 7.4+.
  526. */
  527. private function tryEnableSignals(): void
  528. {
  529. if (function_exists('pcntl_async_signals') && function_exists('pcntl_signal')) {
  530. pcntl_async_signals(true);
  531. $message = 'Async signals enabled';
  532. if (!self::$inRestart) {
  533. // Restarting, so ignore SIGINT in parent
  534. pcntl_signal(SIGINT, SIG_IGN);
  535. } elseif (is_int(pcntl_signal_get_handler(SIGINT))) {
  536. // Restarted, no handler set so force default action
  537. pcntl_signal(SIGINT, SIG_DFL);
  538. }
  539. }
  540. if (!self::$inRestart && function_exists('sapi_windows_set_ctrl_handler')) {
  541. // Restarting, so set a handler to ignore CTRL events in the parent.
  542. // This ensures that CTRL+C events will be available in the child
  543. // process without having to enable them there, which is unreliable.
  544. sapi_windows_set_ctrl_handler(function ($evt) {});
  545. }
  546. }
  547. /**
  548. * Returns $_SERVER['argv'] if it is as expected
  549. *
  550. * @return non-empty-list<string>|null
  551. */
  552. private function checkServerArgv(): ?array
  553. {
  554. $result = [];
  555. if (isset($_SERVER['argv']) && is_array($_SERVER['argv'])) {
  556. foreach ($_SERVER['argv'] as $value) {
  557. if (!is_string($value)) {
  558. return null;
  559. }
  560. $result[] = $value;
  561. }
  562. }
  563. return count($result) > 0 ? $result : null;
  564. }
  565. /**
  566. * Sets static properties $xdebugActive, $xdebugVersion and $xdebugMode
  567. */
  568. private static function setXdebugDetails(): void
  569. {
  570. if (self::$xdebugActive !== null) {
  571. return;
  572. }
  573. self::$xdebugActive = false;
  574. if (!extension_loaded('xdebug')) {
  575. return;
  576. }
  577. $version = phpversion('xdebug');
  578. self::$xdebugVersion = $version !== false ? $version : 'unknown';
  579. if (version_compare(self::$xdebugVersion, '3.1', '>=')) {
  580. $modes = xdebug_info('mode');
  581. self::$xdebugMode = count($modes) === 0 ? 'off' : implode(',', $modes);
  582. self::$xdebugActive = self::$xdebugMode !== 'off';
  583. return;
  584. }
  585. // See if xdebug.mode is supported in this version
  586. $iniMode = ini_get('xdebug.mode');
  587. if ($iniMode === false) {
  588. self::$xdebugActive = true;
  589. return;
  590. }
  591. // Environment value wins but cannot be empty
  592. $envMode = (string) getenv('XDEBUG_MODE');
  593. if ($envMode !== '') {
  594. self::$xdebugMode = $envMode;
  595. } else {
  596. self::$xdebugMode = $iniMode !== '' ? $iniMode : 'off';
  597. }
  598. // An empty comma-separated list is treated as mode 'off'
  599. if (Preg::isMatch('/^,+$/', str_replace(' ', '', self::$xdebugMode))) {
  600. self::$xdebugMode = 'off';
  601. }
  602. self::$xdebugActive = self::$xdebugMode !== 'off';
  603. }
  604. }