Skip to content

Commit c66ee1e

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

24 files changed

Lines changed: 1828 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: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -231,6 +231,22 @@ 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, checked against the closure's line relative to the class, and
241+
the name of the referenced callable, e.g. "$prop@Foo::bar", for
242+
first-class callables), resolved against the loaded class. The payload is
243+
a tagged union ([<properties>, [<tag>, <reference>]]) so the format can
244+
grow further reference kinds. Closures created at runtime, bound to
245+
an object, rebound to another scope, or declared in class constant
246+
values, property defaults or outside a class still refuse to serialize.
247+
The unserialize() allowed_classes filter applies to Closure as to any
248+
other class. Added Closure::fromConstExpr($class, $id) plus
249+
ReflectionFunction::getConstExprId()/getConstExprClass() for exporters.
234250

235251
- Curl:
236252
. 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: 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"
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
--TEST--
2+
Unserializing invalid or stale Closure declaration-site references
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+
$closure = (new ReflectionProperty(Demo::class, 'p'))->getAttributes()[0]->getArguments()[0];
17+
$id = (new ReflectionFunction($closure))->getConstExprId();
18+
// The stored line is relative to the declaring class.
19+
$rel = (new ReflectionFunction($closure))->getStartLine() - (new ReflectionClass(Demo::class))->getStartLine();
20+
21+
// Serialize an array and rebrand it as a Closure object payload, so we can
22+
// feed __unserialize() arbitrary tagged-union shapes.
23+
$obj = static fn (array $data): string => 'O:7:"Closure":' . substr(serialize($data), 2);
24+
25+
// Tagged-union payload: [ [], ["const-expr", [class, id, line?]] ].
26+
$mk = static function (string $class, string $id, ?int $line) use ($obj): string {
27+
$payload = $line === null ? [$class, $id] : [$class, $id, $line];
28+
return $obj([[], ['const-expr', $payload]]);
29+
};
30+
31+
// Sanity check: a valid reference works.
32+
var_dump(unserialize($mk('Demo', $id, $rel))());
33+
34+
$payloads = [
35+
'empty data' => $obj([]),
36+
'one element' => $obj([[]]),
37+
'unknown tag' => $obj([[], ['whatever', ['Demo', $id, $rel]]]),
38+
'tag not list' => $obj([[], 'const-expr']),
39+
'wrong id type' => $obj([[], ['const-expr', ['Demo', 0, $rel]]]),
40+
'unknown class' => $mk('NoSuchClass', $id, $rel),
41+
'internal class' => $mk('stdClass', $id, $rel),
42+
'unknown site' => $mk('Demo', '$nope@0', $rel),
43+
'malformed id' => $mk('Demo', 'no-at-sign', $rel),
44+
'anon missing line' => $mk('Demo', $id, null),
45+
'stale line' => $mk('Demo', $id, $rel + 1),
46+
];
47+
48+
foreach ($payloads as $name => $payload) {
49+
try {
50+
unserialize($payload);
51+
echo "$name: unserialized!?\n";
52+
} catch (Exception $e) {
53+
echo "$name: {$e->getMessage()}\n";
54+
}
55+
}
56+
57+
// __unserialize() cannot be used to reinitialize a live closure.
58+
try {
59+
$closure->__unserialize([[], ['const-expr', ['Demo', $id, $rel]]]);
60+
} catch (Exception $e) {
61+
echo $e->getMessage(), "\n";
62+
}
63+
64+
?>
65+
--EXPECT--
66+
string(2) "ok"
67+
empty data: Invalid serialization data for Closure object
68+
one element: Invalid serialization data for Closure object
69+
unknown tag: Invalid serialization data for Closure object
70+
tag not list: Invalid serialization data for Closure object
71+
wrong id type: Invalid serialization data for Closure object
72+
unknown class: Invalid serialization data for Closure object (cannot load class "NoSuchClass")
73+
internal class: Invalid serialization data for Closure object (cannot load class "stdClass")
74+
unknown site: Invalid serialization data for Closure object (constant-expression closure "$nope@0" of class Demo not found)
75+
malformed id: Invalid serialization data for Closure object (constant-expression closure "no-at-sign" of class Demo not found)
76+
anon missing line: Invalid serialization data for Closure object
77+
stale line: Invalid serialization data for Closure object (constant-expression closure "$p@0" of class Demo not found)
78+
Cannot unserialize an already initialized Closure
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
--TEST--
2+
Misc behaviors of serializable const-expr closures: static vars, identity, scope binding, fromConstExpr() errors
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+
private const SECRET = 'secret';
13+
14+
#[A(static function () { static $n = 0; return ++$n; })]
15+
#[A(static function () { return self::SECRET; })]
16+
public int $p = 0;
17+
}
18+
19+
$attributes = (new ReflectionProperty(Demo::class, 'p'))->getAttributes();
20+
$counter = $attributes[0]->getArguments()[0];
21+
$scoped = $attributes[1]->getArguments()[0];
22+
23+
// A reference does not carry the state of static variables: unserializing
24+
// produces the closure as if it was freshly evaluated.
25+
var_dump($counter(), $counter());
26+
$u = unserialize(serialize($counter));
27+
var_dump($u());
28+
29+
// Unserialized closures are new instances.
30+
var_dump($u === $counter);
31+
32+
// Scope binding is restored: private members of the class are accessible.
33+
var_dump(unserialize(serialize($scoped))());
34+
35+
// fromConstExpr() error cases
36+
try {
37+
Closure::fromConstExpr('NoSuchClass', 0);
38+
} catch (Error $e) {
39+
echo get_class($e), ': ', $e->getMessage(), "\n";
40+
}
41+
try {
42+
Closure::fromConstExpr('stdClass', 0);
43+
} catch (ValueError $e) {
44+
echo get_class($e), ': ', $e->getMessage(), "\n";
45+
}
46+
try {
47+
Closure::fromConstExpr('Demo', 999);
48+
} catch (ValueError $e) {
49+
echo get_class($e), ': ', $e->getMessage(), "\n";
50+
}
51+
52+
?>
53+
--EXPECT--
54+
int(1)
55+
int(2)
56+
int(1)
57+
bool(false)
58+
string(6) "secret"
59+
Error: Class "NoSuchClass" not found
60+
ValueError: Closure::fromConstExpr(): Argument #2 ($id) does not refer to a constant-expression closure of class stdClass
61+
ValueError: Closure::fromConstExpr(): Argument #2 ($id) does not refer to a constant-expression closure of class Demo

0 commit comments

Comments
 (0)