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 @@ -140,6 +140,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
10 changes: 10 additions & 0 deletions UPGRADING
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,16 @@ PHP 8.6 UPGRADE NOTES
RFC: https://wiki.php.net/rfc/debugable-enums
. #[\Override] can now be applied to class constants, including enum cases.
RFC: https://wiki.php.net/rfc/override_constants
. Closures declared in constant expressions of a class member (anonymous
closures and first-class callables, in attribute arguments and parameter
default values) can now be serialized and unserialized. Payloads contain
no code, only a reference to the declaration site, verified on resolve
and valid for the code revision that produced it. Closures created at
runtime, bound to an object, or declared in class constant values or
property defaults still refuse to serialize. Added
Closure::fromConstExpr() and
ReflectionFunction::getConstExprId()/getConstExprClass().
RFC: https://wiki.php.net/rfc/serializable_closures

- Curl:
. curl_getinfo() return array now includes a new size_delivered key, which
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
--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);

// Resolving a reference autoloads its declaring class and pulls a closure out
// of it, so that class is subject to the filter too: allowing Closure alone is
// not enough when the reference points into another class.
try {
unserialize($payloads['fcc site'], ['allowed_classes' => ['Closure']]);
} catch (Exception $e) {
echo $e->getMessage(), "\n";
}

// Both the Closure and the referenced class must be opted in.
$r = unserialize($payloads['fcc site'], ['allowed_classes' => ['Closure', 'Demo']]);
var_dump($r instanceof Closure, $r('test'));

?>
--EXPECT--
string(9) "anonymous"
bool(true)
string(8) "fcc site"
bool(true)
bool(true)
Invalid serialization data for Closure object (class "Demo" is not allowed)
bool(true)
int(4)
86 changes: 86 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,86 @@
--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). The
// "#<hash>" code-hash suffix is stripped here: its value is not part of the
// addressing contract (see serialization_code_hash.phpt).
$ids = array_map(
static fn ($c) => preg_replace('/#[0-9a-f]{8}$/', '', (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"]=>
string(2) "@0"
["const"]=>
string(5) "FOO@0"
["prop-1"]=>
string(7) "$name@0"
["prop-2"]=>
string(7) "$name@1"
["method"]=>
string(5) "m()@0"
["param"]=>
string(5) "m()@1"
["case"]=>
string(3) "X@0"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
--TEST--
Anonymous const-expr closure references embed a code hash in their id
--FILE--
<?php

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

// Serialize the closure of attribute A (rank 0 of the property), then resolve
// the same reference against a class where A and B are swapped: rank 0 now
// names B's closure, but the code hash embedded in the id no longer matches,
// so it fails loudly instead of silently returning B's closure.
class Ordered {
#[A(static function () { return 'a'; })]
#[B(static function () { return 'b'; })]
public int $p = 0;
}
class Swapped {
#[B(static function () { return 'b'; })]
#[A(static function () { return 'a'; })]
public int $q = 0;
}

$id = (new ReflectionFunction(
(new ReflectionProperty(Ordered::class, 'p'))->getAttributes()[0]->getArguments()[0]
))->getConstExprId();

// The id is "<site>@<rank>#<hash>"; split it to retarget the reference.
var_dump(preg_match('/^\$p@0#[0-9a-f]{8}$/', $id));
$hash = substr($id, strpos($id, '#'));

$ref = static fn (string $class, string $id): string =>
'O:7:"Closure":' . substr(serialize([[], ['const-expr', [$class, $id]]]), 2);

// Same class: resolves, and fromConstExpr() verifies the same way.
var_dump(unserialize($ref('Ordered', $id))());
var_dump(Closure::fromConstExpr('Ordered', $id)());

// The swapped class has B at rank 0 of $q; same rank, but the hash guards it.
try {
unserialize($ref('Swapped', '$q@0' . $hash));
} catch (Exception $e) {
echo $e->getMessage(), "\n";
}
try {
Closure::fromConstExpr('Swapped', '$q@0' . $hash);
} catch (ValueError $e) {
echo $e->getMessage(), "\n";
}

// The hash is over the closure's code, insensitive to layout and comments: a
// reference resolves against a reformatted body...
class Reformatted {
#[A(static function () {
// a comment; different whitespace and layout
return 'a' ;
})]
public int $p = 0;
}
var_dump(unserialize($ref('Reformatted', '$p@0' . $hash))());

// ...but not against a changed body.
class Changed {
#[A(static function () { return 'CHANGED'; })]
public int $p = 0;
}
try {
unserialize($ref('Changed', '$p@0' . $hash));
} catch (Exception $e) {
echo $e->getMessage(), "\n";
}

// An id without a hash (a producer that cannot compute it) resolves
// positionally, unverified.
var_dump(unserialize($ref('Ordered', '$p@0'))());

?>
--EXPECTF--
int(1)
string(1) "a"
string(1) "a"
Invalid serialization data for Closure object (constant-expression closure "$q@0#%s" of class Swapped changed)
Closure::fromConstExpr(): Argument #2 ($id) refers to a constant-expression closure of class Swapped that has changed
string(1) "a"
Invalid serialization data for Closure object (constant-expression closure "$p@0#%s" of class Changed changed)
string(1) "a"
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"
Loading
Loading