Skip to content

Commit ea8be95

Browse files
Serialize closures declared in constant expressions
Anonymous closures and first-class callables declared in constant expressions of a class member serialize as references to their declaration site (class name, deterministic per-class id, start line). unserialize() resolves the reference against the loaded class; payloads contain no code. Runtime-created closures keep refusing to serialize. Adds Closure::fromConstExpr() and ReflectionFunction::getConstExprId()/getConstExprClass(). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 20e99a7 commit ea8be95

23 files changed

Lines changed: 1346 additions & 19 deletions

NEWS

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,11 @@ PHP NEWS
55
- Core:
66
. Added first-class callable cache to share instances for the duration of the
77
request. (ilutov)
8+
. Closures declared in constant expressions (anonymous closures and
9+
first-class callables) can now be serialized as references to their
10+
declaration site. Added Closure::fromConstExpr(),
11+
ReflectionFunction::getConstExprId() and getConstExprClass().
12+
(nicolas-grekas)
813
. It is now possible to use reference assign on WeakMap without the key
914
needing to be present beforehand. (ndossche)
1015
. Added `clamp()`. (kylekatarnls, thinkverse)

UPGRADING

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,17 @@ PHP 8.6 UPGRADE NOTES
181181
needing to be present beforehand.
182182
. It is now possible to define the `__debugInfo()` magic method on enums.
183183
RFC: https://wiki.php.net/rfc/debugable-enums
184+
. Closures declared in constant expressions of a class member (anonymous
185+
closures and first-class callables, of any visibility, in attribute
186+
arguments and parameter default values) can now be serialized and
187+
unserialized. The payload contains no code: it is a reference to the
188+
declaration site (class name, deterministic per-class id, start line),
189+
resolved against the loaded class. Closures created at runtime, bound to
190+
an object, rebound to another scope, or declared in class constant
191+
values, property defaults or outside a class still refuse to serialize.
192+
The unserialize() allowed_classes filter applies to Closure as to any
193+
other class. Added Closure::fromConstExpr($class, $id) plus
194+
ReflectionFunction::getConstExprId()/getConstExprClass() for exporters.
184195

185196
- Fileinfo:
186197
. finfo_file() now works with remote streams.
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+
int(0)
72+
["const"]=>
73+
int(1)
74+
["prop-1"]=>
75+
int(2)
76+
["prop-2"]=>
77+
int(3)
78+
["method"]=>
79+
int(4)
80+
["param"]=>
81+
int(5)
82+
["case"]=>
83+
int(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: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
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+
$r = new ReflectionFunction($closure);
18+
$id = $r->getConstExprId();
19+
$line = $r->getStartLine();
20+
21+
$mk = static fn (string $class, int $id, int $line) => sprintf(
22+
'O:7:"Closure":3:{s:5:"class";s:%d:"%s";s:2:"id";i:%d;s:4:"line";i:%d;}',
23+
strlen($class), $class, $id, $line
24+
);
25+
26+
// Sanity check: a valid reference works.
27+
var_dump(unserialize($mk('Demo', $id, $line))());
28+
29+
$payloads = [
30+
'empty data' => 'O:7:"Closure":0:{}',
31+
'missing keys' => 'O:7:"Closure":1:{s:5:"class";s:4:"Demo";}',
32+
'wrong types' => 'O:7:"Closure":3:{s:5:"class";s:4:"Demo";s:2:"id";s:1:"0";s:4:"line";i:1;}',
33+
'unknown class' => $mk('NoSuchClass', $id, $line),
34+
'internal class' => $mk('stdClass', $id, $line),
35+
'unknown id' => $mk('Demo', 999, $line),
36+
'negative id' => $mk('Demo', -1, $line),
37+
'stale line' => $mk('Demo', $id, $line + 1),
38+
];
39+
40+
foreach ($payloads as $name => $payload) {
41+
try {
42+
unserialize($payload);
43+
echo "$name: unserialized!?\n";
44+
} catch (Exception $e) {
45+
echo "$name: {$e->getMessage()}\n";
46+
}
47+
}
48+
49+
// __unserialize() cannot be used to reinitialize a live closure.
50+
try {
51+
$closure->__unserialize(['class' => 'Demo', 'id' => $id, 'line' => $line]);
52+
} catch (Exception $e) {
53+
echo $e->getMessage(), "\n";
54+
}
55+
56+
?>
57+
--EXPECT--
58+
string(2) "ok"
59+
empty data: Invalid serialization data for Closure object
60+
missing keys: Invalid serialization data for Closure object
61+
wrong types: Invalid serialization data for Closure object
62+
unknown class: Invalid serialization data for Closure object (cannot load class "NoSuchClass")
63+
internal class: Invalid serialization data for Closure object (cannot load class "stdClass")
64+
unknown id: Invalid serialization data for Closure object (constant-expression closure 999 of class Demo not found)
65+
negative id: Invalid serialization data for Closure object (constant-expression closure -1 of class Demo not found)
66+
stale line: Invalid serialization data for Closure object (constant-expression closure 0 of class Demo not found)
67+
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)