Skip to content

Commit 402dfd2

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

25 files changed

Lines changed: 1662 additions & 17 deletions

NEWS

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,10 @@ 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: serialize() records a
145+
reference to their declaration site, and unserialize() recreates them
146+
bounded to what the class declares. (nicolas-grekas)
143147
. It is now possible to use reference assign on WeakMap without the key
144148
needing to be present beforehand. (ndossche)
145149
. Added `clamp()`. (kylekatarnls, thinkverse)

UPGRADING

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -231,6 +231,17 @@ 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, in attribute arguments and parameter
236+
default values) can now be serialized and unserialized. serialize()
237+
records a reference to the declaration site (the payload contains no code),
238+
and unserialize() recreates the closure by re-evaluating that declaration,
239+
so it can only ever produce a closure the named class declares, never an
240+
arbitrary callable. The reference is verified on resolve and valid for the
241+
code revision that produced it. Closures created at runtime, bound to an
242+
object, or declared in class constant values or property defaults still
243+
refuse to serialize.
244+
RFC: https://wiki.php.net/rfc/serializable_closures
234245

235246
- Curl:
236247
. curl_getinfo() return array now includes a new size_delivered key, which
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
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+
// Resolving a reference autoloads its declaring class and pulls a closure out
36+
// of it, so that class is subject to the filter too: allowing Closure alone is
37+
// not enough when the reference points into another class.
38+
try {
39+
unserialize($payloads['fcc site'], ['allowed_classes' => ['Closure']]);
40+
} catch (Exception $e) {
41+
echo $e->getMessage(), "\n";
42+
}
43+
44+
// Both the Closure and the referenced class must be opted in.
45+
$r = unserialize($payloads['fcc site'], ['allowed_classes' => ['Closure', 'Demo']]);
46+
var_dump($r instanceof Closure, $r('test'));
47+
48+
?>
49+
--EXPECT--
50+
string(9) "anonymous"
51+
bool(true)
52+
string(8) "fcc site"
53+
bool(true)
54+
bool(true)
55+
Invalid serialization data for Closure object (class "Demo" is not allowed)
56+
bool(true)
57+
int(4)
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
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+
// The reference lives in the __serialize() payload: [ [], ["const-expr",
42+
// [class, site, key, hash]] ]. These read it out and rebuild a closure from a
43+
// bare reference the way an exporter would.
44+
$refOf = static fn (Closure $c) => $c->__serialize()[1][1]; // [class, site, key, hash]
45+
$fromRef = static fn (array $ref) => unserialize('O:7:"Closure":' . substr(serialize([[], ['const-expr', $ref]]), 2));
46+
47+
foreach ($closures as $expected => $closure) {
48+
$unserialized = unserialize(serialize($closure));
49+
$recreated = $fromRef($refOf($closure));
50+
51+
var_dump($expected === $closure() && $expected === $unserialized() && $expected === $recreated());
52+
}
53+
54+
// References are assigned in canonical walk order: class attributes first, then
55+
// constants, then properties, then methods (including parameters). The display
56+
// below is "<site>@<key>"; the code hash (the fourth field) is not part of the
57+
// addressing contract (see serialization_code_hash.phpt).
58+
$ids = array_map(
59+
static fn ($c) => $refOf($c)[1] . '@' . $refOf($c)[2],
60+
$closures
61+
);
62+
var_dump($ids);
63+
64+
?>
65+
--EXPECT--
66+
bool(true)
67+
bool(true)
68+
bool(true)
69+
bool(true)
70+
bool(true)
71+
bool(true)
72+
bool(true)
73+
array(7) {
74+
["class"]=>
75+
string(2) "@0"
76+
["const"]=>
77+
string(5) "FOO@0"
78+
["prop-1"]=>
79+
string(7) "$name@0"
80+
["prop-2"]=>
81+
string(7) "$name@1"
82+
["method"]=>
83+
string(5) "m()@0"
84+
["param"]=>
85+
string(5) "m()@1"
86+
["case"]=>
87+
string(3) "X@0"
88+
}
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
--TEST--
2+
Anonymous const-expr closure references carry 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 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+
$ref = (new ReflectionProperty(Ordered::class, 'p'))->getAttributes()[0]->getArguments()[0]
31+
->__serialize()[1][1];
32+
33+
// The reference is [class, site, rank, hash]: rank 0 of $p, with a non-zero
34+
// code hash in the fourth field.
35+
var_dump($ref[1] === '$p' && $ref[2] === 0 && is_int($ref[3]) && $ref[3] !== 0);
36+
$hash = $ref[3];
37+
38+
$make = static fn (string $class, string $site, int|string $key, int $hash): string =>
39+
'O:7:"Closure":' . substr(serialize([[], ['const-expr', [$class, $site, $key, $hash]]]), 2);
40+
41+
// Same class: resolves.
42+
var_dump(unserialize($make('Ordered', '$p', 0, $hash))());
43+
44+
// The swapped class has B at rank 0 of $q; same rank, but the hash guards it.
45+
try {
46+
unserialize($make('Swapped', '$q', 0, $hash));
47+
} catch (Exception $e) {
48+
echo $e->getMessage(), "\n";
49+
}
50+
51+
// The hash is over the closure's code, insensitive to layout and comments: a
52+
// reference resolves against a reformatted body...
53+
class Reformatted {
54+
#[A(static function () {
55+
// a comment; different whitespace and layout
56+
return 'a' ;
57+
})]
58+
public int $p = 0;
59+
}
60+
var_dump(unserialize($make('Reformatted', '$p', 0, $hash))());
61+
62+
// ...but not against a changed body.
63+
class Changed {
64+
#[A(static function () { return 'CHANGED'; })]
65+
public int $p = 0;
66+
}
67+
try {
68+
unserialize($make('Changed', '$p', 0, $hash));
69+
} catch (Exception $e) {
70+
echo $e->getMessage(), "\n";
71+
}
72+
73+
// A reference without a hash (a producer that cannot compute it) resolves
74+
// positionally, unverified.
75+
var_dump(unserialize($make('Ordered', '$p', 0, 0))());
76+
77+
?>
78+
--EXPECT--
79+
bool(true)
80+
string(1) "a"
81+
Invalid serialization data for Closure object (constant-expression closure "$q@0" of class Swapped changed)
82+
string(1) "a"
83+
Invalid serialization data for Closure object (constant-expression closure "$p@0" of class Changed changed)
84+
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)