diff --git a/CompiledRoute.php b/CompiledRoute.php index 03215e368..f2a06e32a 100644 --- a/CompiledRoute.php +++ b/CompiledRoute.php @@ -16,7 +16,7 @@ * * @author Fabien Potencier */ -class CompiledRoute implements \Serializable +class CompiledRoute { private array $variables; private array $tokens; @@ -63,16 +63,15 @@ public function __serialize(): array ]; } - /** - * @internal - */ - final public function serialize(): string - { - throw new \BadMethodCallException('Cannot serialize '.__CLASS__); - } - public function __unserialize(array $data): void { + if (($data['path_prefix'] ?? null) instanceof \Stringable + || ($data['path_regex'] ?? null) instanceof \Stringable + || ($data['host_regex'] ?? null) instanceof \Stringable + ) { + throw new \BadMethodCallException('Cannot unserialize '.self::class); + } + $this->variables = $data['vars']; $this->staticPrefix = $data['path_prefix']; $this->regex = $data['path_regex']; @@ -83,14 +82,6 @@ public function __unserialize(array $data): void $this->hostVariables = $data['host_vars']; } - /** - * @internal - */ - final public function unserialize(string $serialized): void - { - $this->__unserialize(unserialize($serialized, ['allowed_classes' => false])); - } - /** * Returns the static prefix. */ diff --git a/Generator/Dumper/CompiledUrlGeneratorDumper.php b/Generator/Dumper/CompiledUrlGeneratorDumper.php index 555c5bfbd..881a6d507 100644 --- a/Generator/Dumper/CompiledUrlGeneratorDumper.php +++ b/Generator/Dumper/CompiledUrlGeneratorDumper.php @@ -91,14 +91,14 @@ public function getCompiledAliases(): array public function dump(array $options = []): string { return <<generateDeclaredRoutes()} -]; + return [{$this->generateDeclaredRoutes()} + ]; -EOF; + EOF; } /** diff --git a/Generator/UrlGenerator.php b/Generator/UrlGenerator.php index 4c8f8f74c..919017bef 100644 --- a/Generator/UrlGenerator.php +++ b/Generator/UrlGenerator.php @@ -177,7 +177,7 @@ protected function doGenerate(array $variables, array $defaults, array $requirem if (!$optional || $important || !\array_key_exists($varName, $defaults) || (null !== $mergedParams[$varName] && (string) $mergedParams[$varName] !== (string) $defaults[$varName])) { // check requirement (while ignoring look-around patterns) - if (null !== $this->strictRequirements && !preg_match('#^'.preg_replace('/\(\?(?:=|<=|!|strictRequirements && !preg_match('#^(?:'.preg_replace('/\(\?(?:=|<=|!|strictRequirements) { throw new InvalidParameterException(strtr($message, ['{parameter}' => $varName, '{route}' => $name, '{expected}' => $token[2], '{given}' => $mergedParams[$varName]])); } @@ -207,11 +207,16 @@ protected function doGenerate(array $variables, array $defaults, array $requirem // the path segments "." and ".." are interpreted as relative reference when resolving a URI; see http://tools.ietf.org/html/rfc3986#section-3.3 // so we need to encode them as they are not used for this purpose here // otherwise we would generate a URI that, when followed by a user agent (e.g. browser), does not match this route - $url = strtr($url, ['/../' => '/%2E%2E/', '/./' => '/%2E/']); - if (str_ends_with($url, '/..')) { - $url = substr($url, 0, -2).'%2E%2E'; - } elseif (str_ends_with($url, '/.')) { - $url = substr($url, 0, -1).'%2E'; + if (str_contains($url, '/.')) { + $segments = explode('/', $url); + foreach ($segments as $i => $segment) { + if ('.' === $segment) { + $segments[$i] = '%2E'; + } elseif ('..' === $segment) { + $segments[$i] = '%2E%2E'; + } + } + $url = implode('/', $segments); } $schemeAuthority = ''; @@ -230,7 +235,7 @@ protected function doGenerate(array $variables, array $defaults, array $requirem foreach ($hostTokens as $token) { if ('variable' === $token[0]) { // check requirement (while ignoring look-around patterns) - if (null !== $this->strictRequirements && !preg_match('#^'.preg_replace('/\(\?(?:=|<=|!|strictRequirements && !preg_match('#^(?:'.preg_replace('/\(\?(?:=|<=|!|strictRequirements) { throw new InvalidParameterException(strtr($message, ['{parameter}' => $token[3], '{route}' => $name, '{expected}' => $token[2], '{given}' => $mergedParams[$token[3]]])); } @@ -275,7 +280,7 @@ protected function doGenerate(array $variables, array $defaults, array $requirem } // add a query string if needed - $extra = array_udiff_assoc(array_diff_key($parameters, $variables), $defaults, fn ($a, $b) => $a == $b ? 0 : 1); + $extra = array_udiff_assoc(array_diff_key($parameters, $variables), $defaults, static fn ($a, $b) => $a == $b ? 0 : 1); array_walk_recursive($extra, $caster = static function (&$v) use (&$caster) { if (\is_object($v)) { diff --git a/Loader/AttributeDirectoryLoader.php b/Loader/AttributeDirectoryLoader.php index a070937d3..645f5c329 100644 --- a/Loader/AttributeDirectoryLoader.php +++ b/Loader/AttributeDirectoryLoader.php @@ -37,11 +37,11 @@ public function load(mixed $path, ?string $type = null): ?RouteCollection $files = iterator_to_array(new \RecursiveIteratorIterator( new \RecursiveCallbackFilterIterator( new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS | \FilesystemIterator::FOLLOW_SYMLINKS), - fn (\SplFileInfo $current) => !str_starts_with($current->getBasename(), '.') + static fn (\SplFileInfo $current) => !str_starts_with($current->getBasename(), '.') ), \RecursiveIteratorIterator::LEAVES_ONLY )); - usort($files, fn (\SplFileInfo $a, \SplFileInfo $b) => (string) $a > (string) $b ? 1 : -1); + usort($files, static fn (\SplFileInfo $a, \SplFileInfo $b) => (string) $a > (string) $b ? 1 : -1); foreach ($files as $file) { if (!$file->isFile() || !str_ends_with($file->getFilename(), '.php')) { diff --git a/Loader/Psr4DirectoryLoader.php b/Loader/Psr4DirectoryLoader.php index bbf99418c..61220e39e 100644 --- a/Loader/Psr4DirectoryLoader.php +++ b/Loader/Psr4DirectoryLoader.php @@ -38,12 +38,13 @@ public function __construct( */ public function load(mixed $resource, ?string $type = null): ?RouteCollection { + $excluded = $resource['_excluded'] ?? []; $path = $this->locator->locate($resource['path'], $this->currentDirectory); if (!is_dir($path)) { return new RouteCollection(); } - return $this->loadFromDirectory($path, trim($resource['namespace'], '\\')); + return $this->loadFromDirectory($path, trim($resource['namespace'], '\\'), $excluded); } public function supports(mixed $resource, ?string $type = null): bool @@ -59,23 +60,28 @@ public function forDirectory(string $currentDirectory): static return $loader; } - private function loadFromDirectory(string $directory, string $psr4Prefix): RouteCollection + private function loadFromDirectory(string $directory, string $psr4Prefix, array $excluded = []): RouteCollection { $collection = new RouteCollection(); $collection->addResource(new DirectoryResource($directory, '/\.php$/')); $files = iterator_to_array(new \RecursiveIteratorIterator( new \RecursiveCallbackFilterIterator( new \RecursiveDirectoryIterator($directory, \FilesystemIterator::SKIP_DOTS | \FilesystemIterator::FOLLOW_SYMLINKS), - fn (\SplFileInfo $current) => !str_starts_with($current->getBasename(), '.') + static fn (\SplFileInfo $current) => !str_starts_with($current->getBasename(), '.') ), \RecursiveIteratorIterator::SELF_FIRST )); - usort($files, fn (\SplFileInfo $a, \SplFileInfo $b) => (string) $a > (string) $b ? 1 : -1); + usort($files, static fn (\SplFileInfo $a, \SplFileInfo $b) => (string) $a > (string) $b ? 1 : -1); /** @var \SplFileInfo $file */ foreach ($files as $file) { + $normalizedPath = rtrim(str_replace('\\', '/', $file->getPathname()), '/'); + if (isset($excluded[$normalizedPath])) { + continue; + } + if ($file->isDir()) { - $collection->addCollection($this->loadFromDirectory($file->getPathname(), $psr4Prefix.'\\'.$file->getFilename())); + $collection->addCollection($this->loadFromDirectory($file->getPathname(), $psr4Prefix.'\\'.$file->getFilename(), $excluded)); continue; } diff --git a/Matcher/Dumper/CompiledUrlMatcherDumper.php b/Matcher/Dumper/CompiledUrlMatcherDumper.php index f71264ce9..fcdc1d160 100644 --- a/Matcher/Dumper/CompiledUrlMatcherDumper.php +++ b/Matcher/Dumper/CompiledUrlMatcherDumper.php @@ -37,17 +37,17 @@ class CompiledUrlMatcherDumper extends MatcherDumper public function dump(array $options = []): string { return <<generateCompiledRoutes()}]; + return [ + {$this->generateCompiledRoutes()}]; -EOF; + EOF; } /** @@ -115,12 +115,12 @@ public function getCompiledRoutes(bool $forDump = false): array } $checkConditionCode = <<indent(implode("\n", $conditions), 3)} - } - } -EOF; + static function (\$condition, \$context, \$request, \$params) { // \$checkCondition + switch (\$condition) { + {$this->indent(implode("\n", $conditions), 3)} + } + } + EOF; $compiledRoutes[4] = $forDump ? $checkConditionCode.",\n" : eval('return '.$checkConditionCode.';'); } else { $compiledRoutes[4] = $forDump ? " null, // \$checkCondition\n" : null; diff --git a/Matcher/ExpressionLanguageProvider.php b/Matcher/ExpressionLanguageProvider.php index a910a07a2..fee10a394 100644 --- a/Matcher/ExpressionLanguageProvider.php +++ b/Matcher/ExpressionLanguageProvider.php @@ -37,7 +37,7 @@ public function getFunctions(): array $functions[] = new ExpressionFunction( $function, static fn (...$args) => \sprintf('($context->getParameter(\'_functions\')->get(%s)(%s))', var_export($function, true), implode(', ', $args)), - fn ($values, ...$args) => $values['context']->getParameter('_functions')->get($function)(...$args) + static fn ($values, ...$args) => $values['context']->getParameter('_functions')->get($function)(...$args) ); } diff --git a/Matcher/UrlMatcher.php b/Matcher/UrlMatcher.php index f3ab414f0..374eb3a0f 100644 --- a/Matcher/UrlMatcher.php +++ b/Matcher/UrlMatcher.php @@ -94,12 +94,15 @@ public function match(string $pathinfo): array public function matchRequest(Request $request): array { $this->request = $request; - - $ret = $this->match($request->getPathInfo()); - - $this->request = null; - - return $ret; + $originalContext = $this->context; + $this->context = (clone $originalContext)->fromRequest($request); + + try { + return $this->match($request->getPathInfo()); + } finally { + $this->context = $originalContext; + $this->request = null; + } } /** diff --git a/Route.php b/Route.php index e34dcb75e..4cd1c5ae5 100644 --- a/Route.php +++ b/Route.php @@ -17,7 +17,7 @@ * @author Fabien Potencier * @author Tobias Schultze */ -class Route implements \Serializable +class Route { private string $path = '/'; private string $host = ''; @@ -73,16 +73,15 @@ public function __serialize(): array ]; } - /** - * @internal - */ - final public function serialize(): string - { - throw new \BadMethodCallException('Cannot serialize '.__CLASS__); - } - public function __unserialize(array $data): void { + if (($data['path'] ?? null) instanceof \Stringable + || ($data['host'] ?? null) instanceof \Stringable + || ($data['condition'] ?? null) instanceof \Stringable + ) { + throw new \BadMethodCallException('Cannot unserialize '.self::class); + } + $this->path = $data['path']; $this->host = $data['host']; $this->defaults = $data['defaults']; @@ -99,14 +98,6 @@ public function __unserialize(array $data): void } } - /** - * @internal - */ - final public function unserialize(string $serialized): void - { - $this->__unserialize(unserialize($serialized)); - } - public function getPath(): string { return $this->path; diff --git a/RouteCompiler.php b/RouteCompiler.php index a96fb9ad9..8c5fc06a3 100644 --- a/RouteCompiler.php +++ b/RouteCompiler.php @@ -292,28 +292,26 @@ private static function computeRegexp(array $tokens, int $index, int $firstOptio if ('text' === $token[0]) { // Text tokens return preg_quote($token[1]); - } else { - // Variable tokens - if (0 === $index && 0 === $firstOptional) { - // When the only token is an optional variable token, the separator is required - return \sprintf('%s(?P<%s>%s)?', preg_quote($token[1]), $token[3], $token[2]); - } else { - $regexp = \sprintf('%s(?P<%s>%s)', preg_quote($token[1]), $token[3], $token[2]); - if ($index >= $firstOptional) { - // Enclose each optional token in a subpattern to make it optional. - // "?:" means it is non-capturing, i.e. the portion of the subject string that - // matched the optional subpattern is not passed back. - $regexp = "(?:$regexp"; - $nbTokens = \count($tokens); - if ($nbTokens - 1 == $index) { - // Close the optional subpatterns - $regexp .= str_repeat(')?', $nbTokens - $firstOptional - (0 === $firstOptional ? 1 : 0)); - } - } - - return $regexp; + } + // Variable tokens + if (0 === $index && 0 === $firstOptional) { + // When the only token is an optional variable token, the separator is required + return \sprintf('%s(?P<%s>%s)?', preg_quote($token[1]), $token[3], $token[2]); + } + $regexp = \sprintf('%s(?P<%s>%s)', preg_quote($token[1]), $token[3], $token[2]); + if ($index >= $firstOptional) { + // Enclose each optional token in a subpattern to make it optional. + // "?:" means it is non-capturing, i.e. the portion of the subject string that + // matched the optional subpattern is not passed back. + $regexp = "(?:$regexp"; + $nbTokens = \count($tokens); + if ($nbTokens - 1 == $index) { + // Close the optional subpatterns + $regexp .= str_repeat(')?', $nbTokens - $firstOptional - (0 === $firstOptional ? 1 : 0)); } } + + return $regexp; } private static function transformCapturingGroupsToNonCapturings(string $regexp): string diff --git a/Tests/CompiledRouteTest.php b/Tests/CompiledRouteTest.php index 9c9f4f286..ff74e157f 100644 --- a/Tests/CompiledRouteTest.php +++ b/Tests/CompiledRouteTest.php @@ -14,6 +14,18 @@ use PHPUnit\Framework\TestCase; use Symfony\Component\Routing\CompiledRoute; +class CompiledRouteTestToStringGadget +{ + public static bool $fired = false; + + public function __toString(): string + { + self::$fired = true; + + return ''; + } +} + class CompiledRouteTest extends TestCase { public function testAccessors() @@ -24,4 +36,43 @@ public function testAccessors() $this->assertEquals(['tokens'], $compiled->getTokens(), '__construct() takes an array of tokens as its fourth argument'); $this->assertEquals(['variables'], $compiled->getVariables(), '__construct() takes an array of variables as its ninth argument'); } + + /** + * @dataProvider provideTrampolineProperties + */ + public function testUnserializeRejectsObjectInTypedScalarProperty(string $property) + { + $data = [ + 'vars' => [], + 'path_prefix' => '', + 'path_regex' => '', + 'path_tokens' => [], + 'path_vars' => [], + 'host_regex' => null, + 'host_tokens' => [], + 'host_vars' => [], + ]; + $data[$property] = new CompiledRouteTestToStringGadget(); + $payload = \sprintf('O:%d:"%s":%d:{', \strlen(CompiledRoute::class), CompiledRoute::class, \count($data)); + foreach ($data as $key => $value) { + $payload .= serialize($key).serialize($value); + } + $payload .= '}'; + CompiledRouteTestToStringGadget::$fired = false; + + try { + unserialize($payload); + $this->fail('Expected BadMethodCallException.'); + } catch (\BadMethodCallException $e) { + } + + $this->assertFalse(CompiledRouteTestToStringGadget::$fired, '__toString gadget must not fire during unserialize'); + } + + public static function provideTrampolineProperties(): iterable + { + yield ['path_prefix']; + yield ['path_regex']; + yield ['host_regex']; + } } diff --git a/Tests/Generator/UrlGeneratorTest.php b/Tests/Generator/UrlGeneratorTest.php index 733239d6f..041197d0a 100644 --- a/Tests/Generator/UrlGeneratorTest.php +++ b/Tests/Generator/UrlGeneratorTest.php @@ -366,6 +366,14 @@ public function testGenerateForRouteWithInvalidParameter() $this->getGenerator($routes)->generate('test', ['foo' => '0'], UrlGeneratorInterface::ABSOLUTE_URL); } + public function testGenerateForRouteWithAlternationRequirementRejectsSubstringMatch() + { + $routes = $this->getRoutes('test', new Route('/{_locale}/blog', [], ['_locale' => 'en|fr|vi|de'])); + + $this->expectException(InvalidParameterException::class); + $this->getGenerator($routes)->generate('test', ['_locale' => '/evil.com']); + } + public function testGenerateForRouteWithInvalidOptionalParameterNonStrict() { $routes = $this->getRoutes('test', new Route('/testing/{foo}', ['foo' => '1'], ['foo' => 'd+'])); @@ -521,6 +529,18 @@ public function testEncodingOfRelativePathSegments() $this->assertSame('/app.php/a./.a/a../..a/...', $this->getGenerator($routes)->generate('test')); } + public function testEncodingOfChainedRelativePathSegments() + { + $routes = $this->getRoutes('test', new Route('/foo/{path}/bar', [], ['path' => '.+'])); + $this->assertSame('/app.php/foo/%2E%2E/%2E%2E/%2E%2E/bar', $this->getGenerator($routes)->generate('test', ['path' => '../../..'])); + $this->assertSame('/app.php/foo/%2E/%2E/%2E/bar', $this->getGenerator($routes)->generate('test', ['path' => '././.'])); + $this->assertSame('/app.php/foo/%2E%2E/%2E/%2E/%2E%2E/bar', $this->getGenerator($routes)->generate('test', ['path' => '../././..'])); + + $routes = $this->getRoutes('test', new Route('/foo/{path}', [], ['path' => '.+'])); + $this->assertSame('/app.php/foo/%2E%2E/%2E%2E/%2E%2E', $this->getGenerator($routes)->generate('test', ['path' => '../../..'])); + $this->assertSame('/app.php/foo/%2E/%2E/%2E', $this->getGenerator($routes)->generate('test', ['path' => '././.'])); + } + public function testEncodingOfSlashInPath() { $routes = $this->getRoutes('test', new Route('/dir/{path}/dir2', [], ['path' => '.+'])); diff --git a/Tests/Loader/ClosureLoaderTest.php b/Tests/Loader/ClosureLoaderTest.php index 85ecd8769..0588a925c 100644 --- a/Tests/Loader/ClosureLoaderTest.php +++ b/Tests/Loader/ClosureLoaderTest.php @@ -22,7 +22,7 @@ public function testSupports() { $loader = new ClosureLoader(); - $closure = function () {}; + $closure = static function () {}; $this->assertTrue($loader->supports($closure), '->supports() returns true if the resource is loadable'); $this->assertFalse($loader->supports('foo.foo'), '->supports() returns true if the resource is loadable'); diff --git a/Tests/Loader/Psr4DirectoryLoaderTest.php b/Tests/Loader/Psr4DirectoryLoaderTest.php index a007d4c99..925d38059 100644 --- a/Tests/Loader/Psr4DirectoryLoaderTest.php +++ b/Tests/Loader/Psr4DirectoryLoaderTest.php @@ -65,6 +65,38 @@ public function testAbstractController() $this->assertSame(MyChildController::class.'::someAction', $route->getDefault('_controller')); } + public function testExcludeSubNamespace() + { + $fixturesPath = \dirname(__DIR__).'/Fixtures'; + $excluded = [ + rtrim(str_replace('\\', '/', $fixturesPath.'/Psr4Controllers/SubNamespace'), '/') => true, + ]; + $collection = $this->getLoader()->load( + ['path' => 'Psr4Controllers', 'namespace' => 'Symfony\Component\Routing\Tests\Fixtures\Psr4Controllers', '_excluded' => $excluded], + 'attribute' + ); + + $this->assertNotNull($collection->get('my_route')); + $this->assertNull($collection->get('my_other_controller_one')); + $this->assertNull($collection->get('my_controller_with_a_trait')); + $this->assertNull($collection->get('my_child_controller_from_abstract')); + } + + public function testExcludeSingleFile() + { + $fixturesPath = \dirname(__DIR__).'/Fixtures'; + $excluded = [ + rtrim(str_replace('\\', '/', $fixturesPath.'/Psr4Controllers/MyController.php'), '/') => true, + ]; + $collection = $this->getLoader()->load( + ['path' => 'Psr4Controllers', 'namespace' => 'Symfony\Component\Routing\Tests\Fixtures\Psr4Controllers', '_excluded' => $excluded], + 'attribute' + ); + + $this->assertNull($collection->get('my_route')); + $this->assertNotNull($collection->get('my_other_controller_one')); + } + /** * @dataProvider provideNamespacesThatNeedTrimming */ diff --git a/Tests/Matcher/Dumper/StaticPrefixCollectionTest.php b/Tests/Matcher/Dumper/StaticPrefixCollectionTest.php index 9935ced44..0a92d44dd 100644 --- a/Tests/Matcher/Dumper/StaticPrefixCollectionTest.php +++ b/Tests/Matcher/Dumper/StaticPrefixCollectionTest.php @@ -44,10 +44,10 @@ public static function routeProvider() ['/leading/segment/', 'leading_segment'], ], << [ [ @@ -56,11 +56,11 @@ public static function routeProvider() ['/prefix/segment/bb', 'leading_segment'], ], << prefix_segment --> leading_segment -EOF, + root + /prefix/segment/ + -> prefix_segment + -> leading_segment + EOF, ], 'Nested - contains item at intersection' => [ [ @@ -69,11 +69,11 @@ public static function routeProvider() ['/prefix/segment/bb', 'leading_segment'], ], << prefix_segment --> leading_segment -EOF, + root + /prefix/segment/ + -> prefix_segment + -> leading_segment + EOF, ], 'Simple one level nesting' => [ [ @@ -83,12 +83,12 @@ public static function routeProvider() ['/group/other/', 'other_segment'], ], << nested_segment --> some_segment --> other_segment -EOF, + root + /group/ + -> nested_segment + -> some_segment + -> other_segment + EOF, ], 'Retain matching order with groups' => [ [ @@ -101,16 +101,16 @@ public static function routeProvider() ['/group/ff/', 'ff'], ], << aa --> bb --> cc -root -/group/ --> dd --> ee --> ff -EOF, + /group/ + -> aa + -> bb + -> cc + root + /group/ + -> dd + -> ee + -> ff + EOF, ], 'Retain complex matching order with groups at base' => [ [ @@ -127,22 +127,22 @@ public static function routeProvider() ['/aaa/333/', 'third_aaa'], ], << first_aaa --> second_aaa --> third_aaa -/prefixed/ --> /prefixed/group/ --> -> aa --> -> bb --> -> cc --> root --> /prefixed/group/ --> -> dd --> -> ee --> -> ff --> parent -EOF, + /aaa/ + -> first_aaa + -> second_aaa + -> third_aaa + /prefixed/ + -> /prefixed/group/ + -> -> aa + -> -> bb + -> -> cc + -> root + -> /prefixed/group/ + -> -> dd + -> -> ee + -> -> ff + -> parent + EOF, ], 'Group regardless of segments' => [ @@ -155,15 +155,15 @@ public static function routeProvider() ['/group-cc/', 'g3'], ], << a1 --> a2 --> a3 -/group- --> g1 --> g2 --> g3 -EOF, + /aaa- + -> a1 + -> a2 + -> a3 + /group- + -> g1 + -> g2 + -> g3 + EOF, ], ]; } diff --git a/Tests/Matcher/ExpressionLanguageProviderTest.php b/Tests/Matcher/ExpressionLanguageProviderTest.php index 712802571..55193302c 100644 --- a/Tests/Matcher/ExpressionLanguageProviderTest.php +++ b/Tests/Matcher/ExpressionLanguageProviderTest.php @@ -25,12 +25,12 @@ class ExpressionLanguageProviderTest extends TestCase protected function setUp(): void { $functionProvider = new ServiceLocator([ - 'env' => fn () => fn (string $arg) => [ + 'env' => static fn () => static fn (string $arg) => [ 'APP_ENV' => 'test', 'PHP_VERSION' => '7.2', ][$arg] ?? null, - 'sum' => fn () => fn ($a, $b) => $a + $b, - 'foo' => fn () => fn () => 'bar', + 'sum' => static fn () => static fn ($a, $b) => $a + $b, + 'foo' => static fn () => static fn () => 'bar', ]); $this->context = new RequestContext(); diff --git a/Tests/Matcher/UrlMatcherTest.php b/Tests/Matcher/UrlMatcherTest.php index 12e09bdf1..f714b4263 100644 --- a/Tests/Matcher/UrlMatcherTest.php +++ b/Tests/Matcher/UrlMatcherTest.php @@ -12,6 +12,7 @@ namespace Symfony\Component\Routing\Tests\Matcher; use PHPUnit\Framework\TestCase; +use Symfony\Component\HttpFoundation\Request; use Symfony\Component\Routing\Exception\MethodNotAllowedException; use Symfony\Component\Routing\Exception\NoConfigurationException; use Symfony\Component\Routing\Exception\ResourceNotFoundException; @@ -57,6 +58,33 @@ public function testMethodNotAllowed() } } + public function testMatchRequestHonorsTheRequestsMethodOverTheStaticContext() + { + $coll = new RouteCollection(); + $coll->add('foo', new Route('/foo', [], [], [], '', [], ['GET'])); + + $matcher = $this->getUrlMatcher($coll, new RequestContext('', 'POST')); + + $this->assertSame( + ['_route' => 'foo'], + $matcher->matchRequest(Request::create('/foo', 'GET')) + ); + } + + public function testMatchRequestRestoresTheContextAfterwards() + { + $coll = new RouteCollection(); + $coll->add('foo', new Route('/foo', [], [], [], '', [], ['GET'])); + + $originalContext = new RequestContext('', 'POST', 'example.com'); + $matcher = $this->getUrlMatcher($coll, $originalContext); + $matcher->matchRequest(Request::create('https://other.example/foo', 'GET')); + + $this->assertSame('POST', $originalContext->getMethod()); + $this->assertSame('example.com', $originalContext->getHost()); + $this->assertSame($originalContext, $matcher->getContext()); + } + public function testMethodNotAllowedOnRoot() { $coll = new RouteCollection(); diff --git a/Tests/RouteTest.php b/Tests/RouteTest.php index da64d6cfb..add0351ef 100644 --- a/Tests/RouteTest.php +++ b/Tests/RouteTest.php @@ -17,6 +17,18 @@ use Symfony\Component\Routing\Tests\Fixtures\CustomCompiledRoute; use Symfony\Component\Routing\Tests\Fixtures\CustomRouteCompiler; +class RouteTestToStringGadget +{ + public static bool $fired = false; + + public function __toString(): string + { + self::$fired = true; + + return ''; + } +} + class RouteTest extends TestCase { public function testConstructor() @@ -98,7 +110,7 @@ public function testDefaults() $this->assertEquals('bar2', $route->getDefault('foo2'), '->getDefault() return the default value'); $this->assertNull($route->getDefault('not_defined'), '->getDefault() return null if default value is not set'); - $route->setDefault('_controller', $closure = fn () => 'Hello'); + $route->setDefault('_controller', $closure = static fn () => 'Hello'); $this->assertEquals($closure, $route->getDefault('_controller'), '->setDefault() sets a default value'); $route->setDefaults(['foo' => 'foo']); @@ -224,6 +236,45 @@ public function testSerialize() $this->assertNotSame($route, $unserialized); } + /** + * @dataProvider provideTrampolineProperties + */ + public function testUnserializeRejectsObjectInTypedScalarProperty(string $property) + { + $data = [ + 'path' => '/', + 'host' => '', + 'defaults' => [], + 'requirements' => [], + 'options' => [], + 'schemes' => [], + 'methods' => [], + 'condition' => '', + ]; + $data[$property] = new RouteTestToStringGadget(); + $payload = \sprintf('O:%d:"%s":%d:{', \strlen(Route::class), Route::class, \count($data)); + foreach ($data as $key => $value) { + $payload .= serialize($key).serialize($value); + } + $payload .= '}'; + RouteTestToStringGadget::$fired = false; + + try { + unserialize($payload); + $this->fail('Expected BadMethodCallException.'); + } catch (\BadMethodCallException $e) { + } + + $this->assertFalse(RouteTestToStringGadget::$fired, '__toString gadget must not fire during unserialize'); + } + + public static function provideTrampolineProperties(): iterable + { + yield ['path']; + yield ['host']; + yield ['condition']; + } + public function testInlineDefaultAndRequirement() { $this->assertEquals((new Route('/foo/{bar}'))->setDefault('bar', null), new Route('/foo/{bar?}'));