From 18cd4f966bf8f95929cbcad20f9d08871e9bba35 Mon Sep 17 00:00:00 2001 From: Niels Keurentjes Date: Mon, 25 Jan 2016 23:37:54 +0100 Subject: [PATCH] Added silencer utility to more gracefully handle error suppression without hiding errors or worse. Fixes #4203, #4683 --- src/Composer/Autoload/ClassMapGenerator.php | 3 +- src/Composer/Cache.php | 3 +- src/Composer/Command/ConfigCommand.php | 11 ++- src/Composer/Command/CreateProjectCommand.php | 7 +- src/Composer/Config/JsonConfigSource.php | 3 +- src/Composer/Console/Application.php | 11 ++- src/Composer/Factory.php | 5 +- src/Composer/Installer/LibraryInstaller.php | 9 ++- src/Composer/Util/RemoteFilesystem.php | 2 +- src/Composer/Util/Silencer.php | 73 +++++++++++++++++++ tests/Composer/Test/Util/SilencerTest.php | 57 +++++++++++++++ 11 files changed, 164 insertions(+), 20 deletions(-) create mode 100644 src/Composer/Util/Silencer.php create mode 100644 tests/Composer/Test/Util/SilencerTest.php diff --git a/src/Composer/Autoload/ClassMapGenerator.php b/src/Composer/Autoload/ClassMapGenerator.php index b487ed6ed..3f1243ade 100644 --- a/src/Composer/Autoload/ClassMapGenerator.php +++ b/src/Composer/Autoload/ClassMapGenerator.php @@ -18,6 +18,7 @@ namespace Composer\Autoload; +use Composer\Util\Silencer; use Symfony\Component\Finder\Finder; use Composer\IO\IOInterface; @@ -122,7 +123,7 @@ class ClassMapGenerator } try { - $contents = @php_strip_whitespace($path); + $contents = Silencer::call('php_strip_whitespace', $path); if (!$contents) { if (!file_exists($path)) { throw new \Exception('File does not exist'); diff --git a/src/Composer/Cache.php b/src/Composer/Cache.php index 3ba11da1c..8c5bce4ee 100644 --- a/src/Composer/Cache.php +++ b/src/Composer/Cache.php @@ -14,6 +14,7 @@ namespace Composer; use Composer\IO\IOInterface; use Composer\Util\Filesystem; +use Composer\Util\Silencer; use Symfony\Component\Finder\Finder; /** @@ -44,7 +45,7 @@ class Cache $this->filesystem = $filesystem ?: new Filesystem(); if ( - (!is_dir($this->root) && !@mkdir($this->root, 0777, true)) + (!is_dir($this->root) && !Silencer::call('mkdir', $this->root, 0777, true)) || !is_writable($this->root) ) { $this->io->writeError('Cannot create cache directory ' . $this->root . ', or directory is not writable. Proceeding without cache'); diff --git a/src/Composer/Command/ConfigCommand.php b/src/Composer/Command/ConfigCommand.php index 0bf8f97d5..eb1778a94 100644 --- a/src/Composer/Command/ConfigCommand.php +++ b/src/Composer/Command/ConfigCommand.php @@ -12,6 +12,7 @@ namespace Composer\Command; +use Composer\Util\Silencer; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputOption; @@ -142,7 +143,7 @@ EOT ? ($this->config->get('home') . '/config.json') : ($input->getOption('file') ?: trim(getenv('COMPOSER')) ?: 'composer.json'); - // create global composer.json if this was invoked using `composer global config` + // Create global composer.json if this was invoked using `composer global config` if ($configFile === 'composer.json' && !file_exists($configFile) && realpath(getcwd()) === realpath($this->config->get('home'))) { file_put_contents($configFile, "{\n}\n"); } @@ -157,17 +158,19 @@ EOT $this->authConfigFile = new JsonFile($authConfigFile, null, $io); $this->authConfigSource = new JsonConfigSource($this->authConfigFile, true); - // initialize the global file if it's not there + // Initialize the global file if it's not there, ignoring any warnings or notices + Silencer::suppress(); if ($input->getOption('global') && !$this->configFile->exists()) { touch($this->configFile->getPath()); $this->configFile->write(array('config' => new \ArrayObject)); - @chmod($this->configFile->getPath(), 0600); + chmod($this->configFile->getPath(), 0600); } if ($input->getOption('global') && !$this->authConfigFile->exists()) { touch($this->authConfigFile->getPath()); $this->authConfigFile->write(array('http-basic' => new \ArrayObject, 'github-oauth' => new \ArrayObject, 'gitlab-oauth' => new \ArrayObject)); - @chmod($this->authConfigFile->getPath(), 0600); + chmod($this->authConfigFile->getPath(), 0600); } + Silencer::restore(); if (!$this->configFile->exists()) { throw new \RuntimeException(sprintf('File "%s" cannot be found in the current directory', $configFile)); diff --git a/src/Composer/Command/CreateProjectCommand.php b/src/Composer/Command/CreateProjectCommand.php index 144d850a6..df50c4750 100644 --- a/src/Composer/Command/CreateProjectCommand.php +++ b/src/Composer/Command/CreateProjectCommand.php @@ -27,6 +27,7 @@ use Composer\Repository\CompositeRepository; use Composer\Repository\FilesystemRepository; use Composer\Repository\InstalledFilesystemRepository; use Composer\Script\ScriptEvents; +use Composer\Util\Silencer; use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; @@ -224,11 +225,13 @@ EOT chdir($oldCwd); $vendorComposerDir = $composer->getConfig()->get('vendor-dir').'/composer'; if (is_dir($vendorComposerDir) && $fs->isDirEmpty($vendorComposerDir)) { - @rmdir($vendorComposerDir); + Silencer::suppress(); + rmdir($vendorComposerDir); $vendorDir = $composer->getConfig()->get('vendor-dir'); if (is_dir($vendorDir) && $fs->isDirEmpty($vendorDir)) { - @rmdir($vendorDir); + rmdir($vendorDir); } + Silencer::restore(); } return 0; diff --git a/src/Composer/Config/JsonConfigSource.php b/src/Composer/Config/JsonConfigSource.php index 5df29f032..2b6d13096 100644 --- a/src/Composer/Config/JsonConfigSource.php +++ b/src/Composer/Config/JsonConfigSource.php @@ -14,6 +14,7 @@ namespace Composer\Config; use Composer\Json\JsonFile; use Composer\Json\JsonManipulator; +use Composer\Util\Silencer; /** * JSON Configuration Source @@ -173,7 +174,7 @@ class JsonConfigSource implements ConfigSourceInterface } if ($newFile) { - @chmod($this->file->getPath(), 0600); + Silencer::call('chmod', $this->file->getPath(), 0600); } } diff --git a/src/Composer/Console/Application.php b/src/Composer/Console/Application.php index 756cf18c6..10ad2762b 100644 --- a/src/Composer/Console/Application.php +++ b/src/Composer/Console/Application.php @@ -12,6 +12,7 @@ namespace Composer\Console; +use Composer\Util\Silencer; use Symfony\Component\Console\Application as BaseApplication; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; @@ -64,7 +65,7 @@ class Application extends BaseApplication } if (function_exists('date_default_timezone_set') && function_exists('date_default_timezone_get')) { - date_default_timezone_set(@date_default_timezone_get()); + date_default_timezone_set(Silencer::call('date_default_timezone_get')); } if (!$shutdownRegistered) { @@ -203,21 +204,23 @@ class Application extends BaseApplication { $io = $this->getIO(); + Silencer::suppress(); try { $composer = $this->getComposer(false, true); if ($composer) { $config = $composer->getConfig(); $minSpaceFree = 1024 * 1024; - if ((($df = @disk_free_space($dir = $config->get('home'))) !== false && $df < $minSpaceFree) - || (($df = @disk_free_space($dir = $config->get('vendor-dir'))) !== false && $df < $minSpaceFree) - || (($df = @disk_free_space($dir = sys_get_temp_dir())) !== false && $df < $minSpaceFree) + if ((($df = disk_free_space($dir = $config->get('home'))) !== false && $df < $minSpaceFree) + || (($df = disk_free_space($dir = $config->get('vendor-dir'))) !== false && $df < $minSpaceFree) + || (($df = disk_free_space($dir = sys_get_temp_dir())) !== false && $df < $minSpaceFree) ) { $io->writeError('The disk hosting '.$dir.' is full, this may be the cause of the following exception'); } } } catch (\Exception $e) { } + Silencer::restore(); if (defined('PHP_WINDOWS_VERSION_BUILD') && false !== strpos($exception->getMessage(), 'The system cannot find the path specified')) { $io->writeError('The following exception may be caused by a stale entry in your cmd.exe AutoRun'); diff --git a/src/Composer/Factory.php b/src/Composer/Factory.php index e6718278f..eb6072709 100644 --- a/src/Composer/Factory.php +++ b/src/Composer/Factory.php @@ -22,6 +22,7 @@ use Composer\Repository\WritableRepositoryInterface; use Composer\Util\Filesystem; use Composer\Util\ProcessExecutor; use Composer\Util\RemoteFilesystem; +use Composer\Util\Silencer; use Symfony\Component\Console\Formatter\OutputFormatterStyle; use Composer\EventDispatcher\EventDispatcher; use Composer\Autoload\AutoloadGenerator; @@ -163,9 +164,9 @@ class Factory foreach ($dirs as $dir) { if (!file_exists($dir . '/.htaccess')) { if (!is_dir($dir)) { - @mkdir($dir, 0777, true); + Silencer::call('mkdir', $dir, 0777, true); } - @file_put_contents($dir . '/.htaccess', 'Deny from all'); + Silencer::call('file_put_contents', $dir . '/.htaccess', 'Deny from all'); } } diff --git a/src/Composer/Installer/LibraryInstaller.php b/src/Composer/Installer/LibraryInstaller.php index 20a281de7..b14659f7b 100644 --- a/src/Composer/Installer/LibraryInstaller.php +++ b/src/Composer/Installer/LibraryInstaller.php @@ -18,6 +18,7 @@ use Composer\Repository\InstalledRepositoryInterface; use Composer\Package\PackageInterface; use Composer\Util\Filesystem; use Composer\Util\ProcessExecutor; +use Composer\Util\Silencer; /** * Package installation manager. @@ -130,7 +131,7 @@ class LibraryInstaller implements InstallerInterface if (strpos($package->getName(), '/')) { $packageVendorDir = dirname($downloadPath); if (is_dir($packageVendorDir) && $this->filesystem->isDirEmpty($packageVendorDir)) { - @rmdir($packageVendorDir); + Silencer::call('rmdir', $packageVendorDir); } } } @@ -233,7 +234,7 @@ class LibraryInstaller implements InstallerInterface // likely leftover from a previous install, make sure // that the target is still executable in case this // is a fresh install of the vendor. - @chmod($link, 0777 & ~umask()); + Silencer::call('chmod', $link, 0777 & ~umask()); } $this->io->writeError(' Skipped installation of bin '.$bin.' for package '.$package->getName().': name conflicts with an existing file'); continue; @@ -248,7 +249,7 @@ class LibraryInstaller implements InstallerInterface } elseif ($this->binCompat === "full") { $this->installFullBinaries($binPath, $link, $bin, $package); } - @chmod($link, 0777 & ~umask()); + Silencer::call('chmod', $link, 0777 & ~umask()); } } @@ -298,7 +299,7 @@ class LibraryInstaller implements InstallerInterface // attempt removing the bin dir in case it is left empty if ((is_dir($this->binDir)) && ($this->filesystem->isDirEmpty($this->binDir))) { - @rmdir($this->binDir); + Silencer::call('rmdir', $this->binDir); } } diff --git a/src/Composer/Util/RemoteFilesystem.php b/src/Composer/Util/RemoteFilesystem.php index 9a8a25d81..e62ec9d18 100644 --- a/src/Composer/Util/RemoteFilesystem.php +++ b/src/Composer/Util/RemoteFilesystem.php @@ -743,7 +743,7 @@ class RemoteFilesystem ); foreach ($caBundlePaths as $caBundle) { - if (@is_readable($caBundle) && $this->validateCaFile($caBundle)) { + if (Silencer::call('is_readable', $caBundle) && $this->validateCaFile($caBundle)) { return $caPath = $caBundle; } } diff --git a/src/Composer/Util/Silencer.php b/src/Composer/Util/Silencer.php new file mode 100644 index 000000000..bc09d5efd --- /dev/null +++ b/src/Composer/Util/Silencer.php @@ -0,0 +1,73 @@ + + * Jordi Boggiano + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Composer\Util; + +/** + * Temporarily suppress PHP error reporting, usually warnings and below. + * + * @author Niels Keurentjes + */ +class Silencer +{ + /** + * @var int[] Unpop stack + */ + private static $stack = array(); + + /** + * Suppresses given mask or errors. + * + * @param int|null $mask Error levels to suppress, default value NULL indicates all warnings and below. + * @return int The old error reporting level. + */ + public static function suppress($mask = null) + { + if (!isset($mask)) { + $mask = E_WARNING | E_NOTICE | E_USER_WARNING | E_USER_NOTICE | E_DEPRECATED | E_USER_DEPRECATED | E_STRICT; + } + array_push(self::$stack, $old = error_reporting()); + error_reporting($old & ~$mask); + return $old; + } + + /** + * Restores a single state. + */ + public static function restore() + { + if (!empty(self::$stack)) + error_reporting(array_pop(self::$stack)); + } + + /** + * Calls a specified function while silencing warnings and below. + * + * Future improvement: when PHP requirements are raised add Callable type hint (5.4) and variadic parameters (5.6) + * + * @param callable $callable Function to execute. + * @return mixed Return value of the callback. + * @throws \Exception Any exceptions from the callback are rethrown. + */ + public static function call($callable /*, ...$parameters */) + { + try { + self::suppress(); + $result = call_user_func_array($callable, array_slice(func_get_args(), 1)); + self::restore(); + return $result; + } catch(\Exception $e) { + // Use a finally block for this when requirements are raised to PHP 5.5 + self::restore(); + throw $e; + } + } +} \ No newline at end of file diff --git a/tests/Composer/Test/Util/SilencerTest.php b/tests/Composer/Test/Util/SilencerTest.php new file mode 100644 index 000000000..2926cc2a4 --- /dev/null +++ b/tests/Composer/Test/Util/SilencerTest.php @@ -0,0 +1,57 @@ + + * Jordi Boggiano + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Composer\Test\Util; + +use Composer\Util\Silencer; + +/** + * SilencerTest + * + * @author Niels Keurentjes + */ +class SilencerTest extends \PHPUnit_Framework_TestCase +{ + /** + * Test succeeds when no warnings are emitted externally, and original level is restored. + */ + public function testSilencer() + { + $before = error_reporting(); + + // Check warnings are suppressed correctly + Silencer::suppress(); + trigger_error('Test', E_USER_WARNING); + Silencer::restore(); + + // Check all parameters and return values are passed correctly in a silenced call. + $result = Silencer::call(function($a, $b, $c) { + trigger_error('Test', E_USER_WARNING); + return $a * $b * $c; + }, 2, 3, 4); + $this->assertEquals(24, $result); + + // Check the error reporting setting was restored correctly + $this->assertEquals($before, error_reporting()); + } + + /** + * Test whether exception from silent callbacks are correctly forwarded. + */ + public function testSilencedException() + { + $verification = microtime(); + $this->setExpectedException('\RuntimeException', $verification); + Silencer::call(function() use ($verification) { + throw new \RuntimeException($verification); + }); + } +}