Skip to content

Commit b1fa8f4

Browse files
Serialize closures declared in constant expressions
1 parent aaa141a commit b1fa8f4

25 files changed

Lines changed: 1811 additions & 17 deletions

NEWS

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,11 @@ PHP NEWS
140140
- Core:
141141
. Added first-class callable cache to share instances for the duration of the
142142
request. (ilutov)
143+
. Closures declared in constant expressions (anonymous closures and
144+
first-class callables) can now be serialized as references to their
145+
declaration site. Added Closure::fromConstExpr(),
146+
ReflectionFunction::getConstExprId() and getConstExprClass().
147+
(nicolas-grekas)
143148
. It is now possible to use reference assign on WeakMap without the key
144149
needing to be present beforehand. (ndossche)
145150
. Added `clamp()`. (kylekatarnls, thinkverse)

UPGRADING

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -231,6 +231,23 @@ PHP 8.6 UPGRADE NOTES
231231
RFC: https://wiki.php.net/rfc/debugable-enums
232232
. #[\Override] can now be applied to class constants, including enum cases.
233233
RFC: https://wiki.php.net/rfc/override_constants
234+
. Closures declared in constant expressions of a class member (anonymous
235+
closures and first-class callables, of any visibility, in attribute
236+
arguments and parameter default values) can now be serialized and
237+
unserialized. The payload contains no code: it is a reference to the
238+
declaration site (class name plus an element-scoped "<site>@<key>" id;
239+
the key is the closure's rank within the declaring element for anonymous
240+
closures, verified against a hash of the closure's code so a reordered
241+
declaration cannot silently resolve to a different closure, and the name
242+
of the referenced callable, e.g. "$prop@Foo::bar", for first-class
243+
callables), resolved against the loaded class. The payload is a tagged
244+
union ([<properties>, [<tag>, <reference>]]) so the format can grow
245+
further reference kinds. Closures created at runtime, bound to
246+
an object, rebound to another scope, or declared in class constant
247+
values, property defaults or outside a class still refuse to serialize.
248+
The unserialize() allowed_classes filter applies to Closure as to any
249+
other class. Added Closure::fromConstExpr($class, $id) plus
250+
ReflectionFunction::getConstExprId()/getConstExprClass() for exporters.
234251

235252
- Curl:
236253
. curl_getinfo() return array now includes a new size_delivered key, which
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
--TEST--
2+
Serializable closures are gated by the unserialize() allowed_classes filter
3+
--FILE--
4+
<?php
5+
6+
#[Attribute(Attribute::TARGET_ALL)]
7+
class A {
8+
public function __construct(public mixed $cb = null) {}
9+
}
10+
11+
class Demo {
12+
#[A(static function () { return 'ok'; })]
13+
#[A(strlen(...))]
14+
public int $p = 0;
15+
}
16+
17+
$attrs = (new ReflectionProperty(Demo::class, 'p'))->getAttributes();
18+
$payloads = [
19+
'anonymous' => serialize($attrs[0]->getArguments()[0]),
20+
'fcc site' => serialize($attrs[1]->getArguments()[0]),
21+
];
22+
23+
// The recommended safe-unserialize practice (allowed_classes => false) blocks
24+
// every Closure payload: it becomes __PHP_Incomplete_Class and __unserialize()
25+
// is never invoked, exactly like any other object-injection gadget.
26+
foreach ($payloads as $name => $payload) {
27+
$r = unserialize($payload, ['allowed_classes' => false]);
28+
var_dump($name, $r instanceof __PHP_Incomplete_Class);
29+
}
30+
31+
// A list that does not contain Closure also blocks it.
32+
$r = unserialize($payloads['fcc site'], ['allowed_classes' => ['stdClass']]);
33+
var_dump($r instanceof __PHP_Incomplete_Class);
34+
35+
// Closure must be explicitly opted in.
36+
$r = unserialize($payloads['fcc site'], ['allowed_classes' => ['Closure']]);
37+
var_dump($r instanceof Closure, $r('test'));
38+
39+
?>
40+
--EXPECT--
41+
string(9) "anonymous"
42+
bool(true)
43+
string(8) "fcc site"
44+
bool(true)
45+
bool(true)
46+
bool(true)
47+
int(4)
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
--TEST--
2+
Closures in constant expressions are serializable as declaration-site references
3+
--FILE--
4+
<?php
5+
6+
#[Attribute(Attribute::TARGET_ALL)]
7+
class A {
8+
public function __construct(public mixed $cb = null, public mixed $extra = null) {}
9+
}
10+
11+
#[A(static function () { return 'class'; })]
12+
class Demo {
13+
#[A(static function () { return 'const'; })]
14+
public const FOO = 1;
15+
16+
#[A(cb: [static function () { return 'prop-1'; }, static function (): string { return 'prop-2'; }])]
17+
public string $name = '';
18+
19+
#[A(static function () { return 'method'; })]
20+
public function m(
21+
#[A(static function () { return 'param'; })]
22+
int $x = 0,
23+
): void {}
24+
}
25+
26+
enum E {
27+
#[A(static function () { return 'case'; })]
28+
case X;
29+
}
30+
31+
$closures = [
32+
'class' => (new ReflectionClass(Demo::class))->getAttributes()[0]->getArguments()[0],
33+
'const' => (new ReflectionClassConstant(Demo::class, 'FOO'))->getAttributes()[0]->getArguments()[0],
34+
'prop-1' => (new ReflectionProperty(Demo::class, 'name'))->getAttributes()[0]->getArguments()['cb'][0],
35+
'prop-2' => (new ReflectionProperty(Demo::class, 'name'))->getAttributes()[0]->getArguments()['cb'][1],
36+
'method' => (new ReflectionMethod(Demo::class, 'm'))->getAttributes()[0]->getArguments()[0],
37+
'param' => (new ReflectionParameter([Demo::class, 'm'], 'x'))->getAttributes()[0]->getArguments()[0],
38+
'case' => (new ReflectionClassConstant(E::class, 'X'))->getAttributes()[0]->getArguments()[0],
39+
];
40+
41+
foreach ($closures as $expected => $closure) {
42+
$r = new ReflectionFunction($closure);
43+
$id = $r->getConstExprId();
44+
$scope = $r->getClosureScopeClass()->name;
45+
46+
$unserialized = unserialize(serialize($closure));
47+
$recreated = Closure::fromConstExpr($scope, $id);
48+
49+
var_dump($expected === $closure() && $expected === $unserialized() && $expected === $recreated());
50+
}
51+
52+
// Ids are assigned in canonical walk order: class attributes first, then
53+
// constants, then properties, then methods (including parameters).
54+
$ids = array_map(
55+
static fn ($c) => (new ReflectionFunction($c))->getConstExprId(),
56+
$closures
57+
);
58+
var_dump($ids);
59+
60+
?>
61+
--EXPECT--
62+
bool(true)
63+
bool(true)
64+
bool(true)
65+
bool(true)
66+
bool(true)
67+
bool(true)
68+
bool(true)
69+
array(7) {
70+
["class"]=>
71+
string(2) "@0"
72+
["const"]=>
73+
string(5) "FOO@0"
74+
["prop-1"]=>
75+
string(7) "$name@0"
76+
["prop-2"]=>
77+
string(7) "$name@1"
78+
["method"]=>
79+
string(5) "m()@0"
80+
["param"]=>
81+
string(5) "m()@1"
82+
["case"]=>
83+
string(3) "X@0"
84+
}
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
--TEST--
2+
Anonymous const-expr closure references are verified by a code hash
3+
--FILE--
4+
<?php
5+
6+
#[Attribute(Attribute::TARGET_ALL)]
7+
class A {
8+
public function __construct(public mixed $cb = null) {}
9+
}
10+
#[Attribute(Attribute::TARGET_ALL)]
11+
class B {
12+
public function __construct(public mixed $cb = null) {}
13+
}
14+
15+
// Serialize the closure of attribute A (rank 0 of the property), then resolve
16+
// the same payload against a class where A and B are swapped: rank 0 now names
17+
// B's closure, but the code hash no longer matches, so it fails loudly instead
18+
// of silently returning B's closure.
19+
class Ordered {
20+
#[A(static function () { return 'a'; })]
21+
#[B(static function () { return 'b'; })]
22+
public int $p = 0;
23+
}
24+
class Swapped {
25+
#[B(static function () { return 'b'; })]
26+
#[A(static function () { return 'a'; })]
27+
public int $q = 0;
28+
}
29+
30+
$closure = (new ReflectionProperty(Ordered::class, 'p'))->getAttributes()[0]->getArguments()[0];
31+
[, [, [, $id, $hash]]] = $closure->__serialize();
32+
33+
// Retarget the reference at a chosen class/site while keeping A's id and hash.
34+
$obj = static fn (array $data): string => 'O:7:"Closure":' . substr(serialize($data), 2);
35+
$ref = static fn (string $class, string $id, ?int $hash) => $obj(
36+
[[], ['const-expr', $hash === null ? [$class, $id] : [$class, $id, $hash]]]);
37+
38+
// Same class: resolves.
39+
var_dump(unserialize($ref('Ordered', $id, $hash))());
40+
41+
// The swapped class has B at rank 0 of $q; same rank, but the hash guards it.
42+
try {
43+
unserialize($ref('Swapped', '$q@0', $hash));
44+
} catch (Exception $e) {
45+
echo $e->getMessage(), "\n";
46+
}
47+
48+
// The hash is over the closure's code, insensitive to layout and comments: a
49+
// reference resolves against a reformatted body...
50+
class Reformatted {
51+
#[A(static function () {
52+
// a comment; different whitespace and layout
53+
return 'a' ;
54+
})]
55+
public int $p = 0;
56+
}
57+
var_dump(unserialize($ref('Reformatted', $id, $hash))());
58+
59+
// ...but not against a changed body.
60+
class Changed {
61+
#[A(static function () { return 'CHANGED'; })]
62+
public int $p = 0;
63+
}
64+
try {
65+
unserialize($ref('Changed', $id, $hash));
66+
} catch (Exception $e) {
67+
echo $e->getMessage(), "\n";
68+
}
69+
70+
// A payload without a hash (a producer that cannot compute it) resolves
71+
// positionally, unverified.
72+
var_dump(unserialize($ref('Ordered', $id, null))());
73+
74+
?>
75+
--EXPECT--
76+
string(1) "a"
77+
Invalid serialization data for Closure object (constant-expression closure "$q@0" of class Swapped changed)
78+
string(1) "a"
79+
Invalid serialization data for Closure object (constant-expression closure "$p@0" of class Changed changed)
80+
string(1) "a"
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
--TEST--
2+
Const-expr closures inside serialized object graphs
3+
--FILE--
4+
<?php
5+
6+
#[Attribute(Attribute::TARGET_ALL)]
7+
class A {
8+
public function __construct(public mixed $cb = null) {}
9+
}
10+
11+
class Demo {
12+
#[A(static function () { return 'ok'; })]
13+
public int $p = 0;
14+
}
15+
16+
class Holder {
17+
public $c;
18+
public function __wakeup() {
19+
// Delayed calls run in creation order: the closure is already
20+
// initialized when this runs.
21+
echo "wakeup sees: ", ($this->c)(), "\n";
22+
}
23+
}
24+
25+
$h = new Holder();
26+
$h->c = (new ReflectionProperty(Demo::class, 'p'))->getAttributes()[0]->getArguments()[0];
27+
28+
$u = unserialize(serialize([$h, $h->c, [$h->c]]));
29+
30+
echo "after: ", ($u[1])(), "\n";
31+
// Shared instances are preserved within the graph.
32+
var_dump($u[0]->c === $u[1], $u[1] === $u[2][0]);
33+
34+
?>
35+
--EXPECT--
36+
wakeup sees: ok
37+
after: ok
38+
bool(true)
39+
bool(true)
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
--TEST--
2+
Serialization of const-expr closures with inheritance and traits
3+
--FILE--
4+
<?php
5+
6+
#[Attribute(Attribute::TARGET_ALL)]
7+
class A {
8+
public function __construct(public mixed $cb = null) {}
9+
}
10+
11+
class Base {
12+
#[A(static function () { return 'base'; })]
13+
public int $p = 0;
14+
}
15+
16+
class Child extends Base {}
17+
18+
trait T {
19+
public function m(
20+
#[A(static function () { return 'trait'; })]
21+
$x = null,
22+
) {}
23+
}
24+
25+
class UsesTrait {
26+
use T;
27+
}
28+
29+
// Attribute on an inherited property: the closure is scoped to the
30+
// declaring class and the reference uses that class.
31+
$c = (new ReflectionProperty(Child::class, 'p'))->getAttributes()[0]->getArguments()[0];
32+
var_dump((new ReflectionFunction($c))->getClosureScopeClass()->name);
33+
$payload = serialize($c);
34+
var_dump(str_contains($payload, '"Base"'));
35+
var_dump(unserialize($payload)());
36+
37+
// Attribute on a parameter of a trait method: the copied method is scoped
38+
// to the using class and the reference resolves through it.
39+
$c = (new ReflectionParameter([UsesTrait::class, 'm'], 'x'))->getAttributes()[0]->getArguments()[0];
40+
var_dump((new ReflectionFunction($c))->getClosureScopeClass()->name);
41+
$u = unserialize(serialize($c));
42+
var_dump($u());
43+
44+
?>
45+
--EXPECT--
46+
string(4) "Base"
47+
bool(true)
48+
string(4) "base"
49+
string(9) "UsesTrait"
50+
string(5) "trait"

0 commit comments

Comments
 (0)