diff --git a/bin/composer b/bin/composer index 401efb5b0..927ebd1cb 100755 --- a/bin/composer +++ b/bin/composer @@ -7,10 +7,15 @@ if (PHP_SAPI !== 'cli') { require __DIR__.'/../src/bootstrap.php'; +use Composer\XdebugHandler; use Composer\Console\Application; error_reporting(-1); +$xdebug = new XdebugHandler($argv); +$xdebug->check(); +unset($xdebug); + if (function_exists('ini_set')) { @ini_set('display_errors', 1); diff --git a/src/Composer/XdebugHandler.php b/src/Composer/XdebugHandler.php new file mode 100644 index 000000000..ca2887eb2 --- /dev/null +++ b/src/Composer/XdebugHandler.php @@ -0,0 +1,277 @@ + + * Jordi Boggiano + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Composer; + +/** + * @author John Stevenson + */ +class XdebugHandler +{ + const ENV_ALLOW = 'COMPOSER_ALLOW_XDEBUG'; + + private $argv; + private $loaded; + private $tmpIni; + private $scanDir; + + /** + * @param array $argv The global argv passed to script + */ + public function __construct(array $argv) + { + $this->argv = $argv; + $this->loaded = extension_loaded('xdebug'); + + $tmp = sys_get_temp_dir(); + $this->tmpIni = $tmp.DIRECTORY_SEPARATOR.'composer-php.ini'; + $this->scanDir = $tmp.DIRECTORY_SEPARATOR.'composer-php-empty'; + } + + /** + * Checks if xdebug is loaded and composer needs to be restarted + * + * If so, then a tmp ini is created with the xdebug ini entry commented out. + * If additional inis have been loaded, these are combined into the tmp ini + * and PHP_INI_SCAN_DIR is set to an empty directory. An environment + * variable is set so that the new process is created only once. + */ + public function check() + { + if (!$this->needsRestart()) { + return; + } + + if ($this->prepareRestart($command)) { + $this->restart($command); + } + } + + /** + * Executes the restarted command + * + * @param string $command + */ + protected function restart($command) + { + passthru($command, $exitCode); + exit($exitCode); + } + + /** + * Returns true if a restart is needed + * + * @return bool + */ + private function needsRestart() + { + if (PHP_SAPI !== 'cli' || !defined('PHP_BINARY')) { + return false; + } + + return !getenv(self::ENV_ALLOW) && $this->loaded; + } + + /** + * 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 + * - tmp scan dir creation + * + * @param null|string $command The command to run, set by method + * + * @return bool + */ + private function prepareRestart(&$command) + { + $iniFiles = array(); + if ($loadedIni = php_ini_loaded_file()) { + $iniFiles[] = $loadedIni; + } + + $additional = $this->getAdditionalInis($iniFiles, $replace); + if ($this->writeTmpIni($iniFiles, $replace)) { + $command = $this->getCommand($additional); + } + + return !empty($command) && putenv(self::ENV_ALLOW.'=1'); + } + + /** + * Writes the temporary ini file, or clears its name if no ini + * + * If there are no ini files, the tmp ini name is + * @param array $iniFiles The php.ini locations + * @param bool $replace Whether we need to modify the files + * + * @return bool False if the tmp ini could not be created + */ + private function writeTmpIni(array $iniFiles, $replace) + { + if (empty($iniFiles)) { + // Unlikely, maybe xdebug was loaded through the -d option. + $this->tmpIni = ''; + return true; + } + + $content = $this->getIniHeader($iniFiles); + foreach ($iniFiles as $file) { + $content .= $this->getIniData($file, $replace); + } + + return @file_put_contents($this->tmpIni, $content); + } + + /** + * Return true if additional inis were loaded + * + * @param array $iniFiles Populated by method + * @param bool $replace Whether we need to modify the files + * + * @return bool + */ + private function getAdditionalInis(array &$iniFiles , &$replace) + { + $replace = true; + + if ($scanned = php_ini_scanned_files()) { + $list = explode(',', $scanned); + + foreach ($list as $file) { + $file = trim($file); + if (preg_match('/xdebug.ini$/', $file)) { + // Skip the file, no need for regex replacing + $replace = false; + } else { + $iniFiles[] = $file; + } + } + } + + return !empty($scanned); + } + + /** + * Returns formatted ini file data + * + * @param string $iniFile The location of the ini file + * @param bool $replace Whether to regex replace content + * + * @return string The ini data + */ + private function getIniData($iniFile, $replace) + { + $data = str_repeat("\n", 3); + $data .= sprintf('; %s%s', $iniFile, PHP_EOL); + $contents = file_get_contents($iniFile); + + if ($replace) { + // Comment out xdebug config + $regex = '/^\s*(zend_extension\s*=.*xdebug.*)$/mi'; + $data .= preg_replace($regex, ';$1', $contents); + } else { + $data .= $contents; + } + + return $data; + } + + /** + * Returns the command line to restart composer + * + * @param bool $additional Whether additional inis were loaded + * + * @return string The command line + */ + private function getCommand($additional) + { + if ($additional) { + if (!file_exists($this->scanDir) && !@mkdir($this->scanDir, 0777)) { + return; + } + putenv('PHP_INI_SCAN_DIR='.$this->scanDir); + } + + $phpArgs = array(PHP_BINARY, '-c', $this->tmpIni); + $params = array_merge($phpArgs, $this->argv); + + return implode(' ', array_map(array($this, 'escape'), $params)); + } + + /** + * Escapes a string to be used as a shell argument. + * + * From https://github.com/johnstevenson/winbox-args + * MIT Licensed (c) John Stevenson + * + * @param string $arg The argument to be escaped + * @param bool $meta Additionally escape cmd.exe meta characters + * + * @return string The escaped argument + */ + private function escape($arg, $meta = true) + { + if (!defined('PHP_WINDOWS_VERSION_BUILD')) { + return escapeshellarg($arg); + } + + $quote = strpbrk($arg, " \t") !== false || $arg === ''; + $arg = preg_replace('/(\\\\*)"/', '$1$1\\"', $arg, -1, $dquotes); + + if ($meta) { + $meta = $dquotes || preg_match('/%[^%]+%/', $arg); + + if (!$meta && !$quote) { + $quote = strpbrk($arg, '^&|<>()') !== false; + } + } + + if ($quote) { + $arg = preg_replace('/(\\\\*)$/', '$1$1', $arg); + $arg = '"'.$arg.'"'; + } + + if ($meta) { + $arg = preg_replace('/(["^&|<>()%])/', '^$1', $arg); + } + + return $arg; + } + + /** + * Returns the location of the original ini data used. + * + * @param array $iniFiles loaded php.ini locations + * + * @return string + */ + private function getIniHeader($iniFiles) + { + $ini = implode(PHP_EOL.'; ', $iniFiles); + $header = << + * Jordi Boggiano + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Composer\Test\Mock; + +use Composer\XdebugHandler; + +class XdebugHandlerMock extends XdebugHandler +{ + public $command; + public $restarted; + + public function __construct(array $argv, $loaded) + { + parent::__construct($argv); + + $class = new \ReflectionClass(get_parent_class($this)); + $prop = $class->getProperty('loaded'); + $prop->setAccessible(true); + $prop->setValue($this, $loaded); + + $this->command = ''; + $this->restarted = false; + } + + protected function restart($command) + { + $this->command = $command; + $this->restarted = true; + } +} diff --git a/tests/Composer/Test/XdebugHandlerTest.php b/tests/Composer/Test/XdebugHandlerTest.php new file mode 100644 index 000000000..3917896cd --- /dev/null +++ b/tests/Composer/Test/XdebugHandlerTest.php @@ -0,0 +1,56 @@ + + * Jordi Boggiano + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Composer\Test; + +use Composer\Test\Mock\XdebugHandlerMock as XdebugHandler; + +/** + * @author John Stevenson + */ +class XdebugHandlerTest extends \PHPUnit_Framework_TestCase +{ + protected $argv; + + public function setup() + { + $this->argv = $GLOBALS['argv']; + } + + public function testRestartWhenLoaded() + { + $loaded = true; + + $xdebug = new XdebugHandler($this->argv, $loaded); + $xdebug->check(); + $this->assertTrue($xdebug->restarted || !defined('PHP_BINARY')); + } + + public function testNoRestartWhenNotLoaded() + { + $loaded = false; + + $xdebug = new XdebugHandler($this->argv, $loaded); + $xdebug->check(); + $this->assertFalse($xdebug->restarted); + } + + public function testNoRestartWhenLoadedAndAllowed() + { + $loaded = true; + putenv(XdebugHandler::ENV_ALLOW.'=1'); + + $xdebug = new XdebugHandler($this->argv, $loaded); + $xdebug->check(); + $this->assertFalse($xdebug->restarted); + } +}