From fffacae48372e0f9b5edf13a087b1cb720174b0f Mon Sep 17 00:00:00 2001 From: Nicolas Grekas Date: Wed, 10 Jun 2026 18:20:44 +0200 Subject: [PATCH] Serialize closures declared in constant expressions --- NEWS | 5 + UPGRADING | 10 + .../serialization_allowed_classes.phpt | 57 ++ .../serialization_basic.phpt | 86 ++ .../serialization_code_hash.phpt | 92 ++ .../serialization_graph.phpt | 39 + .../serialization_inheritance.phpt | 50 + .../serialization_invalid_payload.phpt | 85 ++ .../serialization_misc.phpt | 61 ++ .../serialization_named.phpt | 115 +++ .../serialization_nested.phpt | 66 ++ .../serialization_not_allowed.phpt | 86 ++ .../serialization_tagged_format.phpt | 42 + Zend/zend_ast.c | 8 + Zend/zend_ast.h | 1 + Zend/zend_closures.c | 879 +++++++++++++++++- Zend/zend_closures.h | 12 + Zend/zend_closures.stub.php | 7 +- Zend/zend_closures_arginfo.h | 22 +- Zend/zend_compile.c | 16 + Zend/zend_compile.h | 9 +- Zend/zend_execute.h | 7 + Zend/zend_execute_API.c | 1 + ext/reflection/php_reflection.c | 47 + ext/reflection/php_reflection.stub.php | 4 + ext/reflection/php_reflection_arginfo.h | 14 +- ext/reflection/php_reflection_decl.h | 8 +- .../ReflectionFunction_getConstExprId.phpt | 60 ++ ext/standard/var.c | 56 +- 29 files changed, 1921 insertions(+), 24 deletions(-) create mode 100644 Zend/tests/closures/closure_const_expr/serialization_allowed_classes.phpt create mode 100644 Zend/tests/closures/closure_const_expr/serialization_basic.phpt create mode 100644 Zend/tests/closures/closure_const_expr/serialization_code_hash.phpt create mode 100644 Zend/tests/closures/closure_const_expr/serialization_graph.phpt create mode 100644 Zend/tests/closures/closure_const_expr/serialization_inheritance.phpt create mode 100644 Zend/tests/closures/closure_const_expr/serialization_invalid_payload.phpt create mode 100644 Zend/tests/closures/closure_const_expr/serialization_misc.phpt create mode 100644 Zend/tests/closures/closure_const_expr/serialization_named.phpt create mode 100644 Zend/tests/closures/closure_const_expr/serialization_nested.phpt create mode 100644 Zend/tests/closures/closure_const_expr/serialization_not_allowed.phpt create mode 100644 Zend/tests/closures/closure_const_expr/serialization_tagged_format.phpt create mode 100644 ext/reflection/tests/ReflectionFunction_getConstExprId.phpt diff --git a/NEWS b/NEWS index fb54cab61c0f..36ea98737c66 100644 --- a/NEWS +++ b/NEWS @@ -140,6 +140,11 @@ PHP NEWS - Core: . Added first-class callable cache to share instances for the duration of the request. (ilutov) + . Closures declared in constant expressions (anonymous closures and + first-class callables) can now be serialized as references to their + declaration site. Added Closure::fromConstExpr(), + ReflectionFunction::getConstExprId() and getConstExprClass(). + (nicolas-grekas) . It is now possible to use reference assign on WeakMap without the key needing to be present beforehand. (ndossche) . Added `clamp()`. (kylekatarnls, thinkverse) diff --git a/UPGRADING b/UPGRADING index 729dfed0363e..ae656f3c4402 100644 --- a/UPGRADING +++ b/UPGRADING @@ -231,6 +231,16 @@ PHP 8.6 UPGRADE NOTES RFC: https://wiki.php.net/rfc/debugable-enums . #[\Override] can now be applied to class constants, including enum cases. RFC: https://wiki.php.net/rfc/override_constants + . Closures declared in constant expressions of a class member (anonymous + closures and first-class callables, in attribute arguments and parameter + default values) can now be serialized and unserialized. Payloads contain + no code, only a reference to the declaration site, verified on resolve + and valid for the code revision that produced it. Closures created at + runtime, bound to an object, or declared in class constant values or + property defaults still refuse to serialize. Added + Closure::fromConstExpr() and + ReflectionFunction::getConstExprId()/getConstExprClass(). + RFC: https://wiki.php.net/rfc/serializable_closures - Curl: . curl_getinfo() return array now includes a new size_delivered key, which diff --git a/Zend/tests/closures/closure_const_expr/serialization_allowed_classes.phpt b/Zend/tests/closures/closure_const_expr/serialization_allowed_classes.phpt new file mode 100644 index 000000000000..17510d027470 --- /dev/null +++ b/Zend/tests/closures/closure_const_expr/serialization_allowed_classes.phpt @@ -0,0 +1,57 @@ +--TEST-- +Serializable closures are gated by the unserialize() allowed_classes filter +--FILE-- +getAttributes(); +$payloads = [ + 'anonymous' => serialize($attrs[0]->getArguments()[0]), + 'fcc site' => serialize($attrs[1]->getArguments()[0]), +]; + +// The recommended safe-unserialize practice (allowed_classes => false) blocks +// every Closure payload: it becomes __PHP_Incomplete_Class and __unserialize() +// is never invoked, exactly like any other object-injection gadget. +foreach ($payloads as $name => $payload) { + $r = unserialize($payload, ['allowed_classes' => false]); + var_dump($name, $r instanceof __PHP_Incomplete_Class); +} + +// A list that does not contain Closure also blocks it. +$r = unserialize($payloads['fcc site'], ['allowed_classes' => ['stdClass']]); +var_dump($r instanceof __PHP_Incomplete_Class); + +// Resolving a reference autoloads its declaring class and pulls a closure out +// of it, so that class is subject to the filter too: allowing Closure alone is +// not enough when the reference points into another class. +try { + unserialize($payloads['fcc site'], ['allowed_classes' => ['Closure']]); +} catch (Exception $e) { + echo $e->getMessage(), "\n"; +} + +// Both the Closure and the referenced class must be opted in. +$r = unserialize($payloads['fcc site'], ['allowed_classes' => ['Closure', 'Demo']]); +var_dump($r instanceof Closure, $r('test')); + +?> +--EXPECT-- +string(9) "anonymous" +bool(true) +string(8) "fcc site" +bool(true) +bool(true) +Invalid serialization data for Closure object (class "Demo" is not allowed) +bool(true) +int(4) diff --git a/Zend/tests/closures/closure_const_expr/serialization_basic.phpt b/Zend/tests/closures/closure_const_expr/serialization_basic.phpt new file mode 100644 index 000000000000..5d7e309cedc7 --- /dev/null +++ b/Zend/tests/closures/closure_const_expr/serialization_basic.phpt @@ -0,0 +1,86 @@ +--TEST-- +Closures in constant expressions are serializable as declaration-site references +--FILE-- + (new ReflectionClass(Demo::class))->getAttributes()[0]->getArguments()[0], + 'const' => (new ReflectionClassConstant(Demo::class, 'FOO'))->getAttributes()[0]->getArguments()[0], + 'prop-1' => (new ReflectionProperty(Demo::class, 'name'))->getAttributes()[0]->getArguments()['cb'][0], + 'prop-2' => (new ReflectionProperty(Demo::class, 'name'))->getAttributes()[0]->getArguments()['cb'][1], + 'method' => (new ReflectionMethod(Demo::class, 'm'))->getAttributes()[0]->getArguments()[0], + 'param' => (new ReflectionParameter([Demo::class, 'm'], 'x'))->getAttributes()[0]->getArguments()[0], + 'case' => (new ReflectionClassConstant(E::class, 'X'))->getAttributes()[0]->getArguments()[0], +]; + +foreach ($closures as $expected => $closure) { + $r = new ReflectionFunction($closure); + $id = $r->getConstExprId(); + $scope = $r->getClosureScopeClass()->name; + + $unserialized = unserialize(serialize($closure)); + $recreated = Closure::fromConstExpr($scope, $id); + + var_dump($expected === $closure() && $expected === $unserialized() && $expected === $recreated()); +} + +// Ids are assigned in canonical walk order: class attributes first, then +// constants, then properties, then methods (including parameters). The +// "#" code-hash suffix is stripped here: its value is not part of the +// addressing contract (see serialization_code_hash.phpt). +$ids = array_map( + static fn ($c) => preg_replace('/#[0-9a-f]{8}$/', '', (new ReflectionFunction($c))->getConstExprId()), + $closures +); +var_dump($ids); + +?> +--EXPECT-- +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +array(7) { + ["class"]=> + string(2) "@0" + ["const"]=> + string(5) "FOO@0" + ["prop-1"]=> + string(7) "$name@0" + ["prop-2"]=> + string(7) "$name@1" + ["method"]=> + string(5) "m()@0" + ["param"]=> + string(5) "m()@1" + ["case"]=> + string(3) "X@0" +} diff --git a/Zend/tests/closures/closure_const_expr/serialization_code_hash.phpt b/Zend/tests/closures/closure_const_expr/serialization_code_hash.phpt new file mode 100644 index 000000000000..68da223c85c6 --- /dev/null +++ b/Zend/tests/closures/closure_const_expr/serialization_code_hash.phpt @@ -0,0 +1,92 @@ +--TEST-- +Anonymous const-expr closure references embed a code hash in their id +--FILE-- +getAttributes()[0]->getArguments()[0] +))->getConstExprId(); + +// The id is "@#"; split it to retarget the reference. +var_dump(preg_match('/^\$p@0#[0-9a-f]{8}$/', $id)); +$hash = substr($id, strpos($id, '#')); + +$ref = static fn (string $class, string $id): string => + 'O:7:"Closure":' . substr(serialize([[], ['const-expr', [$class, $id]]]), 2); + +// Same class: resolves, and fromConstExpr() verifies the same way. +var_dump(unserialize($ref('Ordered', $id))()); +var_dump(Closure::fromConstExpr('Ordered', $id)()); + +// The swapped class has B at rank 0 of $q; same rank, but the hash guards it. +try { + unserialize($ref('Swapped', '$q@0' . $hash)); +} catch (Exception $e) { + echo $e->getMessage(), "\n"; +} +try { + Closure::fromConstExpr('Swapped', '$q@0' . $hash); +} catch (ValueError $e) { + echo $e->getMessage(), "\n"; +} + +// The hash is over the closure's code, insensitive to layout and comments: a +// reference resolves against a reformatted body... +class Reformatted { + #[A(static function () { + // a comment; different whitespace and layout + return 'a' ; + })] + public int $p = 0; +} +var_dump(unserialize($ref('Reformatted', '$p@0' . $hash))()); + +// ...but not against a changed body. +class Changed { + #[A(static function () { return 'CHANGED'; })] + public int $p = 0; +} +try { + unserialize($ref('Changed', '$p@0' . $hash)); +} catch (Exception $e) { + echo $e->getMessage(), "\n"; +} + +// An id without a hash (a producer that cannot compute it) resolves +// positionally, unverified. +var_dump(unserialize($ref('Ordered', '$p@0'))()); + +?> +--EXPECTF-- +int(1) +string(1) "a" +string(1) "a" +Invalid serialization data for Closure object (constant-expression closure "$q@0#%s" of class Swapped changed) +Closure::fromConstExpr(): Argument #2 ($id) refers to a constant-expression closure of class Swapped that has changed +string(1) "a" +Invalid serialization data for Closure object (constant-expression closure "$p@0#%s" of class Changed changed) +string(1) "a" diff --git a/Zend/tests/closures/closure_const_expr/serialization_graph.phpt b/Zend/tests/closures/closure_const_expr/serialization_graph.phpt new file mode 100644 index 000000000000..d57e5ca567c0 --- /dev/null +++ b/Zend/tests/closures/closure_const_expr/serialization_graph.phpt @@ -0,0 +1,39 @@ +--TEST-- +Const-expr closures inside serialized object graphs +--FILE-- +c)(), "\n"; + } +} + +$h = new Holder(); +$h->c = (new ReflectionProperty(Demo::class, 'p'))->getAttributes()[0]->getArguments()[0]; + +$u = unserialize(serialize([$h, $h->c, [$h->c]])); + +echo "after: ", ($u[1])(), "\n"; +// Shared instances are preserved within the graph. +var_dump($u[0]->c === $u[1], $u[1] === $u[2][0]); + +?> +--EXPECT-- +wakeup sees: ok +after: ok +bool(true) +bool(true) diff --git a/Zend/tests/closures/closure_const_expr/serialization_inheritance.phpt b/Zend/tests/closures/closure_const_expr/serialization_inheritance.phpt new file mode 100644 index 000000000000..ff3b3ed6683c --- /dev/null +++ b/Zend/tests/closures/closure_const_expr/serialization_inheritance.phpt @@ -0,0 +1,50 @@ +--TEST-- +Serialization of const-expr closures with inheritance and traits +--FILE-- +getAttributes()[0]->getArguments()[0]; +var_dump((new ReflectionFunction($c))->getClosureScopeClass()->name); +$payload = serialize($c); +var_dump(str_contains($payload, '"Base"')); +var_dump(unserialize($payload)()); + +// Attribute on a parameter of a trait method: the copied method is scoped +// to the using class and the reference resolves through it. +$c = (new ReflectionParameter([UsesTrait::class, 'm'], 'x'))->getAttributes()[0]->getArguments()[0]; +var_dump((new ReflectionFunction($c))->getClosureScopeClass()->name); +$u = unserialize(serialize($c)); +var_dump($u()); + +?> +--EXPECT-- +string(4) "Base" +bool(true) +string(4) "base" +string(9) "UsesTrait" +string(5) "trait" diff --git a/Zend/tests/closures/closure_const_expr/serialization_invalid_payload.phpt b/Zend/tests/closures/closure_const_expr/serialization_invalid_payload.phpt new file mode 100644 index 000000000000..df7ca939c512 --- /dev/null +++ b/Zend/tests/closures/closure_const_expr/serialization_invalid_payload.phpt @@ -0,0 +1,85 @@ +--TEST-- +Unserializing invalid or stale Closure declaration-site references +--FILE-- +getAttributes()[0]->getArguments()[0]; +// "@#": the hash suffix verifies the closure's code. A +// producer that cannot compute it may omit it; resolution is then positional. +$id = (new ReflectionFunction($closure))->getConstExprId(); +// Flip the last hex nibble of the code hash (staying int-safe on 32-bit). +$stale = '$p@0#' . substr($id, -8, 7) . dechex(hexdec(substr($id, -1)) ^ 1); + +// Serialize an array and rebrand it as a Closure object payload, so we can +// feed __unserialize() arbitrary tagged-union shapes. +$obj = static fn (array $data): string => 'O:7:"Closure":' . substr(serialize($data), 2); + +// Tagged-union payload: [ [], ["const-expr", [class, id]] ]. +$mk = static fn (string $class, string $id): string => $obj([[], ['const-expr', [$class, $id]]]); + +// Sanity checks: a full reference works, and so does a hash-less one. +var_dump(unserialize($mk('Demo', $id))()); +var_dump(unserialize($mk('Demo', '$p@0'))()); + +$payloads = [ + 'empty data' => $obj([]), + 'one element' => $obj([[]]), + 'unknown tag' => $obj([[], ['whatever', ['Demo', $id]]]), + 'tag not list' => $obj([[], 'const-expr']), + 'wrong id type' => $obj([[], ['const-expr', ['Demo', 0]]]), + 'three-slot reference' => $obj([[], ['const-expr', ['Demo', $id, 1]]]), + 'unknown class' => $mk('NoSuchClass', $id), + 'internal class' => $mk('stdClass', $id), + 'unknown site' => $mk('Demo', '$nope@0'), + 'malformed id' => $mk('Demo', 'no-at-sign'), + 'malformed hash' => $mk('Demo', '$p@0#xyzwxyzw'), + 'short hash' => $mk('Demo', '$p@0#1234'), + 'zero hash' => $mk('Demo', '$p@0#00000000'), + 'stale hash' => $mk('Demo', $stale), +]; + +foreach ($payloads as $name => $payload) { + try { + unserialize($payload); + echo "$name: unserialized!?\n"; + } catch (Exception $e) { + echo "$name: {$e->getMessage()}\n"; + } +} + +// __unserialize() cannot be used to reinitialize a live closure. +try { + $closure->__unserialize([[], ['const-expr', ['Demo', $id]]]); +} catch (Exception $e) { + echo $e->getMessage(), "\n"; +} + +?> +--EXPECTF-- +string(2) "ok" +string(2) "ok" +empty data: Invalid serialization data for Closure object +one element: Invalid serialization data for Closure object +unknown tag: Invalid serialization data for Closure object +tag not list: Invalid serialization data for Closure object +wrong id type: Invalid serialization data for Closure object +three-slot reference: Invalid serialization data for Closure object +unknown class: Invalid serialization data for Closure object (cannot load class "NoSuchClass") +internal class: Invalid serialization data for Closure object (cannot load class "stdClass") +unknown site: Invalid serialization data for Closure object (constant-expression closure "$nope@0" of class Demo not found) +malformed id: Invalid serialization data for Closure object (constant-expression closure "no-at-sign" of class Demo not found) +malformed hash: Invalid serialization data for Closure object (constant-expression closure "$p@0#xyzwxyzw" of class Demo not found) +short hash: Invalid serialization data for Closure object (constant-expression closure "$p@0#1234" of class Demo not found) +zero hash: Invalid serialization data for Closure object (constant-expression closure "$p@0#00000000" of class Demo not found) +stale hash: Invalid serialization data for Closure object (constant-expression closure "$p@0#%s" of class Demo changed) +Cannot unserialize an already initialized Closure diff --git a/Zend/tests/closures/closure_const_expr/serialization_misc.phpt b/Zend/tests/closures/closure_const_expr/serialization_misc.phpt new file mode 100644 index 000000000000..60818108b069 --- /dev/null +++ b/Zend/tests/closures/closure_const_expr/serialization_misc.phpt @@ -0,0 +1,61 @@ +--TEST-- +Misc behaviors of serializable const-expr closures: static vars, identity, scope binding, fromConstExpr() errors +--FILE-- +getAttributes(); +$counter = $attributes[0]->getArguments()[0]; +$scoped = $attributes[1]->getArguments()[0]; + +// A reference does not carry the state of static variables: unserializing +// produces the closure as if it was freshly evaluated. +var_dump($counter(), $counter()); +$u = unserialize(serialize($counter)); +var_dump($u()); + +// Unserialized closures are new instances. +var_dump($u === $counter); + +// Scope binding is restored: private members of the class are accessible. +var_dump(unserialize(serialize($scoped))()); + +// fromConstExpr() error cases +try { + Closure::fromConstExpr('NoSuchClass', 0); +} catch (Error $e) { + echo get_class($e), ': ', $e->getMessage(), "\n"; +} +try { + Closure::fromConstExpr('stdClass', 0); +} catch (ValueError $e) { + echo get_class($e), ': ', $e->getMessage(), "\n"; +} +try { + Closure::fromConstExpr('Demo', 999); +} catch (ValueError $e) { + echo get_class($e), ': ', $e->getMessage(), "\n"; +} + +?> +--EXPECT-- +int(1) +int(2) +int(1) +bool(false) +string(6) "secret" +Error: Class "NoSuchClass" not found +ValueError: Closure::fromConstExpr(): Argument #2 ($id) does not refer to a constant-expression closure of class stdClass +ValueError: Closure::fromConstExpr(): Argument #2 ($id) does not refer to a constant-expression closure of class Demo diff --git a/Zend/tests/closures/closure_const_expr/serialization_named.phpt b/Zend/tests/closures/closure_const_expr/serialization_named.phpt new file mode 100644 index 000000000000..760ab2803a18 --- /dev/null +++ b/Zend/tests/closures/closure_const_expr/serialization_named.phpt @@ -0,0 +1,115 @@ +--TEST-- +First-class callable references serialize as name-keyed declaration sites +--FILE-- +getAttributes(); +foreach ($attrs as $i => $attr) { + $closure = $attr->getArguments()[0]; + $payload = serialize($closure); + $u = unserialize($payload); + $args = $i === 4 ? ['abcd'] : []; + // The id is "@Called::method" / "@function", the payload has + // no line field, and the recreated closure behaves identically + // (including static:: binding) and re-serializes byte-identically. + var_dump( + (new ReflectionFunction($closure))->getConstExprId() + . ' | ' . var_export($u(...$args) === $closure(...$args), true) + . ' | ' . var_export(serialize($u) === $payload, true) + ); +} +echo serialize($attrs[0]->getArguments()[0]), "\n"; + +// parent:: and self:: forms resolve their distinct static:: bindings. +var_dump(unserialize(serialize($attrs[1]->getArguments()[0]))()); +var_dump(unserialize(serialize($attrs[2]->getArguments()[0]))()); + +// No rank is consumed by first-class callable references. +$qattrs = (new ReflectionProperty(Demo::class, 'q'))->getAttributes(); +var_dump(preg_replace('/#[0-9a-f]{8}$/', '', (new ReflectionFunction($qattrs[1]->getArguments()[0]))->getConstExprId())); + +// Runtime-created named closures are not declaration sites and refuse, +// even when an identical reference exists in an attribute. +foreach ([strlen(...), Validators::check(...), Closure::fromCallable('strlen')] as $closure) { + try { + serialize($closure); + echo "serialized!?\n"; + } catch (Exception $e) { + echo $e->getMessage(), "\n"; + } +} + +// The name is the address, the declaration is the guard: keys that no +// element declares do not resolve, whatever they name. +foreach (['$p@system', '$p@Demo::nope', '$q@Validators::check', '$p@0'] as $id) { + try { + Closure::fromConstExpr('Demo', $id); + echo "resolved!?\n"; + } catch (ValueError $e) { + echo $id, ": ", $e->getMessage(), "\n"; + } +} +try { + unserialize('O:7:"Closure":2:' . substr(serialize([[], ['const-expr', ['Demo', '$p@system']]]), 4)); +} catch (Exception $e) { + echo $e->getMessage(), "\n"; +} + +} +?> +--EXPECT-- +string(27) "$p@Demo::priv | true | true" +string(27) "$p@Demo::prot | true | true" +string(27) "$p@Base::prot | true | true" +string(34) "$p@Validators::check | true | true" +string(23) "$p@strlen | true | true" +string(27) "$p@App\helper | true | true" +O:7:"Closure":2:{i:0;a:0:{}i:1;a:2:{i:0;s:10:"const-expr";i:1;a:2:{i:0;s:4:"Demo";i:1;s:13:"$p@Demo::priv";}}} +string(9) "prot-Demo" +string(9) "prot-Base" +string(4) "$q@0" +Serialization of 'Closure' is not allowed +Serialization of 'Closure' is not allowed +Serialization of 'Closure' is not allowed +$p@system: Closure::fromConstExpr(): Argument #2 ($id) does not refer to a constant-expression closure of class Demo +$p@Demo::nope: Closure::fromConstExpr(): Argument #2 ($id) does not refer to a constant-expression closure of class Demo +$q@Validators::check: Closure::fromConstExpr(): Argument #2 ($id) does not refer to a constant-expression closure of class Demo +$p@0: Closure::fromConstExpr(): Argument #2 ($id) does not refer to a constant-expression closure of class Demo +Invalid serialization data for Closure object (constant-expression closure "$p@system" of class Demo not found) diff --git a/Zend/tests/closures/closure_const_expr/serialization_nested.phpt b/Zend/tests/closures/closure_const_expr/serialization_nested.phpt new file mode 100644 index 000000000000..e21b8a30cf45 --- /dev/null +++ b/Zend/tests/closures/closure_const_expr/serialization_nested.phpt @@ -0,0 +1,66 @@ +--TEST-- +Serialization of const-expr closures in parameter defaults, hooks and nested functions +--FILE-- + $this->v; + } + + public function withDefault($cb = static function () { return 'default'; }) { + return $cb; + } + + public function makeClosure() { + return #[A(static function () { return 'inner'; })] static function () {}; + } +} + +class Nested { + #[A(static function ( + #[A(static function () { return 'deep'; })] $x = null, + ) { return 'outer'; })] + public int $p = 0; +} + +$roundtrip = static function (Closure $c) { + $u = unserialize(serialize($c)); + var_dump($u(), preg_replace('/#[0-9a-f]{8}$/', '', (new ReflectionFunction($c))->getConstExprId())); +}; + +// Attribute on a property hook +$hook = (new ReflectionProperty(Demo::class, 'v'))->getHook(PropertyHookType::Get); +$roundtrip($hook->getAttributes()[0]->getArguments()[0]); + +// Parameter default value +$roundtrip((new Demo)->withDefault()); + +// Attribute on a runtime closure declared in a method body +$runtime = (new Demo)->makeClosure(); +$roundtrip((new ReflectionFunction($runtime))->getAttributes()[0]->getArguments()[0]); + +// Const-expr closure nested in the parameter attribute of another one +$outer = (new ReflectionProperty(Nested::class, 'p'))->getAttributes()[0]->getArguments()[0]; +$inner = (new ReflectionFunction($outer))->getParameters()[0]->getAttributes()[0]->getArguments()[0]; +$roundtrip($outer); +$roundtrip($inner); + +?> +--EXPECT-- +string(8) "get-hook" +string(11) "$v::get()@0" +string(7) "default" +string(15) "withDefault()@0" +string(5) "inner" +string(15) "makeClosure()@0" +string(5) "outer" +string(4) "$p@0" +string(4) "deep" +string(4) "$p@1" diff --git a/Zend/tests/closures/closure_const_expr/serialization_not_allowed.phpt b/Zend/tests/closures/closure_const_expr/serialization_not_allowed.phpt new file mode 100644 index 000000000000..fbcef956cf1c --- /dev/null +++ b/Zend/tests/closures/closure_const_expr/serialization_not_allowed.phpt @@ -0,0 +1,86 @@ +--TEST-- +Closures that are not addressable by a declaration-site reference still refuse to serialize +--FILE-- +getAttributes()[0]->getArguments()[0]; + +$cases = [ + 'runtime closure' => function () {}, + 'static runtime closure' => static function () {}, + 'arrow function' => fn () => 1, + 'runtime function callable' => strlen(...), + 'runtime method callable' => Demo::privateFcc(...), + 'bound method callable' => (new Demo)->boundMethod(...), + 'private method callable' => Demo::privateFcc(), + '__callStatic trampoline' => Demo::someUndefinedMethod(...), + 'class constant value' => Demo::BAD, + 'property default' => (new Demo)->bad, + 'rebound scope' => Closure::bind($attrClosure, null, A::class), + 'free function attribute' => (new ReflectionFunction('freeFunction'))->getAttributes()[0]->getArguments()[0], + 'anonymous class attribute' => (new ReflectionClass($anon))->getAttributes()[0]->getArguments()[0], +]; + +foreach ($cases as $name => $closure) { + try { + serialize($closure); + echo "$name: serialized!?\n"; + } catch (Exception $e) { + echo "$name: {$e->getMessage()}\n"; + } + var_dump((new ReflectionFunction($closure))->getConstExprId()); +} + +?> +--EXPECT-- +runtime closure: Serialization of 'Closure' is not allowed +NULL +static runtime closure: Serialization of 'Closure' is not allowed +NULL +arrow function: Serialization of 'Closure' is not allowed +NULL +runtime function callable: Serialization of 'Closure' is not allowed +NULL +runtime method callable: Serialization of 'Closure' is not allowed +NULL +bound method callable: Serialization of 'Closure' is not allowed +NULL +private method callable: Serialization of 'Closure' is not allowed +NULL +__callStatic trampoline: Serialization of 'Closure' is not allowed +NULL +class constant value: Serialization of 'Closure' is not allowed +NULL +property default: Serialization of 'Closure' is not allowed +NULL +rebound scope: Serialization of 'Closure' is not allowed +NULL +free function attribute: Serialization of 'Closure' is not allowed +NULL +anonymous class attribute: Serialization of 'Closure' is not allowed +NULL diff --git a/Zend/tests/closures/closure_const_expr/serialization_tagged_format.phpt b/Zend/tests/closures/closure_const_expr/serialization_tagged_format.phpt new file mode 100644 index 000000000000..6e81b9d5f827 --- /dev/null +++ b/Zend/tests/closures/closure_const_expr/serialization_tagged_format.phpt @@ -0,0 +1,42 @@ +--TEST-- +Closure serialization payload is a tagged union of [class, id] references +--FILE-- +getAttributes(); +$anon = $attrs[0]->getArguments()[0]; +$fcc = $attrs[1]->getArguments()[0]; + +// The payload is [ , [ , [class, id] ] ]. The +// properties slot is empty, the tag is "const-expr". An anonymous closure's +// id embeds a code hash ("@#"); a first-class callable's +// id is its name key, which cannot drift and carries none. +$anonPayload = $anon->__serialize(); +$fccPayload = $fcc->__serialize(); +var_dump($anonPayload[0], $anonPayload[1][0]); +var_dump(count($anonPayload[1][1]), count($fccPayload[1][1])); +var_dump($anonPayload[1][1][0]); +var_dump(preg_match('/^\$p@0#[0-9a-f]{8}$/', $anonPayload[1][1][1])); +var_dump($fccPayload[1][1][1]); + +?> +--EXPECT-- +array(0) { +} +string(10) "const-expr" +int(2) +int(2) +string(4) "Demo" +int(1) +string(9) "$p@strlen" diff --git a/Zend/zend_ast.c b/Zend/zend_ast.c index 3aa04de860a1..70bb475b3220 100644 --- a/Zend/zend_ast.c +++ b/Zend/zend_ast.c @@ -119,6 +119,7 @@ ZEND_API zend_ast * ZEND_FASTCALL zend_ast_create_op_array(zend_op_array *op_arr ast->kind = ZEND_AST_OP_ARRAY; ast->attr = 0; ast->lineno = CG(zend_lineno); + ast->code_hash = 0; ast->op_array = op_array; return (zend_ast *) ast; @@ -1244,6 +1245,12 @@ static zend_result ZEND_FASTCALL zend_ast_evaluate_inner( zend_create_fake_closure(result, fptr, fptr->common.scope, called_scope, NULL); + if (scope) { + /* Remember the declaration site, so that the closure can be + * serialized as a reference to it. */ + zend_closure_mark_as_constexpr_fcc(result, scope); + } + return SUCCESS; } case ZEND_AST_OP_ARRAY: @@ -1410,6 +1417,7 @@ static void* ZEND_FASTCALL zend_ast_tree_copy(zend_ast *ast, void *buf) new->kind = old->kind; new->attr = old->attr; new->lineno = old->lineno; + new->code_hash = old->code_hash; new->op_array = old->op_array; function_add_ref((zend_function *)new->op_array); buf = (void*)((char*)buf + sizeof(zend_ast_op_array)); diff --git a/Zend/zend_ast.h b/Zend/zend_ast.h index 86a68c1cbaf9..ca680d866be2 100644 --- a/Zend/zend_ast.h +++ b/Zend/zend_ast.h @@ -213,6 +213,7 @@ typedef struct _zend_ast_op_array { zend_ast_kind kind; zend_ast_attr attr; uint32_t lineno; + uint32_t code_hash; /* of the pretty-printed declaration, for serialization */ zend_op_array *op_array; } zend_ast_op_array; diff --git a/Zend/zend_closures.c b/Zend/zend_closures.c index 840d2dbe32e1..7ce4784d2af1 100644 --- a/Zend/zend_closures.c +++ b/Zend/zend_closures.c @@ -19,8 +19,11 @@ #include "zend.h" #include "zend_API.h" +#include "zend_ast.h" +#include "zend_attributes.h" #include "zend_closures.h" #include "zend_exceptions.h" +#include "zend_execute.h" #include "zend_interfaces.h" #include "zend_objects.h" #include "zend_objects_API.h" @@ -33,6 +36,10 @@ typedef struct _zend_closure { zval this_ptr; zend_class_entry *called_scope; zif_handler orig_internal_handler; + /* The class whose constant expressions declared this closure, when it was + * created by evaluating a first-class callable in one of them (a fake + * closure carrying ZEND_ACC2_CONSTEXPR_CLOSURE). */ + zend_class_entry *constexpr_site; } zend_closure; /* non-static since it needs to be referenced */ @@ -40,6 +47,8 @@ ZEND_API zend_class_entry *zend_ce_closure; static zend_object_handlers closure_handlers; static zend_result zend_closure_get_closure(zend_object *obj, zend_class_entry **ce_ptr, zend_function **fptr_ptr, zend_object **obj_ptr, bool check_only); +static ZEND_COLD ZEND_NAMED_FUNCTION(zend_closure_uninitialized_handler); +static void zend_closure_init_ex(zend_closure *closure, zend_function *func, zend_class_entry *scope, zend_class_entry *called_scope, zval *this_ptr, bool is_fake); ZEND_METHOD(Closure, __invoke) /* {{{ */ { @@ -448,6 +457,844 @@ ZEND_METHOD(Closure, getCurrent) RETURN_OBJ_COPY(obj); } +/* Closures declared in constant expressions + * + * Closures declared in constant expressions are static, capture no variables + * and are fully described by their compiled op_array. This makes them + * addressable by (class, id), where the id is the position of the closure in + * a canonical walk over all constant expression ASTs that are reachable from + * the class and that are not subject to in-place evaluation: attribute + * arguments and parameter default values, including those of nested + * functions. Class constant values and property default values are evaluated + * (and their AST freed) in place, so closures declared there are not + * addressable this way. + * + * The walk visits candidates in a deterministic order that only depends on + * the compiled class, not on runtime evaluation state, so ids computed by one + * process can be resolved by another process running the same code. + */ + +/* Reflection-element kinds for the site descriptor. */ +typedef enum _zend_constexpr_site_kind { + ZEND_CEXPR_SITE_CLASS, + ZEND_CEXPR_SITE_CONST, + ZEND_CEXPR_SITE_PROP, + ZEND_CEXPR_SITE_HOOK, + ZEND_CEXPR_SITE_METHOD, +} zend_constexpr_site_kind; + +typedef struct zend_constexpr_closure_walk { + /* Rank of the next closure WITHIN the current reflection element; reset to + * 0 at each element boundary by zend_constexpr_closure_walk_class. A + * reference is (element site, local rank), not a single class-global rank, + * so adding/removing/reordering closures in one element does not renumber + * closures in other elements. */ + uint32_t next_id; + /* When by_id is true, search for target_id (a LOCAL rank among the + * anonymous closures of a single element; the caller has already + * positioned the walk on that element). When target_key is set, search + * for the first-class callable reference matching that "Called::method" + * / "function" key. Otherwise search for the op_array of an anonymous + * closure identified by its opcodes. Serialization and resolution both + * match first-class callables by key, so a closure is serializable + * exactly when its reference resolves. */ + bool by_id; + uint32_t target_id; + const char *target_key; + size_t target_key_len; + const zend_op *opcodes; + /* The class being walked; used to resolve self/parent references. */ + zend_class_entry *site_class; + /* The reflection element currently being walked, kept as a cheap + * (kind, borrowed name, hook) descriptor rather than a built string, so no + * allocation happens for the elements the walk merely passes through. The + * "@" string is materialized once, from found_*, on a match. */ + zend_constexpr_site_kind cur_kind; + zend_string *cur_name; + uint8_t cur_hook; + /* The found site: either a ZEND_AST_OP_ARRAY node, or a ZEND_AST_CALL / + * ZEND_AST_STATIC_CALL node whose argument list is a first-class callable + * placeholder. */ + zend_ast *found; + uint32_t found_id; + zend_constexpr_site_kind found_kind; + zend_string *found_name; + uint8_t found_hook; +} zend_constexpr_closure_walk; + +static bool zend_constexpr_closure_walk_op_array(zend_constexpr_closure_walk *w, zend_op_array *op_array); + +/* Whether the first-class callable declared by the given site matches a + * "Called::method" / "function" key. Pure string comparisons: class + * references are compile-time resolved names ('self'/'parent' resolve + * against the walked class), so no class or function is ever loaded. */ +static bool zend_constexpr_fcc_key_matches(const zend_constexpr_closure_walk *w, const zend_ast *ast) +{ + const char *key = w->target_key; + size_t key_len = w->target_key_len; + const char *sep = NULL; + + for (size_t i = 0; i + 1 < key_len; i++) { + if (key[i] == ':' && key[i + 1] == ':') { + sep = key + i; + break; + } + } + + if (ast->kind == ZEND_AST_STATIC_CALL) { + const zend_ast *class_ast = ast->child[0]; + zend_string *method = zend_ast_get_str(ast->child[1]); + zend_string *ref_name; + + if (!sep) { + return false; + } + + switch (class_ast->attr >> ZEND_CONST_EXPR_NEW_FETCH_TYPE_SHIFT) { + case ZEND_FETCH_CLASS_SELF: + ref_name = w->site_class->name; + break; + case ZEND_FETCH_CLASS_PARENT: + ref_name = w->site_class->parent ? w->site_class->parent->name : NULL; + break; + default: + ref_name = zend_ast_get_str((zend_ast *) class_ast); + break; + } + + return ref_name + && ZSTR_LEN(ref_name) == (size_t) (sep - key) + && zend_binary_strcasecmp(ZSTR_VAL(ref_name), ZSTR_LEN(ref_name), key, sep - key) == 0 + && ZSTR_LEN(method) == key_len - (sep - key) - 2 + && zend_binary_strcasecmp(ZSTR_VAL(method), ZSTR_LEN(method), sep + 2, ZSTR_LEN(method)) == 0; + } + + ZEND_ASSERT(ast->kind == ZEND_AST_CALL); + + if (sep) { + return false; + } + + zend_string *fname = zend_ast_get_str(ast->child[0]); + if (ZSTR_LEN(fname) == key_len + && zend_binary_strcasecmp(ZSTR_VAL(fname), ZSTR_LEN(fname), key, key_len) == 0) { + return true; + } + /* Non-fully-qualified names in namespaced code may resolve to the global + * function, whose name is what serialization emits as the key. */ + if (ast->child[0]->attr != ZEND_NAME_FQ) { + const char *backslash = zend_memrchr(ZSTR_VAL(fname), '\\', ZSTR_LEN(fname)); + if (backslash) { + size_t tail_len = ZSTR_LEN(fname) - (backslash + 1 - ZSTR_VAL(fname)); + return tail_len == key_len + && zend_binary_strcasecmp(backslash + 1, tail_len, key, key_len) == 0; + } + } + return false; +} + +static bool zend_constexpr_closure_visit_op_array(zend_constexpr_closure_walk *w, zend_ast *ast) +{ + zend_op_array *op_array = zend_ast_get_op_array(ast)->op_array; + uint32_t id = w->next_id++; + + if (w->by_id ? w->target_id == id : (w->opcodes && w->opcodes == op_array->opcodes)) { + w->found = ast; + w->found_id = id; + w->found_kind = w->cur_kind; + w->found_name = w->cur_name; + w->found_hook = w->cur_hook; + return true; + } + + /* The closure may itself declare closures in its attributes or in its + * parameter default values. */ + return zend_constexpr_closure_walk_op_array(w, op_array); +} + +static bool zend_constexpr_closure_walk_ast(zend_constexpr_closure_walk *w, zend_ast *ast) +{ + if (!ast) { + return false; + } + + switch (ast->kind) { + case ZEND_AST_OP_ARRAY: + return zend_constexpr_closure_visit_op_array(w, ast); + case ZEND_AST_ZVAL: + case ZEND_AST_CONSTANT: + return false; + case ZEND_AST_CALL: + case ZEND_AST_STATIC_CALL: { + zend_ast *args = ast->kind == ZEND_AST_CALL ? ast->child[1] : ast->child[2]; + + /* In constant expressions, calls only exist in their first-class + * callable form: each one is a closure declaration site, keyed by + * the name of its target. It does not consume a rank: adding or + * removing a callable reference never renumbers the anonymous + * closures of the element. */ + if (args && args->kind == ZEND_AST_CALLABLE_CONVERT) { + if (w->target_key && zend_constexpr_fcc_key_matches(w, ast)) { + w->found = ast; + w->found_kind = w->cur_kind; + w->found_name = w->cur_name; + w->found_hook = w->cur_hook; + return true; + } + return false; + } + break; + } + case ZEND_AST_CALLABLE_CONVERT: + /* Visited through its enclosing call node. */ + return false; + default: + break; + } + + if (zend_ast_is_list(ast)) { + zend_ast_list *list = zend_ast_get_list(ast); + for (uint32_t i = 0; i < list->children; i++) { + if (zend_constexpr_closure_walk_ast(w, list->child[i])) { + return true; + } + } + return false; + } + + if (zend_ast_is_decl(ast)) { + /* Closure declarations in constant expressions are compiled to + * ZEND_AST_OP_ARRAY nodes, so declarations cannot appear here. */ + ZEND_UNREACHABLE(); + return false; + } + + uint32_t children = zend_ast_get_num_children(ast); + for (uint32_t i = 0; i < children; i++) { + if (zend_constexpr_closure_walk_ast(w, ast->child[i])) { + return true; + } + } + return false; +} + +static bool zend_constexpr_closure_walk_zval(zend_constexpr_closure_walk *w, zval *zv) +{ + if (Z_TYPE_P(zv) == IS_CONSTANT_AST) { + return zend_constexpr_closure_walk_ast(w, Z_ASTVAL_P(zv)); + } + return false; +} + +static bool zend_constexpr_closure_walk_attributes(zend_constexpr_closure_walk *w, HashTable *attributes) +{ + if (!attributes) { + return false; + } + + /* Attribute lists are always packed (c.f. zend_get_attribute()). */ + ZEND_HASH_PACKED_FOREACH_PTR(attributes, zend_attribute *attr) { + for (uint32_t i = 0; i < attr->argc; i++) { + if (zend_constexpr_closure_walk_zval(w, &attr->args[i].value)) { + return true; + } + } + } ZEND_HASH_FOREACH_END(); + + return false; +} + +static bool zend_constexpr_closure_walk_op_array(zend_constexpr_closure_walk *w, zend_op_array *op_array) +{ + if (op_array->type != ZEND_USER_FUNCTION) { + return false; + } + + if (zend_constexpr_closure_walk_attributes(w, op_array->attributes)) { + return true; + } + + /* Parameter default values are the op2 of the RECV_INIT for each optional + * parameter, at the head of the op array in parameter order. */ + for (uint32_t i = 0; i < op_array->num_args; i++) { + const zend_op *recv = &op_array->opcodes[i]; + if (recv->opcode == ZEND_RECV_INIT + && zend_constexpr_closure_walk_zval(w, RT_CONSTANT(recv, recv->op2))) { + return true; + } + } + + for (uint32_t i = 0; i < op_array->num_dynamic_func_defs; i++) { + if (zend_constexpr_closure_walk_op_array(w, op_array->dynamic_func_defs[i])) { + return true; + } + } + + return false; +} + +/* Enter a reflection element: record its (kind, borrowed name, hook) descriptor + * and restart the local rank. No allocation for elements merely passed through; + * the "@" string is built once from found_* on a match. */ +#define WALK_ELEMENT(kind_, name_, hook_) \ + (w->cur_kind = (kind_), w->cur_name = (name_), w->cur_hook = (hook_), w->next_id = 0) + +static bool zend_constexpr_closure_walk_class(zend_constexpr_closure_walk *w, zend_class_entry *ce) +{ + zend_string *cname, *pname; + zend_class_constant *c; + zend_property_info *prop_info; + zend_function *func; + + if (ce->type != ZEND_USER_CLASS) { + return false; + } + + WALK_ELEMENT(ZEND_CEXPR_SITE_CLASS, NULL, 0); + if (zend_constexpr_closure_walk_attributes(w, ce->attributes)) { + return true; + } + + ZEND_HASH_MAP_FOREACH_STR_KEY_PTR(&ce->constants_table, cname, c) { + WALK_ELEMENT(ZEND_CEXPR_SITE_CONST, cname, 0); + if (zend_constexpr_closure_walk_attributes(w, c->attributes)) { + return true; + } + } ZEND_HASH_FOREACH_END(); + + ZEND_HASH_MAP_FOREACH_STR_KEY_PTR(&ce->properties_info, pname, prop_info) { + WALK_ELEMENT(ZEND_CEXPR_SITE_PROP, pname, 0); + if (zend_constexpr_closure_walk_attributes(w, prop_info->attributes)) { + return true; + } + if (prop_info->hooks) { + for (uint32_t i = 0; i < ZEND_PROPERTY_HOOK_COUNT; i++) { + if (prop_info->hooks[i]) { + WALK_ELEMENT(ZEND_CEXPR_SITE_HOOK, pname, (uint8_t) i); + if (zend_constexpr_closure_walk_op_array(w, &prop_info->hooks[i]->op_array)) { + return true; + } + } + } + } + } ZEND_HASH_FOREACH_END(); + + ZEND_HASH_MAP_FOREACH_PTR(&ce->function_table, func) { + WALK_ELEMENT(ZEND_CEXPR_SITE_METHOD, func->common.function_name, 0); + if (zend_constexpr_closure_walk_op_array(w, &func->op_array)) { + return true; + } + } ZEND_HASH_FOREACH_END(); + + return false; +} + +#undef WALK_ELEMENT + +/* Materialize the opaque "@" reference from a found descriptor. + * The key is the local rank for an anonymous closure, and for a first-class + * callable the "Called::method" / "function" name key the walk searched for. */ +static zend_string *zend_constexpr_closure_build_ref(const zend_constexpr_closure_walk *w) +{ + zend_string *key, *ref; + + if (w->found->kind == ZEND_AST_OP_ARRAY) { + /* The rank is suffixed with the closure's code hash, so that both + * unserialize() and Closure::fromConstExpr() can verify that the rank + * still names the same code. */ + key = zend_strpprintf(0, "%u#%08x", (unsigned) w->found_id, + (unsigned) zend_ast_get_op_array(w->found)->code_hash); + } else { + key = zend_string_init(w->target_key, w->target_key_len, 0); + } + + switch (w->found_kind) { + case ZEND_CEXPR_SITE_CONST: + ref = zend_strpprintf(0, "%s@%s", ZSTR_VAL(w->found_name), ZSTR_VAL(key)); + break; + case ZEND_CEXPR_SITE_PROP: + ref = zend_strpprintf(0, "$%s@%s", ZSTR_VAL(w->found_name), ZSTR_VAL(key)); + break; + case ZEND_CEXPR_SITE_HOOK: + ref = zend_strpprintf(0, "$%s::%s()@%s", ZSTR_VAL(w->found_name), + w->found_hook == ZEND_PROPERTY_HOOK_GET ? "get" : "set", ZSTR_VAL(key)); + break; + case ZEND_CEXPR_SITE_METHOD: + ref = zend_strpprintf(0, "%s()@%s", ZSTR_VAL(w->found_name), ZSTR_VAL(key)); + break; + case ZEND_CEXPR_SITE_CLASS: + ref = zend_strpprintf(0, "@%s", ZSTR_VAL(key)); + break; + default: + ZEND_UNREACHABLE(); + } + + zend_string_release_ex(key, 0); + return ref; +} + +/* Resolve (element site, local rank) to a declaration-site AST: locate the one + * named element by a direct hash lookup, then walk only it. This is where + * element-scoping pays off on decode: instead of a class-global scan to the + * Nth site, resolution is bounded by a single element's const-expr surface. */ +static zend_ast *zend_constexpr_closure_site_by_element( + zend_class_entry *ce, const char *site, size_t site_len, + uint32_t rank, const char *key, size_t key_len) +{ + zend_constexpr_closure_walk w = {0}; + + if (ce->type != ZEND_USER_CLASS) { + return NULL; + } + + if (key) { + w.target_key = key; + w.target_key_len = key_len; + } else { + w.by_id = true; + w.target_id = rank; + } + w.site_class = ce; + + if (site_len == 0) { + /* class attributes */ + zend_constexpr_closure_walk_attributes(&w, ce->attributes); + return w.found; + } + + if (site[0] == '$') { + /* property "$name" or hook "$name::get()" / "$name::set()" */ + const char *sep = NULL; + for (size_t i = 1; i + 1 < site_len; i++) { + if (site[i] == ':' && site[i + 1] == ':') { + sep = site + i; + break; + } + } + size_t name_len = (sep ? (size_t) (sep - site) : site_len) - 1; + zend_property_info *prop = zend_hash_str_find_ptr(&ce->properties_info, site + 1, name_len); + if (!prop) { + return NULL; + } + if (!sep) { + zend_constexpr_closure_walk_attributes(&w, prop->attributes); + return w.found; + } + const char *spec = sep + 2; + size_t spec_len = site_len - (spec - site); + uint32_t hook_kind; + if (spec_len == 5 && memcmp(spec, "get()", 5) == 0) { + hook_kind = ZEND_PROPERTY_HOOK_GET; + } else if (spec_len == 5 && memcmp(spec, "set()", 5) == 0) { + hook_kind = ZEND_PROPERTY_HOOK_SET; + } else { + return NULL; + } + if (!prop->hooks || !prop->hooks[hook_kind] + || prop->hooks[hook_kind]->type != ZEND_USER_FUNCTION) { + return NULL; + } + zend_constexpr_closure_walk_op_array(&w, &prop->hooks[hook_kind]->op_array); + return w.found; + } + + if (site_len >= 2 && site[site_len - 2] == '(' && site[site_len - 1] == ')') { + /* method "name()" */ + zend_function *func = zend_hash_str_find_ptr_lc(&ce->function_table, site, site_len - 2); + if (!func || func->type != ZEND_USER_FUNCTION) { + return NULL; + } + zend_constexpr_closure_walk_op_array(&w, &func->op_array); + return w.found; + } + + /* class constant / enum case "NAME" */ + zend_class_constant *c = zend_hash_str_find_ptr(&ce->constants_table, site, site_len); + if (!c) { + return NULL; + } + zend_constexpr_closure_walk_attributes(&w, c->attributes); + return w.found; +} + +/* Parse the opaque "@[#]" reference and resolve it. An + * all-digits key is the local rank of an anonymous closure; anything else is + * the name of a first-class callable ("Called::method" or "function"; names + * cannot start with a digit, so the two forms cannot collide). A rank may be + * suffixed with a "#" of the closure's code; *verify_hash receives it, + * or 0 when absent (a producer that cannot compute it may omit it, trading + * verification for positional resolution). Name keys carry no hash. */ +static zend_ast *zend_constexpr_closure_site_by_ref(zend_class_entry *ce, zend_string *id, uint32_t *verify_hash) +{ + const char *s = ZSTR_VAL(id); + size_t len = ZSTR_LEN(id); + + *verify_hash = 0; + const char *sharp = zend_memrchr(s, '#', len); + if (sharp) { + if (len - (sharp - s) - 1 != 8) { + return NULL; + } + uint32_t h = 0; + for (size_t i = 1; i <= 8; i++) { + char c = sharp[i]; + if (c >= '0' && c <= '9') { + h = h << 4 | (c - '0'); + } else if (c >= 'a' && c <= 'f') { + h = h << 4 | (c - 'a' + 10); + } else { + return NULL; + } + } + if (!h) { + /* The engine never produces a zero hash. */ + return NULL; + } + *verify_hash = h; + len = sharp - s; + } + + const char *at = zend_memrchr(s, '@', len); + if (!at) { + return NULL; + } + size_t site_len = at - s; + const char *key = at + 1; + size_t key_len = len - site_len - 1; + if (key_len == 0) { + return NULL; + } + + bool is_rank = true; + uint64_t rank = 0; + for (size_t i = 0; i < key_len; i++) { + if (key[i] < '0' || key[i] > '9') { + is_rank = false; + break; + } + rank = rank * 10 + (key[i] - '0'); + if (rank > UINT32_MAX) { + return NULL; + } + } + if (is_rank) { + if (key_len > 1 && key[0] == '0') { + return NULL; + } + return zend_constexpr_closure_site_by_element(ce, s, site_len, (uint32_t) rank, NULL, 0); + } + + if (*verify_hash) { + /* Name keys cannot drift and carry no hash. */ + return NULL; + } + + return zend_constexpr_closure_site_by_element(ce, s, site_len, 0, key, key_len); +} + +/* Instantiate the closure a declaration site declares, exactly as evaluating + * its constant expression would: an anonymous closure from its op_array, a + * first-class callable by re-evaluating the reference in the scope of the + * declaring class, re-running its visibility checks. */ +static zend_result zend_constexpr_closure_instantiate(zend_ast *site, zend_class_entry *ce, zval *result) +{ + if (site->kind == ZEND_AST_OP_ARRAY) { + zend_create_closure(result, (zend_function *) zend_ast_get_op_array(site)->op_array, ce, ce, NULL); + return SUCCESS; + } + return zend_ast_evaluate(result, site, ce); +} + +ZEND_API zend_result zend_constexpr_closure_ref(zend_object *closure_obj, zend_class_entry **ce, zend_string **id) +{ + const zend_closure *closure = (const zend_closure *) closure_obj; + zend_constexpr_closure_walk w = {0}; + zend_class_entry *site_class; + zend_string *key = NULL; + + if (!(closure->func.common.fn_flags2 & ZEND_ACC2_CONSTEXPR_CLOSURE)) { + return FAILURE; + } + + if (!(closure->func.common.fn_flags & ZEND_ACC_FAKE_CLOSURE)) { + /* Anonymous closure declared in a constant expression: identified by + * its op_array, found in the class it is scoped to. */ + if (closure->func.type != ZEND_USER_FUNCTION) { + return FAILURE; + } + site_class = closure->func.common.scope; + w.opcodes = closure->func.op_array.opcodes; + } else { + /* First-class callable evaluated in a constant expression: identified + * by the name key its reference serializes to, matched with the same + * comparison resolution uses, so the closure is serializable exactly + * when its reference resolves. */ + if (Z_TYPE(closure->this_ptr) != IS_UNDEF) { + return FAILURE; + } + site_class = closure->constexpr_site; + if (closure->func.common.scope && closure->called_scope) { + zend_string *called = closure->called_scope->name; + zend_string *method = closure->func.common.function_name; + key = zend_string_concat3( + ZSTR_VAL(called), ZSTR_LEN(called), "::", 2, ZSTR_VAL(method), ZSTR_LEN(method)); + } else if (!closure->func.common.scope) { + key = zend_string_copy(closure->func.common.function_name); + } else { + return FAILURE; + } + w.target_key = ZSTR_VAL(key); + w.target_key_len = ZSTR_LEN(key); + } + + w.site_class = site_class; + + if (!site_class || site_class->type != ZEND_USER_CLASS || (site_class->ce_flags & ZEND_ACC_ANON_CLASS) + || !zend_constexpr_closure_walk_class(&w, site_class)) { + if (key) { + zend_string_release_ex(key, 0); + } + return FAILURE; + } + + *ce = site_class; + /* Opaque element-scoped reference "@[#]": site names the + * declaring reflection element; the key is a local rank, suffixed with a + * non-zero hash of the closure's code so that the reference cannot + * silently resolve to a different closure after the element's declarations + * were reordered, or a callable name, which cannot drift and carries no + * hash. Callers treat the whole string as an opaque token (pass it to + * Closure::fromConstExpr); the grammar is an engine detail. */ + *id = zend_constexpr_closure_build_ref(&w); + if (key) { + zend_string_release_ex(key, 0); + } + return SUCCESS; +} + +ZEND_API void zend_closure_mark_as_constexpr_fcc(zval *closure_zv, zend_class_entry *site_class) +{ + zend_closure *closure = (zend_closure *) Z_OBJ_P(closure_zv); + + ZEND_ASSERT(closure->func.common.fn_flags & ZEND_ACC_FAKE_CLOSURE); + closure->func.common.fn_flags2 |= ZEND_ACC2_CONSTEXPR_CLOSURE; + closure->constexpr_site = site_class; +} + +/* Serialize a closure as a reference to the declaration site of the + constant expression that declared it: either an anonymous closure + declaration, or a first-class callable reference */ +ZEND_METHOD(Closure, __serialize) +{ + zend_class_entry *ce; + zend_string *id; + zval payload, tagged; + + ZEND_PARSE_PARAMETERS_NONE(); + + if (zend_constexpr_closure_ref(Z_OBJ_P(ZEND_THIS), &ce, &id) == FAILURE) { + /* Only closures declared in a constant expression are serializable + * (handled above); every other closure stays non-serializable, with + * the same message as before this feature existed. */ + zend_throw_exception(NULL, "Serialization of 'Closure' is not allowed", 0); + RETURN_THROWS(); + } + + /* Tagged-union payload: [ , [ , ] ]. + * A Closure has no properties, so the first slot is an empty array; the + * tag names the reference kind so the format can grow new kinds without + * ambiguity. The "const-expr" payload is [class, id], the id carrying its + * own verification hash when the reference is positional. */ + array_init_size(&payload, 2); + add_next_index_str(&payload, zend_string_copy(ce->name)); + add_next_index_str(&payload, id); + + array_init_size(&tagged, 2); + add_next_index_string(&tagged, "const-expr"); + add_next_index_zval(&tagged, &payload); + + array_init_size(return_value, 2); + add_next_index_array(return_value, zend_new_array(0)); + add_next_index_zval(return_value, &tagged); +} + +/* Recreate a closure from a reference to its declaration site */ +ZEND_METHOD(Closure, __unserialize) +{ + zend_closure *closure = (zend_closure *) Z_OBJ_P(ZEND_THIS); + HashTable *data; + zval *z_class, *z_id; + zend_class_entry *ce; + zend_ast *site; + uint32_t verify_hash; + + ZEND_PARSE_PARAMETERS_START(1, 1) + Z_PARAM_ARRAY_HT(data) + ZEND_PARSE_PARAMETERS_END(); + + if (closure->func.type != ZEND_INTERNAL_FUNCTION + || closure->func.internal_function.handler != zend_closure_uninitialized_handler) { + zend_throw_exception(NULL, "Cannot unserialize an already initialized Closure", 0); + RETURN_THROWS(); + } + + /* Tagged-union payload: [ , [ , ] ]. + * Only the "const-expr" tag is known; an unknown tag from a future format + * is rejected cleanly rather than misread. */ + zval *z_props, *z_tagged, *z_tag, *z_payload; + HashTable *tagged, *payload; + + z_props = zend_hash_index_find(data, 0); + z_tagged = zend_hash_index_find(data, 1); + if (zend_hash_num_elements(data) != 2 || !z_props || !z_tagged) { + zend_throw_exception(NULL, "Invalid serialization data for Closure object", 0); + RETURN_THROWS(); + } + ZVAL_DEREF(z_tagged); + if (Z_TYPE_P(z_tagged) != IS_ARRAY) { + zend_throw_exception(NULL, "Invalid serialization data for Closure object", 0); + RETURN_THROWS(); + } + tagged = Z_ARRVAL_P(z_tagged); + z_tag = zend_hash_index_find(tagged, 0); + z_payload = zend_hash_index_find(tagged, 1); + if (zend_hash_num_elements(tagged) != 2 || !z_tag || !z_payload) { + zend_throw_exception(NULL, "Invalid serialization data for Closure object", 0); + RETURN_THROWS(); + } + ZVAL_DEREF(z_tag); + ZVAL_DEREF(z_payload); + if (Z_TYPE_P(z_tag) != IS_STRING || !zend_string_equals_literal(Z_STR_P(z_tag), "const-expr") + || Z_TYPE_P(z_payload) != IS_ARRAY) { + zend_throw_exception(NULL, "Invalid serialization data for Closure object", 0); + RETURN_THROWS(); + } + payload = Z_ARRVAL_P(z_payload); + + z_class = zend_hash_index_find(payload, 0); + z_id = zend_hash_index_find(payload, 1); + if (z_class) { + ZVAL_DEREF(z_class); + } + if (z_id) { + ZVAL_DEREF(z_id); + } + if (zend_hash_num_elements(payload) != 2 + || !z_class || Z_TYPE_P(z_class) != IS_STRING + || !z_id || Z_TYPE_P(z_id) != IS_STRING) { + zend_throw_exception(NULL, "Invalid serialization data for Closure object", 0); + RETURN_THROWS(); + } + + /* Honor unserialize()'s allowed_classes: resolving the reference autoloads + * the named class and pulls a closure out of it, so a class the filter + * excludes must not be reachable through a Closure payload. Outside + * unserialize() (a direct __unserialize() call) the hook allows everything. */ + if (zend_unserialize_class_allowed && !zend_unserialize_class_allowed(Z_STR_P(z_class))) { + zend_throw_exception_ex(NULL, 0, + "Invalid serialization data for Closure object (class \"%s\" is not allowed)", + Z_STRVAL_P(z_class)); + RETURN_THROWS(); + } + + ce = zend_lookup_class(Z_STR_P(z_class)); + if (!ce || ce->type != ZEND_USER_CLASS) { + if (!EG(exception)) { + zend_throw_exception_ex(NULL, 0, + "Invalid serialization data for Closure object (cannot load class \"%s\")", + Z_STRVAL_P(z_class)); + } + RETURN_THROWS(); + } + + site = zend_constexpr_closure_site_by_ref(ce, Z_STR_P(z_id), &verify_hash); + if (!site) { + zend_throw_exception_ex(NULL, 0, + "Invalid serialization data for Closure object (constant-expression closure \"%s\" of class %s not found)", + Z_STRVAL_P(z_id), ZSTR_VAL(ce->name)); + RETURN_THROWS(); + } + + /* The id of a positional reference embeds a hash of the closure's code + * (the engine always writes one; a producer that cannot compute it may + * omit it): it guards against the rank now naming a different closure, + * e.g. after the element's declarations were reordered. */ + if (verify_hash && verify_hash != zend_ast_get_op_array(site)->code_hash) { + zend_throw_exception_ex(NULL, 0, + "Invalid serialization data for Closure object (constant-expression closure \"%s\" of class %s changed)", + Z_STRVAL_P(z_id), ZSTR_VAL(ce->name)); + RETURN_THROWS(); + } + + zval tmp; + zend_closure *tmp_closure; + + if (zend_constexpr_closure_instantiate(site, ce, &tmp) == FAILURE) { + if (!EG(exception)) { + zend_throw_exception(NULL, "Invalid serialization data for Closure object", 0); + } + RETURN_THROWS(); + } + + ZEND_ASSERT(Z_TYPE(tmp) == IS_OBJECT && Z_OBJCE(tmp) == zend_ce_closure); + tmp_closure = (zend_closure *) Z_OBJ(tmp); + + /* Move the resolved closure into the object being unserialized. */ + bool is_fake = (tmp_closure->func.common.fn_flags & ZEND_ACC_FAKE_CLOSURE) != 0; + zend_closure_init_ex(closure, &tmp_closure->func, + tmp_closure->func.common.scope, tmp_closure->called_scope, NULL, is_fake); + closure->constexpr_site = tmp_closure->constexpr_site; + if (is_fake) { + closure->func.common.fn_flags |= ZEND_ACC_FAKE_CLOSURE; + GC_ADD_FLAGS(&closure->std, GC_NOT_COLLECTABLE); + } + + zval_ptr_dtor(&tmp); +} + +/* Recreate a closure declared in a constant expression of the given class */ +ZEND_METHOD(Closure, fromConstExpr) +{ + zend_string *class_name; + zend_string *id; + zend_class_entry *ce; + zend_ast *site; + + ZEND_PARSE_PARAMETERS_START(2, 2) + Z_PARAM_STR(class_name) + Z_PARAM_STR(id) + ZEND_PARSE_PARAMETERS_END(); + + ce = zend_lookup_class(class_name); + if (!ce) { + if (!EG(exception)) { + zend_throw_error(NULL, "Class \"%s\" not found", ZSTR_VAL(class_name)); + } + RETURN_THROWS(); + } + + uint32_t verify_hash; + site = zend_constexpr_closure_site_by_ref(ce, id, &verify_hash); + if (!site) { + zend_value_error("Closure::fromConstExpr(): Argument #2 ($id) does not refer to a constant-expression closure of class %s", ZSTR_VAL(ce->name)); + RETURN_THROWS(); + } + + /* Ids embed a hash of the closure's code; generated code (e.g. exported + * cache files) gets the same staleness protection as serialized payloads. */ + if (verify_hash && verify_hash != zend_ast_get_op_array(site)->code_hash) { + zend_value_error("Closure::fromConstExpr(): Argument #2 ($id) refers to a constant-expression closure of class %s that has changed", ZSTR_VAL(ce->name)); + RETURN_THROWS(); + } + + if (zend_constexpr_closure_instantiate(site, ce, return_value) == FAILURE) { + if (!EG(exception)) { + zend_value_error("Closure::fromConstExpr(): Argument #2 ($id) does not refer to a constant-expression closure of class %s", ZSTR_VAL(ce->name)); + } + RETURN_THROWS(); + } +} + static ZEND_COLD zend_function *zend_closure_get_constructor(zend_object *object) /* {{{ */ { zend_throw_error(NULL, "Instantiation of class Closure is not allowed"); @@ -570,6 +1417,12 @@ static void zend_closure_free_storage(zend_object *object) /* {{{ */ } /* }}} */ +static ZEND_COLD ZEND_NAMED_FUNCTION(zend_closure_uninitialized_handler) /* {{{ */ +{ + zend_throw_error(NULL, "Cannot call an uninitialized Closure"); +} +/* }}} */ + static zend_object *zend_closure_new(zend_class_entry *class_type) /* {{{ */ { zend_closure *closure; @@ -579,6 +1432,17 @@ static zend_object *zend_closure_new(zend_class_entry *class_type) /* {{{ */ zend_object_std_init(&closure->std, class_type); + /* Initialize the function as a safe placeholder. Until the closure is + * actually initialized through zend_closure_init_ex() it may be observed + * by userland code (e.g. while delayed __unserialize() calls are still + * pending) and must not crash any object handler. The placeholder mimics + * a fake internal closure with a handler that always throws. */ + closure->func.type = ZEND_INTERNAL_FUNCTION; + closure->func.common.fn_flags = ZEND_ACC_PUBLIC | ZEND_ACC_CLOSURE | ZEND_ACC_FAKE_CLOSURE; + closure->func.common.function_name = ZSTR_EMPTY_ALLOC(); + closure->func.internal_function.handler = zend_closure_uninitialized_handler; + closure->orig_internal_handler = zend_closure_uninitialized_handler; + return (zend_object*)closure; } /* }}} */ @@ -590,6 +1454,7 @@ static zend_object *zend_closure_clone(zend_object *zobject) /* {{{ */ zend_create_closure(&result, &closure->func, closure->func.common.scope, closure->called_scope, &closure->this_ptr); + ((zend_closure *) Z_OBJ(result))->constexpr_site = closure->constexpr_site; return Z_OBJ(result); } /* }}} */ @@ -757,15 +1622,10 @@ static ZEND_NAMED_FUNCTION(zend_closure_internal_handler) /* {{{ */ } /* }}} */ -static void zend_create_closure_ex(zval *res, zend_function *func, zend_class_entry *scope, zend_class_entry *called_scope, zval *this_ptr, bool is_fake) /* {{{ */ +static void zend_closure_init_ex(zend_closure *closure, zend_function *func, zend_class_entry *scope, zend_class_entry *called_scope, zval *this_ptr, bool is_fake) /* {{{ */ { - zend_closure *closure; void *ptr; - object_init_ex(res, zend_ce_closure); - - closure = (zend_closure *)Z_OBJ_P(res); - if ((scope == NULL) && this_ptr && (Z_TYPE_P(this_ptr) != IS_UNDEF)) { /* use dummy scope if we're binding an object without specifying a scope */ /* maybe it would be better to create one for this purpose */ @@ -859,6 +1719,13 @@ static void zend_create_closure_ex(zval *res, zend_function *func, zend_class_en } /* }}} */ +static void zend_create_closure_ex(zval *res, zend_function *func, zend_class_entry *scope, zend_class_entry *called_scope, zval *this_ptr, bool is_fake) /* {{{ */ +{ + object_init_ex(res, zend_ce_closure); + zend_closure_init_ex((zend_closure *) Z_OBJ_P(res), func, scope, called_scope, this_ptr, is_fake); +} +/* }}} */ + ZEND_API void zend_create_closure(zval *res, zend_function *func, zend_class_entry *scope, zend_class_entry *called_scope, zval *this_ptr) { zend_create_closure_ex(res, func, scope, called_scope, this_ptr, diff --git a/Zend/zend_closures.h b/Zend/zend_closures.h index 2ff4934f2a3a..9ec594dad4ed 100644 --- a/Zend/zend_closures.h +++ b/Zend/zend_closures.h @@ -40,6 +40,18 @@ ZEND_API zend_function *zend_get_closure_invoke_method(zend_object *obj); ZEND_API const zend_function *zend_get_closure_method_def(zend_object *obj); ZEND_API zval* zend_get_closure_this_ptr(zval *obj); +/* Closures declared in constant expressions (anonymous declarations as well + * as first-class callable references) are addressable by an element-scoped + * reference: an opaque "@[#]" string where names the + * declaring reflection element and is the closure's rank within that + * element (for an anonymous closure, then suffixed with a hash of the + * closure's code as a staleness check) or the referenced callable's name (for + * a first-class callable; names cannot drift and carry no hash). + * zend_constexpr_closure_ref() returns the reference (a fresh zend_string the + * caller owns) for a Closure object, when it has one. */ +ZEND_API zend_result zend_constexpr_closure_ref(zend_object *closure_obj, zend_class_entry **ce, zend_string **id); +ZEND_API void zend_closure_mark_as_constexpr_fcc(zval *closure_zv, zend_class_entry *site_class); + END_EXTERN_C() #endif diff --git a/Zend/zend_closures.stub.php b/Zend/zend_closures.stub.php index 46b51617eef9..88800e532f68 100644 --- a/Zend/zend_closures.stub.php +++ b/Zend/zend_closures.stub.php @@ -4,7 +4,6 @@ /** * @strict-properties - * @not-serializable */ final class Closure { @@ -22,5 +21,11 @@ public function call(object $newThis, mixed ...$args): mixed {} public static function fromCallable(callable $callback): Closure {} + public static function fromConstExpr(string $class, string $id): Closure {} + public static function getCurrent(): Closure {} + + public function __serialize(): array {} + + public function __unserialize(array $data): void {} } diff --git a/Zend/zend_closures_arginfo.h b/Zend/zend_closures_arginfo.h index 5bc983a97c2c..bbd2fe37305e 100644 --- a/Zend/zend_closures_arginfo.h +++ b/Zend/zend_closures_arginfo.h @@ -1,5 +1,5 @@ /* This is a generated file, edit zend_closures.stub.php instead. - * Stub hash: e0626e52adb2d38dad1140c1a28cc7774cc84500 */ + * Stub hash: d84d010e45e23e4a61d3b40e795d1075bcbae91c */ ZEND_BEGIN_ARG_INFO_EX(arginfo_class_Closure___construct, 0, 0, 0) ZEND_END_ARG_INFO() @@ -24,15 +24,30 @@ ZEND_BEGIN_ARG_WITH_RETURN_OBJ_INFO_EX(arginfo_class_Closure_fromCallable, 0, 1, ZEND_ARG_TYPE_INFO(0, callback, IS_CALLABLE, 0) ZEND_END_ARG_INFO() +ZEND_BEGIN_ARG_WITH_RETURN_OBJ_INFO_EX(arginfo_class_Closure_fromConstExpr, 0, 2, Closure, 0) + ZEND_ARG_TYPE_INFO(0, class, IS_STRING, 0) + ZEND_ARG_TYPE_INFO(0, id, IS_STRING, 0) +ZEND_END_ARG_INFO() + ZEND_BEGIN_ARG_WITH_RETURN_OBJ_INFO_EX(arginfo_class_Closure_getCurrent, 0, 0, Closure, 0) ZEND_END_ARG_INFO() +ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_Closure___serialize, 0, 0, IS_ARRAY, 0) +ZEND_END_ARG_INFO() + +ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_Closure___unserialize, 0, 1, IS_VOID, 0) + ZEND_ARG_TYPE_INFO(0, data, IS_ARRAY, 0) +ZEND_END_ARG_INFO() + ZEND_METHOD(Closure, __construct); ZEND_METHOD(Closure, bind); ZEND_METHOD(Closure, bindTo); ZEND_METHOD(Closure, call); ZEND_METHOD(Closure, fromCallable); +ZEND_METHOD(Closure, fromConstExpr); ZEND_METHOD(Closure, getCurrent); +ZEND_METHOD(Closure, __serialize); +ZEND_METHOD(Closure, __unserialize); static const zend_function_entry class_Closure_methods[] = { ZEND_ME(Closure, __construct, arginfo_class_Closure___construct, ZEND_ACC_PRIVATE) @@ -40,7 +55,10 @@ static const zend_function_entry class_Closure_methods[] = { ZEND_ME(Closure, bindTo, arginfo_class_Closure_bindTo, ZEND_ACC_PUBLIC) ZEND_ME(Closure, call, arginfo_class_Closure_call, ZEND_ACC_PUBLIC) ZEND_ME(Closure, fromCallable, arginfo_class_Closure_fromCallable, ZEND_ACC_PUBLIC|ZEND_ACC_STATIC) + ZEND_ME(Closure, fromConstExpr, arginfo_class_Closure_fromConstExpr, ZEND_ACC_PUBLIC|ZEND_ACC_STATIC) ZEND_ME(Closure, getCurrent, arginfo_class_Closure_getCurrent, ZEND_ACC_PUBLIC|ZEND_ACC_STATIC) + ZEND_ME(Closure, __serialize, arginfo_class_Closure___serialize, ZEND_ACC_PUBLIC) + ZEND_ME(Closure, __unserialize, arginfo_class_Closure___unserialize, ZEND_ACC_PUBLIC) ZEND_FE_END }; @@ -49,7 +67,7 @@ static zend_class_entry *register_class_Closure(void) zend_class_entry ce, *class_entry; INIT_CLASS_ENTRY(ce, "Closure", class_Closure_methods); - class_entry = zend_register_internal_class_with_flags(&ce, NULL, ZEND_ACC_FINAL|ZEND_ACC_NO_DYNAMIC_PROPERTIES|ZEND_ACC_NOT_SERIALIZABLE); + class_entry = zend_register_internal_class_with_flags(&ce, NULL, ZEND_ACC_FINAL|ZEND_ACC_NO_DYNAMIC_PROPERTIES); return class_entry; } diff --git a/Zend/zend_compile.c b/Zend/zend_compile.c index ac5a9d71a6ea..708ed3bd9f4d 100644 --- a/Zend/zend_compile.c +++ b/Zend/zend_compile.c @@ -11812,11 +11812,27 @@ static void zend_compile_const_expr_closure(zend_ast **ast_ptr) "Cannot use(...) variables in constant expression"); } + /* Serialization verifies a declaration-site reference against a hash of + * the closure's code, so that a reference cannot silently resolve to a + * different closure after the surrounding declarations were reordered. + * Hash the pretty-printed declaration (whitespace- and layout-insensitive) + * before it is compiled, while nested declarations are still source ASTs. + * 0 is reserved for "no hash" (a first-class callable reference carries + * none), so a zero result maps to 1. */ + zend_string *code = zend_ast_export("", (zend_ast *) closure_ast, ""); + uint32_t code_hash = (uint32_t) zend_inline_hash_func(ZSTR_VAL(code), ZSTR_LEN(code)); + zend_string_release_ex(code, 0); + if (code_hash == 0) { + code_hash = 1; + } + znode node; zend_op_array *op = zend_compile_func_decl(&node, (zend_ast*)closure_ast, FUNC_DECL_LEVEL_CONSTEXPR); + op->fn_flags2 |= ZEND_ACC2_CONSTEXPR_CLOSURE; zend_ast_destroy(*ast_ptr); *ast_ptr = zend_ast_create_op_array(op); + zend_ast_get_op_array(*ast_ptr)->code_hash = code_hash; } static void zend_compile_const_expr_fcc(zend_ast **ast_ptr) diff --git a/Zend/zend_compile.h b/Zend/zend_compile.h index 2351882a560d..78068abadd11 100644 --- a/Zend/zend_compile.h +++ b/Zend/zend_compile.h @@ -412,11 +412,18 @@ typedef struct _zend_oparray_context { /* op_array uses strict mode types | | | */ #define ZEND_ACC_STRICT_TYPES (1U << 31) /* | X | | */ /* | | | */ -/* Function Flags 2 (fn_flags2) (unused: 1-31) | | | */ +/* Function Flags 2 (fn_flags2) (unused: 3-31) | | | */ /* ============================ | | | */ /* | | | */ /* Function forbids dynamic calls | | | */ #define ZEND_ACC2_FORBID_DYN_CALLS (1 << 0) /* | X | | */ +/* | | | */ +/* Closure declared in a constant expression: either an | | | */ +/* anonymous closure (on its compiled op_array) or a fake | | | */ +/* closure from a first-class callable evaluated there | | | */ +/* (on the closure's own function copy). The two are told | | | */ +/* apart by ZEND_ACC_FAKE_CLOSURE. | | | */ +#define ZEND_ACC2_CONSTEXPR_CLOSURE (1 << 1) /* | X | | */ #define ZEND_ACC_PPP_MASK (ZEND_ACC_PUBLIC | ZEND_ACC_PROTECTED | ZEND_ACC_PRIVATE) #define ZEND_ACC_PPP_SET_MASK (ZEND_ACC_PUBLIC_SET | ZEND_ACC_PROTECTED_SET | ZEND_ACC_PRIVATE_SET) diff --git a/Zend/zend_execute.h b/Zend/zend_execute.h index ba48b19bcfe1..db4ec620fdf8 100644 --- a/Zend/zend_execute.h +++ b/Zend/zend_execute.h @@ -36,6 +36,13 @@ ZEND_API extern void (*zend_execute_internal)(zend_execute_data *execute_data, z /* The lc_name may be stack allocated! */ ZEND_API extern zend_class_entry *(*zend_autoload)(zend_string *name, zend_string *lc_name); +/* Installed by ext/standard so that a magic __unserialize() which resolves a + * class by name (e.g. Closure) can honor unserialize()'s allowed_classes + * filter. Returns whether class_name may be resolved in the current context: + * always true outside unserialize(), or when no filter restricts classes. + * NULL when ext/standard is not present. */ +ZEND_API extern bool (*zend_unserialize_class_allowed)(zend_string *class_name); + void init_executor(void); void shutdown_executor(void); void shutdown_destructors(void); diff --git a/Zend/zend_execute_API.c b/Zend/zend_execute_API.c index cb48e6234371..452273dcb9ea 100644 --- a/Zend/zend_execute_API.c +++ b/Zend/zend_execute_API.c @@ -51,6 +51,7 @@ ZEND_API void (*zend_execute_ex)(zend_execute_data *execute_data); ZEND_API void (*zend_execute_internal)(zend_execute_data *execute_data, zval *return_value); ZEND_API zend_class_entry *(*zend_autoload)(zend_string *name, zend_string *lc_name); +ZEND_API bool (*zend_unserialize_class_allowed)(zend_string *class_name) = NULL; #ifdef ZEND_WIN32 ZEND_TLS HANDLE tq_timer = NULL; diff --git a/ext/reflection/php_reflection.c b/ext/reflection/php_reflection.c index 13df0b2bbb4d..3b5d24d29784 100644 --- a/ext/reflection/php_reflection.c +++ b/ext/reflection/php_reflection.c @@ -1952,6 +1952,53 @@ ZEND_METHOD(ReflectionFunction, isAnonymous) } /* }}} */ +/* {{{ Returns the declaration-site id of a closure declared in a constant + expression (an anonymous closure or a first-class callable reference), + for use with Closure::fromConstExpr(), or null */ +ZEND_METHOD(ReflectionFunction, getConstExprId) +{ + reflection_object *intern; + zend_class_entry *ce; + zend_string *id; + + ZEND_PARSE_PARAMETERS_NONE(); + + GET_REFLECTION_OBJECT(); + + if (Z_ISUNDEF(intern->obj) + || Z_OBJCE(intern->obj) != zend_ce_closure + || zend_constexpr_closure_ref(Z_OBJ(intern->obj), &ce, &id) == FAILURE) { + RETURN_NULL(); + } + + RETURN_STR(id); +} +/* }}} */ + +/* {{{ Returns the class whose constant expressions declared this closure, + for use with Closure::fromConstExpr() together with getConstExprId(), + or null */ +ZEND_METHOD(ReflectionFunction, getConstExprClass) +{ + reflection_object *intern; + zend_class_entry *ce; + zend_string *id; + + ZEND_PARSE_PARAMETERS_NONE(); + + GET_REFLECTION_OBJECT(); + + if (Z_ISUNDEF(intern->obj) + || Z_OBJCE(intern->obj) != zend_ce_closure + || zend_constexpr_closure_ref(Z_OBJ(intern->obj), &ce, &id) == FAILURE) { + RETURN_NULL(); + } + + zend_string_release(id); + RETURN_STR_COPY(ce->name); +} +/* }}} */ + /* {{{ Returns whether this function has been disabled or not */ ZEND_METHOD(ReflectionFunction, isDisabled) { diff --git a/ext/reflection/php_reflection.stub.php b/ext/reflection/php_reflection.stub.php index dd605100f8ba..b438064b7329 100644 --- a/ext/reflection/php_reflection.stub.php +++ b/ext/reflection/php_reflection.stub.php @@ -128,6 +128,10 @@ public function __toString(): string {} public function isAnonymous(): bool {} + public function getConstExprId(): ?string {} + + public function getConstExprClass(): ?string {} + /** * @tentative-return-type */ diff --git a/ext/reflection/php_reflection_arginfo.h b/ext/reflection/php_reflection_arginfo.h index 65571f38d43c..5d1671b6de08 100644 --- a/ext/reflection/php_reflection_arginfo.h +++ b/ext/reflection/php_reflection_arginfo.h @@ -1,5 +1,5 @@ /* This is a generated file, edit php_reflection.stub.php instead. - * Stub hash: c80946cc8c8215bb6527e09bb71b3a97a76a6a98 + * Stub hash: 6617d3a93a0de137083bf0a8c309da867a34f5c9 * Has decl header: yes */ ZEND_BEGIN_ARG_WITH_TENTATIVE_RETURN_TYPE_INFO_EX(arginfo_class_Reflection_getModifierNames, 0, 1, IS_ARRAY, 0) @@ -96,6 +96,11 @@ ZEND_END_ARG_INFO() #define arginfo_class_ReflectionFunction_isAnonymous arginfo_class_ReflectionFunctionAbstract_hasTentativeReturnType +ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_ReflectionFunction_getConstExprId, 0, 0, IS_STRING, 1) +ZEND_END_ARG_INFO() + +#define arginfo_class_ReflectionFunction_getConstExprClass arginfo_class_ReflectionFunction_getConstExprId + #define arginfo_class_ReflectionFunction_isDisabled arginfo_class_ReflectionFunctionAbstract_inNamespace ZEND_BEGIN_ARG_WITH_TENTATIVE_RETURN_TYPE_INFO_EX(arginfo_class_ReflectionFunction_invoke, 0, 0, IS_MIXED, 0) @@ -698,8 +703,7 @@ ZEND_END_ARG_INFO() ZEND_BEGIN_ARG_WITH_RETURN_OBJ_INFO_EX(arginfo_class_ReflectionFiber_getFiber, 0, 0, Fiber, 0) ZEND_END_ARG_INFO() -ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_ReflectionFiber_getExecutingFile, 0, 0, IS_STRING, 1) -ZEND_END_ARG_INFO() +#define arginfo_class_ReflectionFiber_getExecutingFile arginfo_class_ReflectionFunction_getConstExprId ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_ReflectionFiber_getExecutingLine, 0, 0, IS_LONG, 1) ZEND_END_ARG_INFO() @@ -771,6 +775,8 @@ ZEND_METHOD(ReflectionFunctionAbstract, getAttributes); ZEND_METHOD(ReflectionFunction, __construct); ZEND_METHOD(ReflectionFunction, __toString); ZEND_METHOD(ReflectionFunction, isAnonymous); +ZEND_METHOD(ReflectionFunction, getConstExprId); +ZEND_METHOD(ReflectionFunction, getConstExprClass); ZEND_METHOD(ReflectionFunction, isDisabled); ZEND_METHOD(ReflectionFunction, invoke); ZEND_METHOD(ReflectionFunction, invokeArgs); @@ -1055,6 +1061,8 @@ static const zend_function_entry class_ReflectionFunction_methods[] = { ZEND_ME(ReflectionFunction, __construct, arginfo_class_ReflectionFunction___construct, ZEND_ACC_PUBLIC) ZEND_ME(ReflectionFunction, __toString, arginfo_class_ReflectionFunction___toString, ZEND_ACC_PUBLIC) ZEND_ME(ReflectionFunction, isAnonymous, arginfo_class_ReflectionFunction_isAnonymous, ZEND_ACC_PUBLIC) + ZEND_ME(ReflectionFunction, getConstExprId, arginfo_class_ReflectionFunction_getConstExprId, ZEND_ACC_PUBLIC) + ZEND_ME(ReflectionFunction, getConstExprClass, arginfo_class_ReflectionFunction_getConstExprClass, ZEND_ACC_PUBLIC) ZEND_ME(ReflectionFunction, isDisabled, arginfo_class_ReflectionFunction_isDisabled, ZEND_ACC_PUBLIC|ZEND_ACC_DEPRECATED) ZEND_ME(ReflectionFunction, invoke, arginfo_class_ReflectionFunction_invoke, ZEND_ACC_PUBLIC) ZEND_ME(ReflectionFunction, invokeArgs, arginfo_class_ReflectionFunction_invokeArgs, ZEND_ACC_PUBLIC) diff --git a/ext/reflection/php_reflection_decl.h b/ext/reflection/php_reflection_decl.h index a87e1635419b..d5998220c744 100644 --- a/ext/reflection/php_reflection_decl.h +++ b/ext/reflection/php_reflection_decl.h @@ -1,12 +1,12 @@ /* This is a generated file, edit php_reflection.stub.php instead. - * Stub hash: c80946cc8c8215bb6527e09bb71b3a97a76a6a98 */ + * Stub hash: 6617d3a93a0de137083bf0a8c309da867a34f5c9 */ -#ifndef ZEND_PHP_REFLECTION_DECL_c80946cc8c8215bb6527e09bb71b3a97a76a6a98_H -#define ZEND_PHP_REFLECTION_DECL_c80946cc8c8215bb6527e09bb71b3a97a76a6a98_H +#ifndef ZEND_PHP_REFLECTION_DECL_6617d3a93a0de137083bf0a8c309da867a34f5c9_H +#define ZEND_PHP_REFLECTION_DECL_6617d3a93a0de137083bf0a8c309da867a34f5c9_H typedef enum zend_enum_PropertyHookType { ZEND_ENUM_PropertyHookType_Get = 1, ZEND_ENUM_PropertyHookType_Set = 2, } zend_enum_PropertyHookType; -#endif /* ZEND_PHP_REFLECTION_DECL_c80946cc8c8215bb6527e09bb71b3a97a76a6a98_H */ +#endif /* ZEND_PHP_REFLECTION_DECL_6617d3a93a0de137083bf0a8c309da867a34f5c9_H */ diff --git a/ext/reflection/tests/ReflectionFunction_getConstExprId.phpt b/ext/reflection/tests/ReflectionFunction_getConstExprId.phpt new file mode 100644 index 000000000000..74395473b7a9 --- /dev/null +++ b/ext/reflection/tests/ReflectionFunction_getConstExprId.phpt @@ -0,0 +1,60 @@ +--TEST-- +ReflectionFunction::getConstExprId() and getConstExprClass() +--FILE-- +getAttributes(); + +// Anonymous closure declared in a constant expression: the id embeds a code +// hash, stripped here since its value is not part of the addressing contract. +$r = new ReflectionFunction($attrs[0]->getArguments()[0]); +var_dump(preg_replace('/#[0-9a-f]{8}$/', '', $r->getConstExprId()), $r->getConstExprClass()); + +$recreated = Closure::fromConstExpr($r->getConstExprClass(), $r->getConstExprId()); +var_dump($recreated()); + +// First-class callable reference declared in a constant expression: the +// const-expr class is the declaring site, not the callable's scope, and +// the id is keyed by the callable's name, not by a rank. +$r = new ReflectionFunction($attrs[1]->getArguments()[0]); +var_dump($r->getConstExprId(), $r->getConstExprClass(), $r->getClosureScopeClass()->name); + +$recreated = Closure::fromConstExpr($r->getConstExprClass(), $r->getConstExprId()); +var_dump($recreated()); + +// Null for anything that is not declared in a constant expression +var_dump((new ReflectionFunction(function () {}))->getConstExprId()); +var_dump((new ReflectionFunction(strlen(...)))->getConstExprId()); +var_dump((new ReflectionFunction(Validators::check(...)))->getConstExprId()); +var_dump((new ReflectionFunction('strlen'))->getConstExprId()); +var_dump((new ReflectionFunction('strlen'))->getConstExprClass()); + +?> +--EXPECT-- +string(4) "$p@0" +string(4) "Demo" +string(2) "ok" +string(20) "$p@Validators::check" +string(4) "Demo" +string(10) "Validators" +string(7) "checked" +NULL +NULL +NULL +NULL +NULL diff --git a/ext/standard/var.c b/ext/standard/var.c index 3137a270f661..7e76d702357c 100644 --- a/ext/standard/var.c +++ b/ext/standard/var.c @@ -25,6 +25,7 @@ #include "zend_smart_str.h" #include "basic_functions.h" #include "php_incomplete_class.h" +#include "zend_execute.h" #include "zend_enum.h" #include "zend_exceptions.h" #include "zend_types.h" @@ -1488,17 +1489,32 @@ PHPAPI void php_unserialize_with_options(zval *return_value, const char *buf, co } cleanup: - if (class_hash) { - zend_hash_destroy(class_hash); - FREE_HASHTABLE(class_hash); - } - - /* Reset to previous options in case this is a nested call */ - php_var_unserialize_set_allowed_classes(var_hash, prev_class_hash); + /* Reset to previous options in case this is a nested call. The + * allowed_classes filter is restored (and its table freed) only for a + * genuinely nested call that shares var_hash; for the call that owns + * var_hash it is kept installed across DESTROY, so that the delayed + * __unserialize() hooks run there still observe it (a Closure payload + * resolves its referenced class by name inside __unserialize()), then + * torn down together with var_hash below. */ php_var_unserialize_set_max_depth(var_hash, prev_max_depth); php_var_unserialize_set_cur_depth(var_hash, prev_cur_depth); + + bool owns_var_hash = BG(serialize_lock) || BG(unserialize).level == 1; + if (!owns_var_hash) { + php_var_unserialize_set_allowed_classes(var_hash, prev_class_hash); + if (class_hash) { + zend_hash_destroy(class_hash); + FREE_HASHTABLE(class_hash); + } + } + PHP_VAR_UNSERIALIZE_DESTROY(var_hash); + if (owns_var_hash && class_hash) { + zend_hash_destroy(class_hash); + FREE_HASHTABLE(class_hash); + } + /* Per calling convention we must not return a reference here, so unwrap. We're doing this at * the very end, because __wakeup() calls performed during UNSERIALIZE_DESTROY might affect * the value we unwrap here. This is compatible with behavior in PHP <=7.0. */ @@ -1563,8 +1579,34 @@ PHP_INI_BEGIN() STD_PHP_INI_ENTRY("unserialize_max_depth", "4096", PHP_INI_ALL, OnUpdateLong, unserialize_max_depth, php_basic_globals, basic_globals) PHP_INI_END() +/* Installed as zend_unserialize_class_allowed so the engine can apply the + * innermost active unserialize()'s allowed_classes filter to a class a magic + * __unserialize() resolves by name (e.g. Closure). Mirrors the object path's + * unserialize_allowed_class(): no active unserialize() or no filter allows + * everything; an empty filter allows nothing. */ +static bool php_unserialize_class_allowed(zend_string *class_name) +{ + if (!BG(unserialize).level) { + return true; + } + + HashTable *classes = php_var_unserialize_get_allowed_classes(BG(unserialize).data); + if (classes == NULL) { + return true; + } + if (!zend_hash_num_elements(classes)) { + return false; + } + + zend_string *lcname = zend_string_tolower(class_name); + bool allowed = zend_hash_exists(classes, lcname); + zend_string_release_ex(lcname, 0); + return allowed; +} + PHP_MINIT_FUNCTION(var) { REGISTER_INI_ENTRIES(); + zend_unserialize_class_allowed = php_unserialize_class_allowed; return SUCCESS; }