diff --git a/CHANGELOG b/CHANGELOG index 511ba7475..1ca2712c6 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,6 +1,6 @@ # 3.27.2 (2026-XX-XX) - * n/a + * Skip the sandbox `__toString` check on arguments whose PHP parameter type cannot implicitly coerce to string # 3.27.1 (2026-05-30) diff --git a/src/Node/Expression/CallExpression.php b/src/Node/Expression/CallExpression.php index 70c56c3c6..ad6d3881a 100644 --- a/src/Node/Expression/CallExpression.php +++ b/src/Node/Expression/CallExpression.php @@ -282,28 +282,9 @@ abstract class CallExpression extends AbstractExpression { $twigCallable = $this->getAttribute('twig_callable'); $rc = $this->reflectCallable($twigCallable); - $r = $rc->getReflector(); $callableName = $rc->getName(); - $parameters = $r->getParameters(); - if ($this->hasNode('node')) { - array_shift($parameters); - } - if ($twigCallable->needsCharset()) { - array_shift($parameters); - } - if ($twigCallable->needsEnvironment()) { - array_shift($parameters); - } - if ($twigCallable->needsContext()) { - array_shift($parameters); - } - if (self::needsIsSandboxed($twigCallable)) { - array_shift($parameters); - } - foreach ($twigCallable->getArguments() as $argument) { - array_shift($parameters); - } + $parameters = $rc->getTwigParameters($this->hasNode('node')); $isPhpVariadic = false; if ($isVariadic) { diff --git a/src/NodeVisitor/SandboxNodeVisitor.php b/src/NodeVisitor/SandboxNodeVisitor.php index 65667c2ff..be39cc778 100644 --- a/src/NodeVisitor/SandboxNodeVisitor.php +++ b/src/NodeVisitor/SandboxNodeVisitor.php @@ -27,6 +27,7 @@ use Twig\Node\Expression\Variable\ContextVariable; use Twig\Node\ModuleNode; use Twig\Node\Node; use Twig\Node\Nodes; +use Twig\Util\CallableParameters; /** * @author Fabien Potencier @@ -85,7 +86,22 @@ final class SandboxNodeVisitor implements NodeVisitorInterface // wrap children that the node itself will string-coerce at runtime; // applies to ModuleNode (`parent` slot for {% extends %}) too if ($this->inAModule && $node instanceof CoercesChildrenToStringInterface) { + $params = CallableParameters::fromNode($node, $env); foreach ($node->getStringCoercedChildNames() as $childName) { + // For Filter/Function/Test calls, consult the PHP callable + // signature: skip wrapping arguments whose param type cannot + // implicitly string-coerce (e.g. `int`, a `final` value object). + if (null !== $params && 'arguments' === $childName) { + $this->wrapArguments($node, $params); + + continue; + } + if (null !== $params && 'node' === $childName && $node instanceof FilterExpression) { + // The filter's input value maps to the first PHP parameter. + if (isset($params[0]) && CallableParameters::isStringCoercionSafe($params[0]->getType(), $params[0]->getDeclaringClass())) { + continue; + } + } $this->wrapNode($node, $childName); } } @@ -105,6 +121,61 @@ final class SandboxNodeVisitor implements NodeVisitorInterface return $node; } + /** + * Wraps each entry in the `arguments` slot only when the corresponding + * PHP parameter type can implicitly string-coerce. + * + * @param list<\ReflectionParameter> $params parameters relative to the + * first template argument (for + * filters and tests: starting + * after the `node`/input + * parameter) + */ + private function wrapArguments(Node $node, array $params): void + { + $arguments = $node->getNode('arguments'); + if (!$arguments instanceof Nodes && !$arguments instanceof ArrayExpression) { + $this->wrapNode($node, 'arguments'); + + return; + } + + // Filters and tests pass their input value (`node`) as the first PHP + // param, so their template arguments start at offset 1. + $positional = \array_slice($params, $node->hasNode('node') ? 1 : 0); + $variadic = null; + $byName = []; + foreach ($positional as $p) { + if ($p->isVariadic()) { + $variadic = $p; + break; + } + $byName[$this->normalizeName($p->getName())] ??= $p; + } + + $positionalIdx = 0; + foreach ($arguments as $key => $_) { + if (\is_int($key)) { + $param = $positional[$positionalIdx] ?? $variadic; + if (null !== $param && !$param->isVariadic()) { + ++$positionalIdx; + } + } else { + $param = $byName[$this->normalizeName($key)] ?? $variadic; + } + + if (null !== $param && CallableParameters::isStringCoercionSafe($param->getType(), $param->getDeclaringClass())) { + continue; + } + $this->wrapNode($arguments, (string) $key); + } + } + + private function normalizeName(string $name): string + { + return strtolower(str_replace('_', '', $name)); + } + private function wrapNode(Node $node, string $name): void { $expr = $node->getNode($name); diff --git a/src/Util/CallableArgumentsExtractor.php b/src/Util/CallableArgumentsExtractor.php index c7bf54088..e3e1f82bd 100644 --- a/src/Util/CallableArgumentsExtractor.php +++ b/src/Util/CallableArgumentsExtractor.php @@ -13,7 +13,6 @@ namespace Twig\Util; use Twig\Error\SyntaxError; use Twig\Node\Expression\ArrayExpression; -use Twig\Node\Expression\CallExpression; use Twig\Node\Expression\ConstantExpression; use Twig\Node\Expression\VariadicExpression; use Twig\Node\Node; @@ -184,25 +183,7 @@ final class CallableArgumentsExtractor private function getCallableParameters(): array { - $parameters = $this->rc->getReflector()->getParameters(); - if ($this->node->hasNode('node')) { - array_shift($parameters); - } - if ($this->twigCallable->needsCharset()) { - array_shift($parameters); - } - if ($this->twigCallable->needsEnvironment()) { - array_shift($parameters); - } - if ($this->twigCallable->needsContext()) { - array_shift($parameters); - } - if (CallExpression::needsIsSandboxed($this->twigCallable)) { - array_shift($parameters); - } - foreach ($this->twigCallable->getArguments() as $argument) { - array_shift($parameters); - } + $parameters = $this->rc->getTwigParameters($this->node->hasNode('node')); $isPhpVariadic = false; if ($this->twigCallable->isVariadic()) { diff --git a/src/Util/CallableParameters.php b/src/Util/CallableParameters.php new file mode 100644 index 000000000..4321bb291 --- /dev/null +++ b/src/Util/CallableParameters.php @@ -0,0 +1,161 @@ + + * + * @internal + */ +final class CallableParameters +{ + /** + * Returns the PHP parameters of a Filter/Function/Test call mapped to its + * template-level arguments. + * + * The parameters Twig injects automatically + * (`needs_charset/environment/context/is_sandboxed`) and the bound + * `arguments` are stripped. For a filter, the first returned parameter is + * the filter's input value. Returns null when reflection fails or the node + * is not a callable expression. + * + * @return list<\ReflectionParameter>|null + */ + public static function fromNode(Node $node, Environment $env): ?array + { + if (!$node instanceof FilterExpression && !$node instanceof FunctionExpression && !$node instanceof TestExpression) { + return null; + } + + $callable = self::resolveTwigCallable($node, $env); + if (null === $callable || null === $callable->getCallable()) { + return null; + } + + try { + return (new ReflectionCallable($callable))->getTwigParameters(); + } catch (\LogicException) { + return null; + } + } + + /** + * Returns true when a PHP parameter type proves the value reaching it + * cannot implicitly string-coerce (directly or by iterating it). + * + * Safe: `int`, `float`, `bool`, `null`, `false`, `true`, `void`, `never`, + * enums, and `final` class types that are neither `Stringable` nor + * `Traversable`. + * + * Unsafe: `string`, `array`, `iterable`, `mixed`, `object`, + * untyped, interfaces, non-final classes, and any type that is + * `Stringable` or `Traversable`. Interfaces and non-final classes are + * open: a subtype could add `Stringable`/`Traversable` and reach the host + * code, bypassing the `__toString` policy, so only a `final` class (enums + * included) is closed enough. + * + * @param \ReflectionClass|null $scope resolves the relative types `self`/`parent`/`static` (which + * older PHP versions report verbatim instead of the declaring class) + */ + public static function isStringCoercionSafe(?\ReflectionType $type, ?\ReflectionClass $scope = null): bool + { + if (null === $type) { + return false; + } + + // A union value is one of its members, so every member must be safe. + if ($type instanceof \ReflectionUnionType) { + foreach ($type->getTypes() as $t) { + if (!self::isStringCoercionSafe($t, $scope)) { + return false; + } + } + + return true; + } + + // An intersection value satisfies all its members at once. A safe + // member is necessarily a final class, which pins the concrete class, + // so the value is that non-coercible class whatever the other members. + if ($type instanceof \ReflectionIntersectionType) { + foreach ($type->getTypes() as $t) { + if (self::isStringCoercionSafe($t, $scope)) { + return true; + } + } + + return false; + } + + if (!$type instanceof \ReflectionNamedType) { + return false; + } + + $name = $type->getName(); + + if ($type->isBuiltin()) { + return match ($name) { + // `null` (e.g. as a union member) cannot have a __toString. + 'null', 'int', 'float', 'bool', 'true', 'false', 'void', 'never' => true, + default => false, // string, array, iterable, object, mixed, callable + }; + } + + // Resolve `self`/`parent`/`static` to the concrete class + // `static` is treated like `self` since a `final` class cannot be subclassed anyway + $class = match ($name) { + 'self', 'static' => $scope, + 'parent' => $scope ? ($scope->getParentClass() ?: null) : null, + default => class_exists($name, false) ? new \ReflectionClass($name) : null, + }; + + // Interfaces and non-final classes are open: a subtype could add + // Stringable/Traversable, so only a final non-coercible class is safe + if (null === $class) { + return false; + } + if (is_a($class->getName(), \Stringable::class, true) || is_a($class->getName(), \Traversable::class, true)) { + return false; + } + + return $class->isFinal(); + } + + private static function resolveTwigCallable(Node $node, Environment $env): ?TwigCallableInterface + { + if ($node->hasAttribute('twig_callable')) { + return $node->getAttribute('twig_callable'); + } + if (!$node->hasAttribute('name')) { + return null; + } + $name = $node->getAttribute('name'); + try { + return match (true) { + $node instanceof FilterExpression => $env->getFilter($name), + $node instanceof FunctionExpression => $env->getFunction($name), + $node instanceof TestExpression => $env->getTest($name), + }; + } catch (\Throwable) { + return null; + } + } +} diff --git a/src/Util/ReflectionCallable.php b/src/Util/ReflectionCallable.php index 0298e291d..9616e3b29 100644 --- a/src/Util/ReflectionCallable.php +++ b/src/Util/ReflectionCallable.php @@ -11,6 +11,7 @@ namespace Twig\Util; +use Twig\Node\Expression\CallExpression; use Twig\TwigCallableInterface; /** @@ -25,7 +26,7 @@ final class ReflectionCallable private $name; public function __construct( - TwigCallableInterface $twigCallable, + private TwigCallableInterface $twigCallable, ) { $callable = $twigCallable->getCallable(); if (\is_string($callable) && false !== $pos = strpos($callable, '::')) { @@ -80,6 +81,41 @@ final class ReflectionCallable return $this->reflector; } + /** + * Returns the PHP parameters that map to the callable's template-level + * arguments. + * + * The parameters Twig injects automatically (the piped input value when + * $stripInput is true, then needs_charset/environment/context/is_sandboxed) + * and the bound arguments are stripped. + * + * @return list<\ReflectionParameter> + */ + public function getTwigParameters(bool $stripInput = false): array + { + $parameters = $this->reflector->getParameters(); + if ($stripInput) { + array_shift($parameters); + } + if ($this->twigCallable->needsCharset()) { + array_shift($parameters); + } + if ($this->twigCallable->needsEnvironment()) { + array_shift($parameters); + } + if ($this->twigCallable->needsContext()) { + array_shift($parameters); + } + if (CallExpression::needsIsSandboxed($this->twigCallable)) { + array_shift($parameters); + } + foreach ($this->twigCallable->getArguments() as $argument) { + array_shift($parameters); + } + + return array_values($parameters); + } + /** * @return callable */ diff --git a/tests/Extension/SandboxTest.php b/tests/Extension/SandboxTest.php index de6594b6d..25b71f6ce 100644 --- a/tests/Extension/SandboxTest.php +++ b/tests/Extension/SandboxTest.php @@ -1409,6 +1409,228 @@ EOF } } + /** + * @dataProvider getSafePhpTypesSkipToStringWrap + */ + public function testSafePhpParamTypesSkipToStringWrap(string $template, callable $func, array $params): void + { + // The sandbox visitor must not wrap arguments whose target PHP + // parameter type cannot implicitly coerce to string (int, float, + // bool, non-Stringable/non-Traversable classes, ...). We observe + // the optimization by passing values whose `__toString` is NOT in + // the policy: with the wrap, the render throws; without it, it + // succeeds. + $twig = $this->getEnvironment(true, [], ['index' => $template]); + $twig->addFunction(new TwigFunction('safe_fn', $func)); + $policy = $twig->getExtension(SandboxExtension::class)->getSecurityPolicy(); + $policy->setAllowedFunctions(['safe_fn']); + + $this->assertSame('ok', $twig->load('index')->render($params)); + } + + public static function getSafePhpTypesSkipToStringWrap(): iterable + { + yield 'int param' => [ + '{{ safe_fn(n) }}', + static fn (int $n) => 'ok', + ['n' => 42], + ]; + yield 'float param' => [ + '{{ safe_fn(n) }}', + static fn (float $n) => 'ok', + ['n' => 3.14], + ]; + yield 'bool param' => [ + '{{ safe_fn(b) }}', + static fn (bool $b) => 'ok', + ['b' => true], + ]; + yield 'non-stringable class param' => [ + '{{ safe_fn(obj) }}', + static fn (ColumnObject $o) => 'ok', + ['obj' => new ColumnObject()], + ]; + yield 'nullable int param with null value' => [ + '{{ safe_fn(n) }}', + static fn (?int $n) => 'ok', + ['n' => null], + ]; + yield 'int|float union param' => [ + '{{ safe_fn(n) }}', + static fn (int|float $n) => 'ok', + ['n' => 7], + ]; + } + + /** + * @dataProvider getUnsafePhpTypesStillWrap + */ + public function testUnsafePhpParamTypesStillWrap(string $template, callable $func, array $params): void + { + // Conversely, an unsafe parameter type (`mixed`, untyped, `string`, + // `iterable`, `Stringable`, ...) must keep wrapping arguments so the + // sandbox can still block disallowed `__toString` calls. + $twig = $this->getEnvironment(true, [], ['index' => $template]); + $twig->addFunction(new TwigFunction('unsafe_fn', $func)); + $policy = $twig->getExtension(SandboxExtension::class)->getSecurityPolicy(); + $policy->setAllowedFunctions(['unsafe_fn']); + + try { + $twig->load('index')->render($params); + $this->fail('Sandbox should still check __toString when the PHP parameter type can implicitly coerce to string.'); + } catch (SecurityNotAllowedMethodError $e) { + $this->assertSame('Twig\Tests\Extension\FooObject', $e->getClassName()); + $this->assertSame('__tostring', $e->getMethodName()); + } + } + + public static function getUnsafePhpTypesStillWrap(): iterable + { + $params = ['obj' => new FooObject()]; + yield 'untyped param' => ['{{ unsafe_fn(obj) }}', static fn ($x) => (string) $x, $params]; + yield 'mixed param' => ['{{ unsafe_fn(obj) }}', static fn (mixed $x) => (string) $x, $params]; + yield 'string param' => ['{{ unsafe_fn(obj) }}', static fn (string $x) => $x, $params]; + yield 'object param' => ['{{ unsafe_fn(obj) }}', static fn (object $x) => (string) $x, $params]; + yield 'Stringable param' => ['{{ unsafe_fn(obj) }}', static fn (\Stringable $x) => (string) $x, $params]; + } + + /** + * @dataProvider getOpenPhpTypesStillWrap + */ + public function testOpenPhpParamTypesStillWrap(callable $func, object $obj, string $class): void + { + // Interfaces and non-final classes are "open": a Stringable subtype + // can satisfy them, so the sandbox must keep gating __toString. + // Skipping the wrap on these would bypass the policy. + $twig = $this->getEnvironment(true, [], ['index' => '{{ unsafe_fn(obj) }}']); + $twig->addFunction(new TwigFunction('unsafe_fn', $func)); + $policy = $twig->getExtension(SandboxExtension::class)->getSecurityPolicy(); + $policy->setAllowedFunctions(['unsafe_fn']); + + try { + $twig->load('index')->render(['obj' => $obj]); + $this->fail('Sandbox must still check __toString for an interface or non-final class parameter.'); + } catch (SecurityNotAllowedMethodError $e) { + $this->assertSame($class, $e->getClassName()); + $this->assertSame('__tostring', $e->getMethodName()); + } + } + + public static function getOpenPhpTypesStillWrap(): iterable + { + yield 'interface param' => [static fn (\Countable $x) => (string) $x, new CountableFooObject(), CountableFooObject::class]; + yield 'non-final class param' => [static fn (PlainBaseObject $x) => (string) $x, new StringablePlainObject(), StringablePlainObject::class]; + } + + public function testTestArgumentsMapAfterTheTestedValueParameter(): void + { + // A test's tested value is its first PHP parameter, so its template + // arguments must be mapped to the parameters *after* it. Mapping the + // first argument to the (safe-typed) value parameter would skip its + // __toString wrap and bypass the policy. + $twig = $this->getEnvironment(true, [], ['index' => '{{ 5 is my_test(obj) }}']); + $twig->addTest(new TwigTest('my_test', static fn (int $value, $arg) => 'x' === (string) $arg)); + + try { + $twig->load('index')->render(['obj' => new FooObject()]); + $this->fail('Sandbox must check __toString on a test argument bound to an unsafe parameter.'); + } catch (SecurityNotAllowedMethodError $e) { + $this->assertSame('Twig\Tests\Extension\FooObject', $e->getClassName()); + $this->assertSame('__tostring', $e->getMethodName()); + } + } + + public function testSafeVariadicPhpTypeSkipsToStringWrap(): void + { + // PHP-variadic with a safe type: all spilled arguments skip the wrap. + $twig = $this->getEnvironment(true, [], ['index' => '{{ safe_fn(1, 2, 3) }}']); + $twig->addFunction(new TwigFunction('safe_fn', static fn (int ...$x) => 'ok')); + $policy = $twig->getExtension(SandboxExtension::class)->getSecurityPolicy(); + $policy->setAllowedFunctions(['safe_fn']); + + $this->assertSame('ok', $twig->load('index')->render()); + } + + public function testSpreadIntoUnsafeVariadicStillWraps(): void + { + // A spread fills an unsafe (untyped) variadic param, so every spilled + // element must keep its __toString wrap. + $twig = $this->getEnvironment(true, [], ['index' => '{{ unsafe_fn(...args) }}']); + $twig->addFunction(new TwigFunction('unsafe_fn', static fn (...$x) => (string) $x[0])); + $policy = $twig->getExtension(SandboxExtension::class)->getSecurityPolicy(); + $policy->setAllowedFunctions(['unsafe_fn']); + + try { + $twig->load('index')->render(['args' => [new FooObject()]]); + $this->fail('Sandbox must check __toString on spread elements bound to an unsafe variadic parameter.'); + } catch (SecurityNotAllowedMethodError $e) { + $this->assertSame('Twig\Tests\Extension\FooObject', $e->getClassName()); + $this->assertSame('__tostring', $e->getMethodName()); + } + } + + public function testNormalizedNamedArgumentDoesNotFallThroughToSafeVariadic(): void + { + // The compiler normalizes named arguments (`foo_bar` maps to + // `$fooBar`). The sandbox visitor must use the same mapping and not + // fall back to the safe typed variadic tail, or it would skip the + // __toString check on `$fooBar`. + $twig = $this->getEnvironment(true, [], ['index' => '{{ unsafe_fn(foo_bar: obj) }}']); + $twig->addFunction(new TwigFunction('unsafe_fn', static fn ($fooBar, int ...$rest) => (string) $fooBar, ['is_variadic' => true])); + $policy = $twig->getExtension(SandboxExtension::class)->getSecurityPolicy(); + $policy->setAllowedFunctions(['unsafe_fn']); + + try { + $twig->load('index')->render(['obj' => new FooObject()]); + $this->fail('Sandbox must check __toString on normalized named arguments before considering the variadic tail.'); + } catch (SecurityNotAllowedMethodError $e) { + $this->assertSame('Twig\Tests\Extension\FooObject', $e->getClassName()); + $this->assertSame('__tostring', $e->getMethodName()); + } + } + + public function testNormalizedNamedFilterArgumentDoesNotFallThroughToSafeVariadic(): void + { + $twig = $this->getEnvironment(true, [], ['index' => '{{ 1|unsafe_filter(foo_bar: obj) }}']); + $twig->addFilter(new TwigFilter('unsafe_filter', static fn ($value, $fooBar, int ...$rest) => (string) $fooBar, ['is_variadic' => true])); + $policy = $twig->getExtension(SandboxExtension::class)->getSecurityPolicy(); + $policy->setAllowedFilters(['unsafe_filter']); + + try { + $twig->load('index')->render(['obj' => new FooObject()]); + $this->fail('Sandbox must check __toString on normalized named filter arguments before considering the variadic tail.'); + } catch (SecurityNotAllowedMethodError $e) { + $this->assertSame('Twig\Tests\Extension\FooObject', $e->getClassName()); + $this->assertSame('__tostring', $e->getMethodName()); + } + } + + public function testNormalizedNamedTestArgumentDoesNotFallThroughToSafeVariadic(): void + { + $twig = $this->getEnvironment(true, [], ['index' => '{{ 1 is unsafe_test(foo_bar: obj) ? "yes" : "no" }}']); + $twig->addTest(new TwigTest('unsafe_test', static fn ($value, $fooBar, int ...$rest) => 'x' === (string) $fooBar, ['is_variadic' => true])); + + try { + $twig->load('index')->render(['obj' => new FooObject()]); + $this->fail('Sandbox must check __toString on normalized named test arguments before considering the variadic tail.'); + } catch (SecurityNotAllowedMethodError $e) { + $this->assertSame('Twig\Tests\Extension\FooObject', $e->getClassName()); + $this->assertSame('__tostring', $e->getMethodName()); + } + } + + public function testFilterInputTypeSkipsToStringWrap(): void + { + // A filter whose first PHP param has a safe type also skips the + // input (`node`) wrap. + $twig = $this->getEnvironment(true, [], ['index' => '{{ n|safe_filter }}']); + $twig->addFilter(new TwigFilter('safe_filter', static fn (int $n) => 'ok')); + $policy = $twig->getExtension(SandboxExtension::class)->getSecurityPolicy(); + $policy->setAllowedFilters(['safe_filter']); + + $this->assertSame('ok', $twig->load('index')->render(['n' => 42])); + } + public function testColumnFilterUnaffectedOutsideSandbox() { $params = ['obj' => new ColumnObject()]; @@ -1916,6 +2138,26 @@ class ColumnObject public $bar = 'bar'; } +class CountableFooObject extends FooObject implements \Countable +{ + public function count(): int + { + return 0; + } +} + +class PlainBaseObject +{ +} + +class StringablePlainObject extends PlainBaseObject implements \Stringable +{ + public function __toString(): string + { + return 'plain'; + } +} + // Implements both Stringable and Traversable: a sandbox policy may legitimately // allow the container's own `__toString`, but the elements yielded by // `getIterator()` must still be policy-checked when consumers (`join`, `replace`, diff --git a/tests/Util/CallableParametersTest.php b/tests/Util/CallableParametersTest.php new file mode 100644 index 000000000..92e61a578 --- /dev/null +++ b/tests/Util/CallableParametersTest.php @@ -0,0 +1,168 @@ +assertSame($expected, CallableParameters::isStringCoercionSafe($type, $scope)); + } + + public function testIsStringCoercionSafeDoesNotAutoloadUnknownClasses(): void + { + $autoloaded = false; + $autoload = static function () use (&$autoloaded): void { + $autoloaded = true; + }; + spl_autoload_register($autoload); + + try { + $type = (new \ReflectionFunction(eval('return static fn (CallableParametersAutoloadProbe $x) => null;')))->getParameters()[0]->getType(); + + $this->assertFalse(CallableParameters::isStringCoercionSafe($type)); + $this->assertFalse($autoloaded); + } finally { + spl_autoload_unregister($autoload); + } + } + + public static function provideTypes(): iterable + { + $param = static fn (\Closure $c): ?\ReflectionType => (new \ReflectionFunction($c))->getParameters()[0]->getType(); + $mParam = static fn (string $class, string $m): ?\ReflectionType => (new \ReflectionMethod($class, $m))->getParameters()[0]->getType(); + $mReturn = static fn (string $class, string $m): ?\ReflectionType => (new \ReflectionMethod($class, $m))->getReturnType(); + + // No type information: must keep the check. + yield 'untyped' => [$param(static fn ($x) => null), false]; + + // Builtin scalars that cannot hold a string-coercible value. + yield 'int' => [$param(static fn (int $x) => null), true]; + yield 'float' => [$param(static fn (float $x) => null), true]; + yield 'bool' => [$param(static fn (bool $x) => null), true]; + yield 'never (return)' => [(new \ReflectionFunction(static function (): never { throw new \LogicException(); }))->getReturnType(), true]; + + // Builtins that can carry or coerce to a string. + yield 'string' => [$param(static fn (string $x) => null), false]; + yield 'array' => [$param(static fn (array $x) => null), false]; + yield 'iterable' => [$param(static fn (iterable $x) => null), false]; + yield 'object' => [$param(static fn (object $x) => null), false]; + yield 'mixed' => [$param(static fn (mixed $x) => null), false]; + yield 'callable' => [$param(static fn (callable $x) => null), false]; + + // Class types: safe only when final and neither Stringable nor + // Traversable. Interfaces and non-final classes are open (a subtype + // could add Stringable/Traversable), so they must stay unsafe. + yield 'final plain class' => [$param(static fn (FinalPlainObject $x) => null), true]; + yield 'nullable final class' => [$param(static fn (?FinalPlainObject $x) => null), true]; + yield 'non-final class' => [$param(static fn (\stdClass $x) => null), false]; + yield 'final Stringable class' => [$param(static fn (FinalStringableObject $x) => null), false]; + yield 'Countable interface' => [$param(static fn (\Countable $x) => null), false]; + yield 'Stringable interface' => [$param(static fn (\Stringable $x) => null), false]; + yield 'Traversable interface' => [$param(static fn (\Iterator $x) => null), false]; + yield 'Traversable class' => [$param(static fn (\ArrayIterator $x) => null), false]; + + // Enums are implicitly final and cannot be Stringable (no __toString). + yield 'backed enum' => [$param(static fn (SampleBackedEnum $x) => null), true]; + yield 'pure enum' => [$param(static fn (SamplePureEnum $x) => null), true]; + + // `self`/`parent`/`static` resolve to the declaring class (passed as + // scope) and are then judged by the class rules. PHP < 8.4 reports + // them verbatim, recent versions resolve them in getName(); both paths + // must yield the same verdict. + yield 'self -> Stringable class' => [$mParam(CoercionFixture::class, 'selfParam'), false, new \ReflectionClass(CoercionFixture::class)]; + yield 'self -> final class' => [$mParam(FinalSelfFixture::class, 'selfParam'), true, new \ReflectionClass(FinalSelfFixture::class)]; + yield 'parent -> non-final class' => [$mParam(CoercionFixture::class, 'parentParam'), false, new \ReflectionClass(CoercionFixture::class)]; + yield 'static (return) -> self' => [$mReturn(CoercionFixture::class, 'staticReturn'), false, new \ReflectionClass(CoercionFixture::class)]; + + // Union: the value is one of the members, so all must be safe. + yield 'int|float union' => [$param(static fn (int|float $x) => null), true]; + yield 'int|null union' => [$param(static fn (?int $x) => null), true]; + yield 'int|float|null union' => [$param(static fn (int|float|null $x) => null), true]; + yield 'int|string union' => [$param(static fn (int|string $x) => null), false]; + + // Intersection: safe as soon as one member is a final, non-coercible + // class (it pins the concrete class); otherwise open and unsafe. + yield 'final class intersection' => [$param(static fn (FinalMarkedObject&SampleMarker $x) => null), true]; + yield 'interface intersection' => [$param(static fn (\Countable&\ArrayAccess $x) => null), false]; + yield 'Stringable intersection' => [$param(static fn (\Stringable&\Countable $x) => null), false]; + yield 'Traversable intersection' => [$param(static fn (\Traversable&\Countable $x) => null), false]; + } +} + +enum SampleBackedEnum: string +{ + case A = 'a'; +} + +enum SamplePureEnum +{ + case A; +} + +interface SampleMarker +{ +} + +final class FinalMarkedObject implements SampleMarker +{ +} + +final class FinalPlainObject +{ +} + +final class FinalStringableObject implements \Stringable +{ + public function __toString(): string + { + return ''; + } +} + +final class FinalSelfFixture +{ + public function selfParam(self $x): void + { + } +} + +class CoercionFixtureParent +{ +} + +class CoercionFixture extends CoercionFixtureParent implements \Stringable +{ + public function selfParam(self $x): void + { + } + + public function parentParam(parent $x): void + { + } + + public function staticReturn(): static + { + return $this; + } + + public function __toString(): string + { + return ''; + } +}