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
4 changes: 4 additions & 0 deletions NEWS
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,10 @@ 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: serialize() records a
reference to their declaration site, and unserialize() recreates them
bounded to what the class declares. (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
8 changes: 8 additions & 0 deletions UPGRADING
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,14 @@ PHP 8.6 UPGRADE NOTES
RFC: https://wiki.php.net/rfc/override_constants
. Implemented partial function application
RFC: https://wiki.php.net/rfc/partial_function_application_v2
. Closures declared in a class's constant expressions (anonymous closures
and first-class callables, in attribute arguments and parameter default
values) can now be serialized. The payload is a reference to the
declaration site and carries no code; unserialize() re-evaluates that
declaration, so it can only produce a closure the named class declares.
References are verified on resolve and are valid for the code revision
that produced them. Runtime-created and bound closures still refuse.
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)
88 changes: 88 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,88 @@
--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],
];

// The reference lives in the __serialize() payload: [ [], ["const-expr",
// [class, site, key, hash]] ]. These read it out and rebuild a closure from a
// bare reference the way an exporter would.
$refOf = static fn (Closure $c) => $c->__serialize()[1][1]; // [class, site, key, hash]
$fromRef = static fn (array $ref) => unserialize('O:7:"Closure":' . substr(serialize([[], ['const-expr', $ref]]), 2));

foreach ($closures as $expected => $closure) {
$unserialized = unserialize(serialize($closure));
$recreated = $fromRef($refOf($closure));

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

// References are assigned in canonical walk order: class attributes first, then
// constants, then properties, then methods (including parameters). The display
// below is "<site>@<key>"; the code hash (the fourth field) is not part of the
// addressing contract (see serialization_code_hash.phpt).
$ids = array_map(
static fn ($c) => $refOf($c)[1] . '@' . $refOf($c)[2],
$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,84 @@
--TEST--
Anonymous const-expr closure references carry a code hash
--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;
}

$ref = (new ReflectionProperty(Ordered::class, 'p'))->getAttributes()[0]->getArguments()[0]
->__serialize()[1][1];

// The reference is [class, site, rank, hash]: rank 0 of $p, with a non-zero
// code hash in the fourth field.
var_dump($ref[1] === '$p' && $ref[2] === 0 && is_int($ref[3]) && $ref[3] !== 0);
$hash = $ref[3];

$make = static fn (string $class, string $site, int|string $key, int $hash): string =>
'O:7:"Closure":' . substr(serialize([[], ['const-expr', [$class, $site, $key, $hash]]]), 2);

// Same class: resolves.
var_dump(unserialize($make('Ordered', '$p', 0, $hash))());

// The swapped class has B at rank 0 of $q; same rank, but the hash guards it.
try {
unserialize($make('Swapped', '$q', 0, $hash));
} catch (Exception $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($make('Reformatted', '$p', 0, $hash))());

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

// A reference without a hash (a producer that cannot compute it) resolves
// positionally, unverified.
var_dump(unserialize($make('Ordered', '$p', 0, 0))());

?>
--EXPECT--
bool(true)
string(1) "a"
Invalid serialization data for Closure object (constant-expression closure "$q@0" of class Swapped changed)
string(1) "a"
Invalid serialization data for Closure object (constant-expression closure "$p@0" 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