diff --git a/src/Symfony/Component/Debug/DebugClassLoader.php b/src/Symfony/Component/Debug/DebugClassLoader.php index a0c6c5a4f9..2080944792 100644 --- a/src/Symfony/Component/Debug/DebugClassLoader.php +++ b/src/Symfony/Component/Debug/DebugClassLoader.php @@ -14,46 +14,67 @@ namespace Symfony\Component\Debug; /** * Autoloader checking if the class is really defined in the file found. * - * The ClassLoader will wrap all registered autoloaders providing a - * findFile method and will throw an exception if a file is found but does + * The ClassLoader will wrap all registered autoloaders + * and will throw an exception if a file is found but does * not declare the class. * * @author Fabien Potencier * @author Christophe Coevoet + * @author Nicolas Grekas * * @api */ class DebugClassLoader { - private $classFinder; + private $classLoader; + private $isFinder; + private $wasFinder; /** * Constructor. * - * @param object $classFinder + * @param callable|object $classLoader * * @api + * @deprecated since 2.5, passing an object is deprecated and support for it will be removed in 3.0 */ - public function __construct($classFinder) + public function __construct($classLoader) { - $this->classFinder = $classFinder; + $this->wasFinder = is_object($classLoader) && method_exists($classLoader, 'findFile'); + + if ($this->wasFinder) { + $this->classLoader = array($classLoader, 'loadClass'); + $this->isFinder = true; + } else { + $this->classLoader = $classLoader; + $this->isFinder = is_array($classLoader) && method_exists($classLoader[0], 'findFile'); + } } /** * Gets the wrapped class loader. * - * @return object a class loader instance + * @return callable|object a class loader + * + * @deprecated since 2.5, returning an object is deprecated and support for it will be removed in 3.0 */ public function getClassLoader() { - return $this->classFinder; + if ($this->wasFinder) { + return $this->classLoader[0]; + } else { + return $this->classLoader; + } } /** - * Replaces all autoloaders implementing a findFile method by a DebugClassLoader wrapper. + * Wraps all autoloaders */ public static function enable() { + // Ensures we don't hit https://bugs.php.net/42098 + class_exists(__NAMESPACE__.'\ErrorHandler', true); + if (!is_array($functions = spl_autoload_functions())) { return; } @@ -63,8 +84,8 @@ class DebugClassLoader } foreach ($functions as $function) { - if (is_array($function) && !$function[0] instanceof self && method_exists($function[0], 'findFile')) { - $function = array(new static($function[0]), 'loadClass'); + if (!is_array($function) || !$function[0] instanceof self) { + $function = array(new static($function), 'loadClass'); } spl_autoload_register($function); @@ -86,7 +107,7 @@ class DebugClassLoader foreach ($functions as $function) { if (is_array($function) && $function[0] instanceof self) { - $function[0] = $function[0]->getClassLoader(); + $function = $function[0]->getClassLoader(); } spl_autoload_register($function); @@ -99,10 +120,14 @@ class DebugClassLoader * @param string $class A class name to resolve to file * * @return string|null + * + * @deprecated Deprecated since 2.5, to be removed in 3.0. */ public function findFile($class) { - return $this->classFinder->findFile($class); + if ($this->wasFinder) { + return $this->classLoader[0]->findFile($class); + } } /** @@ -116,10 +141,55 @@ class DebugClassLoader */ public function loadClass($class) { - if ($file = $this->classFinder->findFile($class)) { - require $file; + ErrorHandler::stackErrors(); - if (!class_exists($class, false) && !interface_exists($class, false) && (!function_exists('trait_exists') || !trait_exists($class, false))) { + try { + if ($this->isFinder) { + if ($file = $this->classLoader[0]->findFile($class)) { + require $file; + } + } else { + call_user_func($this->classLoader, $class); + $file = false; + } + } catch (\Exception $e) { + ErrorHandler::unstackErrors(); + + throw $e; + } + + ErrorHandler::unstackErrors(); + + $exists = class_exists($class, false) || interface_exists($class, false) || (function_exists('trait_exists') && trait_exists($class, false)); + + if ($exists) { + $name = new \ReflectionClass($class); + $name = $name->getName(); + + if ($name !== $class) { + throw new \RuntimeException(sprintf('Case mismatch between loaded and declared class names: %s vs %s', $class, $name)); + } + } + + if ($file) { + if ('\\' == $class[0]) { + $class = substr($class, 1); + } + + $i = -1; + $tail = str_replace('\\', DIRECTORY_SEPARATOR, $class).'.php'; + $len = strlen($tail); + + do { + $tail = substr($tail, $i+1); + $len -= $i+1; + + if (! substr_compare($file, $tail, -$len, $len, true) && substr_compare($file, $tail, -$len, $len, false)) { + throw new \RuntimeException(sprintf('Case mismatch between class and source file names: %s vs %s', $class, $file)); + } + } while (false !== $i = strpos($tail, '\\')); + + if (! $exists) { if (false !== strpos($class, '/')) { throw new \RuntimeException(sprintf('Trying to autoload a class with an invalid name "%s". Be careful that the namespace separator is "\" in PHP, not "/".', $class)); } diff --git a/src/Symfony/Component/Debug/ErrorHandler.php b/src/Symfony/Component/Debug/ErrorHandler.php index dd1bc60307..318c6716e2 100644 --- a/src/Symfony/Component/Debug/ErrorHandler.php +++ b/src/Symfony/Component/Debug/ErrorHandler.php @@ -25,6 +25,7 @@ use Symfony\Component\Debug\FatalErrorHandler\FatalErrorHandlerInterface; * * @author Fabien Potencier * @author Konstantin Myakshin + * @author Nicolas Grekas */ class ErrorHandler { @@ -57,6 +58,10 @@ class ErrorHandler */ private static $loggers = array(); + private static $stackedErrors = array(); + + private static $stackedErrorLevels = array(); + /** * Registers the error handler. * @@ -121,45 +126,46 @@ class ErrorHandler if ($level & (E_USER_DEPRECATED | E_DEPRECATED)) { if (isset(self::$loggers['deprecation'])) { - if (version_compare(PHP_VERSION, '5.4', '<')) { - $stack = array_map( - function ($row) { - unset($row['args']); - - return $row; - }, - array_slice(debug_backtrace(false), 0, 10) - ); + if (self::$stackedErrorLevels) { + self::$stackedErrors[] = func_get_args(); } else { - $stack = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 10); - } + if (version_compare(PHP_VERSION, '5.4', '<')) { + $stack = array_map( + function ($row) { + unset($row['args']); - self::$loggers['deprecation']->warning($message, array('type' => self::TYPE_DEPRECATION, 'stack' => $stack)); + return $row; + }, + array_slice(debug_backtrace(false), 0, 10) + ); + } else { + $stack = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 10); + } + + self::$loggers['deprecation']->warning($message, array('type' => self::TYPE_DEPRECATION, 'stack' => $stack)); + } } return true; } if ($this->displayErrors && error_reporting() & $level && $this->level & $level) { - // make sure the ContextErrorException class is loaded (https://bugs.php.net/bug.php?id=65322) - if (!class_exists('Symfony\Component\Debug\Exception\ContextErrorException')) { - require __DIR__.'/Exception/ContextErrorException.php'; - } - - $exception = new ContextErrorException(sprintf('%s: %s in %s line %d', isset($this->levels[$level]) ? $this->levels[$level] : $level, $message, $file, $line), 0, $level, $file, $line, $context); - // Exceptions thrown from error handlers are sometimes not caught by the exception // handler, so we invoke it directly (https://bugs.php.net/bug.php?id=54275) - $exceptionHandler = set_exception_handler(function () {}); + $exceptionHandler = set_exception_handler('var_dump'); restore_exception_handler(); if (is_array($exceptionHandler) && $exceptionHandler[0] instanceof ExceptionHandler) { - $exceptionHandler[0]->handle($exception); + if (self::$stackedErrorLevels) { + self::$stackedErrors[] = func_get_args(); - if (!class_exists('Symfony\Component\Debug\Exception\DummyException')) { - require __DIR__.'/Exception/DummyException.php'; + return true; } + $exception = sprintf('%s: %s in %s line %d', isset($this->levels[$level]) ? $this->levels[$level] : $level, $message, $file, $line); + $exception = new ContextErrorException($exception, 0, $level, $file, $line, $context); + $exceptionHandler[0]->handle($exception); + // we must stop the PHP script execution, as the exception has // already been dealt with, so, let's throw an exception that // will be caught by a dummy exception handler @@ -179,13 +185,61 @@ class ErrorHandler return false; } + /** + * Configure the error handler for delayed handling. + * Ensures also that non-catchable fatal errors are never silenced. + * + * As shown by http://bugs.php.net/42098 and http://bugs.php.net/60724 + * PHP has a compile stage where it behaves unusually. To workaround it, + * we plug an error handler that only stacks errors for later. + * + * The most important feature of this is to prevent + * autoloading until unstackErrors() is called. + */ + public static function stackErrors() + { + self::$stackedErrorLevels[] = error_reporting(error_reporting() | E_PARSE | E_ERROR | E_CORE_ERROR | E_COMPILE_ERROR); + } + + /** + * Unstacks stacked errors and forwards to the regular handler + */ + public static function unstackErrors() + { + $level = array_pop(self::$stackedErrorLevels); + + if (null !== $level) { + error_reporting($level); + } + + if (empty(self::$stackedErrorLevels)) { + $errors = self::$stackedErrors; + self::$stackedErrors = array(); + + $errorHandler = set_error_handler('var_dump'); + restore_error_handler(); + + if ($errorHandler) { + foreach ($errors as $e) { + call_user_func_array($errorHandler, $e); + } + } + } + } + public function handleFatal() { - if (null === $error = error_get_last()) { + $this->reservedMemory = ''; + $error = error_get_last(); + + while (self::$stackedErrorLevels) { + static::unstackErrors(); + } + + if (null === $error) { return; } - $this->reservedMemory = ''; $type = $error['type']; if (0 === $this->level || !in_array($type, array(E_ERROR, E_CORE_ERROR, E_COMPILE_ERROR, E_PARSE))) { return; @@ -206,7 +260,7 @@ class ErrorHandler } // get current exception handler - $exceptionHandler = set_exception_handler(function () {}); + $exceptionHandler = set_exception_handler('var_dump'); restore_exception_handler(); if (is_array($exceptionHandler) && $exceptionHandler[0] instanceof ExceptionHandler) { diff --git a/src/Symfony/Component/Debug/Tests/DebugClassLoaderTest.php b/src/Symfony/Component/Debug/Tests/DebugClassLoaderTest.php index a18e5811cc..573869b63b 100644 --- a/src/Symfony/Component/Debug/Tests/DebugClassLoaderTest.php +++ b/src/Symfony/Component/Debug/Tests/DebugClassLoaderTest.php @@ -12,46 +12,134 @@ namespace Symfony\Component\Debug\Tests; use Symfony\Component\Debug\DebugClassLoader; +use Symfony\Component\Debug\ErrorHandler; class DebugClassLoaderTest extends \PHPUnit_Framework_TestCase { + /** + * @var int Error reporting level before running tests. + */ + private $errorReporting; + private $loader; protected function setUp() { + $this->errorReporting = error_reporting(E_ALL | E_STRICT); $this->loader = new ClassLoader(); spl_autoload_register(array($this->loader, 'loadClass')); + DebugClassLoader::enable(); } protected function tearDown() { + DebugClassLoader::disable(); spl_autoload_unregister(array($this->loader, 'loadClass')); + error_reporting($this->errorReporting); } public function testIdempotence() { DebugClassLoader::enable(); - DebugClassLoader::enable(); $functions = spl_autoload_functions(); foreach ($functions as $function) { if (is_array($function) && $function[0] instanceof DebugClassLoader) { $reflClass = new \ReflectionClass($function[0]); - $reflProp = $reflClass->getProperty('classFinder'); + $reflProp = $reflClass->getProperty('classLoader'); $reflProp->setAccessible(true); $this->assertNotInstanceOf('Symfony\Component\Debug\DebugClassLoader', $reflProp->getValue($function[0])); - DebugClassLoader::disable(); - return; } } - DebugClassLoader::disable(); - $this->fail('DebugClassLoader did not register'); } + + public function testUnsilencing() + { + ob_start(); + $bak = array( + ini_set('log_errors', 0), + ini_set('display_errors', 1), + ); + + // See below: this will fail with parse error + // but this should not be @-silenced. + @ class_exists(__NAMESPACE__.'\TestingUnsilencing', true); + + ini_set('log_errors', $bak[0]); + ini_set('display_errors', $bak[1]); + $output = ob_get_clean(); + + $this->assertStringMatchesFormat('%aParse error%a', $output); + } + + /** + * @expectedException \Symfony\Component\Debug\Exception\DummyException + */ + public function testStacking() + { + // the ContextErrorException must not be loaded to test the workaround + // for https://bugs.php.net/65322. + if (class_exists('Symfony\Component\Debug\Exception\ContextErrorException', false)) { + $this->markTestSkipped('The ContextErrorException class is already loaded.'); + } + + $exceptionHandler = $this->getMock('Symfony\Component\Debug\ExceptionHandler', array('handle')); + set_exception_handler(array($exceptionHandler, 'handle')); + + $that = $this; + $exceptionCheck = function ($exception) use ($that) { + $that->assertInstanceOf('Symfony\Component\Debug\Exception\ContextErrorException', $exception); + $that->assertEquals(E_STRICT, $exception->getSeverity()); + $that->assertStringStartsWith(__FILE__, $exception->getFile()); + $that->assertRegexp('/^Runtime Notice: Declaration/', $exception->getMessage()); + }; + + $exceptionHandler->expects($this->once()) + ->method('handle') + ->will($this->returnCallback($exceptionCheck)); + ErrorHandler::register(); + + try { + // Trigger autoloading + E_STRICT at compile time + // which in turn triggers $errorHandler->handle() + // that again triggers autoloading for ContextErrorException. + // Error stacking works around the bug above and everything is fine. + + eval(' + namespace '.__NAMESPACE__.'; + class ChildTestingStacking extends TestingStacking { function foo($bar) {} } + '); + } catch (\Exception $e) { + restore_error_handler(); + restore_exception_handler(); + + throw $e; + } + + restore_error_handler(); + restore_exception_handler(); + } + + /** + * @expectedException \RuntimeException + */ + public function testNameCaseMismatch() + { + class_exists(__NAMESPACE__.'\TestingCaseMismatch', true); + } + + /** + * @expectedException \RuntimeException + */ + public function testFileCaseMismatch() + { + class_exists(__NAMESPACE__.'\Fixtures\CaseMismatch', true); + } } class ClassLoader @@ -62,5 +150,14 @@ class ClassLoader public function findFile($class) { + if (__NAMESPACE__.'\TestingUnsilencing' === $class) { + eval('-- parse error --'); + } elseif (__NAMESPACE__.'\TestingStacking' === $class) { + eval('namespace '.__NAMESPACE__.'; class TestingStacking { function foo() {} }'); + } elseif (__NAMESPACE__.'\TestingCaseMismatch' === $class) { + eval('namespace '.__NAMESPACE__.'; class TestingCaseMisMatch {}'); + } elseif (__NAMESPACE__.'\Fixtures\CaseMismatch' === $class) { + return __DIR__ . '/Fixtures/casemismatch.php'; + } } } diff --git a/src/Symfony/Component/Debug/Tests/ErrorHandlerTest.php b/src/Symfony/Component/Debug/Tests/ErrorHandlerTest.php index c6e67e6da2..129797c15a 100644 --- a/src/Symfony/Component/Debug/Tests/ErrorHandlerTest.php +++ b/src/Symfony/Component/Debug/Tests/ErrorHandlerTest.php @@ -44,65 +44,6 @@ class ErrorHandlerTest extends \PHPUnit_Framework_TestCase error_reporting($this->errorReporting); } - public function testCompileTimeError() - { - // the ContextErrorException must not be loaded to test the workaround - // for https://bugs.php.net/bug.php?id=65322. - if (class_exists('Symfony\Component\Debug\Exception\ContextErrorException', false)) { - $this->markTestSkipped('The ContextErrorException class is already loaded.'); - } - - $exceptionHandler = $this->getMock('Symfony\Component\Debug\ExceptionHandler', array('handle')); - - // the following code forces some PHPUnit classes to be loaded - // so that they will be available in the exception handler - // as they won't be autoloaded by PHP - class_exists('PHPUnit_Framework_MockObject_Invocation_Object'); - $this->assertInstanceOf('stdClass', new \stdClass()); - $this->assertEquals(1, 1); - $this->assertStringStartsWith('foo', 'foobar'); - $this->assertArrayHasKey('bar', array('bar' => 'foo')); - - $that = $this; - $exceptionCheck = function ($exception) use ($that) { - $that->assertInstanceOf('Symfony\Component\Debug\Exception\ContextErrorException', $exception); - $that->assertEquals(E_STRICT, $exception->getSeverity()); - $that->assertEquals(2, $exception->getLine()); - $that->assertStringStartsWith('Runtime Notice: Declaration of _CompileTimeError::foo() should be compatible with', $exception->getMessage()); - $that->assertArrayHasKey('bar', $exception->getContext()); - }; - - $exceptionHandler->expects($this->once()) - ->method('handle') - ->will($this->returnCallback($exceptionCheck)) - ; - - ErrorHandler::register(); - set_exception_handler(array($exceptionHandler, 'handle')); - - // dummy variable to check for in error handler. - $bar = 123; - - // trigger compile time error - try { - eval(<<<'PHP' -class _BaseCompileTimeError { function foo() {} } -class _CompileTimeError extends _BaseCompileTimeError { function foo($invalid) {} } -PHP - ); - } catch (DummyException $e) { - // if an exception is thrown, the test passed - } catch (\Exception $e) { - restore_error_handler(); - restore_exception_handler(); - - throw $e; - } - - restore_error_handler(); - restore_exception_handler(); - } - public function testNotice() { $exceptionHandler = $this->getMock('Symfony\Component\Debug\ExceptionHandler', array('handle')); @@ -112,7 +53,6 @@ PHP $exceptionCheck = function ($exception) use ($that) { $that->assertInstanceOf('Symfony\Component\Debug\Exception\ContextErrorException', $exception); $that->assertEquals(E_NOTICE, $exception->getSeverity()); - $that->assertEquals(__LINE__ + 44, $exception->getLine()); $that->assertEquals(__FILE__, $exception->getFile()); $that->assertRegexp('/^Notice: Undefined variable: (foo|bar)/', $exception->getMessage()); $that->assertArrayHasKey('foobar', $exception->getContext()); diff --git a/src/Symfony/Component/Debug/Tests/Fixtures/casemismatch.php b/src/Symfony/Component/Debug/Tests/Fixtures/casemismatch.php new file mode 100644 index 0000000000..691d660fd1 --- /dev/null +++ b/src/Symfony/Component/Debug/Tests/Fixtures/casemismatch.php @@ -0,0 +1,7 @@ +