Skip to content
Closed
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
16 changes: 16 additions & 0 deletions UPGRADING
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,22 @@ 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 plus an element-scoped "<site>@<key>" id;
the key is the closure's rank within the declaring element for anonymous
closures, checked against the closure's line relative to the class, and
the name of the referenced callable, e.g. "$prop@Foo::bar", for
first-class callables), resolved against the loaded class. The payload is
a tagged union ([<properties>, [<tag>, <reference>]]) so the format can
grow further reference kinds. 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"]=>
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"
}
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,78 @@
--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];
$id = (new ReflectionFunction($closure))->getConstExprId();
// The stored line is relative to the declaring class.
$rel = (new ReflectionFunction($closure))->getStartLine() - (new ReflectionClass(Demo::class))->getStartLine();

// Serialize an array and rebrand it as a Closure object payload, so we can
// feed __unserialize() arbitrary tagged-union shapes.
$obj = static fn (array $data): string => 'O:7:"Closure":' . substr(serialize($data), 2);

// Tagged-union payload: [ [], ["const-expr", [class, id, line?]] ].
$mk = static function (string $class, string $id, ?int $line) use ($obj): string {
$payload = $line === null ? [$class, $id] : [$class, $id, $line];
return $obj([[], ['const-expr', $payload]]);
};

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

$payloads = [
'empty data' => $obj([]),
'one element' => $obj([[]]),
'unknown tag' => $obj([[], ['whatever', ['Demo', $id, $rel]]]),
'tag not list' => $obj([[], 'const-expr']),
'wrong id type' => $obj([[], ['const-expr', ['Demo', 0, $rel]]]),
'unknown class' => $mk('NoSuchClass', $id, $rel),
'internal class' => $mk('stdClass', $id, $rel),
'unknown site' => $mk('Demo', '$nope@0', $rel),
'malformed id' => $mk('Demo', 'no-at-sign', $rel),
'anon missing line' => $mk('Demo', $id, null),
'stale line' => $mk('Demo', $id, $rel + 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([[], ['const-expr', ['Demo', $id, $rel]]]);
} catch (Exception $e) {
echo $e->getMessage(), "\n";
}

?>
--EXPECT--
string(2) "ok"
empty data: Invalid serialization data for Closure object
one element: Invalid serialization data for Closure object
unknown tag: Invalid serialization data for Closure object
tag not list: Invalid serialization data for Closure object
wrong id type: 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 site: Invalid serialization data for Closure object (constant-expression closure "$nope@0" of class Demo not found)
malformed id: Invalid serialization data for Closure object (constant-expression closure "no-at-sign" of class Demo not found)
anon missing line: Invalid serialization data for Closure object
stale line: Invalid serialization data for Closure object (constant-expression closure "$p@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