diff --git a/src/DeepClone/DeepClone.php b/src/DeepClone/DeepClone.php index 65dd5f9a..63bf5392 100644 --- a/src/DeepClone/DeepClone.php +++ b/src/DeepClone/DeepClone.php @@ -684,7 +684,7 @@ private static function prepare($values, &$objectsPool, &$refsPool, &$objectsCou // method of that name, so it is gated behind // allow_named_closures, which both ends must enable. if (!$allowNamedClosures) { - throw new \ValueError('deepclone_to_array(): serializing a closure over the named callable "'.$r->name.'" requires enabling the allow_named_closures option'); + throw new \ValueError('deepclone_to_array(): serializing a closure over the named callable "'.$r->name.'" requires enabling the "allow_named_closures" option; do it only if you trust the input; alternatively, install the "deepclone" extension, which can reference callables declared in constant expressions'); } if (null !== $allowedSet && !isset($allowedSet['closure'])) { throw new \ValueError('deepclone_to_array(): class "Closure" is not allowed'); @@ -1655,7 +1655,11 @@ private static function resolveConstExprClosureScalar($value, ?array $allowedCla if (null === $found) { throw new \ValueError('deepclone_from_array(): Argument #1 ($data) malformed payload, const-expr-closure references unknown closure index '.$closureIndex); } - if ($line !== $foundLine = (new \ReflectionFunction($found))->getStartLine()) { + // Internal functions (e.g. a global strlen(...) reference) have no + // start line; getStartLine() returns false, which the extension encodes + // as 0. Normalize so such a reference does not look stale. + $foundLine = (new \ReflectionFunction($found))->getStartLine() ?: 0; + if ($line !== $foundLine) { throw new \ValueError('deepclone_from_array(): Argument #1 ($data) stale payload, const-expr-closure moved from line '.$line.' to line '.$foundLine); } diff --git a/tests/DeepClone/DeepCloneTest.php b/tests/DeepClone/DeepCloneTest.php index 44fccc7e..72e7dc55 100644 --- a/tests/DeepClone/DeepCloneTest.php +++ b/tests/DeepClone/DeepCloneTest.php @@ -584,7 +584,9 @@ public function testClosurePrivateMethodWireFormatAndRoundTrip() public function testToArrayNamedClosureRequiresOptIn() { $this->expectException(\ValueError::class); - $this->expectExceptionMessage('serializing a closure over the named callable "strlen" requires enabling the allow_named_closures option'); + // Substring common to the polyfill and extension messages (the polyfill + // quotes the option name and appends an extension hint). + $this->expectExceptionMessage('serializing a closure over the named callable "strlen" requires enabling the'); deepclone_to_array(\Closure::fromCallable('strlen')); } @@ -2554,4 +2556,21 @@ public function testToArrayConstExprClosureFirstClassCallableUsesDeclarationSite $this->assertSame(ConstExprFccFixture::class, $d['prepared'][0]); $this->assertTrue(deepclone_from_array($d)()); } + + /** + * @requires PHP 8.5 + */ + public function testFromArrayConstExprClosureGlobalInternalFunction() + { + // The extension can reference a global internal function declared in an + // attribute (e.g. #[ConstExprAttr(strlen(...))]); such a reference has + // no start line and is encoded with line 0. The polyfill cannot produce + // these (no reflection hook), but must decode them: ReflectionFunction + // reports no start line for an internal function, so the line-0 + // reference must not be treated as stale. + $payload = ['classes' => '', 'objectMeta' => 0, 'prepared' => [ConstExprGlobalFccFixture::class, '$p', 0, 0, 0], 'mask' => 1]; + + $r = deepclone_from_array($payload); + $this->assertSame(5, $r('hello')); + } } diff --git a/tests/DeepClone/fixtures85.php b/tests/DeepClone/fixtures85.php index 87320124..7f6bc805 100644 --- a/tests/DeepClone/fixtures85.php +++ b/tests/DeepClone/fixtures85.php @@ -148,3 +148,9 @@ public static function helper(): bool return true; } } + +class ConstExprGlobalFccFixture +{ + #[ConstExprAttr(strlen(...))] + public string $p = ''; +}