From ff5f50e6e8cf95caf7add971a8ce39dfff35af6d Mon Sep 17 00:00:00 2001 From: Nicolas Grekas Date: Wed, 10 Jun 2026 18:20:44 +0200 Subject: [PATCH 1/4] Serialize closures declared in constant expressions --- NEWS | 5 + UPGRADING | 11 + .../serialization_allowed_classes.phpt | 47 ++ .../serialization_basic.phpt | 84 +++ .../serialization_graph.phpt | 39 ++ .../serialization_inheritance.phpt | 50 ++ .../serialization_invalid_payload.phpt | 67 +++ .../serialization_misc.phpt | 61 ++ .../serialization_named.phpt | 95 +++ .../serialization_nested.phpt | 66 +++ .../serialization_not_allowed.phpt | 86 +++ Zend/zend_ast.c | 6 + Zend/zend_closures.c | 559 +++++++++++++++++- Zend/zend_closures.h | 11 + Zend/zend_closures.stub.php | 7 +- Zend/zend_closures_arginfo.h | 22 +- Zend/zend_compile.c | 1 + Zend/zend_compile.h | 10 +- ext/reflection/php_reflection.c | 50 ++ ext/reflection/php_reflection.stub.php | 4 + ext/reflection/php_reflection_arginfo.h | 18 +- ext/reflection/php_reflection_decl.h | 8 +- .../ReflectionFunction_getConstExprId.phpt | 58 ++ 23 files changed, 1346 insertions(+), 19 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_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 ext/reflection/tests/ReflectionFunction_getConstExprId.phpt diff --git a/NEWS b/NEWS index 4fbc7e89eb11..adeaf98c29bb 100644 --- a/NEWS +++ b/NEWS @@ -5,6 +5,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 2ec6ce92aa52..a64b1375dcc7 100644 --- a/UPGRADING +++ b/UPGRADING @@ -181,6 +181,17 @@ PHP 8.6 UPGRADE NOTES needing to be present beforehand. . It is now possible to define the `__debugInfo()` magic method on enums. RFC: https://wiki.php.net/rfc/debugable-enums + . Closures declared in constant expressions of a class member (anonymous + closures and first-class callables, of any visibility, in attribute + arguments and parameter default values) can now be serialized and + unserialized. The payload contains no code: it is a reference to the + declaration site (class name, deterministic per-class id, start line), + resolved against the loaded class. Closures created at runtime, bound to + an object, rebound to another scope, or declared in class constant + values, property defaults or outside a class still refuse to serialize. + The unserialize() allowed_classes filter applies to Closure as to any + other class. Added Closure::fromConstExpr($class, $id) plus + ReflectionFunction::getConstExprId()/getConstExprClass() for exporters. - Fileinfo: . finfo_file() now works with remote streams. 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..a3300a8a3d1a --- /dev/null +++ b/Zend/tests/closures/closure_const_expr/serialization_allowed_classes.phpt @@ -0,0 +1,47 @@ +--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); + +// Closure must be explicitly opted in. +$r = unserialize($payloads['fcc site'], ['allowed_classes' => ['Closure']]); +var_dump($r instanceof Closure, $r('test')); + +?> +--EXPECT-- +string(9) "anonymous" +bool(true) +string(8) "fcc site" +bool(true) +bool(true) +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..b8ccaf3d9e4d --- /dev/null +++ b/Zend/tests/closures/closure_const_expr/serialization_basic.phpt @@ -0,0 +1,84 @@ +--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). +$ids = array_map( + static fn ($c) => (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"]=> + int(0) + ["const"]=> + int(1) + ["prop-1"]=> + int(2) + ["prop-2"]=> + int(3) + ["method"]=> + int(4) + ["param"]=> + int(5) + ["case"]=> + int(0) +} 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..7660328435be --- /dev/null +++ b/Zend/tests/closures/closure_const_expr/serialization_invalid_payload.phpt @@ -0,0 +1,67 @@ +--TEST-- +Unserializing invalid or stale Closure declaration-site references +--FILE-- +getAttributes()[0]->getArguments()[0]; +$r = new ReflectionFunction($closure); +$id = $r->getConstExprId(); +$line = $r->getStartLine(); + +$mk = static fn (string $class, int $id, int $line) => sprintf( + 'O:7:"Closure":3:{s:5:"class";s:%d:"%s";s:2:"id";i:%d;s:4:"line";i:%d;}', + strlen($class), $class, $id, $line +); + +// Sanity check: a valid reference works. +var_dump(unserialize($mk('Demo', $id, $line))()); + +$payloads = [ + 'empty data' => 'O:7:"Closure":0:{}', + 'missing keys' => 'O:7:"Closure":1:{s:5:"class";s:4:"Demo";}', + 'wrong types' => 'O:7:"Closure":3:{s:5:"class";s:4:"Demo";s:2:"id";s:1:"0";s:4:"line";i:1;}', + 'unknown class' => $mk('NoSuchClass', $id, $line), + 'internal class' => $mk('stdClass', $id, $line), + 'unknown id' => $mk('Demo', 999, $line), + 'negative id' => $mk('Demo', -1, $line), + 'stale line' => $mk('Demo', $id, $line + 1), +]; + +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(['class' => 'Demo', 'id' => $id, 'line' => $line]); +} catch (Exception $e) { + echo $e->getMessage(), "\n"; +} + +?> +--EXPECT-- +string(2) "ok" +empty data: Invalid serialization data for Closure object +missing keys: Invalid serialization data for Closure object +wrong types: 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 id: Invalid serialization data for Closure object (constant-expression closure 999 of class Demo not found) +negative id: Invalid serialization data for Closure object (constant-expression closure -1 of class Demo not found) +stale line: Invalid serialization data for Closure object (constant-expression closure 0 of class Demo not found) +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..51056621e89d --- /dev/null +++ b/Zend/tests/closures/closure_const_expr/serialization_named.phpt @@ -0,0 +1,95 @@ +--TEST-- +First-class callable references in constant expressions serialize as declaration sites +--FILE-- +getAttributes(); +foreach ($attrs as $i => $attr) { + $closure = $attr->getArguments()[0]; + $payload = serialize($closure); + $u = unserialize($payload); + $args = $i === 4 ? ['abcd'] : []; + // The payload references the declaring class, ids are stable, and the + // recreated closure behaves identically (including static:: binding). + var_dump( + str_contains($payload, '"Demo"') + && $u(...$args) === $closure(...$args) + && serialize($u) === $payload + ); +} + +// 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]))()); + +// 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-based payload format does not exist: only site references resolve. +foreach ([ + 'O:7:"Closure":1:{s:8:"function";s:6:"strlen";}', + 'O:7:"Closure":2:{s:5:"class";s:10:"Validators";s:6:"method";s:5:"check";}', +] as $payload) { + try { + unserialize($payload); + echo "unserialized!?\n"; + } catch (Exception $e) { + echo $e->getMessage(), "\n"; + } +} + +} +?> +--EXPECT-- +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +string(9) "prot-Demo" +string(9) "prot-Base" +Serialization of 'Closure' is not allowed +Serialization of 'Closure' is not allowed +Serialization of 'Closure' is not allowed +Invalid serialization data for Closure object +Invalid serialization data for Closure object 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..9f400137c812 --- /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(), (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" +int(0) +string(7) "default" +int(1) +string(5) "inner" +int(2) +string(5) "outer" +int(0) +string(4) "deep" +int(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/zend_ast.c b/Zend/zend_ast.c index a7e26711cd17..4788a7b69b3d 100644 --- a/Zend/zend_ast.c +++ b/Zend/zend_ast.c @@ -1244,6 +1244,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: diff --git a/Zend/zend_closures.c b/Zend/zend_closures.c index 840d2dbe32e1..b46a309303e7 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 + * (ZEND_ACC2_CONSTEXPR_FCC). */ + 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,524 @@ 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. + */ + +typedef struct { + uint32_t next_id; + /* When by_id is true, search for target_id. Otherwise search for the + * op_array of an anonymous closure identified by its opcodes, or for a + * first-class callable reference matching the fcc closure. */ + bool by_id; + uint32_t target_id; + const zend_op *opcodes; + const zend_closure *fcc; + /* The class being walked; used to resolve self/parent references. */ + zend_class_entry *site_class; + /* 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_closure_walk; + +static bool zend_constexpr_closure_walk_op_array(zend_constexpr_closure_walk *w, zend_op_array *op_array); + +/* Whether the named function resolved from a first-class callable site is the + * one the closure was created from. */ +static bool zend_closure_func_matches(const zend_closure *closure, const zend_function *resolved) +{ + if (resolved->type != closure->func.type) { + return false; + } + if (resolved->type == ZEND_USER_FUNCTION) { + return resolved->op_array.opcodes == closure->func.op_array.opcodes; + } + /* The handler of internal fake closures is wrapped, the original one is + * saved in orig_internal_handler. This also rejects __call/__callstatic + * trampolines, whose handler is not the resolved function. */ + return resolved->internal_function.handler == closure->orig_internal_handler; +} + +/* Whether the first-class callable declared by the given site resolves to the + * function the closure was created from. Resolution is done by name and + * pointer comparisons only: no autoloading, no user code, no exceptions. */ +static bool zend_constexpr_fcc_matches(const zend_constexpr_closure_walk *w, const zend_ast *ast) +{ + const zend_closure *closure = w->fcc; + const zend_function *resolved = NULL; + zend_string *lcname; + + if (!closure) { + return false; + } + + if (ast->kind == ZEND_AST_STATIC_CALL) { + const zend_ast *class_ast = ast->child[0]; + zend_class_entry *ref_ce; + + if (!closure->func.common.scope || !closure->called_scope) { + return false; + } + + switch (class_ast->attr >> ZEND_CONST_EXPR_NEW_FETCH_TYPE_SHIFT) { + case ZEND_FETCH_CLASS_SELF: + ref_ce = w->site_class; + break; + case ZEND_FETCH_CLASS_PARENT: + ref_ce = w->site_class->parent; + break; + default: + ref_ce = zend_string_equals_ci(zend_ast_get_str((zend_ast *) class_ast), closure->called_scope->name) + ? closure->called_scope : NULL; + break; + } + if (!ref_ce || ref_ce != closure->called_scope) { + return false; + } + + lcname = zend_string_tolower(zend_ast_get_str(ast->child[1])); + resolved = zend_hash_find_ptr(&ref_ce->function_table, lcname); + zend_string_release_ex(lcname, 0); + } else { + ZEND_ASSERT(ast->kind == ZEND_AST_CALL); + + if (closure->func.common.scope || closure->called_scope) { + return false; + } + + lcname = zend_string_tolower(zend_ast_get_str(ast->child[0])); + resolved = zend_hash_find_ptr(EG(function_table), lcname); + if (!resolved && ast->child[0]->attr != ZEND_NAME_FQ) { + const char *backslash = zend_memrchr(ZSTR_VAL(lcname), '\\', ZSTR_LEN(lcname)); + if (backslash) { + resolved = zend_hash_str_find_ptr(EG(function_table), + backslash + 1, ZSTR_LEN(lcname) - (backslash - ZSTR_VAL(lcname) + 1)); + } + } + zend_string_release_ex(lcname, 0); + } + + return resolved && zend_closure_func_matches(closure, resolved); +} + +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; + 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. */ + if (args && args->kind == ZEND_AST_CALLABLE_CONVERT) { + uint32_t id = w->next_id++; + + if (w->by_id ? w->target_id == id : zend_constexpr_fcc_matches(w, ast)) { + w->found = ast; + w->found_id = id; + 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) +{ + zend_attribute *attr; + + if (!attributes) { + return false; + } + + ZEND_HASH_FOREACH_PTR(attributes, 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 IS_CONSTANT_AST literals used by RECV_INIT. */ + for (uint32_t i = 0; i < op_array->last_literal; i++) { + if (zend_constexpr_closure_walk_zval(w, &op_array->literals[i])) { + 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; +} + +static bool zend_constexpr_closure_walk_class(zend_constexpr_closure_walk *w, zend_class_entry *ce) +{ + zend_class_constant *c; + zend_property_info *prop_info; + zend_function *func; + + if (ce->type != ZEND_USER_CLASS) { + return false; + } + + if (zend_constexpr_closure_walk_attributes(w, ce->attributes)) { + return true; + } + + ZEND_HASH_MAP_FOREACH_PTR(&ce->constants_table, c) { + if (zend_constexpr_closure_walk_attributes(w, c->attributes)) { + return true; + } + } ZEND_HASH_FOREACH_END(); + + ZEND_HASH_MAP_FOREACH_PTR(&ce->properties_info, prop_info) { + 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] + && 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) { + if (zend_constexpr_closure_walk_op_array(w, &func->op_array)) { + return true; + } + } ZEND_HASH_FOREACH_END(); + + return false; +} + +ZEND_API zend_ast *zend_constexpr_closure_site_by_id(zend_class_entry *ce, zend_long id) +{ + zend_constexpr_closure_walk w; + + if (id < 0 || (zend_ulong) id > UINT32_MAX || ce->type != ZEND_USER_CLASS) { + return NULL; + } + + memset(&w, 0, sizeof(w)); + w.by_id = true; + w.target_id = (uint32_t) id; + w.site_class = ce; + + if (!zend_constexpr_closure_walk_class(&w, ce)) { + return NULL; + } + + return w.found; +} + +static uint32_t zend_constexpr_closure_site_lineno(const zend_ast *site) +{ + if (site->kind == ZEND_AST_OP_ARRAY) { + return zend_ast_get_op_array((zend_ast *) site)->op_array->line_start; + } + return zend_ast_get_lineno((zend_ast *) site); +} + +ZEND_API zend_result zend_constexpr_closure_ref(zend_object *closure_obj, zend_class_entry **ce, uint32_t *id, uint32_t *lineno) +{ + const zend_closure *closure = (const zend_closure *) closure_obj; + zend_constexpr_closure_walk w; + zend_class_entry *site_class; + + memset(&w, 0, sizeof(w)); + + if (closure->func.type == ZEND_USER_FUNCTION + && (closure->func.common.fn_flags2 & ZEND_ACC2_CONSTEXPR_CLOSURE) + && !(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. */ + site_class = closure->func.common.scope; + w.opcodes = closure->func.op_array.opcodes; + } else if ((closure->func.common.fn_flags2 & ZEND_ACC2_CONSTEXPR_FCC) + && (closure->func.common.fn_flags & ZEND_ACC_FAKE_CLOSURE) + && Z_TYPE(closure->this_ptr) == IS_UNDEF) { + /* First-class callable evaluated in a constant expression: identified + * by its target, found in the class that declared the reference. */ + site_class = closure->constexpr_site; + w.fcc = closure; + } else { + return FAILURE; + } + + if (!site_class || site_class->type != ZEND_USER_CLASS || (site_class->ce_flags & ZEND_ACC_ANON_CLASS)) { + return FAILURE; + } + + w.site_class = site_class; + + if (!zend_constexpr_closure_walk_class(&w, site_class)) { + return FAILURE; + } + + *ce = site_class; + *id = w.found_id; + if (lineno) { + *lineno = zend_constexpr_closure_site_lineno(w.found); + } + 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_FCC; + 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; + uint32_t id, lineno; + + ZEND_PARSE_PARAMETERS_NONE(); + + if (zend_constexpr_closure_ref(Z_OBJ_P(ZEND_THIS), &ce, &id, &lineno) == FAILURE) { + zend_throw_exception(NULL, "Serialization of 'Closure' is not allowed", 0); + RETURN_THROWS(); + } + + array_init(return_value); + add_assoc_str(return_value, "class", zend_string_copy(ce->name)); + add_assoc_long(return_value, "id", id); + add_assoc_long(return_value, "line", lineno); +} +/* }}} */ + +/* {{{ 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, *z_line; + zend_class_entry *ce; + zend_ast *site; + + 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(); + } + + z_class = zend_hash_str_find(data, ZEND_STRL("class")); + z_id = zend_hash_str_find(data, ZEND_STRL("id")); + z_line = zend_hash_str_find(data, ZEND_STRL("line")); + if (z_class) { + ZVAL_DEREF(z_class); + } + if (z_id) { + ZVAL_DEREF(z_id); + } + if (z_line) { + ZVAL_DEREF(z_line); + } + if (!z_class || Z_TYPE_P(z_class) != IS_STRING + || !z_id || Z_TYPE_P(z_id) != IS_LONG + || !z_line || Z_TYPE_P(z_line) != IS_LONG) { + zend_throw_exception(NULL, "Invalid serialization data for Closure object", 0); + 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_id(ce, Z_LVAL_P(z_id)); + if (!site || (zend_long) zend_constexpr_closure_site_lineno(site) != Z_LVAL_P(z_line)) { + zend_throw_exception_ex(NULL, 0, + "Invalid serialization data for Closure object (constant-expression closure " ZEND_LONG_FMT " of class %s not found)", + Z_LVAL_P(z_id), ZSTR_VAL(ce->name)); + RETURN_THROWS(); + } + + if (site->kind == ZEND_AST_OP_ARRAY) { + zend_closure_init_ex(closure, + (zend_function *) zend_ast_get_op_array(site)->op_array, ce, ce, NULL, /* is_fake */ false); + } else { + /* First-class callable site: re-evaluate it like attribute evaluation + * would, including its visibility checks against the site class. */ + zval tmp; + zend_closure *tmp_closure; + + if (zend_ast_evaluate(&tmp, site, ce) == 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); + + zend_closure_init_ex(closure, &tmp_closure->func, + tmp_closure->func.common.scope, tmp_closure->called_scope, NULL, /* is_fake */ true); + closure->func.common.fn_flags |= ZEND_ACC_FAKE_CLOSURE; + closure->constexpr_site = tmp_closure->constexpr_site; + 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_long id; + zend_class_entry *ce; + zend_ast *site; + + ZEND_PARSE_PARAMETERS_START(2, 2) + Z_PARAM_STR(class_name) + Z_PARAM_LONG(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(); + } + + site = zend_constexpr_closure_site_by_id(ce, id); + 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(); + } + + if (site->kind == ZEND_AST_OP_ARRAY) { + zend_create_closure(return_value, (zend_function *) zend_ast_get_op_array(site)->op_array, ce, ce, NULL); + } else if (zend_ast_evaluate(return_value, site, ce) == 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 +1097,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 +1112,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 +1134,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 +1302,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 +1399,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..0b97af21f723 100644 --- a/Zend/zend_closures.h +++ b/Zend/zend_closures.h @@ -40,6 +40,17 @@ 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 a canonical + * per-class id (see zend_closures.c for the definition of the walk). + * zend_constexpr_closure_site_by_id() returns the declaration site for an id: + * either a ZEND_AST_OP_ARRAY node or a ZEND_AST_CALL/ZEND_AST_STATIC_CALL + * node in first-class callable form. zend_constexpr_closure_ref() returns the + * reference for a Closure object, when it has one. */ +ZEND_API zend_ast *zend_constexpr_closure_site_by_id(zend_class_entry *ce, zend_long id); +ZEND_API zend_result zend_constexpr_closure_ref(zend_object *closure_obj, zend_class_entry **ce, uint32_t *id, uint32_t *lineno); +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..527d4f82e75b 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, int $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..a9c46e4b0f02 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: 6bae83b479dd62881fe60643e4692e0218d436a5 */ 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_LONG, 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 105f99d24171..87e497246da7 100644 --- a/Zend/zend_compile.c +++ b/Zend/zend_compile.c @@ -11788,6 +11788,7 @@ static void zend_compile_const_expr_closure(zend_ast **ast_ptr) 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); diff --git a/Zend/zend_compile.h b/Zend/zend_compile.h index 0e31332c97f0..7df202da60d3 100644 --- a/Zend/zend_compile.h +++ b/Zend/zend_compile.h @@ -412,11 +412,19 @@ 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 was declared in a constant expression | | | */ +#define ZEND_ACC2_CONSTEXPR_CLOSURE (1 << 1) /* | X | | */ +/* | | | */ +/* Fake closure was created by evaluating a first-class | | | */ +/* callable in a constant expression (set on the | | | */ +/* closure's own function copy, never on the original) | | | */ +#define ZEND_ACC2_CONSTEXPR_FCC (1 << 2) /* | 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/ext/reflection/php_reflection.c b/ext/reflection/php_reflection.c index af565ed53a32..52b7b906258d 100644 --- a/ext/reflection/php_reflection.c +++ b/ext/reflection/php_reflection.c @@ -1963,6 +1963,56 @@ 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_function *fptr; + zend_class_entry *ce; + uint32_t id; + + ZEND_PARSE_PARAMETERS_NONE(); + + GET_REFLECTION_OBJECT_PTR(fptr); + (void) fptr; + + if (Z_ISUNDEF(intern->obj) + || Z_OBJCE(intern->obj) != zend_ce_closure + || zend_constexpr_closure_ref(Z_OBJ(intern->obj), &ce, &id, NULL) == FAILURE) { + RETURN_NULL(); + } + + RETURN_LONG(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_function *fptr; + zend_class_entry *ce; + uint32_t id; + + ZEND_PARSE_PARAMETERS_NONE(); + + GET_REFLECTION_OBJECT_PTR(fptr); + (void) fptr; + + if (Z_ISUNDEF(intern->obj) + || Z_OBJCE(intern->obj) != zend_ce_closure + || zend_constexpr_closure_ref(Z_OBJ(intern->obj), &ce, &id, NULL) == FAILURE) { + RETURN_NULL(); + } + + 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..af6b43f3b4af 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(): ?int {} + + 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..5e986aaa1c4b 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: 3b5292eac2a57e3c46caaba3d93b26cb876894b5 * 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,12 @@ 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_LONG, 1) +ZEND_END_ARG_INFO() + +ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_ReflectionFunction_getConstExprClass, 0, 0, IS_STRING, 1) +ZEND_END_ARG_INFO() + #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,11 +704,9 @@ 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_getConstExprClass -ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_ReflectionFiber_getExecutingLine, 0, 0, IS_LONG, 1) -ZEND_END_ARG_INFO() +#define arginfo_class_ReflectionFiber_getExecutingLine arginfo_class_ReflectionFunction_getConstExprId ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_ReflectionFiber_getCallable, 0, 0, IS_CALLABLE, 0) 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..f56e63826114 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: 3b5292eac2a57e3c46caaba3d93b26cb876894b5 */ -#ifndef ZEND_PHP_REFLECTION_DECL_c80946cc8c8215bb6527e09bb71b3a97a76a6a98_H -#define ZEND_PHP_REFLECTION_DECL_c80946cc8c8215bb6527e09bb71b3a97a76a6a98_H +#ifndef ZEND_PHP_REFLECTION_DECL_3b5292eac2a57e3c46caaba3d93b26cb876894b5_H +#define ZEND_PHP_REFLECTION_DECL_3b5292eac2a57e3c46caaba3d93b26cb876894b5_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_3b5292eac2a57e3c46caaba3d93b26cb876894b5_H */ diff --git a/ext/reflection/tests/ReflectionFunction_getConstExprId.phpt b/ext/reflection/tests/ReflectionFunction_getConstExprId.phpt new file mode 100644 index 000000000000..b89eff350365 --- /dev/null +++ b/ext/reflection/tests/ReflectionFunction_getConstExprId.phpt @@ -0,0 +1,58 @@ +--TEST-- +ReflectionFunction::getConstExprId() and getConstExprClass() +--FILE-- +getAttributes(); + +// Anonymous closure declared in a constant expression +$r = new ReflectionFunction($attrs[0]->getArguments()[0]); +var_dump($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. +$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-- +int(0) +string(4) "Demo" +string(2) "ok" +int(1) +string(4) "Demo" +string(10) "Validators" +string(7) "checked" +NULL +NULL +NULL +NULL +NULL From 4d1fd1fa69fab3528c9202a0e5ea5d82876cd301 Mon Sep 17 00:00:00 2001 From: Nicolas Grekas Date: Thu, 2 Jul 2026 16:19:41 +0200 Subject: [PATCH 2/4] Scope const-expr closure ids to the reflection element The reference returned by ReflectionFunction::getConstExprId() and consumed by Closure::fromConstExpr() was a single per-class rank in a depth-first walk over the whole class, so adding, removing or reordering a closure in one element renumbered the closures of every later element. It is now an opaque "@" string that names the declaring element (the class, a constant, a property, a property hook or a method, parameters folded into their method) and the closure's rank within just that element. References stay valid when unrelated elements of the class change, and resolution locates the one named element instead of scanning the class. getConstExprId() and the id key of the serialize() payload become strings; fromConstExpr()'s $id argument becomes a string. --- UPGRADING | 3 +- .../serialization_basic.phpt | 14 +- .../serialization_invalid_payload.phpt | 18 +- .../serialization_nested.phpt | 10 +- Zend/zend_closures.c | 202 +++++++- Zend/zend_closures.h | 15 +- Zend/zend_closures.stub.php | 2 +- Zend/zend_closures_arginfo.h | 4 +- ext/reflection/php_reflection.c | 7 +- ext/reflection/php_reflection.stub.php | 2 +- ext/reflection/php_reflection_arginfo.h | 12 +- ext/reflection/php_reflection_decl.h | 8 +- .../ReflectionFunction_getConstExprId.phpt | 4 +- serializable_closures.docuwiki | 445 ++++++++++++++++++ 14 files changed, 674 insertions(+), 72 deletions(-) create mode 100644 serializable_closures.docuwiki diff --git a/UPGRADING b/UPGRADING index a64b1375dcc7..33e47d43b759 100644 --- a/UPGRADING +++ b/UPGRADING @@ -185,7 +185,8 @@ PHP 8.6 UPGRADE NOTES closures and first-class callables, of any visibility, in attribute arguments and parameter default values) can now be serialized and unserialized. The payload contains no code: it is a reference to the - declaration site (class name, deterministic per-class id, start line), + declaration site (class name, an element-scoped "@" id naming + the declaring element and the closure's rank within it, start line), resolved against the loaded class. Closures created at runtime, bound to an object, rebound to another scope, or declared in class constant values, property defaults or outside a class still refuse to serialize. diff --git a/Zend/tests/closures/closure_const_expr/serialization_basic.phpt b/Zend/tests/closures/closure_const_expr/serialization_basic.phpt index b8ccaf3d9e4d..962d537ec459 100644 --- a/Zend/tests/closures/closure_const_expr/serialization_basic.phpt +++ b/Zend/tests/closures/closure_const_expr/serialization_basic.phpt @@ -68,17 +68,17 @@ bool(true) bool(true) array(7) { ["class"]=> - int(0) + string(2) "@0" ["const"]=> - int(1) + string(5) "FOO@0" ["prop-1"]=> - int(2) + string(7) "$name@0" ["prop-2"]=> - int(3) + string(7) "$name@1" ["method"]=> - int(4) + string(5) "m()@0" ["param"]=> - int(5) + string(5) "m()@1" ["case"]=> - int(0) + string(3) "X@0" } diff --git a/Zend/tests/closures/closure_const_expr/serialization_invalid_payload.phpt b/Zend/tests/closures/closure_const_expr/serialization_invalid_payload.phpt index 7660328435be..d3d5d8616231 100644 --- a/Zend/tests/closures/closure_const_expr/serialization_invalid_payload.phpt +++ b/Zend/tests/closures/closure_const_expr/serialization_invalid_payload.phpt @@ -18,9 +18,9 @@ $r = new ReflectionFunction($closure); $id = $r->getConstExprId(); $line = $r->getStartLine(); -$mk = static fn (string $class, int $id, int $line) => sprintf( - 'O:7:"Closure":3:{s:5:"class";s:%d:"%s";s:2:"id";i:%d;s:4:"line";i:%d;}', - strlen($class), $class, $id, $line +$mk = static fn (string $class, string $id, int $line) => sprintf( + 'O:7:"Closure":3:{s:5:"class";s:%d:"%s";s:2:"id";s:%d:"%s";s:4:"line";i:%d;}', + strlen($class), $class, strlen($id), $id, $line ); // Sanity check: a valid reference works. @@ -29,11 +29,11 @@ var_dump(unserialize($mk('Demo', $id, $line))()); $payloads = [ 'empty data' => 'O:7:"Closure":0:{}', 'missing keys' => 'O:7:"Closure":1:{s:5:"class";s:4:"Demo";}', - 'wrong types' => 'O:7:"Closure":3:{s:5:"class";s:4:"Demo";s:2:"id";s:1:"0";s:4:"line";i:1;}', + 'wrong types' => 'O:7:"Closure":3:{s:5:"class";s:4:"Demo";s:2:"id";i:0;s:4:"line";i:1;}', 'unknown class' => $mk('NoSuchClass', $id, $line), 'internal class' => $mk('stdClass', $id, $line), - 'unknown id' => $mk('Demo', 999, $line), - 'negative id' => $mk('Demo', -1, $line), + 'unknown site' => $mk('Demo', '$nope@0', $line), + 'malformed id' => $mk('Demo', 'no-at-sign', $line), 'stale line' => $mk('Demo', $id, $line + 1), ]; @@ -61,7 +61,7 @@ missing keys: Invalid serialization data for Closure object wrong types: 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 id: Invalid serialization data for Closure object (constant-expression closure 999 of class Demo not found) -negative id: Invalid serialization data for Closure object (constant-expression closure -1 of class Demo not found) -stale line: Invalid serialization data for Closure object (constant-expression closure 0 of class Demo not found) +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) +stale line: Invalid serialization data for Closure object (constant-expression closure "$p@0" of class Demo not found) Cannot unserialize an already initialized Closure diff --git a/Zend/tests/closures/closure_const_expr/serialization_nested.phpt b/Zend/tests/closures/closure_const_expr/serialization_nested.phpt index 9f400137c812..1cf3c8615311 100644 --- a/Zend/tests/closures/closure_const_expr/serialization_nested.phpt +++ b/Zend/tests/closures/closure_const_expr/serialization_nested.phpt @@ -55,12 +55,12 @@ $roundtrip($inner); ?> --EXPECT-- string(8) "get-hook" -int(0) +string(11) "$v::get()@0" string(7) "default" -int(1) +string(15) "withDefault()@0" string(5) "inner" -int(2) +string(15) "makeClosure()@0" string(5) "outer" -int(0) +string(4) "$p@0" string(4) "deep" -int(1) +string(4) "$p@1" diff --git a/Zend/zend_closures.c b/Zend/zend_closures.c index b46a309303e7..2e770a490cb0 100644 --- a/Zend/zend_closures.c +++ b/Zend/zend_closures.c @@ -475,9 +475,15 @@ ZEND_METHOD(Closure, getCurrent) */ typedef struct { + /* 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. Otherwise search for the - * op_array of an anonymous closure identified by its opcodes, or for a + /* When by_id is true, search for target_id (a LOCAL rank; the caller has + * already positioned the walk on a single element). Otherwise search for + * the op_array of an anonymous closure identified by its opcodes, or for a * first-class callable reference matching the fcc closure. */ bool by_id; uint32_t target_id; @@ -485,13 +491,30 @@ typedef struct { const zend_closure *fcc; /* 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. */ + uint8_t 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; + uint8_t found_kind; + zend_string *found_name; + uint8_t found_hook; } zend_constexpr_closure_walk; +/* Reflection-element kinds for the site descriptor. */ +#define ZEND_CEXPR_SITE_CLASS 0 +#define ZEND_CEXPR_SITE_CONST 1 +#define ZEND_CEXPR_SITE_PROP 2 +#define ZEND_CEXPR_SITE_HOOK 3 +#define ZEND_CEXPR_SITE_METHOD 4 + static bool zend_constexpr_closure_walk_op_array(zend_constexpr_closure_walk *w, zend_op_array *op_array); /* Whether the named function resolved from a first-class callable site is the @@ -580,6 +603,9 @@ static bool zend_constexpr_closure_visit_op_array(zend_constexpr_closure_walk *w 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; } @@ -612,6 +638,9 @@ static bool zend_constexpr_closure_walk_ast(zend_constexpr_closure_walk *w, zend if (w->by_id ? w->target_id == id : zend_constexpr_fcc_matches(w, ast)) { 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; } return false; @@ -704,8 +733,15 @@ static bool zend_constexpr_closure_walk_op_array(zend_constexpr_closure_walk *w, 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; @@ -714,31 +750,37 @@ static bool zend_constexpr_closure_walk_class(zend_constexpr_closure_walk *w, ze 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_PTR(&ce->constants_table, c) { + 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_PTR(&ce->properties_info, prop_info) { + 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] - && zend_constexpr_closure_walk_op_array(w, &prop_info->hooks[i]->op_array)) { - return true; + 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; } @@ -747,26 +789,135 @@ static bool zend_constexpr_closure_walk_class(zend_constexpr_closure_walk *w, ze return false; } -ZEND_API zend_ast *zend_constexpr_closure_site_by_id(zend_class_entry *ce, zend_long id) +#undef WALK_ELEMENT + +/* Materialize the opaque "@" reference from a found descriptor. */ +static zend_string *zend_constexpr_closure_build_ref(const zend_constexpr_closure_walk *w) +{ + switch (w->found_kind) { + case ZEND_CEXPR_SITE_CONST: + return zend_strpprintf(0, "%s@%u", ZSTR_VAL(w->found_name), (unsigned) w->found_id); + case ZEND_CEXPR_SITE_PROP: + return zend_strpprintf(0, "$%s@%u", ZSTR_VAL(w->found_name), (unsigned) w->found_id); + case ZEND_CEXPR_SITE_HOOK: + return zend_strpprintf(0, "$%s::%s()@%u", ZSTR_VAL(w->found_name), + w->found_hook == ZEND_PROPERTY_HOOK_GET ? "get" : "set", (unsigned) w->found_id); + case ZEND_CEXPR_SITE_METHOD: + return zend_strpprintf(0, "%s()@%u", ZSTR_VAL(w->found_name), (unsigned) w->found_id); + case ZEND_CEXPR_SITE_CLASS: + default: + return zend_strpprintf(0, "@%u", (unsigned) w->found_id); + } +} + +/* 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) { zend_constexpr_closure_walk w; - if (id < 0 || (zend_ulong) id > UINT32_MAX || ce->type != ZEND_USER_CLASS) { + if (ce->type != ZEND_USER_CLASS) { return NULL; } memset(&w, 0, sizeof(w)); w.by_id = true; - w.target_id = (uint32_t) id; + w.target_id = rank; w.site_class = ce; - if (!zend_constexpr_closure_walk_class(&w, ce)) { - return NULL; + 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. */ +static zend_ast *zend_constexpr_closure_site_by_ref(zend_class_entry *ce, zend_string *id) +{ + const char *s = ZSTR_VAL(id); + size_t len = ZSTR_LEN(id); + const char *at = zend_memrchr(s, '@', len); + if (!at) { + return NULL; + } + size_t site_len = at - s; + const char *rank_s = at + 1; + size_t rank_len = len - site_len - 1; + if (rank_len == 0 || (rank_len > 1 && rank_s[0] == '0')) { + return NULL; + } + uint64_t rank = 0; + for (size_t i = 0; i < rank_len; i++) { + if (rank_s[i] < '0' || rank_s[i] > '9') { + return NULL; + } + rank = rank * 10 + (rank_s[i] - '0'); + if (rank > UINT32_MAX) { + return NULL; + } + } + + return zend_constexpr_closure_site_by_element(ce, s, site_len, (uint32_t) rank); +} + static uint32_t zend_constexpr_closure_site_lineno(const zend_ast *site) { if (site->kind == ZEND_AST_OP_ARRAY) { @@ -775,7 +926,7 @@ static uint32_t zend_constexpr_closure_site_lineno(const zend_ast *site) return zend_ast_get_lineno((zend_ast *) site); } -ZEND_API zend_result zend_constexpr_closure_ref(zend_object *closure_obj, zend_class_entry **ce, uint32_t *id, uint32_t *lineno) +ZEND_API zend_result zend_constexpr_closure_ref(zend_object *closure_obj, zend_class_entry **ce, zend_string **id, uint32_t *lineno) { const zend_closure *closure = (const zend_closure *) closure_obj; zend_constexpr_closure_walk w; @@ -812,7 +963,11 @@ ZEND_API zend_result zend_constexpr_closure_ref(zend_object *closure_obj, zend_c } *ce = site_class; - *id = w.found_id; + /* Opaque element-scoped reference "@": site names the declaring + * reflection element, rank is the closure's position within just that + * element. Callers treat it as an opaque token (pass it to + * Closure::fromConstExpr); the grammar is an engine detail. */ + *id = zend_constexpr_closure_build_ref(&w); if (lineno) { *lineno = zend_constexpr_closure_site_lineno(w.found); } @@ -835,7 +990,8 @@ ZEND_API void zend_closure_mark_as_constexpr_fcc(zval *closure_zv, zend_class_en ZEND_METHOD(Closure, __serialize) { zend_class_entry *ce; - uint32_t id, lineno; + zend_string *id; + uint32_t lineno; ZEND_PARSE_PARAMETERS_NONE(); @@ -846,7 +1002,7 @@ ZEND_METHOD(Closure, __serialize) array_init(return_value); add_assoc_str(return_value, "class", zend_string_copy(ce->name)); - add_assoc_long(return_value, "id", id); + add_assoc_str(return_value, "id", id); add_assoc_long(return_value, "line", lineno); } /* }}} */ @@ -883,7 +1039,7 @@ ZEND_METHOD(Closure, __unserialize) ZVAL_DEREF(z_line); } if (!z_class || Z_TYPE_P(z_class) != IS_STRING - || !z_id || Z_TYPE_P(z_id) != IS_LONG + || !z_id || Z_TYPE_P(z_id) != IS_STRING || !z_line || Z_TYPE_P(z_line) != IS_LONG) { zend_throw_exception(NULL, "Invalid serialization data for Closure object", 0); RETURN_THROWS(); @@ -899,11 +1055,11 @@ ZEND_METHOD(Closure, __unserialize) RETURN_THROWS(); } - site = zend_constexpr_closure_site_by_id(ce, Z_LVAL_P(z_id)); + site = zend_constexpr_closure_site_by_ref(ce, Z_STR_P(z_id)); if (!site || (zend_long) zend_constexpr_closure_site_lineno(site) != Z_LVAL_P(z_line)) { zend_throw_exception_ex(NULL, 0, - "Invalid serialization data for Closure object (constant-expression closure " ZEND_LONG_FMT " of class %s not found)", - Z_LVAL_P(z_id), ZSTR_VAL(ce->name)); + "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(); } @@ -941,13 +1097,13 @@ ZEND_METHOD(Closure, __unserialize) ZEND_METHOD(Closure, fromConstExpr) { zend_string *class_name; - zend_long id; + zend_string *id; zend_class_entry *ce; zend_ast *site; ZEND_PARSE_PARAMETERS_START(2, 2) Z_PARAM_STR(class_name) - Z_PARAM_LONG(id) + Z_PARAM_STR(id) ZEND_PARSE_PARAMETERS_END(); ce = zend_lookup_class(class_name); @@ -958,7 +1114,7 @@ ZEND_METHOD(Closure, fromConstExpr) RETURN_THROWS(); } - site = zend_constexpr_closure_site_by_id(ce, id); + site = zend_constexpr_closure_site_by_ref(ce, id); 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(); diff --git a/Zend/zend_closures.h b/Zend/zend_closures.h index 0b97af21f723..9adc1b9e45f0 100644 --- a/Zend/zend_closures.h +++ b/Zend/zend_closures.h @@ -41,14 +41,13 @@ 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 a canonical - * per-class id (see zend_closures.c for the definition of the walk). - * zend_constexpr_closure_site_by_id() returns the declaration site for an id: - * either a ZEND_AST_OP_ARRAY node or a ZEND_AST_CALL/ZEND_AST_STATIC_CALL - * node in first-class callable form. zend_constexpr_closure_ref() returns the - * reference for a Closure object, when it has one. */ -ZEND_API zend_ast *zend_constexpr_closure_site_by_id(zend_class_entry *ce, zend_long id); -ZEND_API zend_result zend_constexpr_closure_ref(zend_object *closure_obj, zend_class_entry **ce, uint32_t *id, uint32_t *lineno); + * 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 position within just that + * element (see zend_closures.c for the walk). 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, uint32_t *lineno); ZEND_API void zend_closure_mark_as_constexpr_fcc(zval *closure_zv, zend_class_entry *site_class); END_EXTERN_C() diff --git a/Zend/zend_closures.stub.php b/Zend/zend_closures.stub.php index 527d4f82e75b..88800e532f68 100644 --- a/Zend/zend_closures.stub.php +++ b/Zend/zend_closures.stub.php @@ -21,7 +21,7 @@ public function call(object $newThis, mixed ...$args): mixed {} public static function fromCallable(callable $callback): Closure {} - public static function fromConstExpr(string $class, int $id): Closure {} + public static function fromConstExpr(string $class, string $id): Closure {} public static function getCurrent(): Closure {} diff --git a/Zend/zend_closures_arginfo.h b/Zend/zend_closures_arginfo.h index a9c46e4b0f02..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: 6bae83b479dd62881fe60643e4692e0218d436a5 */ + * Stub hash: d84d010e45e23e4a61d3b40e795d1075bcbae91c */ ZEND_BEGIN_ARG_INFO_EX(arginfo_class_Closure___construct, 0, 0, 0) ZEND_END_ARG_INFO() @@ -26,7 +26,7 @@ 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_LONG, 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) diff --git a/ext/reflection/php_reflection.c b/ext/reflection/php_reflection.c index 52b7b906258d..7dafd96560c6 100644 --- a/ext/reflection/php_reflection.c +++ b/ext/reflection/php_reflection.c @@ -1971,7 +1971,7 @@ ZEND_METHOD(ReflectionFunction, getConstExprId) reflection_object *intern; zend_function *fptr; zend_class_entry *ce; - uint32_t id; + zend_string *id; ZEND_PARSE_PARAMETERS_NONE(); @@ -1984,7 +1984,7 @@ ZEND_METHOD(ReflectionFunction, getConstExprId) RETURN_NULL(); } - RETURN_LONG(id); + RETURN_STR(id); } /* }}} */ @@ -1996,7 +1996,7 @@ ZEND_METHOD(ReflectionFunction, getConstExprClass) reflection_object *intern; zend_function *fptr; zend_class_entry *ce; - uint32_t id; + zend_string *id; ZEND_PARSE_PARAMETERS_NONE(); @@ -2009,6 +2009,7 @@ ZEND_METHOD(ReflectionFunction, getConstExprClass) RETURN_NULL(); } + zend_string_release(id); RETURN_STR_COPY(ce->name); } /* }}} */ diff --git a/ext/reflection/php_reflection.stub.php b/ext/reflection/php_reflection.stub.php index af6b43f3b4af..b438064b7329 100644 --- a/ext/reflection/php_reflection.stub.php +++ b/ext/reflection/php_reflection.stub.php @@ -128,7 +128,7 @@ public function __toString(): string {} public function isAnonymous(): bool {} - public function getConstExprId(): ?int {} + public function getConstExprId(): ?string {} public function getConstExprClass(): ?string {} diff --git a/ext/reflection/php_reflection_arginfo.h b/ext/reflection/php_reflection_arginfo.h index 5e986aaa1c4b..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: 3b5292eac2a57e3c46caaba3d93b26cb876894b5 + * 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,11 +96,10 @@ 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_LONG, 1) +ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_ReflectionFunction_getConstExprId, 0, 0, IS_STRING, 1) ZEND_END_ARG_INFO() -ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_ReflectionFunction_getConstExprClass, 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 @@ -704,9 +703,10 @@ ZEND_END_ARG_INFO() ZEND_BEGIN_ARG_WITH_RETURN_OBJ_INFO_EX(arginfo_class_ReflectionFiber_getFiber, 0, 0, Fiber, 0) ZEND_END_ARG_INFO() -#define arginfo_class_ReflectionFiber_getExecutingFile arginfo_class_ReflectionFunction_getConstExprClass +#define arginfo_class_ReflectionFiber_getExecutingFile arginfo_class_ReflectionFunction_getConstExprId -#define arginfo_class_ReflectionFiber_getExecutingLine 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() ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_class_ReflectionFiber_getCallable, 0, 0, IS_CALLABLE, 0) ZEND_END_ARG_INFO() diff --git a/ext/reflection/php_reflection_decl.h b/ext/reflection/php_reflection_decl.h index f56e63826114..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: 3b5292eac2a57e3c46caaba3d93b26cb876894b5 */ + * Stub hash: 6617d3a93a0de137083bf0a8c309da867a34f5c9 */ -#ifndef ZEND_PHP_REFLECTION_DECL_3b5292eac2a57e3c46caaba3d93b26cb876894b5_H -#define ZEND_PHP_REFLECTION_DECL_3b5292eac2a57e3c46caaba3d93b26cb876894b5_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_3b5292eac2a57e3c46caaba3d93b26cb876894b5_H */ +#endif /* ZEND_PHP_REFLECTION_DECL_6617d3a93a0de137083bf0a8c309da867a34f5c9_H */ diff --git a/ext/reflection/tests/ReflectionFunction_getConstExprId.phpt b/ext/reflection/tests/ReflectionFunction_getConstExprId.phpt index b89eff350365..ac0c8084bf99 100644 --- a/ext/reflection/tests/ReflectionFunction_getConstExprId.phpt +++ b/ext/reflection/tests/ReflectionFunction_getConstExprId.phpt @@ -44,10 +44,10 @@ var_dump((new ReflectionFunction('strlen'))->getConstExprClass()); ?> --EXPECT-- -int(0) +string(4) "$p@0" string(4) "Demo" string(2) "ok" -int(1) +string(4) "$p@1" string(4) "Demo" string(10) "Validators" string(7) "checked" diff --git a/serializable_closures.docuwiki b/serializable_closures.docuwiki new file mode 100644 index 000000000000..0bdabb439fea --- /dev/null +++ b/serializable_closures.docuwiki @@ -0,0 +1,445 @@ +====== PHP RFC: Serializable closures, by reference to the code that declares them ====== + + * Version: 0.1 + * Date: 2026-06-10 + * Author: Nicolas Grekas + * Status: Draft + * First Published at: https://wiki.php.net/rfc/serializable_closures + * Mailing List Thread: https://news-web.php.net/php.internals/131198 + +===== Introduction ===== + +Closures have never been serializable in PHP, for good structural reasons: a closure may capture +variables by value or by reference, may be bound to an object, and may be rebound at runtime. A +serialization payload for such a closure would have to embed code and captured state, which is +both semantically murky and a deserialization attack surface. + +PHP 8.5 created a class of closures for which none of those reasons apply. The +[[https://wiki.php.net/rfc/closures_in_const_expr|Closures in constant expressions]] and +[[https://wiki.php.net/rfc/fcc_in_const_expr|First-class callables in constant expressions]] RFCs +allow closures in attribute arguments and other constant expressions, with hard restrictions: +anonymous closures must be ''static'' and cannot ''use()'' any variable, and first-class +callables can only reference named functions and static methods. Such a closure carries +**no state at all**. It is fully described by //where it is declared//, the same way an enum +case is fully described by its name. + +This RFC makes these closures serializable, by storing a **reference to their declaration +site** rather than the code itself. This covers both forms a closure can take in a constant +expression: + + * an anonymous closure declaration: ''static function () { ... }''; + * a first-class callable reference: ''strlen(%%...%%)'', ''self::isStrict(%%...%%)'', ''Validators::check(%%...%%)''. + +''unserialize()'' resolves the reference against the loaded code base and recreates an +equivalent closure, as if the declaring constant expression had just been evaluated again. No +code ever travels in the payload, and a payload cannot reference anything that the reader's own +code base does not already declare. + +Closures that carry state (captured variables, bound ''$this'') and closures created at runtime +(anonymous functions and first-class callables in function bodies) remain non-serializable, with +the exact same error as today. + +===== Problem Statement ===== + +==== Attributes now produce closures, and closures poison caches ==== + +Attributes are PHP's declarative metadata system, and the ecosystem caches derived metadata +aggressively: validator metadata, serializer metadata, DI container definitions, routing tables. +These caches are built once and stored through ''serialize()'' (PSR-6/PSR-16 pools) or through +''var_export()'' (opcache-friendly PHP files). + +Since PHP 8.5, attributes can carry closures, and this is not a corner case: it was the +headline use case of the 8.5 RFCs. The Symfony validator, for instance, accepts exactly this: + + +class Order +{ + #[Assert\When(static function () { return self::$strictMode; }, constraints: [new Assert\NotBlank()])] + public string $billingAddress; +} + + +The metadata derived from this attribute contains the closure. The cache layer calls +''serialize()'' on it, which throws, the framework catches the error, and the metadata is silently +recomputed **on every request**. Nothing fails loudly; the application just gets slower. This is +[[https://github.com/symfony/symfony/issues/63228|symfony#63228]], reported shortly after the +PHP 8.5 release, and the same trap exists for every metadata cache in the ecosystem. + +The language added a feature whose natural habitat is cached metadata, while its values are +hostile to caching. This RFC closes that gap. + +==== Userland cannot fix this well ==== + +Userland has two known workarounds, both with significant drawbacks: + + * **Source extraction** ([[https://github.com/opis/closure|opis/closure]], [[https://github.com/laravel/serializable-closure|laravel/serializable-closure]]): re-read the file, slice the closure's source text, store it in the payload, ''eval()'' it on unserialize. This puts executable code in payloads (an injection surface that both libraries mitigate with signing), drifts from the compiled code when files change, and breaks ''%%__FILE__%%''/''%%__LINE__%%''/''self'' semantics unless carefully patched. + * **Re-evaluation wrappers**: frameworks can wrap each closure in an invokable object that records "attribute X, argument Y of class Z" and re-runs reflection on first use. This works (Symfony can ship it for 8.5) but every framework must build and maintain its own referencing system for what is fundamentally one engine-level concept: //this closure is the one declared right there//. + +The engine is the only layer that can give such references first-class, code-free, validated +semantics, uniformly for the whole ecosystem. + +==== Named callables have the same problem with serialize() ==== + +''#[Assert\When(Foo::isStrict(%%...%%))]'' has the same caching behavior as the inline closure: +''serialize()'' refuses fake closures too. The ''var_export()'' side of this is already solved in +userland (Symfony's VarExporter exports named closures as ''\Foo::isStrict(%%...%%)'', +[[https://github.com/symfony/symfony/pull/61657|symfony#61657]]), which makes the asymmetry +worse: the recommended, "cache-friendly" way of writing callable attributes still breaks every +''serialize()''-based cache. + +===== Proposal ===== + +Closures become serializable when, and only when, they are declared by a class's constant +expressions, in attribute arguments and parameter default values (class constant values and +property defaults are excluded for now: see the Rationale). Two forms of declaration exist. + +==== 1. Anonymous closures ==== + +A closure declared in a constant expression attached to a class member becomes serializable: + + * in attribute arguments, of the class itself, of its constants, properties, property hooks, methods and parameters; + * in parameter default values of its methods; + * including when nested in further closures declared there. + +The payload is a reference to the declaration site, made of the **declaring class name**, a +**stable id**, and the **start line** of the closure (used as a staleness check): + + +$closure = (new ReflectionProperty(Order::class, 'billingAddress')) + ->getAttributes()[0]->getArguments()[0]; + +$payload = serialize($closure); +// O:7:"Closure":3:{s:5:"class";s:5:"Order";s:2:"id";i:0;s:4:"line";i:3;} + +$again = unserialize($payload); +$again(); // behaves exactly like $closure() + + +Unserializing autoloads the class if needed and recreates the closure //as if its constant +expression had just been evaluated//: same code, statically scoped to its declaring class +(''self::'', private member access and ''static::'' behave identically), no bound ''$this'', and +fresh ''static'' variables. It is a new Closure instance, the same way two calls to +ReflectionAttribute::getArguments() return two instances today. + +**The id is deterministic.** It is the closure's rank among all closure-declaring constant +expressions of the class (anonymous declarations and first-class callable references alike), +counted in a fixed declaration-order traversal of the class (class attributes first, then +constants, properties and hooks, then methods with their parameter lists). It is derived from +the compiled class alone, never from runtime state, evaluation order or caches, which is where +its stability comes from: every process running the same source computes the same rank, with or +without opcache. The flip side is that editing the class may renumber the ranks. References are +therefore validated when they resolve (see below) and must be treated like the cache artifacts +they are embedded in: valid for the code revision that produced them, and per PHP version. +Userland should obtain ids from the engine (see the Reflection API below) rather than compute +them. + +When the class's source changes, a stored reference either stops resolving or is rejected by the +line check; both throw an Exception on ''unserialize()'', which cache layers already +treat as a miss. + +==== 2. First-class callable references ==== + +A first-class callable in a constant expression is a closure declaration site like any other: +the class's source declares, at a fixed position, "a closure over this callable". Closures +created by evaluating such a reference serialize with the **same payload** as anonymous +declarations: the declaring class, the id, the start line. The engine tracks this provenance +when it evaluates the constant expression; an identical-looking closure created at runtime does +not have it and refuses to serialize. + + +class Order +{ + #[Assert\When(self::isStrict(...), constraints: [new Assert\NotBlank()])] + public string $billingAddress; + + private static function isStrict(): bool { ... } +} + +$closure = /* from ReflectionAttribute::getArguments() */; +serialize($closure); +// O:7:"Closure":3:{s:5:"class";s:5:"Order";s:2:"id";i:0;s:4:"line";i:3;} + + +Resolution re-evaluates the declared reference in the scope of the declaring class, exactly like +attribute evaluation does. Two important properties follow: + + * **Any visibility works**, because the site's own access rules are simply re-run: a private or protected helper referenced from an attribute of its own class (as above, an idiomatic pattern since validation helpers have no reason to be public) round-trips fine, while a forged payload gains nothing: it can only designate references that the class legitimately declares. + * **Late static binding is preserved**: ''self::'', ''parent::'' and explicit class names resolve exactly as they did when the attribute was evaluated, since it is the same evaluation. + +This works uniformly for functions (''strlen(%%...%%)''), own methods (''self::isStrict(%%...%%)''), +and cross-class references (''Validators::check(%%...%%)''), in attribute arguments and parameter +default values alike. + +==== What stays non-serializable ==== + +Everything below keeps today's behavior, including the exact error +(Exception: ''Serialization of 'Closure' is not allowed''): + +^ Closure kind ^ Why ^ +| Anonymous functions declared in function bodies | No declaration site; may capture variables and ''$this'' | +| Arrow functions | Capture by value implicitly | +| Closures over named callables created at runtime (''strlen(%%...%%)'' / ''Foo::bar(%%...%%)'' in function bodies, Closure::fromCallable(), ReflectionMethod::getClosure()) | No declaration site; serializing them by name would make ''unserialize()'' a closure factory over the whole code base instead of over what classes declare | +| Closures bound to an object (''$obj->method(%%...%%)'', ''Closure::bind()'' with ''$this'') | Carry object state | +| Closures created from ''%%__call()%%''/''%%__callStatic()%%'' trampolines | No real backing method; the engine already rejects them in constant expressions anyway | +| Const-expr closures in **class constant values** and **property default values** | See Future Scope | +| Const-expr closures in attributes of free functions, or of anonymous classes | No autoloadable / stable container name | +| Const-expr closures rebound to a different scope | No longer the declared value | + +Note that the boundary is **declaration, not syntax**: the same expression, anonymous closure or +first-class callable, is serializable when it appears in a constant expression and not when it +appears in a method body, because only the former is a declaration the class makes about itself, +with an identity that survives the process. + +==== Security model ==== + +The payload contains **a class name and two integers, never code**. Resolution can only ever +produce a closure that the named class's own source declares in one of its constant +expressions: there is nothing else a payload can express. ''unserialize()'' is not a closure +factory over the code base; it is a lookup into the fixed, finite set of closures that classes +declare about themselves. + +A forged payload can therefore at worst point at a //different// declared closure, the same +class of risk as unserializing an enum case name or a class name today, and far less than what +''unserialize()'' already allows through ''%%__wakeup()%%'' gadgets. Visibility needs no special +rule: resolving a first-class callable site re-runs the same accessibility checks, in the same +scope, that attribute evaluation runs, so a reference resolves for the reader exactly when the +declaration is legal for the declarer. And the existing +''allowed_classes'' hardening applies unchanged: with +''unserialize($data, ['allowed_classes' => [...]])'' not listing Closure, these +payloads produce __PHP_Incomplete_Class like any other non-listed object, and no +resolution happens at all. + +All malformed, unresolvable or stale payloads throw an Exception with a descriptive +message, for example: + + +Invalid serialization data for Closure object (constant-expression closure 3 of class Order not found) +Invalid serialization data for Closure object (cannot load class "Order") + + +==== Reflection and exporter support ==== + +''var_export()''-based caches (PHP files compiled by opcache) need to //generate code// that +recreates the closure, rather than a binary payload. Three additions support this: + + * ReflectionFunction::getConstExprId(): ?int returns the declaration-site id of a closure declared in a constant expression, or ''null'' for any other closure. + * ReflectionFunction::getConstExprClass(): ?string returns the declaring class. This is a distinct accessor because for first-class callable references no existing one carries it: a ''Validators::check(%%...%%)'' reference declared by ''Order'' has getClosureScopeClass() and getClosureCalledClass() both pointing at ''Validators'', a ''parent::'' reference has both pointing at the parent class, and a function reference (''strlen(%%...%%)'') has neither, while all of them are declared, and serialized, by ''Order''. The existing accessors describe the closure's runtime semantics (visibility scope, ''static::'' binding); getConstExprClass() describes where it was declared. + * Closure::fromConstExpr(string $class, int $id): Closure recreates the closure from such a reference. It throws an Error if the class cannot be loaded and a ValueError if the id does not resolve. + +An exporter then emits self-contained, opcache-friendly code: + + +// generated cache file +return \Closure::fromConstExpr(\App\Order::class, 0); + + +For closures over **public** named callables, exporters can keep emitting plain first-class +callable syntax (''\Order::isStrict(%%...%%)''), as Symfony's VarExporter already does: in +generated code the expression itself is the reference. fromConstExpr() is what makes +the remaining cases exportable, anonymous closures and non-public references, since generated +code runs in global scope and could not name a private helper directly. The identity of any +named closure (its name, scope, called scope, staticness) remains introspectable through +existing reflection regardless of visibility; reflection describes closures already in hand and +is deliberately not restricted. + +==== Behavior details ==== + + * **References track live code**: like a ''\Foo::bar(%%...%%)'' expression in a generated cache file resolving to the current implementation of the method, a declaration-site reference resolves to the closure as **currently** declared at that site. Payloads do not pin a snapshot of the body; deploying a fixed closure body fixes already-cached references to it. + * **Fresh instances**: unserialization creates a new Closure object. Within one ''unserialize()'' call, shared references in the payload graph are preserved as usual (the same closure serialized twice in one graph unserializes as one instance). + * **''static'' variables start fresh**: a reference designates the closure //as declared//, not a snapshot of its runtime state. This matches re-evaluating the attribute, and matches what the 8.5 RFCs specify for repeated evaluation. + * **No change to other serialization surfaces**: ''var_export()'' and ''json_encode()'' behave exactly as before; this RFC only touches ''serialize()''/''unserialize()''. + * Closure now declares __serialize()/__unserialize() as regular (engine-provided) methods; they are visible through reflection like any other method. + +===== Rationale ===== + +==== Why references instead of embedding source code ==== + +The Opis-style alternative (store the closure's source text, compile it on unserialize) was +rejected deliberately: + + * payloads would contain executable code, turning every cache store into an injection surface that needs signing to be safe; + * the embedded source can drift from the deployed code (the cache says one thing, the file says another), whereas a reference always resolves against the **single source of truth**, the loaded class; + * ''%%__FILE__%%'', ''%%__LINE__%%'', ''self'', private-member access and ''static::'' all need careful patching when code is re-compiled out of context; a reference recreates the closure in its original context, so all of these are correct by construction. + +The 8.5 restrictions are precisely what make the reference design possible: since the closure can +capture nothing, the reference loses nothing. + +==== Why first-class callables serialize only when declared in constant expressions ==== + +An earlier draft of this proposal serialized //every// closure over a named function or public +static method by name, so that ''serialize(strlen(%%...%%))'' worked anywhere. That design was +dropped for three reasons: + + * **Needless surface.** The motivating problem is cached metadata derived from constant expressions. Making every named closure serializable turns ''unserialize()'' into a closure factory over every function and public static method of the code base, a much larger capability than the problem requires. With declaration-site references only, a payload can designate nothing beyond the finite set of closures that classes declare about themselves. + * **A visibility dilemma it cannot solve.** Name-based payloads cross trust domains, so they must be restricted to what unscoped code could create: public static methods. But attributes legitimately reference //private// helpers of their own class (''#[When(self::isStrict(%%...%%))]'', where there is no reason to make the helper public), and those would have stayed unserializable, re-creating the very cache trap this RFC exists to fix. Site references dissolve the dilemma: resolution re-evaluates the declaration in its own scope, so visibility is enforced by the same rule that allowed the attribute to compile and evaluate in the first place. + * **One principle, one payload.** Anonymous declarations and callable references get the same format, the same staleness contract, and the same security analysis, instead of two payload kinds with different rules. + +The engine tracks the necessary provenance at no observable cost: closures created while +evaluating a constant expression are marked as such, with the declaring class recorded. A +value-identical first-class callable created in a function body does not carry the mark and +refuses to serialize. This asymmetry is the same one anonymous closures already have (the same +body in a method body refuses too), and it is the point: serializability is a property of the +//declaration//, not of the value. + +Extending name-based serialization to runtime-created closures over public callables would +remain possible later, as a pure widening (see Future Scope); this RFC deliberately does not +include it. + +==== Why class constant values and property defaults are excluded for now ==== + +''const FOO = static function () {...};'' and ''public $cb = Validators::check(%%...%%);'' are +constant expressions too, and conceptually they should qualify; both forms of declaration are +affected equally. They are excluded because the engine does not reliably retain these +initializer expressions once they have been evaluated: depending on configuration, they are +evaluated //in place// and freed. A declaration site that may or may not still exist cannot +participate in the id numbering without breaking the contract that references resolve +identically in every process and configuration. Making these sites addressable requires the +engine to retain them, or to register their closures at compile time (the stored-index +alternative discussed above), an engine refactoring with its own trade-offs that is deliberately +left as future scope rather than blocking the attribute use case motivating this proposal. Until +then, these closures keep failing to serialize exactly as they do today: nothing regresses and +the door stays open. + +==== Why this id, and not another addressing scheme ==== + +Two natural alternatives were considered for the id. + +**A compiler-assigned index stored in the class.** Instead of deriving the rank when a reference +is created or resolved, the compiler would number each constant-expression closure while +compiling the class and store the table in the compiled artifact. The observable contract would +be identical: a stored index is renumbered by source edits in exactly the same situations as the +derived rank, so it is neither more nor less stable, and the staleness tripwire is needed either +way. The difference is economics: a stored table costs memory in every compiled class (including +in opcache shared memory) and new persistence plumbing, while deriving the rank costs a class +traversal per closure serialized or resolved, negligible next to unserialization itself. The +proposal therefore specifies the contract (deterministic per source revision and PHP version) and +derives the id; switching to a stored index later would be invisible to userland. A stored index +does have one distinctive power, noted under Future Scope: by keeping the compiled closures of +class constant values and property defaults reachable after their initializers are evaluated, it +could lift the in-place-evaluation exclusion without changing how those initializers are +evaluated. + +**The rank among attribute arguments of any type, not only closures.** Numbering every argument +slot looks simpler but addresses the wrong unit. One argument may declare several closures (an +array of callbacks is a single argument), so a within-argument ordinal is still needed; parameter +default values are not attribute arguments, so a second numbering domain appears; and the id +becomes //less// stable, since adding or removing any scalar argument before the closure renumbers +it, while the closure-only rank is invariant to every edit that does not add, remove or reorder +closures themselves. Counting only closures numbers exactly the things being referenced, with the +smallest possible sensitivity to unrelated edits. + +A fully symbolic variant ("argument ''callback'' of the second attribute of property ''$x''") +reads better in payloads but combines the drawbacks: paths must reach into nested arrays and +chained closures, attribute and parameter-default sites need different address shapes, an ordinal +is still required when one argument declares several closures, and the robustness it buys +(references surviving edits to //other// members of the class) is not actually desirable for cache +artifacts, where failing closed on any change to the file is the expected behavior. The flat +closure rank plus the line tripwire provides the same safety with a two-integer payload. + +==== Why the line number, and why not a stronger fingerprint ==== + +Because the id is positional, an edit to the class can make a stored id designate a //different +declaration site// than the one that was serialized: removing an attribute renumbers every closure +after it. That is the one failure that must not be silent. Storing the closure's start line makes +essentially every renumbering edit fail loudly: for a stale id to resolve silently, the site +that now occupies it would have to sit on the very line recorded in the payload. +''unserialize()'' otherwise throws an exception that cache layers experience as a regular miss. The line number has three properties that make it the right +tripwire: it is already recorded in the compiled class for every declaration site (functions +know their start line, and so do the nodes of constant expressions), resolution never touches +the filesystem, and it means the same thing in every PHP build. + +Stronger checks were considered, and each pins the wrong thing: + + * **A checksum of the compiled bytecode** is not a stable identity: the same source compiles differently with and without opcache's optimizer, across optimization levels, and across PHP versions. Payloads written by one pipeline would be unreadable by another while referencing perfectly identical source code. + * **A hash of the source text** would require re-reading the file, at serialization time and again at resolution time. The engine deliberately never goes back to source files at runtime: the file may be newer than the compiled code that opcache is serving (the exact drift scenario the check is supposed to detect would corrupt the check itself), may be unreachable (phars, streams, ''open_basedir''), or may not exist at all (''eval()''-defined classes). The compiled class in memory is the single source of truth, and the check must come from it. + * **A compile-time fingerprint of the closure's body**, computed once by the compiler and stored in the class, would be technically sound, but it guards against the wrong event. These payloads are //references//, not snapshots: nobody expects a ''\Foo::bar(%%...%%)'' expression in a generated cache file to pin the implementation of the method it names, and a declaration-site reference equally resolves to whatever the site declares **now**. Deploying a bug fix to a closure's body is supposed to be picked up by already-cached references. A body fingerprint would instead turn every legitimate body edit into a resolution failure, and would permanently grow every class using the feature to enforce snapshot semantics that references deliberately do not have. + +In other words: identity of the //site// is what the id encodes and what the line check defends; +identity of the //body// is intentionally not part of the contract. The residual blind spot is an +edit that renumbers sites while keeping the resolved closure's start line unchanged, e.g. +reordering two closures declared on the same line. This falls under the discipline serialized +payloads already require today (a payload is only valid for the code revision that produced it), +and frameworks invalidate their metadata caches on file changes anyway. + +==== Naming ==== + +''fromConstExpr'' / ''getConstExprId'' / ''getConstExprClass'' follow the "constant expression" +terminology established by the 8.5 RFCs. ''fromConstantExpression'' (spelled out) and +''fromDeclarationSite'' are plausible alternatives; the author has no strong attachment and will +follow list feedback. + +===== Backward Incompatible Changes ===== + +No syntax or runtime behavior changes for code that does not serialize closures. Three +observable changes: + + * ''serialize()'' now **succeeds** where it previously threw, for closures declared in constant expressions of a class. Code using the exception as a closure detector (a known anti-pattern) would change behavior for those; refusal is preserved for every stateful or runtime-created closure, so the common defensive case is unaffected. + * ''unserialize()'' now accepts ''O:7:"Closure":...'' payloads (it previously failed with "Unserialization of 'Closure' is not allowed"). Consumers using ''allowed_classes'' are unaffected unless they list Closure. + * method_exists($closure, '__serialize') now returns ''true''. + +===== Proposed PHP Version(s) ===== + +Next minor version (PHP 8.6). + +===== RFC Impact ===== + +==== To SAPIs ==== + +None. + +==== To Existing Extensions ==== + + * ''opcache'': no changes required; references resolve identically with and without opcache, including with the file cache. + * ''reflection'': adds ReflectionFunction::getConstExprId() and ReflectionFunction::getConstExprClass(). + * ''standard'': no changes required; ''serialize()'', ''unserialize()'' and its ''allowed_classes'' filter pick the feature up through the regular __serialize()/__unserialize() protocol. ''var_export()'' is unchanged. + * ''session'': no changes required; declared closures stored in ''$_SESSION'' now serialize instead of failing the session write. + * Other bundled extensions are unaffected. External serializers (igbinary, msgpack) that honor __serialize() pick the feature up automatically; those that special-case Closure keep their current behavior until updated. + +==== To Ecosystem ==== + + * Metadata caches (Symfony validator/serializer, Doctrine, API Platform, PSR-6 marshallers) start working with closure-carrying attributes with **no code changes** on the ''serialize()'' path. + * Exporters (Symfony VarExporter and similar) gain a one-line strategy for anonymous and non-public attribute closures via getConstExprClass() / getConstExprId() / fromConstExpr(), complementing their existing first-class callable emission for public ones. + * Static analyzers and IDEs need to learn the three new methods. + * opis/closure and laravel/serializable-closure remain relevant for what this RFC deliberately does not cover: stateful closures. + +===== Future Scope ===== + + * **Class constant values and property defaults**: letting ''const VALIDATOR = static function () {...};'' serialize under the same model requires the declaration site to remain addressable after the initializer is evaluated in place, either by preserving these constant expressions past first evaluation, or by having the compiler register such closures in the class (the stored-index alternative discussed in the Rationale). Both are engine refactorings with their own trade-offs, deserving their own RFC. + * **Global constants and free-function attributes**: addressable in principle (by constant/function name), but functions and constants are not autoloadable, which weakens the resolution story. Could be added incrementally. + * **Name-based serialization of runtime-created named closures**: making ''serialize(strlen(%%...%%))'' work anywhere by serializing closures over public named callables as their name. This is a pure widening of this proposal, discussed and deliberately left out in the Rationale; the two payload kinds can coexist if a need emerges. + * **Native ''var_export()'' support**: emitting ''\Closure::fromConstExpr(...)'' / ''\Foo::bar(%%...%%)'' from ''var_export()'' itself, instead of leaving it to userland exporters. + +===== Proposed Voting Choices ===== + +Voting opens YYYY-MM-DD and closes YYYY-MM-DD. A single vote on the whole proposal, requiring a +2/3 majority. + + + * Yes + * No + + +===== Patches and Tests ===== + +Implementation: https://github.com/nicolas-grekas/php-src/pull/4 + +Tests live under ''Zend/tests/closures/closure_const_expr/'' and cover: + + * Round-trips from every attribute target (class, constant, enum case, property, hook, method, parameter), parameter defaults, nested and runtime-nested declarations + * First-class callable sites: functions (incl. namespaced), own private/protected methods, inherited methods via ''self::'' and ''parent::'' (with their distinct ''static::'' bindings), cross-class references + * Runtime-created named closures keep refusing, even when an identical reference exists in an attribute + * Inheritance and traits + * Scope restoration: ''self::'', private member access + * Refusals: every row of the "stays non-serializable" table, with unchanged error message + * Forged and stale payloads: unknown class/id, line mismatch, type confusion, name-shaped payloads, double initialization + * ''allowed_classes'' gating (''%%__PHP_Incomplete_Class%%'', no resolution) + * Object-graph behavior: shared instances, ''%%__wakeup()%%'' ordering + * Identical behavior with opcache (shared memory and file cache) and under JIT + +===== References ===== + + * [[https://wiki.php.net/rfc/closures_in_const_expr|RFC: Closures in constant expressions]] (PHP 8.5) + * [[https://wiki.php.net/rfc/fcc_in_const_expr|RFC: First-class callables in constant expressions]] (PHP 8.5) + * [[https://github.com/symfony/symfony/issues/63228|symfony#63228: Static callable in attributes may have performance issues]] + * [[https://github.com/symfony/symfony/pull/61657|symfony#61657: VarExporter support for named closures]] + * [[https://github.com/opis/closure|opis/closure]], [[https://github.com/laravel/serializable-closure|laravel/serializable-closure]]: userland source-extraction serializers From 0b0ca061837b59ba5b857a1c7b1b7778772d9a98 Mon Sep 17 00:00:00 2001 From: Nicolas Grekas Date: Sat, 4 Jul 2026 10:33:43 +0200 Subject: [PATCH 3/4] Key first-class callable references by callable name The id of a first-class callable reference is now "@Called::method" / "@function" instead of an element-local rank: the name is the address, the declared site is the guard. These references carry no positional component and no line tripwire (a name cannot drift silently), and no longer consume a rank, so declaring one never renumbers the anonymous closures of its element. Equivalent references re-serialize to their first declaring element. --- UPGRADING | 6 +- .../serialization_named.phpt | 66 ++- Zend/zend_closures.c | 201 ++++++-- .../ReflectionFunction_getConstExprId.phpt | 5 +- serializable_closures.docuwiki | 445 ------------------ 5 files changed, 218 insertions(+), 505 deletions(-) delete mode 100644 serializable_closures.docuwiki diff --git a/UPGRADING b/UPGRADING index 33e47d43b759..8984fb0ececd 100644 --- a/UPGRADING +++ b/UPGRADING @@ -185,8 +185,10 @@ PHP 8.6 UPGRADE NOTES closures and first-class callables, of any visibility, in attribute arguments and parameter default values) can now be serialized and unserialized. The payload contains no code: it is a reference to the - declaration site (class name, an element-scoped "@" id naming - the declaring element and the closure's rank within it, start line), + declaration site (class name plus an element-scoped "@" id; + the key is the closure's rank within the declaring element for anonymous + closures, checked against the recorded start line, and the name of the + referenced callable, e.g. "$prop@Foo::bar", for first-class callables), resolved against the loaded class. Closures created at runtime, bound to an object, rebound to another scope, or declared in class constant values, property defaults or outside a class still refuse to serialize. diff --git a/Zend/tests/closures/closure_const_expr/serialization_named.phpt b/Zend/tests/closures/closure_const_expr/serialization_named.phpt index 51056621e89d..5f24afb98e27 100644 --- a/Zend/tests/closures/closure_const_expr/serialization_named.phpt +++ b/Zend/tests/closures/closure_const_expr/serialization_named.phpt @@ -1,5 +1,5 @@ --TEST-- -First-class callable references in constant expressions serialize as declaration sites +First-class callable references serialize as name-keyed declaration sites --FILE-- $attr) { $payload = serialize($closure); $u = unserialize($payload); $args = $i === 4 ? ['abcd'] : []; - // The payload references the declaring class, ids are stable, and the - // recreated closure behaves identically (including static:: binding). + // 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( - str_contains($payload, '"Demo"') - && $u(...$args) === $closure(...$args) - && serialize($u) === $payload + (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((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) { @@ -64,32 +76,40 @@ foreach ([strlen(...), Validators::check(...), Closure::fromCallable('strlen')] } } -// The name-based payload format does not exist: only site references resolve. -foreach ([ - 'O:7:"Closure":1:{s:8:"function";s:6:"strlen";}', - 'O:7:"Closure":2:{s:5:"class";s:10:"Validators";s:6:"method";s:5:"check";}', -] as $payload) { +// 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 { - unserialize($payload); - echo "unserialized!?\n"; - } catch (Exception $e) { - echo $e->getMessage(), "\n"; + Closure::fromConstExpr('Demo', $id); + echo "resolved!?\n"; + } catch (ValueError $e) { + echo $id, ": ", $e->getMessage(), "\n"; } } +try { + unserialize('O:7:"Closure":2:{s:5:"class";s:4:"Demo";s:2:"id";s:9:"$p@system";}'); +} catch (Exception $e) { + echo $e->getMessage(), "\n"; +} } ?> --EXPECT-- -bool(true) -bool(true) -bool(true) -bool(true) -bool(true) -bool(true) +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:{s:5:"class";s:4:"Demo";s:2:"id";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 -Invalid serialization data for Closure object -Invalid serialization data for Closure object +$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/zend_closures.c b/Zend/zend_closures.c index 2e770a490cb0..4b3d8b52a3a7 100644 --- a/Zend/zend_closures.c +++ b/Zend/zend_closures.c @@ -481,12 +481,17 @@ typedef struct { * 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; the caller has - * already positioned the walk on a single element). Otherwise search for - * the op_array of an anonymous closure identified by its opcodes, or for a - * first-class callable reference matching the fcc closure. */ + /* 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 within the element. Otherwise search for the op_array + * of an anonymous closure identified by its opcodes, or for a first-class + * callable reference matching the fcc closure. */ bool by_id; uint32_t target_id; + const char *target_key; + size_t target_key_len; const zend_op *opcodes; const zend_closure *fcc; /* The class being walked; used to resolve self/parent references. */ @@ -595,6 +600,75 @@ static bool zend_constexpr_fcc_matches(const zend_constexpr_closure_walk *w, con return resolved && zend_closure_func_matches(closure, resolved); } +/* 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; @@ -631,13 +705,15 @@ static bool zend_constexpr_closure_walk_ast(zend_constexpr_closure_walk *w, zend 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. */ + * 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) { - uint32_t id = w->next_id++; - - if (w->by_id ? w->target_id == id : zend_constexpr_fcc_matches(w, ast)) { + if (w->target_key + ? zend_constexpr_fcc_key_matches(w, ast) + : (!w->by_id && zend_constexpr_fcc_matches(w, ast))) { 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; @@ -791,23 +867,48 @@ static bool zend_constexpr_closure_walk_class(zend_constexpr_closure_walk *w, ze #undef WALK_ELEMENT -/* Materialize the opaque "@" reference from a found descriptor. */ +/* Materialize the opaque "@" reference from a found descriptor. + * The key is the local rank for an anonymous closure, and the name of the + * referenced callable ("Called::method" or "function") for a first-class + * callable, built from the resolved closure so that it carries canonical + * names regardless of how the source spells the reference. */ 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) { + key = zend_long_to_str((zend_long) w->found_id); + } else if (w->fcc->func.common.scope) { + zend_string *called = w->fcc->called_scope->name; + zend_string *method = w->fcc->func.common.function_name; + key = zend_string_concat3( + ZSTR_VAL(called), ZSTR_LEN(called), "::", 2, ZSTR_VAL(method), ZSTR_LEN(method)); + } else { + key = zend_string_copy(w->fcc->func.common.function_name); + } + switch (w->found_kind) { case ZEND_CEXPR_SITE_CONST: - return zend_strpprintf(0, "%s@%u", ZSTR_VAL(w->found_name), (unsigned) w->found_id); + ref = zend_strpprintf(0, "%s@%s", ZSTR_VAL(w->found_name), ZSTR_VAL(key)); + break; case ZEND_CEXPR_SITE_PROP: - return zend_strpprintf(0, "$%s@%u", ZSTR_VAL(w->found_name), (unsigned) w->found_id); + ref = zend_strpprintf(0, "$%s@%s", ZSTR_VAL(w->found_name), ZSTR_VAL(key)); + break; case ZEND_CEXPR_SITE_HOOK: - return zend_strpprintf(0, "$%s::%s()@%u", ZSTR_VAL(w->found_name), - w->found_hook == ZEND_PROPERTY_HOOK_GET ? "get" : "set", (unsigned) w->found_id); + 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: - return zend_strpprintf(0, "%s()@%u", ZSTR_VAL(w->found_name), (unsigned) w->found_id); + ref = zend_strpprintf(0, "%s()@%s", ZSTR_VAL(w->found_name), ZSTR_VAL(key)); + break; case ZEND_CEXPR_SITE_CLASS: default: - return zend_strpprintf(0, "@%u", (unsigned) w->found_id); + ref = zend_strpprintf(0, "@%s", ZSTR_VAL(key)); + break; } + + zend_string_release_ex(key, 0); + return ref; } /* Resolve (element site, local rank) to a declaration-site AST: locate the one @@ -815,7 +916,8 @@ static zend_string *zend_constexpr_closure_build_ref(const zend_constexpr_closur * 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) + 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; @@ -824,8 +926,13 @@ static zend_ast *zend_constexpr_closure_site_by_element( } memset(&w, 0, sizeof(w)); - w.by_id = true; - w.target_id = rank; + 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) { @@ -889,7 +996,10 @@ static zend_ast *zend_constexpr_closure_site_by_element( return w.found; } -/* Parse the opaque "@" reference and resolve it. */ +/* 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). */ static zend_ast *zend_constexpr_closure_site_by_ref(zend_class_entry *ce, zend_string *id) { const char *s = ZSTR_VAL(id); @@ -899,23 +1009,32 @@ static zend_ast *zend_constexpr_closure_site_by_ref(zend_class_entry *ce, zend_s return NULL; } size_t site_len = at - s; - const char *rank_s = at + 1; - size_t rank_len = len - site_len - 1; - if (rank_len == 0 || (rank_len > 1 && rank_s[0] == '0')) { + 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 < rank_len; i++) { - if (rank_s[i] < '0' || rank_s[i] > '9') { - return NULL; + for (size_t i = 0; i < key_len; i++) { + if (key[i] < '0' || key[i] > '9') { + is_rank = false; + break; } - rank = rank * 10 + (rank_s[i] - '0'); + 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); + } - return zend_constexpr_closure_site_by_element(ce, s, site_len, (uint32_t) rank); + return zend_constexpr_closure_site_by_element(ce, s, site_len, 0, key, key_len); } static uint32_t zend_constexpr_closure_site_lineno(const zend_ast *site) @@ -969,7 +1088,10 @@ ZEND_API zend_result zend_constexpr_closure_ref(zend_object *closure_obj, zend_c * Closure::fromConstExpr); the grammar is an engine detail. */ *id = zend_constexpr_closure_build_ref(&w); if (lineno) { - *lineno = zend_constexpr_closure_site_lineno(w.found); + /* First-class callable references are keyed by name and need no line + * tripwire: a name cannot drift silently. */ + *lineno = w.found->kind == ZEND_AST_OP_ARRAY + ? zend_constexpr_closure_site_lineno(w.found) : 0; } return SUCCESS; } @@ -1003,7 +1125,9 @@ ZEND_METHOD(Closure, __serialize) array_init(return_value); add_assoc_str(return_value, "class", zend_string_copy(ce->name)); add_assoc_str(return_value, "id", id); - add_assoc_long(return_value, "line", lineno); + if (lineno) { + add_assoc_long(return_value, "line", lineno); + } } /* }}} */ @@ -1039,8 +1163,7 @@ ZEND_METHOD(Closure, __unserialize) ZVAL_DEREF(z_line); } if (!z_class || Z_TYPE_P(z_class) != IS_STRING - || !z_id || Z_TYPE_P(z_id) != IS_STRING - || !z_line || Z_TYPE_P(z_line) != IS_LONG) { + || !z_id || Z_TYPE_P(z_id) != IS_STRING) { zend_throw_exception(NULL, "Invalid serialization data for Closure object", 0); RETURN_THROWS(); } @@ -1056,7 +1179,7 @@ ZEND_METHOD(Closure, __unserialize) } site = zend_constexpr_closure_site_by_ref(ce, Z_STR_P(z_id)); - if (!site || (zend_long) zend_constexpr_closure_site_lineno(site) != Z_LVAL_P(z_line)) { + 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)); @@ -1064,6 +1187,18 @@ ZEND_METHOD(Closure, __unserialize) } if (site->kind == ZEND_AST_OP_ARRAY) { + /* Anonymous closures are referenced by a positional rank: the start + * line is the staleness tripwire. Name-keyed references carry none. */ + if (!z_line || Z_TYPE_P(z_line) != IS_LONG) { + zend_throw_exception(NULL, "Invalid serialization data for Closure object", 0); + RETURN_THROWS(); + } + if ((zend_long) zend_constexpr_closure_site_lineno(site) != Z_LVAL_P(z_line)) { + 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(); + } zend_closure_init_ex(closure, (zend_function *) zend_ast_get_op_array(site)->op_array, ce, ce, NULL, /* is_fake */ false); } else { diff --git a/ext/reflection/tests/ReflectionFunction_getConstExprId.phpt b/ext/reflection/tests/ReflectionFunction_getConstExprId.phpt index ac0c8084bf99..4257f79bde0f 100644 --- a/ext/reflection/tests/ReflectionFunction_getConstExprId.phpt +++ b/ext/reflection/tests/ReflectionFunction_getConstExprId.phpt @@ -28,7 +28,8 @@ $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. +// 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); @@ -47,7 +48,7 @@ var_dump((new ReflectionFunction('strlen'))->getConstExprClass()); string(4) "$p@0" string(4) "Demo" string(2) "ok" -string(4) "$p@1" +string(20) "$p@Validators::check" string(4) "Demo" string(10) "Validators" string(7) "checked" diff --git a/serializable_closures.docuwiki b/serializable_closures.docuwiki deleted file mode 100644 index 0bdabb439fea..000000000000 --- a/serializable_closures.docuwiki +++ /dev/null @@ -1,445 +0,0 @@ -====== PHP RFC: Serializable closures, by reference to the code that declares them ====== - - * Version: 0.1 - * Date: 2026-06-10 - * Author: Nicolas Grekas - * Status: Draft - * First Published at: https://wiki.php.net/rfc/serializable_closures - * Mailing List Thread: https://news-web.php.net/php.internals/131198 - -===== Introduction ===== - -Closures have never been serializable in PHP, for good structural reasons: a closure may capture -variables by value or by reference, may be bound to an object, and may be rebound at runtime. A -serialization payload for such a closure would have to embed code and captured state, which is -both semantically murky and a deserialization attack surface. - -PHP 8.5 created a class of closures for which none of those reasons apply. The -[[https://wiki.php.net/rfc/closures_in_const_expr|Closures in constant expressions]] and -[[https://wiki.php.net/rfc/fcc_in_const_expr|First-class callables in constant expressions]] RFCs -allow closures in attribute arguments and other constant expressions, with hard restrictions: -anonymous closures must be ''static'' and cannot ''use()'' any variable, and first-class -callables can only reference named functions and static methods. Such a closure carries -**no state at all**. It is fully described by //where it is declared//, the same way an enum -case is fully described by its name. - -This RFC makes these closures serializable, by storing a **reference to their declaration -site** rather than the code itself. This covers both forms a closure can take in a constant -expression: - - * an anonymous closure declaration: ''static function () { ... }''; - * a first-class callable reference: ''strlen(%%...%%)'', ''self::isStrict(%%...%%)'', ''Validators::check(%%...%%)''. - -''unserialize()'' resolves the reference against the loaded code base and recreates an -equivalent closure, as if the declaring constant expression had just been evaluated again. No -code ever travels in the payload, and a payload cannot reference anything that the reader's own -code base does not already declare. - -Closures that carry state (captured variables, bound ''$this'') and closures created at runtime -(anonymous functions and first-class callables in function bodies) remain non-serializable, with -the exact same error as today. - -===== Problem Statement ===== - -==== Attributes now produce closures, and closures poison caches ==== - -Attributes are PHP's declarative metadata system, and the ecosystem caches derived metadata -aggressively: validator metadata, serializer metadata, DI container definitions, routing tables. -These caches are built once and stored through ''serialize()'' (PSR-6/PSR-16 pools) or through -''var_export()'' (opcache-friendly PHP files). - -Since PHP 8.5, attributes can carry closures, and this is not a corner case: it was the -headline use case of the 8.5 RFCs. The Symfony validator, for instance, accepts exactly this: - - -class Order -{ - #[Assert\When(static function () { return self::$strictMode; }, constraints: [new Assert\NotBlank()])] - public string $billingAddress; -} - - -The metadata derived from this attribute contains the closure. The cache layer calls -''serialize()'' on it, which throws, the framework catches the error, and the metadata is silently -recomputed **on every request**. Nothing fails loudly; the application just gets slower. This is -[[https://github.com/symfony/symfony/issues/63228|symfony#63228]], reported shortly after the -PHP 8.5 release, and the same trap exists for every metadata cache in the ecosystem. - -The language added a feature whose natural habitat is cached metadata, while its values are -hostile to caching. This RFC closes that gap. - -==== Userland cannot fix this well ==== - -Userland has two known workarounds, both with significant drawbacks: - - * **Source extraction** ([[https://github.com/opis/closure|opis/closure]], [[https://github.com/laravel/serializable-closure|laravel/serializable-closure]]): re-read the file, slice the closure's source text, store it in the payload, ''eval()'' it on unserialize. This puts executable code in payloads (an injection surface that both libraries mitigate with signing), drifts from the compiled code when files change, and breaks ''%%__FILE__%%''/''%%__LINE__%%''/''self'' semantics unless carefully patched. - * **Re-evaluation wrappers**: frameworks can wrap each closure in an invokable object that records "attribute X, argument Y of class Z" and re-runs reflection on first use. This works (Symfony can ship it for 8.5) but every framework must build and maintain its own referencing system for what is fundamentally one engine-level concept: //this closure is the one declared right there//. - -The engine is the only layer that can give such references first-class, code-free, validated -semantics, uniformly for the whole ecosystem. - -==== Named callables have the same problem with serialize() ==== - -''#[Assert\When(Foo::isStrict(%%...%%))]'' has the same caching behavior as the inline closure: -''serialize()'' refuses fake closures too. The ''var_export()'' side of this is already solved in -userland (Symfony's VarExporter exports named closures as ''\Foo::isStrict(%%...%%)'', -[[https://github.com/symfony/symfony/pull/61657|symfony#61657]]), which makes the asymmetry -worse: the recommended, "cache-friendly" way of writing callable attributes still breaks every -''serialize()''-based cache. - -===== Proposal ===== - -Closures become serializable when, and only when, they are declared by a class's constant -expressions, in attribute arguments and parameter default values (class constant values and -property defaults are excluded for now: see the Rationale). Two forms of declaration exist. - -==== 1. Anonymous closures ==== - -A closure declared in a constant expression attached to a class member becomes serializable: - - * in attribute arguments, of the class itself, of its constants, properties, property hooks, methods and parameters; - * in parameter default values of its methods; - * including when nested in further closures declared there. - -The payload is a reference to the declaration site, made of the **declaring class name**, a -**stable id**, and the **start line** of the closure (used as a staleness check): - - -$closure = (new ReflectionProperty(Order::class, 'billingAddress')) - ->getAttributes()[0]->getArguments()[0]; - -$payload = serialize($closure); -// O:7:"Closure":3:{s:5:"class";s:5:"Order";s:2:"id";i:0;s:4:"line";i:3;} - -$again = unserialize($payload); -$again(); // behaves exactly like $closure() - - -Unserializing autoloads the class if needed and recreates the closure //as if its constant -expression had just been evaluated//: same code, statically scoped to its declaring class -(''self::'', private member access and ''static::'' behave identically), no bound ''$this'', and -fresh ''static'' variables. It is a new Closure instance, the same way two calls to -ReflectionAttribute::getArguments() return two instances today. - -**The id is deterministic.** It is the closure's rank among all closure-declaring constant -expressions of the class (anonymous declarations and first-class callable references alike), -counted in a fixed declaration-order traversal of the class (class attributes first, then -constants, properties and hooks, then methods with their parameter lists). It is derived from -the compiled class alone, never from runtime state, evaluation order or caches, which is where -its stability comes from: every process running the same source computes the same rank, with or -without opcache. The flip side is that editing the class may renumber the ranks. References are -therefore validated when they resolve (see below) and must be treated like the cache artifacts -they are embedded in: valid for the code revision that produced them, and per PHP version. -Userland should obtain ids from the engine (see the Reflection API below) rather than compute -them. - -When the class's source changes, a stored reference either stops resolving or is rejected by the -line check; both throw an Exception on ''unserialize()'', which cache layers already -treat as a miss. - -==== 2. First-class callable references ==== - -A first-class callable in a constant expression is a closure declaration site like any other: -the class's source declares, at a fixed position, "a closure over this callable". Closures -created by evaluating such a reference serialize with the **same payload** as anonymous -declarations: the declaring class, the id, the start line. The engine tracks this provenance -when it evaluates the constant expression; an identical-looking closure created at runtime does -not have it and refuses to serialize. - - -class Order -{ - #[Assert\When(self::isStrict(...), constraints: [new Assert\NotBlank()])] - public string $billingAddress; - - private static function isStrict(): bool { ... } -} - -$closure = /* from ReflectionAttribute::getArguments() */; -serialize($closure); -// O:7:"Closure":3:{s:5:"class";s:5:"Order";s:2:"id";i:0;s:4:"line";i:3;} - - -Resolution re-evaluates the declared reference in the scope of the declaring class, exactly like -attribute evaluation does. Two important properties follow: - - * **Any visibility works**, because the site's own access rules are simply re-run: a private or protected helper referenced from an attribute of its own class (as above, an idiomatic pattern since validation helpers have no reason to be public) round-trips fine, while a forged payload gains nothing: it can only designate references that the class legitimately declares. - * **Late static binding is preserved**: ''self::'', ''parent::'' and explicit class names resolve exactly as they did when the attribute was evaluated, since it is the same evaluation. - -This works uniformly for functions (''strlen(%%...%%)''), own methods (''self::isStrict(%%...%%)''), -and cross-class references (''Validators::check(%%...%%)''), in attribute arguments and parameter -default values alike. - -==== What stays non-serializable ==== - -Everything below keeps today's behavior, including the exact error -(Exception: ''Serialization of 'Closure' is not allowed''): - -^ Closure kind ^ Why ^ -| Anonymous functions declared in function bodies | No declaration site; may capture variables and ''$this'' | -| Arrow functions | Capture by value implicitly | -| Closures over named callables created at runtime (''strlen(%%...%%)'' / ''Foo::bar(%%...%%)'' in function bodies, Closure::fromCallable(), ReflectionMethod::getClosure()) | No declaration site; serializing them by name would make ''unserialize()'' a closure factory over the whole code base instead of over what classes declare | -| Closures bound to an object (''$obj->method(%%...%%)'', ''Closure::bind()'' with ''$this'') | Carry object state | -| Closures created from ''%%__call()%%''/''%%__callStatic()%%'' trampolines | No real backing method; the engine already rejects them in constant expressions anyway | -| Const-expr closures in **class constant values** and **property default values** | See Future Scope | -| Const-expr closures in attributes of free functions, or of anonymous classes | No autoloadable / stable container name | -| Const-expr closures rebound to a different scope | No longer the declared value | - -Note that the boundary is **declaration, not syntax**: the same expression, anonymous closure or -first-class callable, is serializable when it appears in a constant expression and not when it -appears in a method body, because only the former is a declaration the class makes about itself, -with an identity that survives the process. - -==== Security model ==== - -The payload contains **a class name and two integers, never code**. Resolution can only ever -produce a closure that the named class's own source declares in one of its constant -expressions: there is nothing else a payload can express. ''unserialize()'' is not a closure -factory over the code base; it is a lookup into the fixed, finite set of closures that classes -declare about themselves. - -A forged payload can therefore at worst point at a //different// declared closure, the same -class of risk as unserializing an enum case name or a class name today, and far less than what -''unserialize()'' already allows through ''%%__wakeup()%%'' gadgets. Visibility needs no special -rule: resolving a first-class callable site re-runs the same accessibility checks, in the same -scope, that attribute evaluation runs, so a reference resolves for the reader exactly when the -declaration is legal for the declarer. And the existing -''allowed_classes'' hardening applies unchanged: with -''unserialize($data, ['allowed_classes' => [...]])'' not listing Closure, these -payloads produce __PHP_Incomplete_Class like any other non-listed object, and no -resolution happens at all. - -All malformed, unresolvable or stale payloads throw an Exception with a descriptive -message, for example: - - -Invalid serialization data for Closure object (constant-expression closure 3 of class Order not found) -Invalid serialization data for Closure object (cannot load class "Order") - - -==== Reflection and exporter support ==== - -''var_export()''-based caches (PHP files compiled by opcache) need to //generate code// that -recreates the closure, rather than a binary payload. Three additions support this: - - * ReflectionFunction::getConstExprId(): ?int returns the declaration-site id of a closure declared in a constant expression, or ''null'' for any other closure. - * ReflectionFunction::getConstExprClass(): ?string returns the declaring class. This is a distinct accessor because for first-class callable references no existing one carries it: a ''Validators::check(%%...%%)'' reference declared by ''Order'' has getClosureScopeClass() and getClosureCalledClass() both pointing at ''Validators'', a ''parent::'' reference has both pointing at the parent class, and a function reference (''strlen(%%...%%)'') has neither, while all of them are declared, and serialized, by ''Order''. The existing accessors describe the closure's runtime semantics (visibility scope, ''static::'' binding); getConstExprClass() describes where it was declared. - * Closure::fromConstExpr(string $class, int $id): Closure recreates the closure from such a reference. It throws an Error if the class cannot be loaded and a ValueError if the id does not resolve. - -An exporter then emits self-contained, opcache-friendly code: - - -// generated cache file -return \Closure::fromConstExpr(\App\Order::class, 0); - - -For closures over **public** named callables, exporters can keep emitting plain first-class -callable syntax (''\Order::isStrict(%%...%%)''), as Symfony's VarExporter already does: in -generated code the expression itself is the reference. fromConstExpr() is what makes -the remaining cases exportable, anonymous closures and non-public references, since generated -code runs in global scope and could not name a private helper directly. The identity of any -named closure (its name, scope, called scope, staticness) remains introspectable through -existing reflection regardless of visibility; reflection describes closures already in hand and -is deliberately not restricted. - -==== Behavior details ==== - - * **References track live code**: like a ''\Foo::bar(%%...%%)'' expression in a generated cache file resolving to the current implementation of the method, a declaration-site reference resolves to the closure as **currently** declared at that site. Payloads do not pin a snapshot of the body; deploying a fixed closure body fixes already-cached references to it. - * **Fresh instances**: unserialization creates a new Closure object. Within one ''unserialize()'' call, shared references in the payload graph are preserved as usual (the same closure serialized twice in one graph unserializes as one instance). - * **''static'' variables start fresh**: a reference designates the closure //as declared//, not a snapshot of its runtime state. This matches re-evaluating the attribute, and matches what the 8.5 RFCs specify for repeated evaluation. - * **No change to other serialization surfaces**: ''var_export()'' and ''json_encode()'' behave exactly as before; this RFC only touches ''serialize()''/''unserialize()''. - * Closure now declares __serialize()/__unserialize() as regular (engine-provided) methods; they are visible through reflection like any other method. - -===== Rationale ===== - -==== Why references instead of embedding source code ==== - -The Opis-style alternative (store the closure's source text, compile it on unserialize) was -rejected deliberately: - - * payloads would contain executable code, turning every cache store into an injection surface that needs signing to be safe; - * the embedded source can drift from the deployed code (the cache says one thing, the file says another), whereas a reference always resolves against the **single source of truth**, the loaded class; - * ''%%__FILE__%%'', ''%%__LINE__%%'', ''self'', private-member access and ''static::'' all need careful patching when code is re-compiled out of context; a reference recreates the closure in its original context, so all of these are correct by construction. - -The 8.5 restrictions are precisely what make the reference design possible: since the closure can -capture nothing, the reference loses nothing. - -==== Why first-class callables serialize only when declared in constant expressions ==== - -An earlier draft of this proposal serialized //every// closure over a named function or public -static method by name, so that ''serialize(strlen(%%...%%))'' worked anywhere. That design was -dropped for three reasons: - - * **Needless surface.** The motivating problem is cached metadata derived from constant expressions. Making every named closure serializable turns ''unserialize()'' into a closure factory over every function and public static method of the code base, a much larger capability than the problem requires. With declaration-site references only, a payload can designate nothing beyond the finite set of closures that classes declare about themselves. - * **A visibility dilemma it cannot solve.** Name-based payloads cross trust domains, so they must be restricted to what unscoped code could create: public static methods. But attributes legitimately reference //private// helpers of their own class (''#[When(self::isStrict(%%...%%))]'', where there is no reason to make the helper public), and those would have stayed unserializable, re-creating the very cache trap this RFC exists to fix. Site references dissolve the dilemma: resolution re-evaluates the declaration in its own scope, so visibility is enforced by the same rule that allowed the attribute to compile and evaluate in the first place. - * **One principle, one payload.** Anonymous declarations and callable references get the same format, the same staleness contract, and the same security analysis, instead of two payload kinds with different rules. - -The engine tracks the necessary provenance at no observable cost: closures created while -evaluating a constant expression are marked as such, with the declaring class recorded. A -value-identical first-class callable created in a function body does not carry the mark and -refuses to serialize. This asymmetry is the same one anonymous closures already have (the same -body in a method body refuses too), and it is the point: serializability is a property of the -//declaration//, not of the value. - -Extending name-based serialization to runtime-created closures over public callables would -remain possible later, as a pure widening (see Future Scope); this RFC deliberately does not -include it. - -==== Why class constant values and property defaults are excluded for now ==== - -''const FOO = static function () {...};'' and ''public $cb = Validators::check(%%...%%);'' are -constant expressions too, and conceptually they should qualify; both forms of declaration are -affected equally. They are excluded because the engine does not reliably retain these -initializer expressions once they have been evaluated: depending on configuration, they are -evaluated //in place// and freed. A declaration site that may or may not still exist cannot -participate in the id numbering without breaking the contract that references resolve -identically in every process and configuration. Making these sites addressable requires the -engine to retain them, or to register their closures at compile time (the stored-index -alternative discussed above), an engine refactoring with its own trade-offs that is deliberately -left as future scope rather than blocking the attribute use case motivating this proposal. Until -then, these closures keep failing to serialize exactly as they do today: nothing regresses and -the door stays open. - -==== Why this id, and not another addressing scheme ==== - -Two natural alternatives were considered for the id. - -**A compiler-assigned index stored in the class.** Instead of deriving the rank when a reference -is created or resolved, the compiler would number each constant-expression closure while -compiling the class and store the table in the compiled artifact. The observable contract would -be identical: a stored index is renumbered by source edits in exactly the same situations as the -derived rank, so it is neither more nor less stable, and the staleness tripwire is needed either -way. The difference is economics: a stored table costs memory in every compiled class (including -in opcache shared memory) and new persistence plumbing, while deriving the rank costs a class -traversal per closure serialized or resolved, negligible next to unserialization itself. The -proposal therefore specifies the contract (deterministic per source revision and PHP version) and -derives the id; switching to a stored index later would be invisible to userland. A stored index -does have one distinctive power, noted under Future Scope: by keeping the compiled closures of -class constant values and property defaults reachable after their initializers are evaluated, it -could lift the in-place-evaluation exclusion without changing how those initializers are -evaluated. - -**The rank among attribute arguments of any type, not only closures.** Numbering every argument -slot looks simpler but addresses the wrong unit. One argument may declare several closures (an -array of callbacks is a single argument), so a within-argument ordinal is still needed; parameter -default values are not attribute arguments, so a second numbering domain appears; and the id -becomes //less// stable, since adding or removing any scalar argument before the closure renumbers -it, while the closure-only rank is invariant to every edit that does not add, remove or reorder -closures themselves. Counting only closures numbers exactly the things being referenced, with the -smallest possible sensitivity to unrelated edits. - -A fully symbolic variant ("argument ''callback'' of the second attribute of property ''$x''") -reads better in payloads but combines the drawbacks: paths must reach into nested arrays and -chained closures, attribute and parameter-default sites need different address shapes, an ordinal -is still required when one argument declares several closures, and the robustness it buys -(references surviving edits to //other// members of the class) is not actually desirable for cache -artifacts, where failing closed on any change to the file is the expected behavior. The flat -closure rank plus the line tripwire provides the same safety with a two-integer payload. - -==== Why the line number, and why not a stronger fingerprint ==== - -Because the id is positional, an edit to the class can make a stored id designate a //different -declaration site// than the one that was serialized: removing an attribute renumbers every closure -after it. That is the one failure that must not be silent. Storing the closure's start line makes -essentially every renumbering edit fail loudly: for a stale id to resolve silently, the site -that now occupies it would have to sit on the very line recorded in the payload. -''unserialize()'' otherwise throws an exception that cache layers experience as a regular miss. The line number has three properties that make it the right -tripwire: it is already recorded in the compiled class for every declaration site (functions -know their start line, and so do the nodes of constant expressions), resolution never touches -the filesystem, and it means the same thing in every PHP build. - -Stronger checks were considered, and each pins the wrong thing: - - * **A checksum of the compiled bytecode** is not a stable identity: the same source compiles differently with and without opcache's optimizer, across optimization levels, and across PHP versions. Payloads written by one pipeline would be unreadable by another while referencing perfectly identical source code. - * **A hash of the source text** would require re-reading the file, at serialization time and again at resolution time. The engine deliberately never goes back to source files at runtime: the file may be newer than the compiled code that opcache is serving (the exact drift scenario the check is supposed to detect would corrupt the check itself), may be unreachable (phars, streams, ''open_basedir''), or may not exist at all (''eval()''-defined classes). The compiled class in memory is the single source of truth, and the check must come from it. - * **A compile-time fingerprint of the closure's body**, computed once by the compiler and stored in the class, would be technically sound, but it guards against the wrong event. These payloads are //references//, not snapshots: nobody expects a ''\Foo::bar(%%...%%)'' expression in a generated cache file to pin the implementation of the method it names, and a declaration-site reference equally resolves to whatever the site declares **now**. Deploying a bug fix to a closure's body is supposed to be picked up by already-cached references. A body fingerprint would instead turn every legitimate body edit into a resolution failure, and would permanently grow every class using the feature to enforce snapshot semantics that references deliberately do not have. - -In other words: identity of the //site// is what the id encodes and what the line check defends; -identity of the //body// is intentionally not part of the contract. The residual blind spot is an -edit that renumbers sites while keeping the resolved closure's start line unchanged, e.g. -reordering two closures declared on the same line. This falls under the discipline serialized -payloads already require today (a payload is only valid for the code revision that produced it), -and frameworks invalidate their metadata caches on file changes anyway. - -==== Naming ==== - -''fromConstExpr'' / ''getConstExprId'' / ''getConstExprClass'' follow the "constant expression" -terminology established by the 8.5 RFCs. ''fromConstantExpression'' (spelled out) and -''fromDeclarationSite'' are plausible alternatives; the author has no strong attachment and will -follow list feedback. - -===== Backward Incompatible Changes ===== - -No syntax or runtime behavior changes for code that does not serialize closures. Three -observable changes: - - * ''serialize()'' now **succeeds** where it previously threw, for closures declared in constant expressions of a class. Code using the exception as a closure detector (a known anti-pattern) would change behavior for those; refusal is preserved for every stateful or runtime-created closure, so the common defensive case is unaffected. - * ''unserialize()'' now accepts ''O:7:"Closure":...'' payloads (it previously failed with "Unserialization of 'Closure' is not allowed"). Consumers using ''allowed_classes'' are unaffected unless they list Closure. - * method_exists($closure, '__serialize') now returns ''true''. - -===== Proposed PHP Version(s) ===== - -Next minor version (PHP 8.6). - -===== RFC Impact ===== - -==== To SAPIs ==== - -None. - -==== To Existing Extensions ==== - - * ''opcache'': no changes required; references resolve identically with and without opcache, including with the file cache. - * ''reflection'': adds ReflectionFunction::getConstExprId() and ReflectionFunction::getConstExprClass(). - * ''standard'': no changes required; ''serialize()'', ''unserialize()'' and its ''allowed_classes'' filter pick the feature up through the regular __serialize()/__unserialize() protocol. ''var_export()'' is unchanged. - * ''session'': no changes required; declared closures stored in ''$_SESSION'' now serialize instead of failing the session write. - * Other bundled extensions are unaffected. External serializers (igbinary, msgpack) that honor __serialize() pick the feature up automatically; those that special-case Closure keep their current behavior until updated. - -==== To Ecosystem ==== - - * Metadata caches (Symfony validator/serializer, Doctrine, API Platform, PSR-6 marshallers) start working with closure-carrying attributes with **no code changes** on the ''serialize()'' path. - * Exporters (Symfony VarExporter and similar) gain a one-line strategy for anonymous and non-public attribute closures via getConstExprClass() / getConstExprId() / fromConstExpr(), complementing their existing first-class callable emission for public ones. - * Static analyzers and IDEs need to learn the three new methods. - * opis/closure and laravel/serializable-closure remain relevant for what this RFC deliberately does not cover: stateful closures. - -===== Future Scope ===== - - * **Class constant values and property defaults**: letting ''const VALIDATOR = static function () {...};'' serialize under the same model requires the declaration site to remain addressable after the initializer is evaluated in place, either by preserving these constant expressions past first evaluation, or by having the compiler register such closures in the class (the stored-index alternative discussed in the Rationale). Both are engine refactorings with their own trade-offs, deserving their own RFC. - * **Global constants and free-function attributes**: addressable in principle (by constant/function name), but functions and constants are not autoloadable, which weakens the resolution story. Could be added incrementally. - * **Name-based serialization of runtime-created named closures**: making ''serialize(strlen(%%...%%))'' work anywhere by serializing closures over public named callables as their name. This is a pure widening of this proposal, discussed and deliberately left out in the Rationale; the two payload kinds can coexist if a need emerges. - * **Native ''var_export()'' support**: emitting ''\Closure::fromConstExpr(...)'' / ''\Foo::bar(%%...%%)'' from ''var_export()'' itself, instead of leaving it to userland exporters. - -===== Proposed Voting Choices ===== - -Voting opens YYYY-MM-DD and closes YYYY-MM-DD. A single vote on the whole proposal, requiring a -2/3 majority. - - - * Yes - * No - - -===== Patches and Tests ===== - -Implementation: https://github.com/nicolas-grekas/php-src/pull/4 - -Tests live under ''Zend/tests/closures/closure_const_expr/'' and cover: - - * Round-trips from every attribute target (class, constant, enum case, property, hook, method, parameter), parameter defaults, nested and runtime-nested declarations - * First-class callable sites: functions (incl. namespaced), own private/protected methods, inherited methods via ''self::'' and ''parent::'' (with their distinct ''static::'' bindings), cross-class references - * Runtime-created named closures keep refusing, even when an identical reference exists in an attribute - * Inheritance and traits - * Scope restoration: ''self::'', private member access - * Refusals: every row of the "stays non-serializable" table, with unchanged error message - * Forged and stale payloads: unknown class/id, line mismatch, type confusion, name-shaped payloads, double initialization - * ''allowed_classes'' gating (''%%__PHP_Incomplete_Class%%'', no resolution) - * Object-graph behavior: shared instances, ''%%__wakeup()%%'' ordering - * Identical behavior with opcache (shared memory and file cache) and under JIT - -===== References ===== - - * [[https://wiki.php.net/rfc/closures_in_const_expr|RFC: Closures in constant expressions]] (PHP 8.5) - * [[https://wiki.php.net/rfc/fcc_in_const_expr|RFC: First-class callables in constant expressions]] (PHP 8.5) - * [[https://github.com/symfony/symfony/issues/63228|symfony#63228: Static callable in attributes may have performance issues]] - * [[https://github.com/symfony/symfony/pull/61657|symfony#61657: VarExporter support for named closures]] - * [[https://github.com/opis/closure|opis/closure]], [[https://github.com/laravel/serializable-closure|laravel/serializable-closure]]: userland source-extraction serializers From 062c2e97d794bddeb4eaa717750c0e67d3184faf Mon Sep 17 00:00:00 2001 From: Nicolas Grekas Date: Sat, 11 Jul 2026 19:21:27 +0200 Subject: [PATCH 4/4] Make the Closure serialization payload a tagged union with a class-relative line The __serialize() payload is now [, [, ]] (the shape ext/uri and ext/random use), where the tag names the reference kind. Only "const-expr" exists today; the framing lets the format grow new kinds without ambiguity, and an unknown tag is rejected cleanly. The anonymous-closure staleness line is now stored relative to the declaring class rather than absolute, so an edit above the class (a new use import, another declaration) shifts the class and the closure together and leaves the reference valid. The declaring class line is the anchor every consumer can recompute, including userland polyfills via ReflectionClass. --- UPGRADING | 8 +- .../serialization_invalid_payload.phpt | 49 +++++---- .../serialization_named.phpt | 4 +- .../serialization_tagged_format.phpt | 82 ++++++++++++++ Zend/zend_closures.c | 104 +++++++++++++++--- Zend/zend_closures.h | 16 ++- ext/reflection/php_reflection.c | 4 +- 7 files changed, 218 insertions(+), 49 deletions(-) create mode 100644 Zend/tests/closures/closure_const_expr/serialization_tagged_format.phpt diff --git a/UPGRADING b/UPGRADING index 8984fb0ececd..8429629cbf11 100644 --- a/UPGRADING +++ b/UPGRADING @@ -187,9 +187,11 @@ PHP 8.6 UPGRADE NOTES unserialized. The payload contains no code: it is a reference to the declaration site (class name plus an element-scoped "@" id; the key is the closure's rank within the declaring element for anonymous - closures, checked against the recorded start line, and the name of the - referenced callable, e.g. "$prop@Foo::bar", for first-class callables), - resolved against the loaded class. Closures created at runtime, bound to + closures, checked against the closure's line relative to the class, and + the name of the referenced callable, e.g. "$prop@Foo::bar", for + first-class callables), resolved against the loaded class. The payload is + a tagged union ([, [, ]]) so the format can + grow further reference kinds. Closures created at runtime, bound to an object, rebound to another scope, or declared in class constant values, property defaults or outside a class still refuse to serialize. The unserialize() allowed_classes filter applies to Closure as to any diff --git a/Zend/tests/closures/closure_const_expr/serialization_invalid_payload.phpt b/Zend/tests/closures/closure_const_expr/serialization_invalid_payload.phpt index d3d5d8616231..3d43273141a5 100644 --- a/Zend/tests/closures/closure_const_expr/serialization_invalid_payload.phpt +++ b/Zend/tests/closures/closure_const_expr/serialization_invalid_payload.phpt @@ -14,27 +14,35 @@ class Demo { } $closure = (new ReflectionProperty(Demo::class, 'p'))->getAttributes()[0]->getArguments()[0]; -$r = new ReflectionFunction($closure); -$id = $r->getConstExprId(); -$line = $r->getStartLine(); +$id = (new ReflectionFunction($closure))->getConstExprId(); +// The stored line is relative to the declaring class. +$rel = (new ReflectionFunction($closure))->getStartLine() - (new ReflectionClass(Demo::class))->getStartLine(); -$mk = static fn (string $class, string $id, int $line) => sprintf( - 'O:7:"Closure":3:{s:5:"class";s:%d:"%s";s:2:"id";s:%d:"%s";s:4:"line";i:%d;}', - strlen($class), $class, strlen($id), $id, $line -); +// 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, line?]] ]. +$mk = static function (string $class, string $id, ?int $line) use ($obj): string { + $payload = $line === null ? [$class, $id] : [$class, $id, $line]; + return $obj([[], ['const-expr', $payload]]); +}; // Sanity check: a valid reference works. -var_dump(unserialize($mk('Demo', $id, $line))()); +var_dump(unserialize($mk('Demo', $id, $rel))()); $payloads = [ - 'empty data' => 'O:7:"Closure":0:{}', - 'missing keys' => 'O:7:"Closure":1:{s:5:"class";s:4:"Demo";}', - 'wrong types' => 'O:7:"Closure":3:{s:5:"class";s:4:"Demo";s:2:"id";i:0;s:4:"line";i:1;}', - 'unknown class' => $mk('NoSuchClass', $id, $line), - 'internal class' => $mk('stdClass', $id, $line), - 'unknown site' => $mk('Demo', '$nope@0', $line), - 'malformed id' => $mk('Demo', 'no-at-sign', $line), - 'stale line' => $mk('Demo', $id, $line + 1), + 'empty data' => $obj([]), + 'one element' => $obj([[]]), + 'unknown tag' => $obj([[], ['whatever', ['Demo', $id, $rel]]]), + 'tag not list' => $obj([[], 'const-expr']), + 'wrong id type' => $obj([[], ['const-expr', ['Demo', 0, $rel]]]), + 'unknown class' => $mk('NoSuchClass', $id, $rel), + 'internal class' => $mk('stdClass', $id, $rel), + 'unknown site' => $mk('Demo', '$nope@0', $rel), + 'malformed id' => $mk('Demo', 'no-at-sign', $rel), + 'anon missing line' => $mk('Demo', $id, null), + 'stale line' => $mk('Demo', $id, $rel + 1), ]; foreach ($payloads as $name => $payload) { @@ -48,7 +56,7 @@ foreach ($payloads as $name => $payload) { // __unserialize() cannot be used to reinitialize a live closure. try { - $closure->__unserialize(['class' => 'Demo', 'id' => $id, 'line' => $line]); + $closure->__unserialize([[], ['const-expr', ['Demo', $id, $rel]]]); } catch (Exception $e) { echo $e->getMessage(), "\n"; } @@ -57,11 +65,14 @@ try { --EXPECT-- string(2) "ok" empty data: Invalid serialization data for Closure object -missing keys: Invalid serialization data for Closure object -wrong types: 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 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) +anon missing line: Invalid serialization data for Closure object stale line: Invalid serialization data for Closure object (constant-expression closure "$p@0" of class Demo not found) Cannot unserialize an already initialized Closure diff --git a/Zend/tests/closures/closure_const_expr/serialization_named.phpt b/Zend/tests/closures/closure_const_expr/serialization_named.phpt index 5f24afb98e27..3eefb0a1fa21 100644 --- a/Zend/tests/closures/closure_const_expr/serialization_named.phpt +++ b/Zend/tests/closures/closure_const_expr/serialization_named.phpt @@ -87,7 +87,7 @@ foreach (['$p@system', '$p@Demo::nope', '$q@Validators::check', '$p@0'] as $id) } } try { - unserialize('O:7:"Closure":2:{s:5:"class";s:4:"Demo";s:2:"id";s:9:"$p@system";}'); + unserialize('O:7:"Closure":2:' . substr(serialize([[], ['const-expr', ['Demo', '$p@system']]]), 4)); } catch (Exception $e) { echo $e->getMessage(), "\n"; } @@ -101,7 +101,7 @@ 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:{s:5:"class";s:4:"Demo";s:2:"id";s:13:"$p@Demo::priv";} +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" 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..0e7e178c30b1 --- /dev/null +++ b/Zend/tests/closures/closure_const_expr/serialization_tagged_format.phpt @@ -0,0 +1,82 @@ +--TEST-- +Closure serialization payload is a tagged union with a class-relative line +--FILE-- +getAttributes(); +$anon = $attrs[0]->getArguments()[0]; +$fcc = $attrs[1]->getArguments()[0]; + +// The payload is [ , [ , ] ]. The +// properties slot is empty, the tag is "const-expr". An anonymous closure +// carries [class, id, line]; a first-class callable carries just [class, id]. +var_dump($anon->__serialize()); +var_dump($fcc->__serialize()); + +// The line is relative to the declaring class, so it equals the offset of the +// closure from the class declaration, not an absolute line number. +$relLine = $anon->__serialize()[1][1][2]; +$expected = (new ReflectionFunction($anon))->getStartLine() - (new ReflectionClass(Demo::class))->getStartLine(); +var_dump($relLine === $expected); + +// Because the line is class-relative, the same class declared at a different +// offset in the file (here, five blank lines earlier) produces the identical +// reference: an edit above the class does not invalidate stored payloads. +eval("\n\n\n\n\n" . 'class Shifted { + #[A(static function () { return "anon"; })] + public int $p = 0; +}'); +$shifted = (new ReflectionProperty('Shifted', 'p'))->getAttributes()[0]->getArguments()[0]; +var_dump($shifted->__serialize()[1][1][2] === $relLine); + +?> +--EXPECT-- +array(2) { + [0]=> + array(0) { + } + [1]=> + array(2) { + [0]=> + string(10) "const-expr" + [1]=> + array(3) { + [0]=> + string(4) "Demo" + [1]=> + string(4) "$p@0" + [2]=> + int(1) + } + } +} +array(2) { + [0]=> + array(0) { + } + [1]=> + array(2) { + [0]=> + string(10) "const-expr" + [1]=> + array(2) { + [0]=> + string(4) "Demo" + [1]=> + string(9) "$p@strlen" + } + } +} +bool(true) +bool(true) diff --git a/Zend/zend_closures.c b/Zend/zend_closures.c index 4b3d8b52a3a7..0fa2adf8103e 100644 --- a/Zend/zend_closures.c +++ b/Zend/zend_closures.c @@ -1045,7 +1045,18 @@ static uint32_t zend_constexpr_closure_site_lineno(const zend_ast *site) return zend_ast_get_lineno((zend_ast *) site); } -ZEND_API zend_result zend_constexpr_closure_ref(zend_object *closure_obj, zend_class_entry **ce, zend_string **id, uint32_t *lineno) +/* The staleness tripwire is the closure's line relative to its declaring + * class, not an absolute line: an edit above the class (a new use import, + * another declaration) shifts the class and the closure together and leaves + * the offset intact, so only edits between the class line and the closure + * trip it. The declaring class line is the one anchor every consumer can + * recompute, including userland polyfills through ReflectionClass. */ +static zend_long zend_constexpr_closure_rel_lineno(const zend_class_entry *ce, const zend_ast *site) +{ + return (zend_long) zend_constexpr_closure_site_lineno(site) - (zend_long) ce->info.user.line_start; +} + +ZEND_API zend_result zend_constexpr_closure_ref(zend_object *closure_obj, zend_class_entry **ce, zend_string **id, zend_long *lineno, bool *has_line) { const zend_closure *closure = (const zend_closure *) closure_obj; zend_constexpr_closure_walk w; @@ -1087,11 +1098,15 @@ ZEND_API zend_result zend_constexpr_closure_ref(zend_object *closure_obj, zend_c * element. Callers treat it as an opaque token (pass it to * Closure::fromConstExpr); the grammar is an engine detail. */ *id = zend_constexpr_closure_build_ref(&w); + /* Anonymous closures are keyed by rank and carry a relative-line tripwire; + * first-class callable references are keyed by name and need none, since a + * name cannot drift silently. */ + if (has_line) { + *has_line = w.found->kind == ZEND_AST_OP_ARRAY; + } if (lineno) { - /* First-class callable references are keyed by name and need no line - * tripwire: a name cannot drift silently. */ *lineno = w.found->kind == ZEND_AST_OP_ARRAY - ? zend_constexpr_closure_site_lineno(w.found) : 0; + ? zend_constexpr_closure_rel_lineno(site_class, w.found) : 0; } return SUCCESS; } @@ -1113,21 +1128,37 @@ ZEND_METHOD(Closure, __serialize) { zend_class_entry *ce; zend_string *id; - uint32_t lineno; + zend_long lineno; + bool has_line; + zval payload, tagged; ZEND_PARSE_PARAMETERS_NONE(); - if (zend_constexpr_closure_ref(Z_OBJ_P(ZEND_THIS), &ce, &id, &lineno) == FAILURE) { + if (zend_constexpr_closure_ref(Z_OBJ_P(ZEND_THIS), &ce, &id, &lineno, &has_line) == FAILURE) { zend_throw_exception(NULL, "Serialization of 'Closure' is not allowed", 0); RETURN_THROWS(); } - array_init(return_value); - add_assoc_str(return_value, "class", zend_string_copy(ce->name)); - add_assoc_str(return_value, "id", id); - if (lineno) { - add_assoc_long(return_value, "line", lineno); + /* 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] for a first-class + * callable reference (keyed by name) and [class, id, relative-line] for + * an anonymous closure (keyed by rank, line-checked). */ + array_init_size(&payload, has_line ? 3 : 2); + add_next_index_str(&payload, zend_string_copy(ce->name)); + add_next_index_str(&payload, id); + if (has_line) { + add_next_index_long(&payload, lineno); } + + 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); } /* }}} */ @@ -1150,9 +1181,42 @@ ZEND_METHOD(Closure, __unserialize) RETURN_THROWS(); } - z_class = zend_hash_str_find(data, ZEND_STRL("class")); - z_id = zend_hash_str_find(data, ZEND_STRL("id")); - z_line = zend_hash_str_find(data, ZEND_STRL("line")); + /* 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); + z_line = zend_hash_index_find(payload, 2); if (z_class) { ZVAL_DEREF(z_class); } @@ -1187,13 +1251,14 @@ ZEND_METHOD(Closure, __unserialize) } if (site->kind == ZEND_AST_OP_ARRAY) { - /* Anonymous closures are referenced by a positional rank: the start - * line is the staleness tripwire. Name-keyed references carry none. */ + /* Anonymous closures are referenced by a positional rank: the relative + * start line is the staleness tripwire. Name-keyed references carry + * none, so a line here means a malformed payload. */ if (!z_line || Z_TYPE_P(z_line) != IS_LONG) { zend_throw_exception(NULL, "Invalid serialization data for Closure object", 0); RETURN_THROWS(); } - if ((zend_long) zend_constexpr_closure_site_lineno(site) != Z_LVAL_P(z_line)) { + if (zend_constexpr_closure_rel_lineno(ce, site) != Z_LVAL_P(z_line)) { 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)); @@ -1202,6 +1267,11 @@ ZEND_METHOD(Closure, __unserialize) zend_closure_init_ex(closure, (zend_function *) zend_ast_get_op_array(site)->op_array, ce, ce, NULL, /* is_fake */ false); } else { + /* First-class callable site: keyed by name, no line tripwire. */ + if (z_line) { + zend_throw_exception(NULL, "Invalid serialization data for Closure object", 0); + RETURN_THROWS(); + } /* First-class callable site: re-evaluate it like attribute evaluation * would, including its visibility checks against the site class. */ zval tmp; diff --git a/Zend/zend_closures.h b/Zend/zend_closures.h index 9adc1b9e45f0..7220d0809f15 100644 --- a/Zend/zend_closures.h +++ b/Zend/zend_closures.h @@ -42,12 +42,16 @@ 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 position within just that - * element (see zend_closures.c for the walk). 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, uint32_t *lineno); + * reference: an opaque "@" string where names the declaring + * reflection element and is the closure's rank within that element (for + * an anonymous closure) or the referenced callable's name (for a first-class + * callable). zend_constexpr_closure_ref() returns the reference (a fresh + * zend_string the caller owns) for a Closure object, when it has one. For an + * anonymous closure it also sets *has_line and *lineno to the closure's line + * relative to the declaring class (the staleness tripwire); a first-class + * callable reference has no line (*has_line is false). Any out-pointer may be + * NULL. */ +ZEND_API zend_result zend_constexpr_closure_ref(zend_object *closure_obj, zend_class_entry **ce, zend_string **id, zend_long *lineno, bool *has_line); ZEND_API void zend_closure_mark_as_constexpr_fcc(zval *closure_zv, zend_class_entry *site_class); END_EXTERN_C() diff --git a/ext/reflection/php_reflection.c b/ext/reflection/php_reflection.c index 7dafd96560c6..0773934b7b20 100644 --- a/ext/reflection/php_reflection.c +++ b/ext/reflection/php_reflection.c @@ -1980,7 +1980,7 @@ ZEND_METHOD(ReflectionFunction, getConstExprId) if (Z_ISUNDEF(intern->obj) || Z_OBJCE(intern->obj) != zend_ce_closure - || zend_constexpr_closure_ref(Z_OBJ(intern->obj), &ce, &id, NULL) == FAILURE) { + || zend_constexpr_closure_ref(Z_OBJ(intern->obj), &ce, &id, NULL, NULL) == FAILURE) { RETURN_NULL(); } @@ -2005,7 +2005,7 @@ ZEND_METHOD(ReflectionFunction, getConstExprClass) if (Z_ISUNDEF(intern->obj) || Z_OBJCE(intern->obj) != zend_ce_closure - || zend_constexpr_closure_ref(Z_OBJ(intern->obj), &ce, &id, NULL) == FAILURE) { + || zend_constexpr_closure_ref(Z_OBJ(intern->obj), &ce, &id, NULL, NULL) == FAILURE) { RETURN_NULL(); }