Skip to content

Commit 130fc82

Browse files
Reference const-expr closures through engine ids on PHP 8.6
1 parent badf84e commit 130fc82

6 files changed

Lines changed: 365 additions & 1 deletion

CHANGELOG.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
3939
relied on the previous unconditional by-name behavior must pass the flag.
4040
Closures declared in constant expressions are unaffected.
4141

42+
- On PHP 8.6, `deepclone_to_array()` references anonymous closures declared in
43+
attribute arguments and parameter default values as `[class, id, line]`, where
44+
`id` is the engine's canonical const-expr closure id (see
45+
`Closure::fromConstExpr()`). This replaces the per-call declaration-site scan
46+
with the engine's non-evaluating walk. First-class callables, and closures in
47+
class constant values and property defaults, have no such id here and keep the
48+
site-based form. `deepclone_from_array()` accepts both forms: site-based
49+
payloads written on PHP 8.5 keep resolving on 8.6, and engine-id payloads fail
50+
with an explicit message on older PHP.
51+
4252
- On PHP 8.4+, `deepclone_from_array()` now creates object nodes whose
4353
payload slots or replayed `__unserialize` state carry a named-closure or
4454
const-expr-closure marker as

deepclone.c

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1656,6 +1656,66 @@ static void ZEND_FASTCALL dc_attr_new_instance_wrapper(INTERNAL_FUNCTION_PARAMET
16561656
}
16571657
#endif /* PHP_VERSION_ID >= 80500 */
16581658

1659+
/* deepclone_from_array() counterpart for engine-id references [class, id,
1660+
* line], emitted on PHP >= 8.6: the id is the engine's canonical per-class
1661+
* const-expr closure id (see Closure::fromConstExpr()). */
1662+
static void dc_cexpr_resolve_id(HashTable *ht, HashTable *allowed_set, zval *retval)
1663+
{
1664+
zval *zclass = zend_hash_index_find(ht, 0);
1665+
zval *zid = zend_hash_index_find(ht, 1);
1666+
zval *zline = zend_hash_index_find(ht, 2);
1667+
if (!zclass || !zid || !zline || zend_hash_num_elements(ht) != 3) {
1668+
zend_value_error("deepclone_from_array(): malformed payload, const-expr-closure value must have 3 elements");
1669+
return;
1670+
}
1671+
ZVAL_DEREF(zclass);
1672+
ZVAL_DEREF(zid);
1673+
ZVAL_DEREF(zline);
1674+
if (Z_TYPE_P(zclass) != IS_STRING) {
1675+
zend_value_error("deepclone_from_array(): malformed payload, const-expr-closure class name must be of type string, %s given", zend_zval_value_name(zclass));
1676+
return;
1677+
}
1678+
if (Z_TYPE_P(zline) != IS_LONG) {
1679+
zend_value_error("deepclone_from_array(): malformed payload, const-expr-closure line must be of type int, %s given", zend_zval_value_name(zline));
1680+
return;
1681+
}
1682+
1683+
/* Gate before zend_lookup_class(): the payload must not be able to
1684+
* autoload, let alone evaluate, classes outside the allow-list. */
1685+
if (!dc_class_allowed(allowed_set, Z_STR_P(zclass))) {
1686+
zend_value_error("deepclone_from_array(): class \"%s\" is not allowed", Z_STRVAL_P(zclass));
1687+
return;
1688+
}
1689+
1690+
#if PHP_VERSION_ID >= 80600
1691+
zend_class_entry *ce = zend_lookup_class(Z_STR_P(zclass));
1692+
if (!ce) {
1693+
zend_value_error("deepclone_from_array(): malformed payload, const-expr-closure references unknown class \"%s\"", Z_STRVAL_P(zclass));
1694+
return;
1695+
}
1696+
1697+
zend_ast *site = zend_constexpr_closure_site_by_id(ce, Z_LVAL_P(zid));
1698+
if (!site) {
1699+
zend_value_error("deepclone_from_array(): malformed payload, const-expr-closure references unknown closure id " ZEND_LONG_FMT " in class \"%s\"", Z_LVAL_P(zid), ZSTR_VAL(ce->name));
1700+
return;
1701+
}
1702+
if (site->kind != ZEND_AST_OP_ARRAY) {
1703+
zend_value_error("deepclone_from_array(): malformed payload, const-expr-closure references a first-class callable site");
1704+
return;
1705+
}
1706+
1707+
zend_op_array *op = zend_ast_get_op_array(site)->op_array;
1708+
if (Z_LVAL_P(zline) != (zend_long) op->line_start) {
1709+
zend_value_error("deepclone_from_array(): stale payload, const-expr-closure moved from line " ZEND_LONG_FMT " to line %u", Z_LVAL_P(zline), op->line_start);
1710+
return;
1711+
}
1712+
1713+
zend_create_closure(retval, (zend_function *) op, ce, ce, NULL);
1714+
#else
1715+
zend_value_error("deepclone_from_array(): const-expr-closure payload was created on PHP 8.6 or later and cannot be resolved on PHP %s", PHP_VERSION);
1716+
#endif
1717+
}
1718+
16591719
/* deepclone_from_array() counterpart: resolve a declaration-site reference
16601720
* back to a live Closure. */
16611721
static void dc_cexpr_resolve(zval *value, HashTable *allowed_set, zval *retval)
@@ -1665,6 +1725,18 @@ static void dc_cexpr_resolve(zval *value, HashTable *allowed_set, zval *retval)
16651725
return;
16661726
}
16671727
HashTable *ht = Z_ARRVAL_P(value);
1728+
1729+
zval *zid = zend_hash_index_find(ht, 1);
1730+
if (zid) {
1731+
ZVAL_DEREF(zid);
1732+
}
1733+
if (zid && Z_TYPE_P(zid) == IS_LONG) {
1734+
/* The type of element 1 (int id vs string site) discriminates
1735+
* engine-id references from site-based ones. */
1736+
dc_cexpr_resolve_id(ht, allowed_set, retval);
1737+
return;
1738+
}
1739+
16681740
zval *zclass = zend_hash_index_find(ht, 0);
16691741
zval *zsite = zend_hash_index_find(ht, 1);
16701742
zval *zattr = zend_hash_index_find(ht, 2);
@@ -2031,6 +2103,31 @@ static void dc_copy_value(dc_ctx *ctx, zval *src, zval *dst, zval *mask_dst)
20312103
zend_value_error("deepclone_to_array(): class \"Closure\" is not allowed");
20322104
return;
20332105
}
2106+
#if PHP_VERSION_ID >= 80600
2107+
/* The engine assigns a canonical per-class id to anonymous closures
2108+
* declared in attribute arguments and parameter default values; prefer
2109+
* it to the site-based reference below. First-class callables are
2110+
* excluded: their engine id resolves to an fcc site the decode side
2111+
* cannot recreate, so they keep the site-based and by-name paths.
2112+
* Closures in class constant values and property defaults have no id
2113+
* and also fall through. */
2114+
if (!(func->common.fn_flags & ZEND_ACC_FAKE_CLOSURE)) {
2115+
zend_class_entry *site_ce;
2116+
uint32_t cexpr_id, cexpr_line;
2117+
if (zend_constexpr_closure_ref(Z_OBJ_P(src), &site_ce, &cexpr_id, &cexpr_line) == SUCCESS) {
2118+
zval tmp;
2119+
array_init_size(dst, 3);
2120+
ZVAL_STR_COPY(&tmp, site_ce->name);
2121+
zend_hash_index_add_new(Z_ARRVAL_P(dst), 0, &tmp);
2122+
ZVAL_LONG(&tmp, (zend_long) cexpr_id);
2123+
zend_hash_index_add_new(Z_ARRVAL_P(dst), 1, &tmp);
2124+
ZVAL_LONG(&tmp, (zend_long) cexpr_line);
2125+
zend_hash_index_add_new(Z_ARRVAL_P(dst), 2, &tmp);
2126+
DC_MASK_CONSTEXPR_CLOSURE(mask_dst);
2127+
goto handle_value;
2128+
}
2129+
}
2130+
#endif
20342131
zval payload;
20352132
ZVAL_UNDEF(&payload);
20362133
if (dc_cexpr_locate(func, &payload)) {

tests/deepclone_constexpr_closures.phpt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ deepclone references closures declared in constant expressions (PHP 8.5)
44
deepclone
55
--SKIPIF--
66
<?php if (PHP_VERSION_ID < 80500) die('skip requires PHP 8.5'); ?>
7+
<?php if (PHP_VERSION_ID >= 80600) die('skip PHP 8.6 emits engine-id references, covered by deepclone_constexpr_closures_native.phpt'); ?>
78
--FILE--
89
<?php
910

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
--TEST--
2+
deepclone_from_array() rejects engine-id const-expr-closure payloads before PHP 8.6
3+
--EXTENSIONS--
4+
deepclone
5+
--SKIPIF--
6+
<?php if (PHP_VERSION_ID >= 80600) die('skip PHP < 8.6 only'); ?>
7+
--FILE--
8+
<?php
9+
10+
class Fix {}
11+
12+
try {
13+
deepclone_from_array(['classes' => '', 'objectMeta' => 0, 'prepared' => [Fix::class, 0, 1], 'mask' => 1]);
14+
} catch (\ValueError $e) {
15+
echo $e->getMessage(), "\n";
16+
}
17+
?>
18+
--EXPECTF--
19+
deepclone_from_array(): const-expr-closure payload was created on PHP 8.6 or later and cannot be resolved on PHP %s
Lines changed: 228 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,228 @@
1+
--TEST--
2+
deepclone references const-expr closures through engine ids (PHP 8.6)
3+
--EXTENSIONS--
4+
deepclone
5+
--SKIPIF--
6+
<?php if (PHP_VERSION_ID < 80600) die('skip requires PHP 8.6'); ?>
7+
--FILE--
8+
<?php
9+
10+
#[Attribute(Attribute::TARGET_ALL | Attribute::IS_REPEATABLE)]
11+
class CA { public array $args; public function __construct(mixed ...$args) { $this->args = $args; } }
12+
13+
#[CA(static function (): string { return self::SECRET; })]
14+
class Fix {
15+
private const SECRET = 'class-secret';
16+
public const CALLBACKS = ['first' => static function (): string { return 'const-value'; }];
17+
#[CA(cb: [1, ['x' => static function (int $i): int { return $i * 2; }]])]
18+
public string $tagged = 'v';
19+
public ?Closure $factory = static function (): string { return 'prop-default'; };
20+
#[CA('not-a-closure')]
21+
#[CA(static function (): string { return 'repeated'; })]
22+
public function tagged(
23+
#[CA(static function (): string { return 'param-attr'; })]
24+
?Closure $cb = static function (): string { return 'param-default'; },
25+
): void {}
26+
}
27+
28+
$rc = new ReflectionClass(Fix::class);
29+
30+
// ── Wire format: engine-id reference [class, id, line] ──
31+
$c = $rc->getAttributes()[0]->getArguments()[0];
32+
$line = (new ReflectionFunction($c))->getStartLine();
33+
$d = deepclone_to_array($c);
34+
var_dump($d['prepared'] === [Fix::class, 0, $line]);
35+
var_dump($d['mask'] === 1);
36+
$r = deepclone_from_array($d);
37+
var_dump($r instanceof Closure, $r !== $c, $r() === 'class-secret');
38+
39+
// ── The emitted reference matches the engine's ──
40+
$rf = new ReflectionFunction($c);
41+
var_dump($d['prepared'] === [$rf->getConstExprClass(), $rf->getConstExprId(), $line]);
42+
43+
// ── Attribute sites: nested argument, repeated attribute, parameter attribute, parameter default ──
44+
foreach ([
45+
[$rc->getProperty('tagged')->getAttributes()[0]->getArguments()['cb'][1]['x'], [3], 6],
46+
[$rc->getMethod('tagged')->getAttributes()[1]->getArguments()[0], [], 'repeated'],
47+
[$rc->getMethod('tagged')->getParameters()[0]->getAttributes()[0]->getArguments()[0], [], 'param-attr'],
48+
[$rc->getMethod('tagged')->getParameters()[0]->getDefaultValue(), [], 'param-default'],
49+
] as [$c, $args, $expected]) {
50+
$d = deepclone_to_array($c);
51+
$rf = new ReflectionFunction($c);
52+
var_dump($d['prepared'] === [Fix::class, $rf->getConstExprId(), $rf->getStartLine()], deepclone_from_array($d)(...$args) === $expected);
53+
}
54+
55+
// ── Constant values and property defaults have no engine id: site-based form ──
56+
$d = deepclone_to_array(Fix::CALLBACKS['first']);
57+
var_dump(count($d['prepared']) === 5, $d['prepared'][1] === 'CALLBACKS', deepclone_from_array($d)() === 'const-value');
58+
$d = deepclone_to_array($rc->getProperty('factory')->getDefaultValue());
59+
var_dump($d['prepared'][1] === '$factory', deepclone_from_array($d)() === 'prop-default');
60+
61+
// ── Site-based references written on PHP 8.5 still resolve ──
62+
var_dump(deepclone_from_array(['classes' => '', 'objectMeta' => 0, 'prepared' => [Fix::class, '', 0, 0, $line], 'mask' => 1])() === 'class-secret');
63+
64+
// ── Same-line closures get distinct ids ──
65+
#[CA(static function (): string { return 'first'; }, static function (): string { return 'second'; })]
66+
class FixAmbiguous {}
67+
$args = (new ReflectionClass(FixAmbiguous::class))->getAttributes()[0]->getArguments();
68+
$d0 = deepclone_to_array($args[0]);
69+
$d1 = deepclone_to_array($args[1]);
70+
var_dump([$d0['prepared'][1], $d1['prepared'][1]] === [0, 1]);
71+
var_dump(deepclone_from_array($d0)() === 'first', deepclone_from_array($d1)() === 'second');
72+
73+
// ── Enum case attribute gets an id, enum constant value stays site-based ──
74+
enum FixEnum: string {
75+
#[CA(static function (): string { return 'enum-case-attr'; })]
76+
case Active = 'A';
77+
public const FILTER = static function (): string { return 'enum-const'; };
78+
}
79+
$d = deepclone_to_array((new ReflectionClassConstant(FixEnum::class, 'Active'))->getAttributes()[0]->getArguments()[0]);
80+
var_dump(is_int($d['prepared'][1]), deepclone_from_array($d)() === 'enum-case-attr');
81+
$d = deepclone_to_array(FixEnum::FILTER);
82+
var_dump($d['prepared'][1] === 'FILTER', deepclone_from_array($d)() === 'enum-const');
83+
84+
// ── Property hooks ──
85+
class FixHooked {
86+
public string $virtual {
87+
#[CA(static function (): string { return 'get-hook-attr'; })]
88+
get => 'vx';
89+
}
90+
}
91+
$c = (new ReflectionProperty(FixHooked::class, 'virtual'))->getHook(PropertyHookType::Get)->getAttributes()[0]->getArguments()[0];
92+
$d = deepclone_to_array($c);
93+
var_dump(is_int($d['prepared'][1]), deepclone_from_array($d)() === 'get-hook-attr');
94+
95+
// ── Trait method attribute: the using class declares the closure ──
96+
trait FixTrait {
97+
#[CA(static function (): string { return 'trait-attr'; })]
98+
public function traitTagged(): void {}
99+
}
100+
class FixTraitUser { use FixTrait; }
101+
$d = deepclone_to_array((new ReflectionClass(FixTraitUser::class))->getMethod('traitTagged')->getAttributes()[0]->getArguments()[0]);
102+
var_dump(is_int($d['prepared'][1]), deepclone_from_array($d)() === 'trait-attr');
103+
104+
// ── Inherited declaration keeps the declaring class ──
105+
class FixParent {
106+
#[CA(static function (): string { return 'parent-attr'; })]
107+
public function pm(): void {}
108+
}
109+
class FixChild extends FixParent {}
110+
$c = (new ReflectionMethod(FixChild::class, 'pm'))->getAttributes()[0]->getArguments()[0];
111+
$d = deepclone_to_array($c);
112+
var_dump($d['prepared'][0] === FixParent::class, deepclone_from_array($d)() === 'parent-attr');
113+
114+
// ── First-class callables use the site-based reference, not an engine id:
115+
// the engine id of an fcc resolves to a site the decode path cannot recreate,
116+
// so they keep the declaration-site (5-element) form ──
117+
class FixFcc {
118+
#[CA(self::helper(...))]
119+
public static function helper(): bool { return true; }
120+
}
121+
$d = deepclone_to_array((new ReflectionMethod(FixFcc::class, 'helper'))->getAttributes()[0]->getArguments()[0]);
122+
var_dump($d['mask'] === 1, deepclone_from_array($d)() === true);
123+
124+
// ── ... but a crafted payload addressing the FCC site is rejected ──
125+
try {
126+
deepclone_from_array(['classes' => '', 'objectMeta' => 0, 'prepared' => [FixFcc::class, 0, 1], 'mask' => 1]);
127+
} catch (\ValueError $e) {
128+
var_dump($e->getMessage());
129+
}
130+
131+
// ── Runtime closures still refuse, through the engine's own __serialize() ──
132+
try {
133+
deepclone_to_array(static function () { return 'runtime'; });
134+
} catch (\Exception $e) {
135+
var_dump($e->getMessage());
136+
}
137+
138+
// ── Object graph survives a JSON round trip ──
139+
$graph = (object) ['cb' => $rc->getAttributes()[0]->getArguments()[0]];
140+
$d = json_decode(json_encode(deepclone_to_array($graph)), true);
141+
var_dump((deepclone_from_array($d)->cb)() === 'class-secret');
142+
143+
// ── allowed_classes gating, both directions ──
144+
try {
145+
deepclone_to_array($rc->getAttributes()[0]->getArguments()[0], []);
146+
} catch (\ValueError $e) {
147+
var_dump($e->getMessage());
148+
}
149+
$d = deepclone_to_array($rc->getAttributes()[0]->getArguments()[0], ['Closure']);
150+
try {
151+
deepclone_from_array($d, []);
152+
} catch (\ValueError $e) {
153+
var_dump($e->getMessage());
154+
}
155+
try {
156+
deepclone_from_array($d, ['Closure']);
157+
} catch (\ValueError $e) {
158+
var_dump($e->getMessage());
159+
}
160+
var_dump(deepclone_from_array($d, ['Closure', 'Fix'])() === 'class-secret');
161+
162+
// ── Stale payload ──
163+
$d = deepclone_to_array($rc->getAttributes()[0]->getArguments()[0]);
164+
$d['prepared'][2]++;
165+
try {
166+
deepclone_from_array($d);
167+
} catch (\ValueError $e) {
168+
var_dump(str_contains($e->getMessage(), 'stale payload, const-expr-closure moved from line'));
169+
}
170+
171+
// ── Unknown id, unknown class ──
172+
try {
173+
deepclone_from_array(['classes' => '', 'objectMeta' => 0, 'prepared' => [Fix::class, 999, $line], 'mask' => 1]);
174+
} catch (\ValueError $e) {
175+
var_dump($e->getMessage());
176+
}
177+
try {
178+
deepclone_from_array(['classes' => '', 'objectMeta' => 0, 'prepared' => ['No\Such\ClassAtAll', 0, 1], 'mask' => 1]);
179+
} catch (\ValueError $e) {
180+
var_dump($e->getMessage());
181+
}
182+
?>
183+
--EXPECT--
184+
bool(true)
185+
bool(true)
186+
bool(true)
187+
bool(true)
188+
bool(true)
189+
bool(true)
190+
bool(true)
191+
bool(true)
192+
bool(true)
193+
bool(true)
194+
bool(true)
195+
bool(true)
196+
bool(true)
197+
bool(true)
198+
bool(true)
199+
bool(true)
200+
bool(true)
201+
bool(true)
202+
bool(true)
203+
bool(true)
204+
bool(true)
205+
bool(true)
206+
bool(true)
207+
bool(true)
208+
bool(true)
209+
bool(true)
210+
bool(true)
211+
bool(true)
212+
bool(true)
213+
bool(true)
214+
bool(true)
215+
bool(true)
216+
bool(true)
217+
bool(true)
218+
bool(true)
219+
string(100) "deepclone_from_array(): malformed payload, const-expr-closure references a first-class callable site"
220+
string(41) "Serialization of 'Closure' is not allowed"
221+
bool(true)
222+
string(52) "deepclone_to_array(): class "Closure" is not allowed"
223+
string(54) "deepclone_from_array(): class "Closure" is not allowed"
224+
string(50) "deepclone_from_array(): class "Fix" is not allowed"
225+
bool(true)
226+
bool(true)
227+
string(110) "deepclone_from_array(): malformed payload, const-expr-closure references unknown closure id 999 in class "Fix""
228+
string(107) "deepclone_from_array(): malformed payload, const-expr-closure references unknown class "No\Such\ClassAtAll""

0 commit comments

Comments
 (0)