Skip to content

Commit 814db1c

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

25 files changed

Lines changed: 1848 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: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -231,6 +231,26 @@ 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, suffixed with a hash of the closure's code, "$prop@0#4a3f", so
241+
a reordered declaration cannot silently resolve to a different closure,
242+
and the name of the referenced callable, e.g. "$prop@Foo::bar", for
243+
first-class callables), resolved against the loaded class. Both
244+
unserialize() and Closure::fromConstExpr() verify the hash; producers
245+
that cannot compute it may omit the "#<hash>" suffix, trading
246+
verification for positional resolution. The payload is a tagged
247+
union ([<properties>, [<tag>, <reference>]]) so the format can grow
248+
further reference kinds. Closures created at runtime, bound to
249+
an object, rebound to another scope, or declared in class constant
250+
values, property defaults or outside a class still refuse to serialize.
251+
The unserialize() allowed_classes filter applies to Closure as to any
252+
other class. Added Closure::fromConstExpr($class, $id) plus
253+
ReflectionFunction::getConstExprId()/getConstExprClass() for exporters.
234254

235255
- Curl:
236256
. 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: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
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). The
54+
// "#<hash>" code-hash suffix is stripped here: its value is not part of the
55+
// addressing contract (see serialization_code_hash.phpt).
56+
$ids = array_map(
57+
static fn ($c) => preg_replace('/#[0-9a-f]{4}$/', '', (new ReflectionFunction($c))->getConstExprId()),
58+
$closures
59+
);
60+
var_dump($ids);
61+
62+
?>
63+
--EXPECT--
64+
bool(true)
65+
bool(true)
66+
bool(true)
67+
bool(true)
68+
bool(true)
69+
bool(true)
70+
bool(true)
71+
array(7) {
72+
["class"]=>
73+
string(2) "@0"
74+
["const"]=>
75+
string(5) "FOO@0"
76+
["prop-1"]=>
77+
string(7) "$name@0"
78+
["prop-2"]=>
79+
string(7) "$name@1"
80+
["method"]=>
81+
string(5) "m()@0"
82+
["param"]=>
83+
string(5) "m()@1"
84+
["case"]=>
85+
string(3) "X@0"
86+
}
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
--TEST--
2+
Anonymous const-expr closure references embed a code hash in their id
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 reference against a class where A and B are swapped: rank 0 now
17+
// names B's closure, but the code hash embedded in the id no longer matches,
18+
// so it fails loudly instead 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+
$id = (new ReflectionFunction(
31+
(new ReflectionProperty(Ordered::class, 'p'))->getAttributes()[0]->getArguments()[0]
32+
))->getConstExprId();
33+
34+
// The id is "<site>@<rank>#<hash>"; split it to retarget the reference.
35+
var_dump(preg_match('/^\$p@0#[0-9a-f]{4}$/', $id));
36+
$hash = substr($id, strpos($id, '#'));
37+
38+
$ref = static fn (string $class, string $id): string =>
39+
'O:7:"Closure":' . substr(serialize([[], ['const-expr', [$class, $id]]]), 2);
40+
41+
// Same class: resolves, and fromConstExpr() verifies the same way.
42+
var_dump(unserialize($ref('Ordered', $id))());
43+
var_dump(Closure::fromConstExpr('Ordered', $id)());
44+
45+
// The swapped class has B at rank 0 of $q; same rank, but the hash guards it.
46+
try {
47+
unserialize($ref('Swapped', '$q@0' . $hash));
48+
} catch (Exception $e) {
49+
echo $e->getMessage(), "\n";
50+
}
51+
try {
52+
Closure::fromConstExpr('Swapped', '$q@0' . $hash);
53+
} catch (ValueError $e) {
54+
echo $e->getMessage(), "\n";
55+
}
56+
57+
// The hash is over the closure's code, insensitive to layout and comments: a
58+
// reference resolves against a reformatted body...
59+
class Reformatted {
60+
#[A(static function () {
61+
// a comment; different whitespace and layout
62+
return 'a' ;
63+
})]
64+
public int $p = 0;
65+
}
66+
var_dump(unserialize($ref('Reformatted', '$p@0' . $hash))());
67+
68+
// ...but not against a changed body.
69+
class Changed {
70+
#[A(static function () { return 'CHANGED'; })]
71+
public int $p = 0;
72+
}
73+
try {
74+
unserialize($ref('Changed', '$p@0' . $hash));
75+
} catch (Exception $e) {
76+
echo $e->getMessage(), "\n";
77+
}
78+
79+
// An id without a hash (a producer that cannot compute it) resolves
80+
// positionally, unverified.
81+
var_dump(unserialize($ref('Ordered', '$p@0'))());
82+
83+
?>
84+
--EXPECTF--
85+
int(1)
86+
string(1) "a"
87+
string(1) "a"
88+
Invalid serialization data for Closure object (constant-expression closure "$q@0#%s" of class Swapped changed)
89+
Closure::fromConstExpr(): Argument #2 ($id) refers to a constant-expression closure of class Swapped that has changed
90+
string(1) "a"
91+
Invalid serialization data for Closure object (constant-expression closure "$p@0#%s" of class Changed changed)
92+
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)