feature #32637 [ErrorHandler] Decouple from ErrorRenderer component (yceruto)

This PR was squashed before being merged into the 4.4 branch (closes #32637).

Discussion
----------

[ErrorHandler] Decouple from ErrorRenderer component

| Q             | A
| ------------- | ---
| Branch?       | 4.4
| Bug fix?      | no
| New feature?  | no
| BC breaks?    | no
| Deprecations? | no
| Tests pass?   | yes
| Fixed tickets | part of https://github.com/symfony/symfony/issues/32605
| License       | MIT
| Doc PR        | -

built on top of https://github.com/symfony/symfony/pull/32636 (See 2nd commit only)

Commits
-------

8f13fc013d [ErrorHandler] Decouple from ErrorRenderer component
This commit is contained in:
Fabien Potencier 2019-07-25 11:38:56 +02:00
commit f6e93de5cf
9 changed files with 18 additions and 381 deletions

View File

@ -15,7 +15,7 @@ use Symfony\Component\Debug\Exception\FlattenException;
use Symfony\Component\Debug\Exception\OutOfMemoryException;
use Symfony\Component\HttpKernel\Debug\FileLinkFormatter;
@trigger_error(sprintf('The "%s" class is deprecated since Symfony 4.4, use "%s" instead.', ExceptionHandler::class, \Symfony\Component\ErrorHandler\ExceptionHandler::class), E_USER_DEPRECATED);
@trigger_error(sprintf('The "%s" class is deprecated since Symfony 4.4, use "%s" instead.', ExceptionHandler::class, \Symfony\Component\ErrorHandler\ErrorHandler::class), E_USER_DEPRECATED);
/**
* ExceptionHandler converts an exception to a Response object.
@ -31,7 +31,7 @@ use Symfony\Component\HttpKernel\Debug\FileLinkFormatter;
*
* @final since Symfony 4.3
*
* @deprecated since Symfony 4.4, use Symfony\Component\ErrorHandler\ExceptionHandler instead.
* @deprecated since Symfony 4.4, use Symfony\Component\ErrorHandler\ErrorHandler instead.
*/
class ExceptionHandler
{

View File

@ -44,7 +44,6 @@ class Debug
if (!\in_array(\PHP_SAPI, ['cli', 'phpdbg'], true)) {
ini_set('display_errors', 0);
ExceptionHandler::register();
} elseif ($displayErrors && (!filter_var(ini_get('log_errors'), FILTER_VALIDATE_BOOLEAN) || ini_get('error_log'))) {
// CLI - display errors only if they're not already logged to STDERR
ini_set('display_errors', 1);

View File

@ -21,7 +21,6 @@ use Symfony\Component\ErrorHandler\FatalErrorHandler\ClassNotFoundFatalErrorHand
use Symfony\Component\ErrorHandler\FatalErrorHandler\FatalErrorHandlerInterface;
use Symfony\Component\ErrorHandler\FatalErrorHandler\UndefinedFunctionFatalErrorHandler;
use Symfony\Component\ErrorHandler\FatalErrorHandler\UndefinedMethodFatalErrorHandler;
use Symfony\Component\ErrorRenderer\Exception\FlattenException;
/**
* A generic ErrorHandler for the PHP engine.
@ -414,7 +413,7 @@ class ErrorHandler
}
if (false !== strpos($message, "class@anonymous\0")) {
$logMessage = $this->levels[$type].': '.(new FlattenException())->setMessage($message)->getMessage();
$logMessage = $this->parseAnonymousClass($message);
} else {
$logMessage = $this->levels[$type].': '.$message;
}
@ -539,7 +538,7 @@ class ErrorHandler
if (($this->loggedErrors & $type) || $exception instanceof FatalThrowableError) {
if (false !== strpos($message = $exception->getMessage(), "class@anonymous\0")) {
$message = (new FlattenException())->setMessage($message)->getMessage();
$message = $this->parseAnonymousClass($message);
}
if ($exception instanceof FatalErrorException) {
if ($exception instanceof FatalThrowableError) {
@ -712,4 +711,15 @@ class ErrorHandler
return $lightTrace;
}
/**
* Parse the error message by removing the anonymous class notation
* and using the parent class instead if possible.
*/
private function parseAnonymousClass(string $message): string
{
return preg_replace_callback('/class@anonymous\x00.*?\.php0x?[0-9a-fA-F]++/', static function ($m) {
return class_exists($m[0], false) ? get_parent_class($m[0]).'@anonymous' : $m[0];
}, $message);
}
}

View File

@ -1,187 +0,0 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\ErrorHandler;
use Symfony\Component\ErrorHandler\Exception\OutOfMemoryException;
use Symfony\Component\ErrorRenderer\ErrorRenderer\HtmlErrorRenderer;
use Symfony\Component\ErrorRenderer\Exception\FlattenException;
use Symfony\Component\HttpKernel\Debug\FileLinkFormatter;
/**
* ExceptionHandler converts an exception to a Response object.
*
* It is mostly useful in debug mode to replace the default PHP/XDebug
* output with something prettier and more useful.
*
* As this class is mainly used during Kernel boot, where nothing is yet
* available, the Response content is always HTML.
*
* @author Fabien Potencier <fabien@symfony.com>
* @author Nicolas Grekas <p@tchwork.com>
*
* @final since Symfony 4.3
*/
class ExceptionHandler
{
private $debug;
private $charset;
private $handler;
private $caughtBuffer;
private $caughtLength;
private $fileLinkFormat;
private $htmlErrorRenderer;
public function __construct(bool $debug = true, string $charset = null, $fileLinkFormat = null)
{
$this->debug = $debug;
$this->charset = $charset ?: ini_get('default_charset') ?: 'UTF-8';
$this->fileLinkFormat = $fileLinkFormat;
}
/**
* Registers the exception handler.
*
* @param bool $debug Enable/disable debug mode, where the stack trace is displayed
* @param string|null $charset The charset used by exception messages
* @param string|null $fileLinkFormat The IDE link template
*
* @return static
*/
public static function register($debug = true, $charset = null, $fileLinkFormat = null)
{
$handler = new static($debug, $charset, $fileLinkFormat);
$prev = set_exception_handler([$handler, 'handle']);
if (\is_array($prev) && $prev[0] instanceof ErrorHandler) {
restore_exception_handler();
$prev[0]->setExceptionHandler([$handler, 'handle']);
}
return $handler;
}
/**
* Sets a user exception handler.
*
* @param callable $handler An handler that will be called on Exception
*
* @return callable|null The previous exception handler if any
*/
public function setHandler(callable $handler = null)
{
$old = $this->handler;
$this->handler = $handler;
return $old;
}
/**
* Sets the format for links to source files.
*
* @param string|FileLinkFormatter $fileLinkFormat The format for links to source files
*
* @return string The previous file link format
*/
public function setFileLinkFormat($fileLinkFormat)
{
$old = $this->fileLinkFormat;
$this->fileLinkFormat = $fileLinkFormat;
return $old;
}
/**
* Sends a response for the given Exception.
*
* To be as fail-safe as possible, the exception is first handled
* by our simple exception handler, then by the user exception handler.
* The latter takes precedence and any output from the former is cancelled,
* if and only if nothing bad happens in this handling path.
*/
public function handle(\Exception $exception)
{
if (null === $this->handler || $exception instanceof OutOfMemoryException) {
$this->sendPhpResponse($exception);
return;
}
$caughtLength = $this->caughtLength = 0;
ob_start(function ($buffer) {
$this->caughtBuffer = $buffer;
return '';
});
$this->sendPhpResponse($exception);
while (null === $this->caughtBuffer && ob_end_flush()) {
// Empty loop, everything is in the condition
}
if (isset($this->caughtBuffer[0])) {
ob_start(function ($buffer) {
if ($this->caughtLength) {
// use substr_replace() instead of substr() for mbstring overloading resistance
$cleanBuffer = substr_replace($buffer, '', 0, $this->caughtLength);
if (isset($cleanBuffer[0])) {
$buffer = $cleanBuffer;
}
}
return $buffer;
});
echo $this->caughtBuffer;
$caughtLength = ob_get_length();
}
$this->caughtBuffer = null;
try {
($this->handler)($exception);
$this->caughtLength = $caughtLength;
} catch (\Exception $e) {
if (!$caughtLength) {
// All handlers failed. Let PHP handle that now.
throw $exception;
}
}
}
/**
* Sends the error associated with the given Exception as a plain PHP response.
*
* This method uses plain PHP functions like header() and echo to output
* the response.
*
* @param \Throwable|FlattenException $exception A \Throwable or FlattenException instance
*/
public function sendPhpResponse($exception)
{
if ($exception instanceof \Throwable) {
$exception = FlattenException::createFromThrowable($exception);
}
if (!headers_sent()) {
header(sprintf('HTTP/1.0 %s', $exception->getStatusCode()));
foreach ($exception->getHeaders() as $name => $value) {
header($name.': '.$value, false);
}
header('Content-Type: text/html; charset='.$this->charset);
}
if (null === $this->htmlErrorRenderer) {
$this->htmlErrorRenderer = new HtmlErrorRenderer($this->debug, $this->charset, $this->fileLinkFormat);
}
echo $this->htmlErrorRenderer->render($exception);
}
}

View File

@ -1,144 +0,0 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\ErrorHandler\Tests;
use PHPUnit\Framework\TestCase;
use Symfony\Component\ErrorHandler\Exception\OutOfMemoryException;
use Symfony\Component\ErrorHandler\ExceptionHandler;
use Symfony\Component\HttpKernel\Exception\MethodNotAllowedHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
require_once __DIR__.'/HeaderMock.php';
class ExceptionHandlerTest extends TestCase
{
protected function setUp()
{
testHeader();
}
protected function tearDown()
{
testHeader();
}
/**
* @group legacy
*/
public function testDebug()
{
$handler = new ExceptionHandler(false);
ob_start();
$handler->sendPhpResponse(new \RuntimeException('Foo'));
$response = ob_get_clean();
$this->assertContains('The server returned a "500 Internal Server Error".', $response);
$this->assertNotContains('<div class="trace trace-as-html">', $response);
$handler = new ExceptionHandler(true);
ob_start();
$handler->sendPhpResponse(new \RuntimeException('Foo'));
$response = ob_get_clean();
$this->assertContains('<h1 class="break-long-words exception-message">Foo</h1>', $response);
$this->assertContains('<div class="trace trace-as-html" id="trace-box-1">', $response);
// taken from https://www.owasp.org/index.php/Cross-site_Scripting_(XSS)
$htmlWithXss = '<body onload=alert(\'test1\')> <b onmouseover=alert(\'Wufff!\')>click me!</b> <img src="j&#X41vascript:alert(\'test2\')"> <meta http-equiv="refresh" content="0;url=data:text/html;base64,PHNjcmlwdD5hbGVydCgndGVzdDMnKTwvc2NyaXB0Pg">';
ob_start();
$handler->sendPhpResponse(new \RuntimeException($htmlWithXss));
$response = ob_get_clean();
$this->assertContains(sprintf('<h1 class="break-long-words exception-message long">%s</h1>', htmlspecialchars($htmlWithXss, ENT_COMPAT | ENT_SUBSTITUTE, 'UTF-8')), $response);
}
public function testStatusCode()
{
$handler = new ExceptionHandler(false, 'iso8859-1');
ob_start();
$handler->sendPhpResponse(new NotFoundHttpException('Foo'));
$response = ob_get_clean();
$this->assertContains('The server returned a "404 Not Found".', $response);
$expectedHeaders = [
['HTTP/1.0 404', true, null],
['Content-Type: text/html; charset=iso8859-1', true, null],
];
$this->assertSame($expectedHeaders, testHeader());
}
public function testHeaders()
{
$handler = new ExceptionHandler(false, 'iso8859-1');
ob_start();
$handler->sendPhpResponse(new MethodNotAllowedHttpException(['POST']));
$response = ob_get_clean();
$expectedHeaders = [
['HTTP/1.0 405', true, null],
['Allow: POST', false, null],
['Content-Type: text/html; charset=iso8859-1', true, null],
];
$this->assertSame($expectedHeaders, testHeader());
}
public function testNestedExceptions()
{
$handler = new ExceptionHandler(true);
ob_start();
$handler->sendPhpResponse(new \RuntimeException('Foo', 0, new \RuntimeException('Bar')));
$response = ob_get_clean();
$this->assertStringMatchesFormat('%A<h1 class="break-long-words exception-message">Foo</h1>%A<p class="break-long-words trace-message">Bar</p>%A', $response);
}
public function testHandle()
{
$exception = new \Exception('foo');
$handler = $this->getMockBuilder('Symfony\Component\ErrorHandler\ExceptionHandler')->setMethods(['sendPhpResponse'])->getMock();
$handler
->expects($this->exactly(2))
->method('sendPhpResponse');
$handler->handle($exception);
$handler->setHandler(function ($e) use ($exception) {
$this->assertSame($exception, $e);
});
$handler->handle($exception);
}
public function testHandleOutOfMemoryException()
{
$exception = new OutOfMemoryException('foo', 0, E_ERROR, __FILE__, __LINE__);
$handler = $this->getMockBuilder('Symfony\Component\ErrorHandler\ExceptionHandler')->setMethods(['sendPhpResponse'])->getMock();
$handler
->expects($this->once())
->method('sendPhpResponse');
$handler->setHandler(function ($e) {
$this->fail('OutOfMemoryException should bypass the handler');
});
$handler->handle($exception);
}
}

View File

@ -1,24 +0,0 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\ErrorHandler\Tests;
use Symfony\Component\ErrorHandler\ExceptionHandler;
class MockExceptionHandler extends ExceptionHandler
{
public $e;
public function handle(\Exception $e)
{
$this->e = $e;
}
}

View File

@ -17,8 +17,7 @@
],
"require": {
"php": "^7.1.3",
"psr/log": "~1.0",
"symfony/error-renderer": "^4.4|^5.0"
"psr/log": "~1.0"
},
"conflict": {
"symfony/http-kernel": "<3.4"

View File

@ -16,7 +16,6 @@ use Symfony\Component\Console\ConsoleEvents;
use Symfony\Component\Console\Event\ConsoleEvent;
use Symfony\Component\Console\Output\ConsoleOutputInterface;
use Symfony\Component\ErrorHandler\ErrorHandler;
use Symfony\Component\ErrorHandler\ExceptionHandler;
use Symfony\Component\ErrorRenderer\ErrorRenderer;
use Symfony\Component\ErrorRenderer\ErrorRenderer\HtmlErrorRenderer;
use Symfony\Component\ErrorRenderer\Exception\ErrorRendererNotFoundException;
@ -138,19 +137,7 @@ class DebugHandlersListener implements EventSubscriberInterface
}
if ($this->exceptionHandler) {
if ($handler instanceof ErrorHandler) {
$h = $handler->setExceptionHandler('var_dump');
if (\is_array($h) && $h[0] instanceof ExceptionHandler) {
$handler->setExceptionHandler($h);
$handler = $h[0];
} else {
$handler->setExceptionHandler($this->exceptionHandler);
}
}
if ($handler instanceof ExceptionHandler) {
$handler->setHandler($this->exceptionHandler);
if (null !== $this->fileLinkFormat) {
$handler->setFileLinkFormat($this->fileLinkFormat);
}
$handler->setExceptionHandler($this->exceptionHandler);
}
$this->exceptionHandler = null;
}

View File

@ -20,7 +20,6 @@ use Symfony\Component\Console\Helper\HelperSet;
use Symfony\Component\Console\Input\ArgvInput;
use Symfony\Component\Console\Output\ConsoleOutput;
use Symfony\Component\ErrorHandler\ErrorHandler;
use Symfony\Component\ErrorHandler\ExceptionHandler;
use Symfony\Component\EventDispatcher\EventDispatcher;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Event\KernelEvent;
@ -38,9 +37,7 @@ class DebugHandlersListenerTest extends TestCase
$logger = $this->getMockBuilder('Psr\Log\LoggerInterface')->getMock();
$userHandler = function () {};
$listener = new DebugHandlersListener($userHandler, $logger);
$xHandler = new ExceptionHandler();
$eHandler = new ErrorHandler();
$eHandler->setExceptionHandler([$xHandler, 'handle']);
$exception = null;
set_error_handler([$eHandler, 'handleError']);
@ -56,7 +53,7 @@ class DebugHandlersListenerTest extends TestCase
throw $exception;
}
$this->assertSame($userHandler, $xHandler->setHandler('var_dump'));
$this->assertSame($userHandler, $eHandler->setExceptionHandler('var_dump'));
$loggers = $eHandler->setLoggers([]);