Skip to content

Commit d477057

Browse files
Reference const-expr closures through engine ids on PHP 8.6
1 parent c838ae3 commit d477057

7 files changed

Lines changed: 451 additions & 7 deletions

CHANGELOG.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,24 @@ 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. `deepclone_from_array()` accepts both
47+
this and the site-based form: site-based payloads written on PHP 8.5 keep
48+
resolving on 8.6, and engine-id payloads fail with an explicit message on older
49+
PHP.
50+
- On PHP 8.6, first-class callables declared in a constant expression of another
51+
class (`#[When(Validators::check(...))]`) or over a global function
52+
(`#[When(strlen(...))]`) get their declaring class from the engine and
53+
serialize as a (site-based) declaration-site reference with no
54+
`allow_named_closures` opt-in -- the same payload the extension produces on
55+
8.5 through ReflectionAttribute provenance, and that the polyfill produces.
56+
(First-class callables keep the site-based form rather than an engine id: an
57+
engine id resolves to an fcc site whose source line userland cannot reproduce,
58+
which would break interchange with the polyfill.)
59+
4260
- On PHP 8.4+, `deepclone_from_array()` now creates object nodes whose
4361
payload slots or replayed `__unserialize` state carry a named-closure or
4462
const-expr-closure marker as

deepclone.c

Lines changed: 123 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1566,6 +1566,26 @@ static zend_class_entry *dc_provenance_lookup(zend_class_entry *target_ce, zend_
15661566
return zend_lookup_class_ex(decl, NULL, ZEND_FETCH_CLASS_NO_AUTOLOAD);
15671567
}
15681568

1569+
/* The class whose constant expression declares this first-class callable. On
1570+
* PHP 8.6 the engine records it (zend_constexpr_closure_ref), so it is exact
1571+
* and needs no capture; on 8.5 it comes from the ReflectionAttribute-captured
1572+
* index. Either way it feeds the same site-based (5-element) reference, which
1573+
* is interchangeable with the polyfill — unlike the engine-id form, whose fcc
1574+
* line userland cannot reproduce. */
1575+
static zend_class_entry *dc_declaring_class(zval *src, const zend_function *func)
1576+
{
1577+
#if PHP_VERSION_ID >= 80600
1578+
zend_class_entry *ce;
1579+
uint32_t id, line;
1580+
if (zend_constexpr_closure_ref(Z_OBJ_P(src), &ce, &id, &line) == SUCCESS) {
1581+
return ce;
1582+
}
1583+
#endif
1584+
return func->common.function_name
1585+
? dc_provenance_lookup(func->common.scope, func->common.function_name)
1586+
: NULL;
1587+
}
1588+
15691589
/* Walk a value (a getArguments() argument, or a newInstance() attribute object
15701590
* and its properties), recording every cross-class FCC against `scope`. The
15711591
* `seen` set guards cycles: getArguments() values are acyclic constant
@@ -1656,6 +1676,66 @@ static void ZEND_FASTCALL dc_attr_new_instance_wrapper(INTERNAL_FUNCTION_PARAMET
16561676
}
16571677
#endif /* PHP_VERSION_ID >= 80500 */
16581678

1679+
/* deepclone_from_array() counterpart for engine-id references [class, id,
1680+
* line], emitted on PHP >= 8.6: the id is the engine's canonical per-class
1681+
* const-expr closure id (see Closure::fromConstExpr()). */
1682+
static void dc_cexpr_resolve_id(HashTable *ht, HashTable *allowed_set, zval *retval)
1683+
{
1684+
zval *zclass = zend_hash_index_find(ht, 0);
1685+
zval *zid = zend_hash_index_find(ht, 1);
1686+
zval *zline = zend_hash_index_find(ht, 2);
1687+
if (!zclass || !zid || !zline || zend_hash_num_elements(ht) != 3) {
1688+
zend_value_error("deepclone_from_array(): malformed payload, const-expr-closure value must have 3 elements");
1689+
return;
1690+
}
1691+
ZVAL_DEREF(zclass);
1692+
ZVAL_DEREF(zid);
1693+
ZVAL_DEREF(zline);
1694+
if (Z_TYPE_P(zclass) != IS_STRING) {
1695+
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));
1696+
return;
1697+
}
1698+
if (Z_TYPE_P(zline) != IS_LONG) {
1699+
zend_value_error("deepclone_from_array(): malformed payload, const-expr-closure line must be of type int, %s given", zend_zval_value_name(zline));
1700+
return;
1701+
}
1702+
1703+
/* Gate before zend_lookup_class(): the payload must not be able to
1704+
* autoload, let alone evaluate, classes outside the allow-list. */
1705+
if (!dc_class_allowed(allowed_set, Z_STR_P(zclass))) {
1706+
zend_value_error("deepclone_from_array(): class \"%s\" is not allowed", Z_STRVAL_P(zclass));
1707+
return;
1708+
}
1709+
1710+
#if PHP_VERSION_ID >= 80600
1711+
zend_class_entry *ce = zend_lookup_class(Z_STR_P(zclass));
1712+
if (!ce) {
1713+
zend_value_error("deepclone_from_array(): malformed payload, const-expr-closure references unknown class \"%s\"", Z_STRVAL_P(zclass));
1714+
return;
1715+
}
1716+
1717+
zend_ast *site = zend_constexpr_closure_site_by_id(ce, Z_LVAL_P(zid));
1718+
if (!site) {
1719+
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));
1720+
return;
1721+
}
1722+
if (site->kind != ZEND_AST_OP_ARRAY) {
1723+
zend_value_error("deepclone_from_array(): malformed payload, const-expr-closure references a first-class callable site");
1724+
return;
1725+
}
1726+
1727+
zend_op_array *op = zend_ast_get_op_array(site)->op_array;
1728+
if (Z_LVAL_P(zline) != (zend_long) op->line_start) {
1729+
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);
1730+
return;
1731+
}
1732+
1733+
zend_create_closure(retval, (zend_function *) op, ce, ce, NULL);
1734+
#else
1735+
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);
1736+
#endif
1737+
}
1738+
16591739
/* deepclone_from_array() counterpart: resolve a declaration-site reference
16601740
* back to a live Closure. */
16611741
static void dc_cexpr_resolve(zval *value, HashTable *allowed_set, zval *retval)
@@ -1665,6 +1745,18 @@ static void dc_cexpr_resolve(zval *value, HashTable *allowed_set, zval *retval)
16651745
return;
16661746
}
16671747
HashTable *ht = Z_ARRVAL_P(value);
1748+
1749+
zval *zid = zend_hash_index_find(ht, 1);
1750+
if (zid) {
1751+
ZVAL_DEREF(zid);
1752+
}
1753+
if (zid && Z_TYPE_P(zid) == IS_LONG) {
1754+
/* The type of element 1 (int id vs string site) discriminates
1755+
* engine-id references from site-based ones. */
1756+
dc_cexpr_resolve_id(ht, allowed_set, retval);
1757+
return;
1758+
}
1759+
16681760
zval *zclass = zend_hash_index_find(ht, 0);
16691761
zval *zsite = zend_hash_index_find(ht, 1);
16701762
zval *zattr = zend_hash_index_find(ht, 2);
@@ -2031,6 +2123,31 @@ static void dc_copy_value(dc_ctx *ctx, zval *src, zval *dst, zval *mask_dst)
20312123
zend_value_error("deepclone_to_array(): class \"Closure\" is not allowed");
20322124
return;
20332125
}
2126+
#if PHP_VERSION_ID >= 80600
2127+
/* The engine assigns a canonical per-class id to anonymous closures
2128+
* declared in attribute arguments and parameter default values; prefer
2129+
* it to the site-based reference below. First-class callables are
2130+
* excluded: their engine id resolves to an fcc site the decode side
2131+
* cannot recreate, so they keep the site-based and by-name paths.
2132+
* Closures in class constant values and property defaults have no id
2133+
* and also fall through. */
2134+
if (!(func->common.fn_flags & ZEND_ACC_FAKE_CLOSURE)) {
2135+
zend_class_entry *site_ce;
2136+
uint32_t cexpr_id, cexpr_line;
2137+
if (zend_constexpr_closure_ref(Z_OBJ_P(src), &site_ce, &cexpr_id, &cexpr_line) == SUCCESS) {
2138+
zval tmp;
2139+
array_init_size(dst, 3);
2140+
ZVAL_STR_COPY(&tmp, site_ce->name);
2141+
zend_hash_index_add_new(Z_ARRVAL_P(dst), 0, &tmp);
2142+
ZVAL_LONG(&tmp, (zend_long) cexpr_id);
2143+
zend_hash_index_add_new(Z_ARRVAL_P(dst), 1, &tmp);
2144+
ZVAL_LONG(&tmp, (zend_long) cexpr_line);
2145+
zend_hash_index_add_new(Z_ARRVAL_P(dst), 2, &tmp);
2146+
DC_MASK_CONSTEXPR_CLOSURE(mask_dst);
2147+
goto handle_value;
2148+
}
2149+
}
2150+
#endif
20342151
zval payload;
20352152
ZVAL_UNDEF(&payload);
20362153
if (dc_cexpr_locate(func, &payload)) {
@@ -2046,8 +2163,8 @@ static void dc_copy_value(dc_ctx *ctx, zval *src, zval *dst, zval *mask_dst)
20462163
* scope) misses. On 8.5 there is no engine provenance; fall back
20472164
* to a declaring class captured from ReflectionAttribute, if
20482165
* any, and locate the site there. */
2049-
if (DC_G(capture_attribute_closures) && func->common.scope && func->common.function_name) {
2050-
zend_class_entry *decl = dc_provenance_lookup(func->common.scope, func->common.function_name);
2166+
if (func->common.function_name) {
2167+
zend_class_entry *decl = dc_declaring_class(src, func);
20512168
if (decl && decl != func->common.scope && dc_cexpr_locate_ce(func, decl, &payload)) {
20522169
ZVAL_COPY_VALUE(dst, &payload);
20532170
DC_MASK_CONSTEXPR_CLOSURE(mask_dst);
@@ -2061,15 +2178,15 @@ static void dc_copy_value(dc_ctx *ctx, zval *src, zval *dst, zval *mask_dst)
20612178
}
20622179

20632180
/* Global-function first-class callable (no scope, internal or user):
2064-
* the declaring class can only come from captured provenance. Same
2181+
* the declaring class comes from the engine (8.6) or captured
2182+
* provenance (8.5). Same
20652183
* declaration-site reference and Closure gating as above; unresolved
20662184
* ones fall through to the by-name path. */
20672185
if (func && (func->common.fn_flags & ZEND_ACC_FAKE_CLOSURE)
2068-
&& !func->common.scope && func->common.function_name
2069-
&& DC_G(capture_attribute_closures)) {
2186+
&& !func->common.scope && func->common.function_name) {
20702187
zval *this_ptr = zend_get_closure_this_ptr(src);
20712188
if (!this_ptr || Z_TYPE_P(this_ptr) != IS_OBJECT) {
2072-
zend_class_entry *decl = dc_provenance_lookup(NULL, func->common.function_name);
2189+
zend_class_entry *decl = dc_declaring_class(src, func);
20732190
if (decl) {
20742191
if (!dc_class_allowed(ctx->allowed_ht, zend_ce_closure->name)) {
20752192
zend_value_error("deepclone_to_array(): class \"Closure\" is not allowed");

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

0 commit comments

Comments
 (0)