PluginProbe
SendWP / 1.4.4
SendWP v1.4.4
trunk 0.0.1 0.0.2 0.0.3 1.0.0 1.0.1 1.0.2 1.0.3 1.0.4 1.1 1.2.10 1.2.3 1.2.4 1.2.5 1.2.6 1.2.7 1.2.8 1.2.9 1.3 1.3.1 1.4.4 1.4.5 1.4.6 1.4.8
sendwp / vendor / composer / xdebug-handler / src / XdebugHandler.php

XdebugHandler.php in SendWP 1.4.4, at vendor/composer/xdebug-handler/src/XdebugHandler.php

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