From 1d3ebac6326a978c7d934b380dab33016816ea62 Mon Sep 17 00:00:00 2001 From: Nicolas Grekas Date: Wed, 10 Jun 2026 23:39:05 +0200 Subject: [PATCH] [DeepClone] Reference const-expr closures through engine ids on PHP 8.6 --- src/DeepClone/DeepClone.php | 143 +++++++++++++++++++---- tests/DeepClone/DeepCloneTest.php | 188 +++++++++++++++++++++++++++--- tests/DeepClone/fixtures85.php | 32 +++-- 3 files changed, 312 insertions(+), 51 deletions(-) diff --git a/src/DeepClone/DeepClone.php b/src/DeepClone/DeepClone.php index 63bf5392..1bd85895 100644 --- a/src/DeepClone/DeepClone.php +++ b/src/DeepClone/DeepClone.php @@ -658,18 +658,23 @@ private static function prepare($values, &$objectsPool, &$refsPool, &$objectsCou $r = new \ReflectionFunction($value); if (!(\PHP_VERSION_ID >= 80200 ? $r->isAnonymous() : str_contains($r->name, "@anonymous\0"))) { - // First-class callable. When it references a method of its - // own declaring class declared in a constant expression - // (e.g. #[When(self::isStrict(...))]), encode it as a - // declaration-site reference like the extension does, so it - // round-trips without the allow_named_closures opt-in. - // Closure is allow-list-gated first, mirroring the - // extension (reported before any const-expr is evaluated). - if (\PHP_VERSION_ID >= 80500 && !$r->getClosureThis() && $r->getClosureScopeClass()) { + // First-class callable declared in a constant expression, + // encoded as a declaration-site reference (like the + // extension) so it round-trips without the + // allow_named_closures opt-in. On PHP 8.6 the engine names + // the declaring class (getConstExprClass), covering + // cross-class (#[When(Validators::check(...))]) and global + // (#[When(strlen(...))]) references; on 8.5 only a callable + // over a method of its own declaring class is locatable. + // Closure is allow-list-gated first, mirroring the extension. + $declaringClass = \PHP_VERSION_ID >= 80600 ? $r->getConstExprClass() : null; + if (\PHP_VERSION_ID >= 80500 && !$r->getClosureThis() + && (null !== $declaringClass || $r->getClosureScopeClass()) + ) { if (null !== $allowedSet && !isset($allowedSet['closure'])) { throw new \ValueError('deepclone_to_array(): class "Closure" is not allowed'); } - if (null !== $ref = self::locateConstExprClosure($r)) { + if (null !== $ref = self::locateConstExprClosure($r, $declaringClass)) { $value = $ref; $mask[$k] = 1; @@ -703,12 +708,29 @@ private static function prepare($values, &$objectsPool, &$refsPool, &$objectsCou throw new \ValueError('deepclone_to_array(): class "Closure" is not allowed'); } + if (\PHP_VERSION_ID >= 80600 && null !== $id = $r->getConstExprId()) { + // The engine gives a canonical per-class id to closures declared + // in attribute arguments and in parameter default values; closures + // declared in class constant values and in property default values + // have no id and keep using the site-based reference below + $value = [$r->getClosureScopeClass()->name, $id, $r->getStartLine()]; + $mask[$k] = 1; + + goto handle_value; + } + if (\PHP_VERSION_ID >= 80500 && null !== $ref = self::locateConstExprClosure($r)) { $value = $ref; $mask[$k] = 1; goto handle_value; } + + if (\PHP_VERSION_ID >= 80600) { + // No engine id and no usable declaration site: let + // Closure::__serialize() throw the engine's refusal + $value->__serialize(); + } } $class = $value::class; @@ -1273,14 +1295,28 @@ private static function resolveNamedClosureScalar($value, $objects, $refs) * argument, class constant, property or parameter default) to its declaration * site: [class, site, attribute index or null, closure index, start line]. */ - private static function locateConstExprClosure(\ReflectionFunction $r): ?array + private static function locateConstExprClosure(\ReflectionFunction $r, ?string $declaringClass = null): ?array { - if (!$r->isStatic() || $r->getClosureThis() || $r->getClosureUsedVariables() || !($scope = $r->getClosureScopeClass())) { + if ($r->getClosureThis() || $r->getClosureUsedVariables()) { + return null; + } + if (null !== $declaringClass) { + // PHP 8.6: the engine named the declaring class (ReflectionFunction:: + // getConstExprClass), which for a cross-class or global first-class + // callable differs from the closure's scope. Index that class. + try { + $scope = new \ReflectionClass($declaringClass); + } catch (\ReflectionException) { + return null; + } + } elseif (!$r->isStatic() || !($scope = $r->getClosureScopeClass())) { return null; } [$index, $lines] = self::$constExprIndex[$scope->name] ??= self::indexConstExprClosures($scope); $file = $r->getFileName(); + // Raw value (false for an internal function) so it matches the key built + // by indexConstExprClosures(); normalized to 0 in the returned payload. $line = $r->getStartLine(); if (!$candidates = $index[$r->name.':'.$file.':'.$line.':'.$r->getEndLine().':'.self::closureSignature($r)] ?? null) { @@ -1290,11 +1326,24 @@ private static function locateConstExprClosure(\ReflectionFunction $r): ?array // First-class callables are keyed by their target method's identity, so // several declaration sites can map to the same key; they are // equivalent and the first one wins, exactly like the extension. The - // anonymous-only checks below (the same-site ambiguity guard and the - // runtime-aliasing refusal) only concern anonymous closure literals, - // which are told apart by source position. + // checks below (the 8.6 engine-id exclusion, the same-site ambiguity + // guard and the runtime-aliasing refusal) only concern anonymous + // closure literals, which are told apart by source position; an fcc has + // no engine id here and never reaches the engine-id encoder. if (!$r->isAnonymous()) { - return [$scope->name, $candidates[0][0], $candidates[0][1], $candidates[0][2], $line]; + return [$scope->name, $candidates[0][0], $candidates[0][1], $candidates[0][2], $line ?: 0]; + } + + if (\PHP_VERSION_ID >= 80600) { + // Anonymous closures declared in attribute arguments and parameter + // default values carry an engine id and are encoded through it, so + // they never reach this lookup; a closure matching such a candidate + // merely shares its declaration site. + $candidates = array_values(array_filter($candidates, static fn ($c) => null === $c[1] && !preg_match('/\)#\d+$/', $c[0]))); + + if (!$candidates) { + return null; + } } $sites = []; @@ -1314,7 +1363,7 @@ private static function locateConstExprClosure(\ReflectionFunction $r): ?array throw new \ValueError('deepclone_to_array(): cannot reference anonymous closure declared at '.$file.':'.$line.', multiple closures share this declaration site'); } - return [$scope->name, $candidates[0][0], $candidates[0][1], $candidates[0][2], $line]; + return [$scope->name, $candidates[0][0], $candidates[0][1], $candidates[0][2], $line ?: 0]; } private static function countClosureLiterals(string $file, int $line): int @@ -1380,13 +1429,13 @@ private static function indexConstExprClosures(\ReflectionClass $rc): array } $seen[$id] = true; $r = new \ReflectionFunction($value); - // Index anonymous closures declared in this class, and - // first-class callables over a method of this class (their - // scope is the target method's class): both are closures - // the class declares about itself. Cross-class and - // global-function callables have a different (or null) - // scope and are addressed by name instead. - if ($r->getClosureScopeClass()?->name !== $class) { + // Index every first-class callable found in this class's + // constant expressions, whatever its target (own method, + // another class's method, or a global function) -- the class + // declares the reference. Anonymous closures must be scoped + // to this class (a foreign literal reached through a const + // reference is not declared here). + if ($r->isAnonymous() && $r->getClosureScopeClass()?->name !== $class) { return; } $index[$r->name.':'.$r->getFileName().':'.$r->getStartLine().':'.$r->getEndLine().':'.self::closureSignature($r)][] = [$site, $attrIndex, $n]; @@ -1514,6 +1563,54 @@ private static function resolveConstExprClosureScalar($value, ?array $allowedCla if (!\is_array($value)) { throw new \ValueError('deepclone_from_array(): Argument #1 ($data) malformed payload, const-expr-closure value must be of type array, '.self::valueName($value).' given'); } + + // Engine-id reference [class, id, line], emitted on PHP >= 8.6; the type + // of element 1 (int id vs string site) discriminates it from the + // site-based reference handled below + if (\is_int($value[1] ?? null)) { + if (!\array_key_exists(0, $value) || !\array_key_exists(2, $value) || 3 !== \count($value)) { + throw new \ValueError('deepclone_from_array(): Argument #1 ($data) malformed payload, const-expr-closure value must have 3 elements'); + } + [$class, $id, $line] = $value; + + if (!\is_string($class)) { + throw new \ValueError('deepclone_from_array(): Argument #1 ($data) malformed payload, const-expr-closure class name must be of type string, '.self::valueName($class).' given'); + } + if (!\is_int($line)) { + throw new \ValueError('deepclone_from_array(): Argument #1 ($data) malformed payload, const-expr-closure line must be of type int, '.self::valueName($line).' given'); + } + + if (null !== $allowedClasses && !isset(array_change_key_case(array_flip($allowedClasses))[strtolower($class)])) { + throw new \ValueError('deepclone_from_array(): class "'.$class.'" is not allowed'); + } + + if (\PHP_VERSION_ID < 80600) { + throw new \ValueError('deepclone_from_array(): const-expr-closure payload was created on PHP 8.6 or later and cannot be resolved on PHP '.\PHP_VERSION); + } + + try { + new \ReflectionClass($class); + } catch (\ReflectionException) { + throw new \ValueError('deepclone_from_array(): Argument #1 ($data) malformed payload, const-expr-closure references unknown class "'.$class.'"'); + } + + try { + $found = \Closure::fromConstExpr($class, $id); + } catch (\ValueError) { + throw new \ValueError('deepclone_from_array(): Argument #1 ($data) malformed payload, const-expr-closure references unknown closure id '.$id.' in class "'.$class.'"'); + } + + $r = new \ReflectionFunction($found); + if (!str_contains($r->name, '{closure')) { + throw new \ValueError('deepclone_from_array(): Argument #1 ($data) malformed payload, const-expr-closure references a first-class callable site'); + } + if ($line !== $foundLine = $r->getStartLine()) { + throw new \ValueError('deepclone_from_array(): Argument #1 ($data) stale payload, const-expr-closure moved from line '.$line.' to line '.$foundLine); + } + + return $found; + } + if (!\array_key_exists(0, $value) || !\array_key_exists(1, $value) || !\array_key_exists(2, $value) || !\array_key_exists(3, $value) || !\array_key_exists(4, $value) || 5 !== \count($value)) { throw new \ValueError('deepclone_from_array(): Argument #1 ($data) malformed payload, const-expr-closure value must have 5 elements'); } diff --git a/tests/DeepClone/DeepCloneTest.php b/tests/DeepClone/DeepCloneTest.php index 72e7dc55..6b3ae267 100644 --- a/tests/DeepClone/DeepCloneTest.php +++ b/tests/DeepClone/DeepCloneTest.php @@ -2049,6 +2049,21 @@ public function testFromArrayRejectsUnserializeClassWithoutReplayFlag() deepclone_from_array($d); } + /** + * On PHP 8.6 closures declared in attribute arguments and in parameter + * default values are referenced as [class, engine id, line] instead of the + * site-based 5-element form. + */ + private static function expectedConstExprPayload(\Closure $closure, array $siteForm): array + { + if (\PHP_VERSION_ID < 80600) { + return $siteForm; + } + $r = new \ReflectionFunction($closure); + + return [$r->getConstExprClass(), $r->getConstExprId(), $r->getStartLine()]; + } + /** * @requires PHP 8.5 */ @@ -2059,7 +2074,7 @@ public function testToArrayConstExprClosureClassAttributeWireFormat() $d = deepclone_to_array($closure); - $this->assertSame([ConstExprClosureFixture::class, '', 0, 0, $line], $d['prepared']); + $this->assertSame(self::expectedConstExprPayload($closure, [ConstExprClosureFixture::class, '', 0, 0, $line]), $d['prepared']); $this->assertSame(1, $d['mask']); } @@ -2087,7 +2102,7 @@ public function testRoundTripConstExprClosureNestedAttributeArgument() $d = deepclone_to_array($closure); - $this->assertSame([ConstExprClosureFixture::class, '$tagged', 0, 0, $line], $d['prepared']); + $this->assertSame(self::expectedConstExprPayload($closure, [ConstExprClosureFixture::class, '$tagged', 0, 0, $line]), $d['prepared']); $this->assertSame(6, deepclone_from_array($d)(3)); } @@ -2102,7 +2117,7 @@ public function testRoundTripConstExprClosureMultipleClosuresInOneAttribute() $line = (new \ReflectionFunction($args[$i]))->getStartLine(); $d = deepclone_to_array($args[$i]); - $this->assertSame([ConstExprClosureFixture::class, 'TAGGED', 0, $i, $line], $d['prepared']); + $this->assertSame(self::expectedConstExprPayload($args[$i], [ConstExprClosureFixture::class, 'TAGGED', 0, $i, $line]), $d['prepared']); $this->assertSame($expected, deepclone_from_array($d)()); } } @@ -2117,7 +2132,7 @@ public function testRoundTripConstExprClosureRepeatedAttribute() $d = deepclone_to_array($closure); - $this->assertSame([ConstExprClosureFixture::class, 'tagged()', 1, 0, $line], $d['prepared']); + $this->assertSame(self::expectedConstExprPayload($closure, [ConstExprClosureFixture::class, 'tagged()', 1, 0, $line]), $d['prepared']); $this->assertSame('repeated', deepclone_from_array($d)()); } @@ -2131,7 +2146,7 @@ public function testRoundTripConstExprClosureParameterAttribute() $d = deepclone_to_array($closure); - $this->assertSame([ConstExprClosureFixture::class, 'tagged()#0', 0, 0, $line], $d['prepared']); + $this->assertSame(self::expectedConstExprPayload($closure, [ConstExprClosureFixture::class, 'tagged()#0', 0, 0, $line]), $d['prepared']); $this->assertSame('param-attr', deepclone_from_array($d)()); } @@ -2145,7 +2160,7 @@ public function testRoundTripConstExprClosureParameterDefault() $d = deepclone_to_array($closure); - $this->assertSame([ConstExprClosureFixture::class, 'tagged()#0', null, 0, $line], $d['prepared']); + $this->assertSame(self::expectedConstExprPayload($closure, [ConstExprClosureFixture::class, 'tagged()#0', null, 0, $line]), $d['prepared']); $this->assertSame('param-default', deepclone_from_array($d)()); } @@ -2201,7 +2216,7 @@ public function testRoundTripConstExprClosureEnumCaseAttribute() $d = deepclone_to_array($closure); - $this->assertSame([ConstExprEnumFixture::class, 'Active', 0, 0, $line], $d['prepared']); + $this->assertSame(self::expectedConstExprPayload($closure, [ConstExprEnumFixture::class, 'Active', 0, 0, $line]), $d['prepared']); $this->assertSame('enum-case-attr', deepclone_from_array($d)()); } @@ -2242,7 +2257,7 @@ public function testRoundTripConstExprClosurePromotedParameterAttribute() $d = deepclone_to_array($closure); - $this->assertSame([ConstExprPromotedFixture::class, '__construct()#0', 0, 0, $line], $d['prepared']); + $this->assertSame(self::expectedConstExprPayload($closure, [ConstExprPromotedFixture::class, '__construct()#0', 0, 0, $line]), $d['prepared']); $this->assertSame('promoted-attr', deepclone_from_array($d)()); } @@ -2264,8 +2279,9 @@ public function testToArrayConstExprClosureAmbiguousSameLine() { $args = (new \ReflectionClass(ConstExprAmbiguousFixture::class))->getAttributes()[0]->getArguments(); - if (\extension_loaded('deepclone') && !TestListenerTrait::$enabledPolyfills) { - // The extension tells same-line closures apart by op_array identity. + if (\PHP_VERSION_ID >= 80600 || (\extension_loaded('deepclone') && !TestListenerTrait::$enabledPolyfills)) { + // Engine ids (PHP 8.6) and op_array identity (the extension) tell + // same-line closures apart. $this->assertSame('first', deepclone_from_array(deepclone_to_array($args[0]))()); $this->assertSame('second', deepclone_from_array(deepclone_to_array($args[1]))()); @@ -2285,8 +2301,15 @@ public function testToArrayRuntimeStaticClosureIsNotInstantiable() { $closure = static function (): string { return 'runtime'; }; - $this->expectException(\DeepClone\NotInstantiableException::class); - $this->expectExceptionMessage('Type "Closure" is not instantiable.'); + if (\PHP_VERSION_ID >= 80600) { + // Closure::__serialize() exists and refuses closures that were not + // declared in a constant expression + $this->expectException(\Exception::class); + $this->expectExceptionMessage("Serialization of 'Closure' is not allowed"); + } else { + $this->expectException(\DeepClone\NotInstantiableException::class); + $this->expectExceptionMessage('Type "Closure" is not instantiable.'); + } deepclone_to_array($closure); } @@ -2327,7 +2350,7 @@ public function testFromArrayConstExprClosureStaleLineThrows() { $closure = (new \ReflectionClass(ConstExprClosureFixture::class))->getAttributes()[0]->getArguments()[0]; $d = deepclone_to_array($closure); - ++$d['prepared'][4]; + ++$d['prepared'][\PHP_VERSION_ID >= 80600 ? 2 : 4]; $this->expectException(\ValueError::class); $this->expectExceptionMessage('stale payload'); @@ -2405,7 +2428,7 @@ public function testRoundTripConstExprClosureFactoryConstant() $this->assertSame('inner', deepclone_from_array(deepclone_to_array($outer))()()); - $this->expectException(\DeepClone\NotInstantiableException::class); + $this->expectException(\PHP_VERSION_ID >= 80600 ? \Exception::class : \DeepClone\NotInstantiableException::class); deepclone_to_array($outer()); } @@ -2416,6 +2439,18 @@ public function testToArrayRuntimeClosureCollidingWithConstExprSite() { $attrClosure = (new \ReflectionMethod(ConstExprRuntimeCollisionFixture::class, 'make'))->getAttributes()[0]->getArguments()[0]; + if (\PHP_VERSION_ID >= 80600) { + // Engine ids tell the attribute literal and the same-line runtime + // literal apart; the latter hits Closure::__serialize(), which refuses. + $this->assertSame('const-expr', deepclone_from_array(deepclone_to_array($attrClosure))()); + + $this->expectException(\Exception::class); + $this->expectExceptionMessage("Serialization of 'Closure' is not allowed"); + deepclone_to_array(ConstExprRuntimeCollisionFixture::make()); + + return; + } + if (\extension_loaded('deepclone') && !TestListenerTrait::$enabledPolyfills) { // The extension tells the attribute literal and the same-line runtime // literal apart by op_array identity. @@ -2473,8 +2508,9 @@ public function testToArrayConstExprClosureWithoutSourcesInvitesExtension() } $closure = (new \ReflectionMethod(\ConstExprNoSourcesFixture::class, 'm'))->getAttributes()[0]->getArguments()[0]; - if (\extension_loaded('deepclone') && !TestListenerTrait::$enabledPolyfills) { - // The extension matches op_arrays and needs no source files. + if (\PHP_VERSION_ID >= 80600 || (\extension_loaded('deepclone') && !TestListenerTrait::$enabledPolyfills)) { + // Engine ids (PHP 8.6) and op_array identity (the extension) need + // no source files. $this->assertSame('no-sources', deepclone_from_array(deepclone_to_array($closure))()); return; @@ -2494,13 +2530,13 @@ public function testRoundTripConstExprClosurePropertyHookAttributes() $closure = (new \ReflectionProperty(ConstExprHookedFixture::class, 'virtual'))->getHook(\PropertyHookType::Get)->getAttributes()[0]->getArguments()[0]; $d = deepclone_to_array($closure); - $this->assertSame([ConstExprHookedFixture::class, '$virtual::get()', 0, 0, (new \ReflectionFunction($closure))->getStartLine()], $d['prepared']); + $this->assertSame(self::expectedConstExprPayload($closure, [ConstExprHookedFixture::class, '$virtual::get()', 0, 0, (new \ReflectionFunction($closure))->getStartLine()]), $d['prepared']); $this->assertSame('get-hook-attr', deepclone_from_array($d)()); $closure = (new \ReflectionProperty(ConstExprHookedFixture::class, 'stored'))->getHook(\PropertyHookType::Set)->getParameters()[0]->getAttributes()[0]->getArguments()[0]; $d = deepclone_to_array($closure); - $this->assertSame([ConstExprHookedFixture::class, '$stored::set()#0', 0, 0, (new \ReflectionFunction($closure))->getStartLine()], $d['prepared']); + $this->assertSame(self::expectedConstExprPayload($closure, [ConstExprHookedFixture::class, '$stored::set()#0', 0, 0, (new \ReflectionFunction($closure))->getStartLine()]), $d['prepared']); $this->assertSame('set-hook-param-attr', deepclone_from_array($d)()); } @@ -2515,7 +2551,7 @@ public function testFromArrayConstExprClosureMalformedPayloads() [[ConstExprClosureFixture::class], 'const-expr-closure value must have 5 elements'], [[42, '', 0, 0, $line], 'const-expr-closure class name must be of type string, int given'], [['No\\Such\\ClassAtAll', '', 0, 0, $line], 'const-expr-closure references unknown class "No\\Such\\ClassAtAll"'], - [[ConstExprClosureFixture::class, 42, 0, 0, $line], 'const-expr-closure site must be of type string, int given'], + [[ConstExprClosureFixture::class, 42, 0, 0, $line], 'const-expr-closure value must have 3 elements'], [[ConstExprClosureFixture::class, '', 'x', 0, $line], 'const-expr-closure attribute index must be of type int or null, string given'], [[ConstExprClosureFixture::class, '', 0, 'x', $line], 'const-expr-closure closure index must be of type int, string given'], [[ConstExprClosureFixture::class, '', 0, 0, 'x'], 'const-expr-closure line must be of type int, string given'], @@ -2527,6 +2563,11 @@ public function testFromArrayConstExprClosureMalformedPayloads() [[ConstExprClosureFixture::class, '', 0, 9, $line], 'const-expr-closure references unknown closure index 9'], [[ConstExprClosureFixture::class, '', null, 0, $line], 'const-expr-closure attribute index is required for site ""'], [[ConstExprClosureFixture::class, 'tagged()', null, 0, $line], 'const-expr-closure attribute index is required for site "tagged()"'], + // an int element 1 makes the payload an engine-id reference [class, id, line] + [[ConstExprClosureFixture::class, 0], 'const-expr-closure value must have 3 elements'], + [[ConstExprClosureFixture::class, 0, $line, 'x'], 'const-expr-closure value must have 3 elements'], + [[42, 0, $line], 'const-expr-closure class name must be of type string, int given'], + [[ConstExprClosureFixture::class, 0, 'x'], 'const-expr-closure line must be of type int, string given'], ]; foreach ($cases as [$prepared, $expected]) { @@ -2573,4 +2614,113 @@ public function testFromArrayConstExprClosureGlobalInternalFunction() $r = deepclone_from_array($payload); $this->assertSame(5, $r('hello')); } + + /** + * @requires PHP 8.6 + */ + public function testToArrayConstExprClosureSameLineTwinsGetDistinctEngineIds() + { + $args = (new \ReflectionClass(ConstExprAmbiguousFixture::class))->getAttributes()[0]->getArguments(); + + $this->assertSame(0, deepclone_to_array($args[0])['prepared'][1]); + $this->assertSame(1, deepclone_to_array($args[1])['prepared'][1]); + } + + /** + * @requires PHP 8.6 + */ + public function testFromArrayConstExprClosureEngineIdErrorPayloads() + { + $line = (new \ReflectionFunction((new \ReflectionClass(ConstExprClosureFixture::class))->getAttributes()[0]->getArguments()[0]))->getStartLine(); + $cases = [ + [[ConstExprClosureFixture::class, 999, $line], 'const-expr-closure references unknown closure id 999 in class "'.ConstExprClosureFixture::class.'"'], + [['No\\Such\\ClassAtAll', 0, 1], 'const-expr-closure references unknown class "No\\Such\\ClassAtAll"'], + [[ConstExprFccFixture::class, 0, 1], 'const-expr-closure references a first-class callable site'], + ]; + + foreach ($cases as [$prepared, $expected]) { + try { + deepclone_from_array(['classes' => '', 'objectMeta' => 0, 'prepared' => $prepared, 'mask' => 1]); + $this->fail('Expected ValueError was not thrown for: '.$expected); + } catch (\ValueError $e) { + $this->assertStringContainsString($expected, $e->getMessage()); + } + } + } + + /** + * @requires PHP 8.6 + */ + public function testFromArrayConstExprClosureSiteFormWrittenOnPhp85StillResolves() + { + $closure = (new \ReflectionClass(ConstExprClosureFixture::class))->getAttributes()[0]->getArguments()[0]; + $line = (new \ReflectionFunction($closure))->getStartLine(); + + $clone = deepclone_from_array(['classes' => '', 'objectMeta' => 0, 'prepared' => [ConstExprClosureFixture::class, '', 0, 0, $line], 'mask' => 1]); + + $this->assertSame('class-secret', $clone()); + } + + /** + * @requires PHP < 8.6 + */ + public function testFromArrayConstExprClosureEngineIdPayloadRequiresPhp86() + { + $this->expectException(\ValueError::class); + $this->expectExceptionMessage('const-expr-closure payload was created on PHP 8.6 or later and cannot be resolved on PHP '.\PHP_VERSION); + deepclone_from_array(['classes' => '', 'objectMeta' => 0, 'prepared' => [\stdClass::class, 0, 1], 'mask' => 1]); + } + + /** + * @requires PHP 8.6 + */ + public function testConstExprClosureCrossClassAndGlobalFccUseDeclaringClass() + { + // On PHP 8.6 the engine names the declaring class (getConstExprClass), + // so a first-class callable over another class's method or a global + // function serializes -- with no allow_named_closures opt-in -- as a + // declaration-site reference rooted at the declaring class, not the + // target's scope. Userland could not do this before 8.6. + $p = \Symfony\Polyfill\DeepClone\DeepClone::class; + $rp = new \ReflectionClass(ConstExprCrossFccFixture::class); + + $cross = $p::deepclone_to_array($rp->getProperty('x')->getAttributes()[0]->getArguments()[0]); + $this->assertSame(1, $cross['mask']); + $this->assertSame(ConstExprCrossFccFixture::class, $cross['prepared'][0]); + $this->assertTrue($p::deepclone_from_array($cross)()); + + $gi = $p::deepclone_to_array($rp->getProperty('g')->getAttributes()[0]->getArguments()[0]); + $this->assertSame(ConstExprCrossFccFixture::class, $gi['prepared'][0]); + $this->assertSame(5, $p::deepclone_from_array($gi)('hello')); + + $gu = $p::deepclone_to_array($rp->getProperty('u')->getAttributes()[0]->getArguments()[0]); + $this->assertSame(ConstExprCrossFccFixture::class, $gu['prepared'][0]); + $this->assertSame(7, $p::deepclone_from_array($gu)()); + } + + /** + * @requires PHP 8.6 + */ + public function testConstExprClosureExtensionAndPolyfillPayloadsInterchange() + { + if (!\extension_loaded('deepclone')) { + $this->markTestSkipped('Requires the "deepclone" extension.'); + } + + $rx = new \ReflectionProperty(ConstExprCrossFccFixture::class, 'x'); + $ru = new \ReflectionProperty(ConstExprCrossFccFixture::class, 'u'); + foreach ([ + (new \ReflectionClass(ConstExprClosureFixture::class))->getAttributes()[0]->getArguments()[0], + (new \ReflectionMethod(ConstExprClosureFixture::class, 'tagged'))->getParameters()[0]->getDefaultValue(), + $rx->getAttributes()[0]->getArguments()[0], // cross-class first-class callable + $ru->getAttributes()[0]->getArguments()[0], // global-function first-class callable + ] as $closure) { + $ext = \deepclone_to_array($closure); + $polyfill = \Symfony\Polyfill\DeepClone\DeepClone::deepclone_to_array($closure); + + $this->assertSame($ext, $polyfill); + $this->assertSame($closure(), \deepclone_from_array($polyfill)()); + $this->assertSame($closure(), \Symfony\Polyfill\DeepClone\DeepClone::deepclone_from_array($ext)()); + } + } } diff --git a/tests/DeepClone/fixtures85.php b/tests/DeepClone/fixtures85.php index 7f6bc805..d7da2669 100644 --- a/tests/DeepClone/fixtures85.php +++ b/tests/DeepClone/fixtures85.php @@ -126,6 +126,15 @@ traitTagged as traitAliased; } } +class ConstExprFccFixture +{ + #[ConstExprAttr(self::helper(...))] + public static function helper(): bool + { + return true; + } +} + class ConstExprHookedFixture { public string $virtual { @@ -140,17 +149,22 @@ class ConstExprHookedFixture } } -class ConstExprFccFixture -{ - #[ConstExprAttr(self::helper(...))] - public static function helper(): bool - { - return true; - } -} - class ConstExprGlobalFccFixture { #[ConstExprAttr(strlen(...))] public string $p = ''; } + +class ConstExprCrossTarget +{ + public static function check(): bool { return true; } +} + +function dc_constexpr_global_fcc(): int { return 7; } + +class ConstExprCrossFccFixture +{ + #[ConstExprAttr(ConstExprCrossTarget::check(...))] public int $x = 0; + #[ConstExprAttr(strlen(...))] public int $g = 0; + #[ConstExprAttr(dc_constexpr_global_fcc(...))] public int $u = 0; +}