Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions NEWS
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@ PHP NEWS
- Core:
. Added first-class callable cache to share instances for the duration of the
request. (ilutov)
. Closures declared in constant expressions (anonymous closures and
first-class callables) can now be serialized as references to their
declaration site. Added Closure::fromConstExpr(),
ReflectionFunction::getConstExprId() and getConstExprClass().
(nicolas-grekas)
. It is now possible to use reference assign on WeakMap without the key
needing to be present beforehand. (ndossche)
. Added `clamp()`. (kylekatarnls, thinkverse)
Expand Down
11 changes: 11 additions & 0 deletions UPGRADING
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,17 @@ PHP 8.6 UPGRADE NOTES
needing to be present beforehand.
. It is now possible to define the `__debugInfo()` magic method on enums.
RFC: https://wiki.php.net/rfc/debugable-enums
. Closures declared in constant expressions of a class member (anonymous
closures and first-class callables, of any visibility, in attribute
arguments and parameter default values) can now be serialized and
unserialized. The payload contains no code: it is a reference to the
declaration site (class name, deterministic per-class id, start line),
resolved against the loaded class. Closures created at runtime, bound to
an object, rebound to another scope, or declared in class constant
values, property defaults or outside a class still refuse to serialize.
The unserialize() allowed_classes filter applies to Closure as to any
other class. Added Closure::fromConstExpr($class, $id) plus
ReflectionFunction::getConstExprId()/getConstExprClass() for exporters.

- Fileinfo:
. finfo_file() now works with remote streams.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
--TEST--
Serializable closures are gated by the unserialize() allowed_classes filter
--FILE--
<?php

#[Attribute(Attribute::TARGET_ALL)]
class A {
public function __construct(public mixed $cb = null) {}
}

class Demo {
#[A(static function () { return 'ok'; })]
#[A(strlen(...))]
public int $p = 0;
}

$attrs = (new ReflectionProperty(Demo::class, 'p'))->getAttributes();
$payloads = [
'anonymous' => serialize($attrs[0]->getArguments()[0]),
'fcc site' => serialize($attrs[1]->getArguments()[0]),
];

// The recommended safe-unserialize practice (allowed_classes => false) blocks
// every Closure payload: it becomes __PHP_Incomplete_Class and __unserialize()
// is never invoked, exactly like any other object-injection gadget.
foreach ($payloads as $name => $payload) {
$r = unserialize($payload, ['allowed_classes' => false]);
var_dump($name, $r instanceof __PHP_Incomplete_Class);
}

// A list that does not contain Closure also blocks it.
$r = unserialize($payloads['fcc site'], ['allowed_classes' => ['stdClass']]);
var_dump($r instanceof __PHP_Incomplete_Class);

// Closure must be explicitly opted in.
$r = unserialize($payloads['fcc site'], ['allowed_classes' => ['Closure']]);
var_dump($r instanceof Closure, $r('test'));

?>
--EXPECT--
string(9) "anonymous"
bool(true)
string(8) "fcc site"
bool(true)
bool(true)
bool(true)
int(4)
84 changes: 84 additions & 0 deletions Zend/tests/closures/closure_const_expr/serialization_basic.phpt
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
--TEST--
Closures in constant expressions are serializable as declaration-site references
--FILE--
<?php

#[Attribute(Attribute::TARGET_ALL)]
class A {
public function __construct(public mixed $cb = null, public mixed $extra = null) {}
}

#[A(static function () { return 'class'; })]
class Demo {
#[A(static function () { return 'const'; })]
public const FOO = 1;

#[A(cb: [static function () { return 'prop-1'; }, static function (): string { return 'prop-2'; }])]
public string $name = '';

#[A(static function () { return 'method'; })]
public function m(
#[A(static function () { return 'param'; })]
int $x = 0,
): void {}
}

enum E {
#[A(static function () { return 'case'; })]
case X;
}

$closures = [
'class' => (new ReflectionClass(Demo::class))->getAttributes()[0]->getArguments()[0],
'const' => (new ReflectionClassConstant(Demo::class, 'FOO'))->getAttributes()[0]->getArguments()[0],
'prop-1' => (new ReflectionProperty(Demo::class, 'name'))->getAttributes()[0]->getArguments()['cb'][0],
'prop-2' => (new ReflectionProperty(Demo::class, 'name'))->getAttributes()[0]->getArguments()['cb'][1],
'method' => (new ReflectionMethod(Demo::class, 'm'))->getAttributes()[0]->getArguments()[0],
'param' => (new ReflectionParameter([Demo::class, 'm'], 'x'))->getAttributes()[0]->getArguments()[0],
'case' => (new ReflectionClassConstant(E::class, 'X'))->getAttributes()[0]->getArguments()[0],
];

foreach ($closures as $expected => $closure) {
$r = new ReflectionFunction($closure);
$id = $r->getConstExprId();
$scope = $r->getClosureScopeClass()->name;

$unserialized = unserialize(serialize($closure));
$recreated = Closure::fromConstExpr($scope, $id);

var_dump($expected === $closure() && $expected === $unserialized() && $expected === $recreated());
}

// Ids are assigned in canonical walk order: class attributes first, then
// constants, then properties, then methods (including parameters).
$ids = array_map(
static fn ($c) => (new ReflectionFunction($c))->getConstExprId(),
$closures
);
var_dump($ids);

?>
--EXPECT--
bool(true)
bool(true)
bool(true)
bool(true)
bool(true)
bool(true)
bool(true)
array(7) {
["class"]=>
int(0)
["const"]=>
int(1)
["prop-1"]=>
int(2)
["prop-2"]=>
int(3)
["method"]=>
int(4)
["param"]=>
int(5)
["case"]=>
int(0)
}
39 changes: 39 additions & 0 deletions Zend/tests/closures/closure_const_expr/serialization_graph.phpt
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
--TEST--
Const-expr closures inside serialized object graphs
--FILE--
<?php

#[Attribute(Attribute::TARGET_ALL)]
class A {
public function __construct(public mixed $cb = null) {}
}

class Demo {
#[A(static function () { return 'ok'; })]
public int $p = 0;
}

class Holder {
public $c;
public function __wakeup() {
// Delayed calls run in creation order: the closure is already
// initialized when this runs.
echo "wakeup sees: ", ($this->c)(), "\n";
}
}

$h = new Holder();
$h->c = (new ReflectionProperty(Demo::class, 'p'))->getAttributes()[0]->getArguments()[0];

$u = unserialize(serialize([$h, $h->c, [$h->c]]));

echo "after: ", ($u[1])(), "\n";
// Shared instances are preserved within the graph.
var_dump($u[0]->c === $u[1], $u[1] === $u[2][0]);

?>
--EXPECT--
wakeup sees: ok
after: ok
bool(true)
bool(true)
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
--TEST--
Serialization of const-expr closures with inheritance and traits
--FILE--
<?php

#[Attribute(Attribute::TARGET_ALL)]
class A {
public function __construct(public mixed $cb = null) {}
}

class Base {
#[A(static function () { return 'base'; })]
public int $p = 0;
}

class Child extends Base {}

trait T {
public function m(
#[A(static function () { return 'trait'; })]
$x = null,
) {}
}

class UsesTrait {
use T;
}

// Attribute on an inherited property: the closure is scoped to the
// declaring class and the reference uses that class.
$c = (new ReflectionProperty(Child::class, 'p'))->getAttributes()[0]->getArguments()[0];
var_dump((new ReflectionFunction($c))->getClosureScopeClass()->name);
$payload = serialize($c);
var_dump(str_contains($payload, '"Base"'));
var_dump(unserialize($payload)());

// Attribute on a parameter of a trait method: the copied method is scoped
// to the using class and the reference resolves through it.
$c = (new ReflectionParameter([UsesTrait::class, 'm'], 'x'))->getAttributes()[0]->getArguments()[0];
var_dump((new ReflectionFunction($c))->getClosureScopeClass()->name);
$u = unserialize(serialize($c));
var_dump($u());

?>
--EXPECT--
string(4) "Base"
bool(true)
string(4) "base"
string(9) "UsesTrait"
string(5) "trait"
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
--TEST--
Unserializing invalid or stale Closure declaration-site references
--FILE--
<?php

#[Attribute(Attribute::TARGET_ALL)]
class A {
public function __construct(public mixed $cb = null) {}
}

class Demo {
#[A(static function () { return 'ok'; })]
public int $p = 0;
}

$closure = (new ReflectionProperty(Demo::class, 'p'))->getAttributes()[0]->getArguments()[0];
$r = new ReflectionFunction($closure);
$id = $r->getConstExprId();
$line = $r->getStartLine();

$mk = static fn (string $class, int $id, int $line) => sprintf(
'O:7:"Closure":3:{s:5:"class";s:%d:"%s";s:2:"id";i:%d;s:4:"line";i:%d;}',
strlen($class), $class, $id, $line
);

// Sanity check: a valid reference works.
var_dump(unserialize($mk('Demo', $id, $line))());

$payloads = [
'empty data' => 'O:7:"Closure":0:{}',
'missing keys' => 'O:7:"Closure":1:{s:5:"class";s:4:"Demo";}',
'wrong types' => 'O:7:"Closure":3:{s:5:"class";s:4:"Demo";s:2:"id";s:1:"0";s:4:"line";i:1;}',
'unknown class' => $mk('NoSuchClass', $id, $line),
'internal class' => $mk('stdClass', $id, $line),
'unknown id' => $mk('Demo', 999, $line),
'negative id' => $mk('Demo', -1, $line),
'stale line' => $mk('Demo', $id, $line + 1),
];

foreach ($payloads as $name => $payload) {
try {
unserialize($payload);
echo "$name: unserialized!?\n";
} catch (Exception $e) {
echo "$name: {$e->getMessage()}\n";
}
}

// __unserialize() cannot be used to reinitialize a live closure.
try {
$closure->__unserialize(['class' => 'Demo', 'id' => $id, 'line' => $line]);
} catch (Exception $e) {
echo $e->getMessage(), "\n";
}

?>
--EXPECT--
string(2) "ok"
empty data: Invalid serialization data for Closure object
missing keys: Invalid serialization data for Closure object
wrong types: Invalid serialization data for Closure object
unknown class: Invalid serialization data for Closure object (cannot load class "NoSuchClass")
internal class: Invalid serialization data for Closure object (cannot load class "stdClass")
unknown id: Invalid serialization data for Closure object (constant-expression closure 999 of class Demo not found)
negative id: Invalid serialization data for Closure object (constant-expression closure -1 of class Demo not found)
stale line: Invalid serialization data for Closure object (constant-expression closure 0 of class Demo not found)
Cannot unserialize an already initialized Closure
61 changes: 61 additions & 0 deletions Zend/tests/closures/closure_const_expr/serialization_misc.phpt
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
--TEST--
Misc behaviors of serializable const-expr closures: static vars, identity, scope binding, fromConstExpr() errors
--FILE--
<?php

#[Attribute(Attribute::TARGET_ALL)]
class A {
public function __construct(public mixed $cb = null) {}
}

class Demo {
private const SECRET = 'secret';

#[A(static function () { static $n = 0; return ++$n; })]
#[A(static function () { return self::SECRET; })]
public int $p = 0;
}

$attributes = (new ReflectionProperty(Demo::class, 'p'))->getAttributes();
$counter = $attributes[0]->getArguments()[0];
$scoped = $attributes[1]->getArguments()[0];

// A reference does not carry the state of static variables: unserializing
// produces the closure as if it was freshly evaluated.
var_dump($counter(), $counter());
$u = unserialize(serialize($counter));
var_dump($u());

// Unserialized closures are new instances.
var_dump($u === $counter);

// Scope binding is restored: private members of the class are accessible.
var_dump(unserialize(serialize($scoped))());

// fromConstExpr() error cases
try {
Closure::fromConstExpr('NoSuchClass', 0);
} catch (Error $e) {
echo get_class($e), ': ', $e->getMessage(), "\n";
}
try {
Closure::fromConstExpr('stdClass', 0);
} catch (ValueError $e) {
echo get_class($e), ': ', $e->getMessage(), "\n";
}
try {
Closure::fromConstExpr('Demo', 999);
} catch (ValueError $e) {
echo get_class($e), ': ', $e->getMessage(), "\n";
}

?>
--EXPECT--
int(1)
int(2)
int(1)
bool(false)
string(6) "secret"
Error: Class "NoSuchClass" not found
ValueError: Closure::fromConstExpr(): Argument #2 ($id) does not refer to a constant-expression closure of class stdClass
ValueError: Closure::fromConstExpr(): Argument #2 ($id) does not refer to a constant-expression closure of class Demo
Loading
Loading