skipperbent / simple-php-router

Simple, fast and yet powerful PHP router that is easy to get integrated and in any project. Heavily inspired by the way Laravel handles routing, with both simplicity and expand-ability in mind.
647 stars 121 forks source link

Fixed rare double execution of rewrite routes in exception handler #683

Closed ms-afk closed 10 months ago

ms-afk commented 1 year ago

The issue

If a rewrite route is defined in an exception handler, Router's method handleException will, currently, be adding that route to the processedRoutes array without removing the hasPendingRewrite flag. This leads to the associated callback being executed twice if the callback itself returns NULL. This happens because the handleRouteRewrite method, finding that hasPendingRewrite is still set to true, adds the rewriteRoute to the processedRoutes for a second time, before finally setting that flag to false. This should not happen because executing code twice might have unexpected consequences.

How to avoid the issue in previous versions

UnitTest

I wrote a UnitTest to show the issue. I also defined a custom class loader in the file because the current default one always casts return values to string, making the bug disappear (see the "How to aviod the issue in previous versions" section to understand why). It should be noted that the class loader interface IClassLoader doesn't pose any restrictions on the return type of the loadClassMethod and loadClosure methods, so in general the bug can easily manifest if the default loader implementation gets changed or if a custom loader is used.

<?php

use Pecee\Http\Request;
use Pecee\SimpleRouter\Exceptions\NotFoundHttpException;
use Pecee\SimpleRouter\ClassLoader\IClassLoader;

class RewriteCallbackInsideErrorHandlerTest extends \PHPUnit\Framework\TestCase
{
    public function testRewriteCallbackInsideErrorHandler()
    {
        TestRouter::setCustomClassLoader(new MyCustomClassLoader());

        TestRouter::error(function(Request $request, \Exception $exception) {
            if($exception instanceof NotFoundHttpException && $exception->getCode() === 404) {
                $request->setRewriteCallback(static function() {
                    echo('executed');
                });
            }
        });

        $result = TestRouter::debugOutput('/non-existent-route', 'get');
        $this->assertEquals('executed', $result);
    }
}

/**
 * Custom ClassLoader whose loadClassMethod and loadClosure methods can return NULL.
 */
class MyCustomClassLoader implements IClassLoader
{
    public function loadClass(string $class)
    {
        if (\class_exists($class) === false) {
            throw new NotFoundHttpException(sprintf('Class "%s" does not exist', $class), 404);
        }

        return new $class();
    }

    public function loadClassMethod($class, string $method, array $parameters)
    {
        return call_user_func_array([$class, $method], array_values($parameters));
    }

    public function loadClosure(Callable $closure, array $parameters)
    {
        return \call_user_func_array($closure, array_values($parameters));
    }

}

You'll see that in the current version of the library the test will fail because the string 'executed' will be printed twice. After the fix, the test will instead pass. All other UnitTests also are passing after the change.

skipperbent commented 10 months ago

Very detailed walkthrough, thanks so much and superb fix. Thanks for the contribution brother