From d477057b1ef9899b212dde21cd7a359eef7083b1 Mon Sep 17 00:00:00 2001 From: Nicolas Grekas Date: Wed, 10 Jun 2026 23:38:32 +0200 Subject: [PATCH 1/7] Reference const-expr closures through engine ids on PHP 8.6 --- CHANGELOG.md | 18 ++ deepclone.c | 129 +++++++++- tests/deepclone_constexpr_closures.phpt | 1 + ...deepclone_constexpr_closures_id_pre86.phpt | 19 ++ .../deepclone_constexpr_closures_native.phpt | 228 ++++++++++++++++++ ...epclone_constexpr_closures_native_fcc.phpt | 52 ++++ ...epclone_constexpr_closures_validation.phpt | 11 +- 7 files changed, 451 insertions(+), 7 deletions(-) create mode 100644 tests/deepclone_constexpr_closures_id_pre86.phpt create mode 100644 tests/deepclone_constexpr_closures_native.phpt create mode 100644 tests/deepclone_constexpr_closures_native_fcc.phpt diff --git a/CHANGELOG.md b/CHANGELOG.md index 2fd4640..920599b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,6 +39,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 relied on the previous unconditional by-name behavior must pass the flag. Closures declared in constant expressions are unaffected. +- On PHP 8.6, `deepclone_to_array()` references anonymous closures declared in + attribute arguments and parameter default values as `[class, id, line]`, where + `id` is the engine's canonical const-expr closure id (see + `Closure::fromConstExpr()`). This replaces the per-call declaration-site scan + with the engine's non-evaluating walk. `deepclone_from_array()` accepts both + this and the site-based form: site-based payloads written on PHP 8.5 keep + resolving on 8.6, and engine-id payloads fail with an explicit message on older + PHP. +- On PHP 8.6, first-class callables declared in a constant expression of another + class (`#[When(Validators::check(...))]`) or over a global function + (`#[When(strlen(...))]`) get their declaring class from the engine and + serialize as a (site-based) declaration-site reference with no + `allow_named_closures` opt-in -- the same payload the extension produces on + 8.5 through ReflectionAttribute provenance, and that the polyfill produces. + (First-class callables keep the site-based form rather than an engine id: an + engine id resolves to an fcc site whose source line userland cannot reproduce, + which would break interchange with the polyfill.) + - On PHP 8.4+, `deepclone_from_array()` now creates object nodes whose payload slots or replayed `__unserialize` state carry a named-closure or const-expr-closure marker as diff --git a/deepclone.c b/deepclone.c index d53ca97..8b6447e 100644 --- a/deepclone.c +++ b/deepclone.c @@ -1566,6 +1566,26 @@ static zend_class_entry *dc_provenance_lookup(zend_class_entry *target_ce, zend_ return zend_lookup_class_ex(decl, NULL, ZEND_FETCH_CLASS_NO_AUTOLOAD); } +/* The class whose constant expression declares this first-class callable. On + * PHP 8.6 the engine records it (zend_constexpr_closure_ref), so it is exact + * and needs no capture; on 8.5 it comes from the ReflectionAttribute-captured + * index. Either way it feeds the same site-based (5-element) reference, which + * is interchangeable with the polyfill — unlike the engine-id form, whose fcc + * line userland cannot reproduce. */ +static zend_class_entry *dc_declaring_class(zval *src, const zend_function *func) +{ +#if PHP_VERSION_ID >= 80600 + zend_class_entry *ce; + uint32_t id, line; + if (zend_constexpr_closure_ref(Z_OBJ_P(src), &ce, &id, &line) == SUCCESS) { + return ce; + } +#endif + return func->common.function_name + ? dc_provenance_lookup(func->common.scope, func->common.function_name) + : NULL; +} + /* Walk a value (a getArguments() argument, or a newInstance() attribute object * and its properties), recording every cross-class FCC against `scope`. The * `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 } #endif /* PHP_VERSION_ID >= 80500 */ +/* deepclone_from_array() counterpart for engine-id references [class, id, + * line], emitted on PHP >= 8.6: the id is the engine's canonical per-class + * const-expr closure id (see Closure::fromConstExpr()). */ +static void dc_cexpr_resolve_id(HashTable *ht, HashTable *allowed_set, zval *retval) +{ + zval *zclass = zend_hash_index_find(ht, 0); + zval *zid = zend_hash_index_find(ht, 1); + zval *zline = zend_hash_index_find(ht, 2); + if (!zclass || !zid || !zline || zend_hash_num_elements(ht) != 3) { + zend_value_error("deepclone_from_array(): malformed payload, const-expr-closure value must have 3 elements"); + return; + } + ZVAL_DEREF(zclass); + ZVAL_DEREF(zid); + ZVAL_DEREF(zline); + if (Z_TYPE_P(zclass) != IS_STRING) { + 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)); + return; + } + if (Z_TYPE_P(zline) != IS_LONG) { + zend_value_error("deepclone_from_array(): malformed payload, const-expr-closure line must be of type int, %s given", zend_zval_value_name(zline)); + return; + } + + /* Gate before zend_lookup_class(): the payload must not be able to + * autoload, let alone evaluate, classes outside the allow-list. */ + if (!dc_class_allowed(allowed_set, Z_STR_P(zclass))) { + zend_value_error("deepclone_from_array(): class \"%s\" is not allowed", Z_STRVAL_P(zclass)); + return; + } + +#if PHP_VERSION_ID >= 80600 + zend_class_entry *ce = zend_lookup_class(Z_STR_P(zclass)); + if (!ce) { + zend_value_error("deepclone_from_array(): malformed payload, const-expr-closure references unknown class \"%s\"", Z_STRVAL_P(zclass)); + return; + } + + zend_ast *site = zend_constexpr_closure_site_by_id(ce, Z_LVAL_P(zid)); + if (!site) { + 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)); + return; + } + if (site->kind != ZEND_AST_OP_ARRAY) { + zend_value_error("deepclone_from_array(): malformed payload, const-expr-closure references a first-class callable site"); + return; + } + + zend_op_array *op = zend_ast_get_op_array(site)->op_array; + if (Z_LVAL_P(zline) != (zend_long) op->line_start) { + 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); + return; + } + + zend_create_closure(retval, (zend_function *) op, ce, ce, NULL); +#else + 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); +#endif +} + /* deepclone_from_array() counterpart: resolve a declaration-site reference * back to a live Closure. */ 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) return; } HashTable *ht = Z_ARRVAL_P(value); + + zval *zid = zend_hash_index_find(ht, 1); + if (zid) { + ZVAL_DEREF(zid); + } + if (zid && Z_TYPE_P(zid) == IS_LONG) { + /* The type of element 1 (int id vs string site) discriminates + * engine-id references from site-based ones. */ + dc_cexpr_resolve_id(ht, allowed_set, retval); + return; + } + zval *zclass = zend_hash_index_find(ht, 0); zval *zsite = zend_hash_index_find(ht, 1); 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) zend_value_error("deepclone_to_array(): class \"Closure\" is not allowed"); return; } +#if PHP_VERSION_ID >= 80600 + /* The engine assigns a canonical per-class id to anonymous closures + * declared in attribute arguments and parameter default values; prefer + * it to the site-based reference below. First-class callables are + * excluded: their engine id resolves to an fcc site the decode side + * cannot recreate, so they keep the site-based and by-name paths. + * Closures in class constant values and property defaults have no id + * and also fall through. */ + if (!(func->common.fn_flags & ZEND_ACC_FAKE_CLOSURE)) { + zend_class_entry *site_ce; + uint32_t cexpr_id, cexpr_line; + if (zend_constexpr_closure_ref(Z_OBJ_P(src), &site_ce, &cexpr_id, &cexpr_line) == SUCCESS) { + zval tmp; + array_init_size(dst, 3); + ZVAL_STR_COPY(&tmp, site_ce->name); + zend_hash_index_add_new(Z_ARRVAL_P(dst), 0, &tmp); + ZVAL_LONG(&tmp, (zend_long) cexpr_id); + zend_hash_index_add_new(Z_ARRVAL_P(dst), 1, &tmp); + ZVAL_LONG(&tmp, (zend_long) cexpr_line); + zend_hash_index_add_new(Z_ARRVAL_P(dst), 2, &tmp); + DC_MASK_CONSTEXPR_CLOSURE(mask_dst); + goto handle_value; + } + } +#endif zval payload; ZVAL_UNDEF(&payload); 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) * scope) misses. On 8.5 there is no engine provenance; fall back * to a declaring class captured from ReflectionAttribute, if * any, and locate the site there. */ - if (DC_G(capture_attribute_closures) && func->common.scope && func->common.function_name) { - zend_class_entry *decl = dc_provenance_lookup(func->common.scope, func->common.function_name); + if (func->common.function_name) { + zend_class_entry *decl = dc_declaring_class(src, func); if (decl && decl != func->common.scope && dc_cexpr_locate_ce(func, decl, &payload)) { ZVAL_COPY_VALUE(dst, &payload); 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) } /* Global-function first-class callable (no scope, internal or user): - * the declaring class can only come from captured provenance. Same + * the declaring class comes from the engine (8.6) or captured + * provenance (8.5). Same * declaration-site reference and Closure gating as above; unresolved * ones fall through to the by-name path. */ if (func && (func->common.fn_flags & ZEND_ACC_FAKE_CLOSURE) - && !func->common.scope && func->common.function_name - && DC_G(capture_attribute_closures)) { + && !func->common.scope && func->common.function_name) { zval *this_ptr = zend_get_closure_this_ptr(src); if (!this_ptr || Z_TYPE_P(this_ptr) != IS_OBJECT) { - zend_class_entry *decl = dc_provenance_lookup(NULL, func->common.function_name); + zend_class_entry *decl = dc_declaring_class(src, func); if (decl) { if (!dc_class_allowed(ctx->allowed_ht, zend_ce_closure->name)) { zend_value_error("deepclone_to_array(): class \"Closure\" is not allowed"); diff --git a/tests/deepclone_constexpr_closures.phpt b/tests/deepclone_constexpr_closures.phpt index a2ce192..0ed9386 100644 --- a/tests/deepclone_constexpr_closures.phpt +++ b/tests/deepclone_constexpr_closures.phpt @@ -4,6 +4,7 @@ deepclone references closures declared in constant expressions (PHP 8.5) deepclone --SKIPIF-- += 80600) die('skip PHP 8.6 emits engine-id references, covered by deepclone_constexpr_closures_native.phpt'); ?> --FILE-- = 80600) die('skip PHP < 8.6 only'); ?> +--FILE-- + '', 'objectMeta' => 0, 'prepared' => [Fix::class, 0, 1], 'mask' => 1]); +} catch (\ValueError $e) { + echo $e->getMessage(), "\n"; +} +?> +--EXPECTF-- +deepclone_from_array(): const-expr-closure payload was created on PHP 8.6 or later and cannot be resolved on PHP %s diff --git a/tests/deepclone_constexpr_closures_native.phpt b/tests/deepclone_constexpr_closures_native.phpt new file mode 100644 index 0000000..c70e96d --- /dev/null +++ b/tests/deepclone_constexpr_closures_native.phpt @@ -0,0 +1,228 @@ +--TEST-- +deepclone references const-expr closures through engine ids (PHP 8.6) +--EXTENSIONS-- +deepclone +--SKIPIF-- + +--FILE-- +args = $args; } } + +#[CA(static function (): string { return self::SECRET; })] +class Fix { + private const SECRET = 'class-secret'; + public const CALLBACKS = ['first' => static function (): string { return 'const-value'; }]; + #[CA(cb: [1, ['x' => static function (int $i): int { return $i * 2; }]])] + public string $tagged = 'v'; + public ?Closure $factory = static function (): string { return 'prop-default'; }; + #[CA('not-a-closure')] + #[CA(static function (): string { return 'repeated'; })] + public function tagged( + #[CA(static function (): string { return 'param-attr'; })] + ?Closure $cb = static function (): string { return 'param-default'; }, + ): void {} +} + +$rc = new ReflectionClass(Fix::class); + +// ── Wire format: engine-id reference [class, id, line] ── +$c = $rc->getAttributes()[0]->getArguments()[0]; +$line = (new ReflectionFunction($c))->getStartLine(); +$d = deepclone_to_array($c); +var_dump($d['prepared'] === [Fix::class, 0, $line]); +var_dump($d['mask'] === 1); +$r = deepclone_from_array($d); +var_dump($r instanceof Closure, $r !== $c, $r() === 'class-secret'); + +// ── The emitted reference matches the engine's ── +$rf = new ReflectionFunction($c); +var_dump($d['prepared'] === [$rf->getConstExprClass(), $rf->getConstExprId(), $line]); + +// ── Attribute sites: nested argument, repeated attribute, parameter attribute, parameter default ── +foreach ([ + [$rc->getProperty('tagged')->getAttributes()[0]->getArguments()['cb'][1]['x'], [3], 6], + [$rc->getMethod('tagged')->getAttributes()[1]->getArguments()[0], [], 'repeated'], + [$rc->getMethod('tagged')->getParameters()[0]->getAttributes()[0]->getArguments()[0], [], 'param-attr'], + [$rc->getMethod('tagged')->getParameters()[0]->getDefaultValue(), [], 'param-default'], +] as [$c, $args, $expected]) { + $d = deepclone_to_array($c); + $rf = new ReflectionFunction($c); + var_dump($d['prepared'] === [Fix::class, $rf->getConstExprId(), $rf->getStartLine()], deepclone_from_array($d)(...$args) === $expected); +} + +// ── Constant values and property defaults have no engine id: site-based form ── +$d = deepclone_to_array(Fix::CALLBACKS['first']); +var_dump(count($d['prepared']) === 5, $d['prepared'][1] === 'CALLBACKS', deepclone_from_array($d)() === 'const-value'); +$d = deepclone_to_array($rc->getProperty('factory')->getDefaultValue()); +var_dump($d['prepared'][1] === '$factory', deepclone_from_array($d)() === 'prop-default'); + +// ── Site-based references written on PHP 8.5 still resolve ── +var_dump(deepclone_from_array(['classes' => '', 'objectMeta' => 0, 'prepared' => [Fix::class, '', 0, 0, $line], 'mask' => 1])() === 'class-secret'); + +// ── Same-line closures get distinct ids ── +#[CA(static function (): string { return 'first'; }, static function (): string { return 'second'; })] +class FixAmbiguous {} +$args = (new ReflectionClass(FixAmbiguous::class))->getAttributes()[0]->getArguments(); +$d0 = deepclone_to_array($args[0]); +$d1 = deepclone_to_array($args[1]); +var_dump([$d0['prepared'][1], $d1['prepared'][1]] === [0, 1]); +var_dump(deepclone_from_array($d0)() === 'first', deepclone_from_array($d1)() === 'second'); + +// ── Enum case attribute gets an id, enum constant value stays site-based ── +enum FixEnum: string { + #[CA(static function (): string { return 'enum-case-attr'; })] + case Active = 'A'; + public const FILTER = static function (): string { return 'enum-const'; }; +} +$d = deepclone_to_array((new ReflectionClassConstant(FixEnum::class, 'Active'))->getAttributes()[0]->getArguments()[0]); +var_dump(is_int($d['prepared'][1]), deepclone_from_array($d)() === 'enum-case-attr'); +$d = deepclone_to_array(FixEnum::FILTER); +var_dump($d['prepared'][1] === 'FILTER', deepclone_from_array($d)() === 'enum-const'); + +// ── Property hooks ── +class FixHooked { + public string $virtual { + #[CA(static function (): string { return 'get-hook-attr'; })] + get => 'vx'; + } +} +$c = (new ReflectionProperty(FixHooked::class, 'virtual'))->getHook(PropertyHookType::Get)->getAttributes()[0]->getArguments()[0]; +$d = deepclone_to_array($c); +var_dump(is_int($d['prepared'][1]), deepclone_from_array($d)() === 'get-hook-attr'); + +// ── Trait method attribute: the using class declares the closure ── +trait FixTrait { + #[CA(static function (): string { return 'trait-attr'; })] + public function traitTagged(): void {} +} +class FixTraitUser { use FixTrait; } +$d = deepclone_to_array((new ReflectionClass(FixTraitUser::class))->getMethod('traitTagged')->getAttributes()[0]->getArguments()[0]); +var_dump(is_int($d['prepared'][1]), deepclone_from_array($d)() === 'trait-attr'); + +// ── Inherited declaration keeps the declaring class ── +class FixParent { + #[CA(static function (): string { return 'parent-attr'; })] + public function pm(): void {} +} +class FixChild extends FixParent {} +$c = (new ReflectionMethod(FixChild::class, 'pm'))->getAttributes()[0]->getArguments()[0]; +$d = deepclone_to_array($c); +var_dump($d['prepared'][0] === FixParent::class, deepclone_from_array($d)() === 'parent-attr'); + +// ── First-class callables use the site-based reference, not an engine id: +// the engine id of an fcc resolves to a site the decode path cannot recreate, +// so they keep the declaration-site (5-element) form ── +class FixFcc { + #[CA(self::helper(...))] + public static function helper(): bool { return true; } +} +$d = deepclone_to_array((new ReflectionMethod(FixFcc::class, 'helper'))->getAttributes()[0]->getArguments()[0]); +var_dump($d['mask'] === 1, deepclone_from_array($d)() === true); + +// ── ... but a crafted payload addressing the FCC site is rejected ── +try { + deepclone_from_array(['classes' => '', 'objectMeta' => 0, 'prepared' => [FixFcc::class, 0, 1], 'mask' => 1]); +} catch (\ValueError $e) { + var_dump($e->getMessage()); +} + +// ── Runtime closures still refuse, through the engine's own __serialize() ── +try { + deepclone_to_array(static function () { return 'runtime'; }); +} catch (\Exception $e) { + var_dump($e->getMessage()); +} + +// ── Object graph survives a JSON round trip ── +$graph = (object) ['cb' => $rc->getAttributes()[0]->getArguments()[0]]; +$d = json_decode(json_encode(deepclone_to_array($graph)), true); +var_dump((deepclone_from_array($d)->cb)() === 'class-secret'); + +// ── allowed_classes gating, both directions ── +try { + deepclone_to_array($rc->getAttributes()[0]->getArguments()[0], []); +} catch (\ValueError $e) { + var_dump($e->getMessage()); +} +$d = deepclone_to_array($rc->getAttributes()[0]->getArguments()[0], ['Closure']); +try { + deepclone_from_array($d, []); +} catch (\ValueError $e) { + var_dump($e->getMessage()); +} +try { + deepclone_from_array($d, ['Closure']); +} catch (\ValueError $e) { + var_dump($e->getMessage()); +} +var_dump(deepclone_from_array($d, ['Closure', 'Fix'])() === 'class-secret'); + +// ── Stale payload ── +$d = deepclone_to_array($rc->getAttributes()[0]->getArguments()[0]); +$d['prepared'][2]++; +try { + deepclone_from_array($d); +} catch (\ValueError $e) { + var_dump(str_contains($e->getMessage(), 'stale payload, const-expr-closure moved from line')); +} + +// ── Unknown id, unknown class ── +try { + deepclone_from_array(['classes' => '', 'objectMeta' => 0, 'prepared' => [Fix::class, 999, $line], 'mask' => 1]); +} catch (\ValueError $e) { + var_dump($e->getMessage()); +} +try { + deepclone_from_array(['classes' => '', 'objectMeta' => 0, 'prepared' => ['No\Such\ClassAtAll', 0, 1], 'mask' => 1]); +} catch (\ValueError $e) { + var_dump($e->getMessage()); +} +?> +--EXPECT-- +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +string(100) "deepclone_from_array(): malformed payload, const-expr-closure references a first-class callable site" +string(41) "Serialization of 'Closure' is not allowed" +bool(true) +string(52) "deepclone_to_array(): class "Closure" is not allowed" +string(54) "deepclone_from_array(): class "Closure" is not allowed" +string(50) "deepclone_from_array(): class "Fix" is not allowed" +bool(true) +bool(true) +string(110) "deepclone_from_array(): malformed payload, const-expr-closure references unknown closure id 999 in class "Fix"" +string(107) "deepclone_from_array(): malformed payload, const-expr-closure references unknown class "No\Such\ClassAtAll"" diff --git a/tests/deepclone_constexpr_closures_native_fcc.phpt b/tests/deepclone_constexpr_closures_native_fcc.phpt new file mode 100644 index 0000000..168db98 --- /dev/null +++ b/tests/deepclone_constexpr_closures_native_fcc.phpt @@ -0,0 +1,52 @@ +--TEST-- +deepclone resolves cross-class and global first-class-callable declaring classes from the engine (PHP 8.6) +--EXTENSIONS-- +deepclone +--SKIPIF-- + +--FILE-- +cb = $a[0]; } } + +class Target { public static function check(): bool { return true; } } + +function dc_native_global(): int { return 41; } + +class Decl { + #[CA(Target::check(...))] public int $x = 0; // cross-class first-class callable + #[CA(strlen(...))] public int $g = 0; // global internal function + #[CA(dc_native_global(...))] public int $u = 0; // global user function +} + +// The engine yields the declaring class (no ReflectionAttribute capture, no +// allow_named_closures opt-in), and the closure serializes as the same +// site-based reference rooted at the declaring class -- not the target's scope. +$rp = new ReflectionClass(Decl::class); + +$cross = deepclone_to_array($rp->getProperty('x')->getAttributes()[0]->getArguments()[0]); +var_dump($cross['mask'] === 1, $cross['prepared'][0] === Decl::class, deepclone_from_array($cross)() === true); + +$gi = deepclone_to_array($rp->getProperty('g')->getAttributes()[0]->getArguments()[0]); +var_dump($gi['mask'] === 1, $gi['prepared'][0] === Decl::class, deepclone_from_array($gi)('hello') === 5); + +$gu = deepclone_to_array($rp->getProperty('u')->getAttributes()[0]->getArguments()[0]); +var_dump($gu['mask'] === 1, $gu['prepared'][0] === Decl::class, deepclone_from_array($gu)() === 41); + +echo "Done\n"; +?> +--EXPECT-- +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +Done diff --git a/tests/deepclone_constexpr_closures_validation.phpt b/tests/deepclone_constexpr_closures_validation.phpt index bbe0694..4a4abee 100644 --- a/tests/deepclone_constexpr_closures_validation.phpt +++ b/tests/deepclone_constexpr_closures_validation.phpt @@ -41,6 +41,11 @@ $cases = [ [Fix::class, 'tagged()', null, 0, $line], [Fix::class, '$tagged::get()', 0, 0, $line], [Fix::class, '$tagged::bad()', 0, 0, $line], + // an int element 1 makes the payload an engine-id reference [class, id, line] + [Fix::class, 0], + [Fix::class, 0, $line, 'x'], + [42, 0, $line], + [Fix::class, 0, 'x'], ]; foreach ($cases as $prepared) { @@ -57,7 +62,7 @@ deepclone_from_array(): malformed payload, const-expr-closure value must be of t deepclone_from_array(): malformed payload, const-expr-closure value must have 5 elements deepclone_from_array(): malformed payload, const-expr-closure class name must be of type string, int given deepclone_from_array(): malformed payload, const-expr-closure references unknown class "No\Such\ClassAtAll" -deepclone_from_array(): malformed payload, const-expr-closure site must be of type string, int given +deepclone_from_array(): malformed payload, const-expr-closure value must have 3 elements deepclone_from_array(): malformed payload, const-expr-closure attribute index must be of type int or null, string given deepclone_from_array(): malformed payload, const-expr-closure closure index must be of type int, string given deepclone_from_array(): malformed payload, const-expr-closure line must be of type int, string given @@ -74,3 +79,7 @@ deepclone_from_array(): malformed payload, const-expr-closure attribute index is deepclone_from_array(): malformed payload, const-expr-closure attribute index is required for site "tagged()" deepclone_from_array(): malformed payload, const-expr-closure references unknown hook "$tagged::get()" deepclone_from_array(): malformed payload, const-expr-closure references unknown hook "$tagged::bad()" +deepclone_from_array(): malformed payload, const-expr-closure value must have 3 elements +deepclone_from_array(): malformed payload, const-expr-closure value must have 3 elements +deepclone_from_array(): malformed payload, const-expr-closure class name must be of type string, int given +deepclone_from_array(): malformed payload, const-expr-closure line must be of type int, string given From 85f9ec3917fbf8bd80d02db1a18a70bc52311347 Mon Sep 17 00:00:00 2001 From: Nicolas Grekas Date: Thu, 2 Jul 2026 16:53:33 +0200 Subject: [PATCH 2/7] Keep the site-based reference on PHP 8.6, drop the engine-id form The site-based (5-element) const-expr closure reference is already element-scoped and version-independent, so it needs no adaptation for the engine's switch to element-scoped ids, and it keeps payloads interchangeable across PHP 8.5, PHP 8.6 and the polyfill. Remove the PHP 8.6 engine-id (3-element) encode/decode path that referenced closures by the engine's per-class id: it coupled the wire format to that id (now a string), only covered anonymous attribute/parameter-default closures, and could not represent first-class-callable, class-constant or property-default sites. deepclone_to_array() now emits the site-based reference on every version, and dc_declaring_class() consults the engine only for a first-class callable's declaring class (adjusted for zend_constexpr_closure_ref()'s new signature). Also refuse a runtime closure (not declared in a constant expression) with NotInstantiableException instead of falling through to the generic object path, which on PHP 8.6 would call the new Closure::__serialize() and surface the engine's "Serialization of 'Closure' is not allowed". Align the allow_named_closures from_array message quoting with to_array and the polyfill. --- CHANGELOG.md | 20 +- deepclone.c | 120 ++------- tests/deepclone_constexpr_closures.phpt | 1 - ...deepclone_constexpr_closures_id_pre86.phpt | 19 -- .../deepclone_constexpr_closures_native.phpt | 228 ------------------ ...epclone_constexpr_closures_validation.phpt | 11 +- tests/deepclone_named_closure_optin.phpt | 6 +- 7 files changed, 32 insertions(+), 373 deletions(-) delete mode 100644 tests/deepclone_constexpr_closures_id_pre86.phpt delete mode 100644 tests/deepclone_constexpr_closures_native.phpt diff --git a/CHANGELOG.md b/CHANGELOG.md index 920599b..4224848 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,23 +39,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 relied on the previous unconditional by-name behavior must pass the flag. Closures declared in constant expressions are unaffected. -- On PHP 8.6, `deepclone_to_array()` references anonymous closures declared in - attribute arguments and parameter default values as `[class, id, line]`, where - `id` is the engine's canonical const-expr closure id (see - `Closure::fromConstExpr()`). This replaces the per-call declaration-site scan - with the engine's non-evaluating walk. `deepclone_from_array()` accepts both - this and the site-based form: site-based payloads written on PHP 8.5 keep - resolving on 8.6, and engine-id payloads fail with an explicit message on older - PHP. +- Const-expr closures use the site-based (5-element) declaration-site reference + `[class, site, attrIndex, ord, line]` on every PHP version. This reference is + already element-scoped (the site names the declaring element and the ordinal + its position within it) and version-independent, so payloads produced on PHP + 8.5 and 8.6 and by the polyfill are interchangeable. The engine's own + element-scoped id (`getConstExprId()`, a `"@"` string used by + native `serialize()`) is a separate encoding the extension does not emit; on + PHP 8.6, `dc_declaring_class()` still consults the engine only to recover the + declaring class of a first-class callable. - On PHP 8.6, first-class callables declared in a constant expression of another class (`#[When(Validators::check(...))]`) or over a global function (`#[When(strlen(...))]`) get their declaring class from the engine and serialize as a (site-based) declaration-site reference with no `allow_named_closures` opt-in -- the same payload the extension produces on 8.5 through ReflectionAttribute provenance, and that the polyfill produces. - (First-class callables keep the site-based form rather than an engine id: an - engine id resolves to an fcc site whose source line userland cannot reproduce, - which would break interchange with the polyfill.) - On PHP 8.4+, `deepclone_from_array()` now creates object nodes whose payload slots or replayed `__unserialize` state carry a named-closure or diff --git a/deepclone.c b/deepclone.c index 8b6447e..08e9e9b 100644 --- a/deepclone.c +++ b/deepclone.c @@ -1576,8 +1576,10 @@ static zend_class_entry *dc_declaring_class(zval *src, const zend_function *func { #if PHP_VERSION_ID >= 80600 zend_class_entry *ce; - uint32_t id, line; + zend_string *id; + uint32_t line; if (zend_constexpr_closure_ref(Z_OBJ_P(src), &ce, &id, &line) == SUCCESS) { + zend_string_release(id); return ce; } #endif @@ -1676,68 +1678,9 @@ static void ZEND_FASTCALL dc_attr_new_instance_wrapper(INTERNAL_FUNCTION_PARAMET } #endif /* PHP_VERSION_ID >= 80500 */ -/* deepclone_from_array() counterpart for engine-id references [class, id, - * line], emitted on PHP >= 8.6: the id is the engine's canonical per-class - * const-expr closure id (see Closure::fromConstExpr()). */ -static void dc_cexpr_resolve_id(HashTable *ht, HashTable *allowed_set, zval *retval) -{ - zval *zclass = zend_hash_index_find(ht, 0); - zval *zid = zend_hash_index_find(ht, 1); - zval *zline = zend_hash_index_find(ht, 2); - if (!zclass || !zid || !zline || zend_hash_num_elements(ht) != 3) { - zend_value_error("deepclone_from_array(): malformed payload, const-expr-closure value must have 3 elements"); - return; - } - ZVAL_DEREF(zclass); - ZVAL_DEREF(zid); - ZVAL_DEREF(zline); - if (Z_TYPE_P(zclass) != IS_STRING) { - 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)); - return; - } - if (Z_TYPE_P(zline) != IS_LONG) { - zend_value_error("deepclone_from_array(): malformed payload, const-expr-closure line must be of type int, %s given", zend_zval_value_name(zline)); - return; - } - - /* Gate before zend_lookup_class(): the payload must not be able to - * autoload, let alone evaluate, classes outside the allow-list. */ - if (!dc_class_allowed(allowed_set, Z_STR_P(zclass))) { - zend_value_error("deepclone_from_array(): class \"%s\" is not allowed", Z_STRVAL_P(zclass)); - return; - } - -#if PHP_VERSION_ID >= 80600 - zend_class_entry *ce = zend_lookup_class(Z_STR_P(zclass)); - if (!ce) { - zend_value_error("deepclone_from_array(): malformed payload, const-expr-closure references unknown class \"%s\"", Z_STRVAL_P(zclass)); - return; - } - - zend_ast *site = zend_constexpr_closure_site_by_id(ce, Z_LVAL_P(zid)); - if (!site) { - 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)); - return; - } - if (site->kind != ZEND_AST_OP_ARRAY) { - zend_value_error("deepclone_from_array(): malformed payload, const-expr-closure references a first-class callable site"); - return; - } - - zend_op_array *op = zend_ast_get_op_array(site)->op_array; - if (Z_LVAL_P(zline) != (zend_long) op->line_start) { - 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); - return; - } - - zend_create_closure(retval, (zend_function *) op, ce, ce, NULL); -#else - 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); -#endif -} - /* deepclone_from_array() counterpart: resolve a declaration-site reference - * back to a live Closure. */ + * back to a live Closure. The reference is the element-scoped, version- + * independent site-based (5-element) form. */ static void dc_cexpr_resolve(zval *value, HashTable *allowed_set, zval *retval) { if (Z_TYPE_P(value) != IS_ARRAY) { @@ -1746,17 +1689,6 @@ static void dc_cexpr_resolve(zval *value, HashTable *allowed_set, zval *retval) } HashTable *ht = Z_ARRVAL_P(value); - zval *zid = zend_hash_index_find(ht, 1); - if (zid) { - ZVAL_DEREF(zid); - } - if (zid && Z_TYPE_P(zid) == IS_LONG) { - /* The type of element 1 (int id vs string site) discriminates - * engine-id references from site-based ones. */ - dc_cexpr_resolve_id(ht, allowed_set, retval); - return; - } - zval *zclass = zend_hash_index_find(ht, 0); zval *zsite = zend_hash_index_find(ht, 1); zval *zattr = zend_hash_index_find(ht, 2); @@ -2123,31 +2055,11 @@ static void dc_copy_value(dc_ctx *ctx, zval *src, zval *dst, zval *mask_dst) zend_value_error("deepclone_to_array(): class \"Closure\" is not allowed"); return; } -#if PHP_VERSION_ID >= 80600 - /* The engine assigns a canonical per-class id to anonymous closures - * declared in attribute arguments and parameter default values; prefer - * it to the site-based reference below. First-class callables are - * excluded: their engine id resolves to an fcc site the decode side - * cannot recreate, so they keep the site-based and by-name paths. - * Closures in class constant values and property defaults have no id - * and also fall through. */ - if (!(func->common.fn_flags & ZEND_ACC_FAKE_CLOSURE)) { - zend_class_entry *site_ce; - uint32_t cexpr_id, cexpr_line; - if (zend_constexpr_closure_ref(Z_OBJ_P(src), &site_ce, &cexpr_id, &cexpr_line) == SUCCESS) { - zval tmp; - array_init_size(dst, 3); - ZVAL_STR_COPY(&tmp, site_ce->name); - zend_hash_index_add_new(Z_ARRVAL_P(dst), 0, &tmp); - ZVAL_LONG(&tmp, (zend_long) cexpr_id); - zend_hash_index_add_new(Z_ARRVAL_P(dst), 1, &tmp); - ZVAL_LONG(&tmp, (zend_long) cexpr_line); - zend_hash_index_add_new(Z_ARRVAL_P(dst), 2, &tmp); - DC_MASK_CONSTEXPR_CLOSURE(mask_dst); - goto handle_value; - } - } -#endif + /* Anonymous const-expr closures use the site-based (5-element) + * reference, which is element-scoped and version-independent, so + * payloads interchange across PHP 8.5 and 8.6 and with the + * polyfill. The engine's element-scoped id (getConstExprId()) is a + * separate, native serialize()-only encoding not used here. */ zval payload; ZVAL_UNDEF(&payload); if (dc_cexpr_locate(func, &payload)) { @@ -2280,8 +2192,14 @@ static void dc_copy_value(dc_ctx *ctx, zval *src, zval *dst, zval *mask_dst) DC_MASK_NAMED_CLOSURE(mask_dst); goto handle_value; } - /* Other closure (runtime anonymous, arrow fn) — fall through to - * regular object handling, which refuses it as non-instantiable. */ + /* Other closure (runtime anonymous, arrow fn): not declared in a + * constant expression, so it cannot be referenced. Refuse it + * explicitly — on PHP 8.6+ the generic object path would otherwise call + * Closure::__serialize() and surface the engine's "Serialization of + * 'Closure' is not allowed" instead. */ + zend_throw_exception_ex(dc_ce_not_instantiable_exception, 0, + "Type \"%s\" is not instantiable.", ZSTR_VAL(zend_ce_closure->name)); + return; } /* ── Regular object processing ──────────────── */ @@ -4403,7 +4321,7 @@ PHP_FUNCTION(deepclone_from_array) * is rejected wholesale rather than failing mid-hydration. */ if (!allow_named_closures && dc_payload_has_named_closure(zmask, zresolve, zref_masks, zstates)) { - DC_INVALID("deepclone_from_array(): resolving a closure over a named callable requires enabling the allow_named_closures option"); + DC_INVALID("deepclone_from_array(): resolving a closure over a named callable requires enabling the \"allow_named_closures\" option"); } /* ── Build objectMeta ── */ diff --git a/tests/deepclone_constexpr_closures.phpt b/tests/deepclone_constexpr_closures.phpt index 0ed9386..a2ce192 100644 --- a/tests/deepclone_constexpr_closures.phpt +++ b/tests/deepclone_constexpr_closures.phpt @@ -4,7 +4,6 @@ deepclone references closures declared in constant expressions (PHP 8.5) deepclone --SKIPIF-- -= 80600) die('skip PHP 8.6 emits engine-id references, covered by deepclone_constexpr_closures_native.phpt'); ?> --FILE-- = 80600) die('skip PHP < 8.6 only'); ?> ---FILE-- - '', 'objectMeta' => 0, 'prepared' => [Fix::class, 0, 1], 'mask' => 1]); -} catch (\ValueError $e) { - echo $e->getMessage(), "\n"; -} -?> ---EXPECTF-- -deepclone_from_array(): const-expr-closure payload was created on PHP 8.6 or later and cannot be resolved on PHP %s diff --git a/tests/deepclone_constexpr_closures_native.phpt b/tests/deepclone_constexpr_closures_native.phpt deleted file mode 100644 index c70e96d..0000000 --- a/tests/deepclone_constexpr_closures_native.phpt +++ /dev/null @@ -1,228 +0,0 @@ ---TEST-- -deepclone references const-expr closures through engine ids (PHP 8.6) ---EXTENSIONS-- -deepclone ---SKIPIF-- - ---FILE-- -args = $args; } } - -#[CA(static function (): string { return self::SECRET; })] -class Fix { - private const SECRET = 'class-secret'; - public const CALLBACKS = ['first' => static function (): string { return 'const-value'; }]; - #[CA(cb: [1, ['x' => static function (int $i): int { return $i * 2; }]])] - public string $tagged = 'v'; - public ?Closure $factory = static function (): string { return 'prop-default'; }; - #[CA('not-a-closure')] - #[CA(static function (): string { return 'repeated'; })] - public function tagged( - #[CA(static function (): string { return 'param-attr'; })] - ?Closure $cb = static function (): string { return 'param-default'; }, - ): void {} -} - -$rc = new ReflectionClass(Fix::class); - -// ── Wire format: engine-id reference [class, id, line] ── -$c = $rc->getAttributes()[0]->getArguments()[0]; -$line = (new ReflectionFunction($c))->getStartLine(); -$d = deepclone_to_array($c); -var_dump($d['prepared'] === [Fix::class, 0, $line]); -var_dump($d['mask'] === 1); -$r = deepclone_from_array($d); -var_dump($r instanceof Closure, $r !== $c, $r() === 'class-secret'); - -// ── The emitted reference matches the engine's ── -$rf = new ReflectionFunction($c); -var_dump($d['prepared'] === [$rf->getConstExprClass(), $rf->getConstExprId(), $line]); - -// ── Attribute sites: nested argument, repeated attribute, parameter attribute, parameter default ── -foreach ([ - [$rc->getProperty('tagged')->getAttributes()[0]->getArguments()['cb'][1]['x'], [3], 6], - [$rc->getMethod('tagged')->getAttributes()[1]->getArguments()[0], [], 'repeated'], - [$rc->getMethod('tagged')->getParameters()[0]->getAttributes()[0]->getArguments()[0], [], 'param-attr'], - [$rc->getMethod('tagged')->getParameters()[0]->getDefaultValue(), [], 'param-default'], -] as [$c, $args, $expected]) { - $d = deepclone_to_array($c); - $rf = new ReflectionFunction($c); - var_dump($d['prepared'] === [Fix::class, $rf->getConstExprId(), $rf->getStartLine()], deepclone_from_array($d)(...$args) === $expected); -} - -// ── Constant values and property defaults have no engine id: site-based form ── -$d = deepclone_to_array(Fix::CALLBACKS['first']); -var_dump(count($d['prepared']) === 5, $d['prepared'][1] === 'CALLBACKS', deepclone_from_array($d)() === 'const-value'); -$d = deepclone_to_array($rc->getProperty('factory')->getDefaultValue()); -var_dump($d['prepared'][1] === '$factory', deepclone_from_array($d)() === 'prop-default'); - -// ── Site-based references written on PHP 8.5 still resolve ── -var_dump(deepclone_from_array(['classes' => '', 'objectMeta' => 0, 'prepared' => [Fix::class, '', 0, 0, $line], 'mask' => 1])() === 'class-secret'); - -// ── Same-line closures get distinct ids ── -#[CA(static function (): string { return 'first'; }, static function (): string { return 'second'; })] -class FixAmbiguous {} -$args = (new ReflectionClass(FixAmbiguous::class))->getAttributes()[0]->getArguments(); -$d0 = deepclone_to_array($args[0]); -$d1 = deepclone_to_array($args[1]); -var_dump([$d0['prepared'][1], $d1['prepared'][1]] === [0, 1]); -var_dump(deepclone_from_array($d0)() === 'first', deepclone_from_array($d1)() === 'second'); - -// ── Enum case attribute gets an id, enum constant value stays site-based ── -enum FixEnum: string { - #[CA(static function (): string { return 'enum-case-attr'; })] - case Active = 'A'; - public const FILTER = static function (): string { return 'enum-const'; }; -} -$d = deepclone_to_array((new ReflectionClassConstant(FixEnum::class, 'Active'))->getAttributes()[0]->getArguments()[0]); -var_dump(is_int($d['prepared'][1]), deepclone_from_array($d)() === 'enum-case-attr'); -$d = deepclone_to_array(FixEnum::FILTER); -var_dump($d['prepared'][1] === 'FILTER', deepclone_from_array($d)() === 'enum-const'); - -// ── Property hooks ── -class FixHooked { - public string $virtual { - #[CA(static function (): string { return 'get-hook-attr'; })] - get => 'vx'; - } -} -$c = (new ReflectionProperty(FixHooked::class, 'virtual'))->getHook(PropertyHookType::Get)->getAttributes()[0]->getArguments()[0]; -$d = deepclone_to_array($c); -var_dump(is_int($d['prepared'][1]), deepclone_from_array($d)() === 'get-hook-attr'); - -// ── Trait method attribute: the using class declares the closure ── -trait FixTrait { - #[CA(static function (): string { return 'trait-attr'; })] - public function traitTagged(): void {} -} -class FixTraitUser { use FixTrait; } -$d = deepclone_to_array((new ReflectionClass(FixTraitUser::class))->getMethod('traitTagged')->getAttributes()[0]->getArguments()[0]); -var_dump(is_int($d['prepared'][1]), deepclone_from_array($d)() === 'trait-attr'); - -// ── Inherited declaration keeps the declaring class ── -class FixParent { - #[CA(static function (): string { return 'parent-attr'; })] - public function pm(): void {} -} -class FixChild extends FixParent {} -$c = (new ReflectionMethod(FixChild::class, 'pm'))->getAttributes()[0]->getArguments()[0]; -$d = deepclone_to_array($c); -var_dump($d['prepared'][0] === FixParent::class, deepclone_from_array($d)() === 'parent-attr'); - -// ── First-class callables use the site-based reference, not an engine id: -// the engine id of an fcc resolves to a site the decode path cannot recreate, -// so they keep the declaration-site (5-element) form ── -class FixFcc { - #[CA(self::helper(...))] - public static function helper(): bool { return true; } -} -$d = deepclone_to_array((new ReflectionMethod(FixFcc::class, 'helper'))->getAttributes()[0]->getArguments()[0]); -var_dump($d['mask'] === 1, deepclone_from_array($d)() === true); - -// ── ... but a crafted payload addressing the FCC site is rejected ── -try { - deepclone_from_array(['classes' => '', 'objectMeta' => 0, 'prepared' => [FixFcc::class, 0, 1], 'mask' => 1]); -} catch (\ValueError $e) { - var_dump($e->getMessage()); -} - -// ── Runtime closures still refuse, through the engine's own __serialize() ── -try { - deepclone_to_array(static function () { return 'runtime'; }); -} catch (\Exception $e) { - var_dump($e->getMessage()); -} - -// ── Object graph survives a JSON round trip ── -$graph = (object) ['cb' => $rc->getAttributes()[0]->getArguments()[0]]; -$d = json_decode(json_encode(deepclone_to_array($graph)), true); -var_dump((deepclone_from_array($d)->cb)() === 'class-secret'); - -// ── allowed_classes gating, both directions ── -try { - deepclone_to_array($rc->getAttributes()[0]->getArguments()[0], []); -} catch (\ValueError $e) { - var_dump($e->getMessage()); -} -$d = deepclone_to_array($rc->getAttributes()[0]->getArguments()[0], ['Closure']); -try { - deepclone_from_array($d, []); -} catch (\ValueError $e) { - var_dump($e->getMessage()); -} -try { - deepclone_from_array($d, ['Closure']); -} catch (\ValueError $e) { - var_dump($e->getMessage()); -} -var_dump(deepclone_from_array($d, ['Closure', 'Fix'])() === 'class-secret'); - -// ── Stale payload ── -$d = deepclone_to_array($rc->getAttributes()[0]->getArguments()[0]); -$d['prepared'][2]++; -try { - deepclone_from_array($d); -} catch (\ValueError $e) { - var_dump(str_contains($e->getMessage(), 'stale payload, const-expr-closure moved from line')); -} - -// ── Unknown id, unknown class ── -try { - deepclone_from_array(['classes' => '', 'objectMeta' => 0, 'prepared' => [Fix::class, 999, $line], 'mask' => 1]); -} catch (\ValueError $e) { - var_dump($e->getMessage()); -} -try { - deepclone_from_array(['classes' => '', 'objectMeta' => 0, 'prepared' => ['No\Such\ClassAtAll', 0, 1], 'mask' => 1]); -} catch (\ValueError $e) { - var_dump($e->getMessage()); -} -?> ---EXPECT-- -bool(true) -bool(true) -bool(true) -bool(true) -bool(true) -bool(true) -bool(true) -bool(true) -bool(true) -bool(true) -bool(true) -bool(true) -bool(true) -bool(true) -bool(true) -bool(true) -bool(true) -bool(true) -bool(true) -bool(true) -bool(true) -bool(true) -bool(true) -bool(true) -bool(true) -bool(true) -bool(true) -bool(true) -bool(true) -bool(true) -bool(true) -bool(true) -bool(true) -bool(true) -bool(true) -string(100) "deepclone_from_array(): malformed payload, const-expr-closure references a first-class callable site" -string(41) "Serialization of 'Closure' is not allowed" -bool(true) -string(52) "deepclone_to_array(): class "Closure" is not allowed" -string(54) "deepclone_from_array(): class "Closure" is not allowed" -string(50) "deepclone_from_array(): class "Fix" is not allowed" -bool(true) -bool(true) -string(110) "deepclone_from_array(): malformed payload, const-expr-closure references unknown closure id 999 in class "Fix"" -string(107) "deepclone_from_array(): malformed payload, const-expr-closure references unknown class "No\Such\ClassAtAll"" diff --git a/tests/deepclone_constexpr_closures_validation.phpt b/tests/deepclone_constexpr_closures_validation.phpt index 4a4abee..bbe0694 100644 --- a/tests/deepclone_constexpr_closures_validation.phpt +++ b/tests/deepclone_constexpr_closures_validation.phpt @@ -41,11 +41,6 @@ $cases = [ [Fix::class, 'tagged()', null, 0, $line], [Fix::class, '$tagged::get()', 0, 0, $line], [Fix::class, '$tagged::bad()', 0, 0, $line], - // an int element 1 makes the payload an engine-id reference [class, id, line] - [Fix::class, 0], - [Fix::class, 0, $line, 'x'], - [42, 0, $line], - [Fix::class, 0, 'x'], ]; foreach ($cases as $prepared) { @@ -62,7 +57,7 @@ deepclone_from_array(): malformed payload, const-expr-closure value must be of t deepclone_from_array(): malformed payload, const-expr-closure value must have 5 elements deepclone_from_array(): malformed payload, const-expr-closure class name must be of type string, int given deepclone_from_array(): malformed payload, const-expr-closure references unknown class "No\Such\ClassAtAll" -deepclone_from_array(): malformed payload, const-expr-closure value must have 3 elements +deepclone_from_array(): malformed payload, const-expr-closure site must be of type string, int given deepclone_from_array(): malformed payload, const-expr-closure attribute index must be of type int or null, string given deepclone_from_array(): malformed payload, const-expr-closure closure index must be of type int, string given deepclone_from_array(): malformed payload, const-expr-closure line must be of type int, string given @@ -79,7 +74,3 @@ deepclone_from_array(): malformed payload, const-expr-closure attribute index is deepclone_from_array(): malformed payload, const-expr-closure attribute index is required for site "tagged()" deepclone_from_array(): malformed payload, const-expr-closure references unknown hook "$tagged::get()" deepclone_from_array(): malformed payload, const-expr-closure references unknown hook "$tagged::bad()" -deepclone_from_array(): malformed payload, const-expr-closure value must have 3 elements -deepclone_from_array(): malformed payload, const-expr-closure value must have 3 elements -deepclone_from_array(): malformed payload, const-expr-closure class name must be of type string, int given -deepclone_from_array(): malformed payload, const-expr-closure line must be of type int, string given diff --git a/tests/deepclone_named_closure_optin.phpt b/tests/deepclone_named_closure_optin.phpt index 3cc236a..2af1d06 100644 --- a/tests/deepclone_named_closure_optin.phpt +++ b/tests/deepclone_named_closure_optin.phpt @@ -96,11 +96,11 @@ bool(true) bool(true) bool(true) == 5. a by-name payload is refused by from_array without the opt-in == -from_array: ValueError: deepclone_from_array(): resolving a closure over a named callable requires enabling the allow_named_closures option +from_array: ValueError: deepclone_from_array(): resolving a closure over a named callable requires enabling the "allow_named_closures" option == 6. a hostile system() payload is refused by default == -system: ValueError: deepclone_from_array(): resolving a closure over a named callable requires enabling the allow_named_closures option +system: ValueError: deepclone_from_array(): resolving a closure over a named callable requires enabling the "allow_named_closures" option == 7. a named closure nested in an object graph is refused wholesale (before instantiation) == -nested: ValueError: deepclone_from_array(): resolving a closure over a named callable requires enabling the allow_named_closures option +nested: ValueError: deepclone_from_array(): resolving a closure over a named callable requires enabling the "allow_named_closures" option bool(true) bool(true) == 8. allowed_classes still gates Closure even with the opt-in == From 70d6f9330b46578bdd97f3c2a6846301fb4c6e3d Mon Sep 17 00:00:00 2001 From: Nicolas Grekas Date: Fri, 3 Jul 2026 15:43:41 +0200 Subject: [PATCH 3/7] Reference const-expr closures by element-scoped "@" ids One wire format on every PHP version: [class, "@", line], where the id names the declaring reflection element and the closure rank within just that element, matching ReflectionFunction::getConstExprId(). Encoding walks one element at a time (attributes in declaration order, nested const-expr surfaces depth-first, then parameter defaults, then constant/property values, which extend the rank space past what the engine addresses); on PHP 8.6 anonymous closures short-circuit through zend_constexpr_closure_ref(), skipping all evaluation. Decoding parses the id, tries Closure::fromConstExpr() first on PHP 8.6 (with a line check), and falls back to evaluating only the named element. The 5-element site form and its attrIndex dimension are removed. --- CHANGELOG.md | 22 +- deepclone.c | 562 ++++++++---------- tests/deepclone_attribute_provenance.phpt | 2 +- tests/deepclone_constexpr_closures.phpt | 19 +- ...epclone_constexpr_closures_validation.phpt | 60 +- 5 files changed, 312 insertions(+), 353 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4224848..cc069d9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,15 +39,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 relied on the previous unconditional by-name behavior must pass the flag. Closures declared in constant expressions are unaffected. -- Const-expr closures use the site-based (5-element) declaration-site reference - `[class, site, attrIndex, ord, line]` on every PHP version. This reference is - already element-scoped (the site names the declaring element and the ordinal - its position within it) and version-independent, so payloads produced on PHP - 8.5 and 8.6 and by the polyfill are interchangeable. The engine's own - element-scoped id (`getConstExprId()`, a `"@"` string used by - native `serialize()`) is a separate encoding the extension does not emit; on - PHP 8.6, `dc_declaring_class()` still consults the engine only to recover the - declaring class of a first-class callable. +- Const-expr closures are referenced as `[class, "@", line]` on + every PHP version: the id names the declaring reflection element (the class, + a constant, a property, a property hook or a method, parameters folded into + their method) and the closure's rank among the element's closures, in + evaluation order (attribute arguments first, nested const-expr surfaces + depth-first, then parameter defaults, then the constant or property default + value). The id equals the engine's own `getConstExprId()` for the sites the + engine addresses, and payloads produced on PHP 8.5, on PHP 8.6 and by the + polyfill are identical. On PHP 8.6 encoding uses the engine's non-evaluating + walk for anonymous closures and decoding tries `Closure::fromConstExpr()` + first, falling back to the evaluating walk (which alone covers constant and + property-default values). The previous site-based 5-element reference is + gone, with no backward compatibility: it never shipped in a release. - On PHP 8.6, first-class callables declared in a constant expression of another class (`#[When(Validators::check(...))]`) or over a global function (`#[When(strlen(...))]`) get their declaring class from the engine and diff --git a/deepclone.c b/deepclone.c index 08e9e9b..1de1d83 100644 --- a/deepclone.c +++ b/deepclone.c @@ -1122,6 +1122,8 @@ static zend_always_inline bool dc_cexpr_walk_done(const dc_cexpr_walk *w) /* Depth-first walk counting every Closure instance. The order must match the * polyfill's walk exactly or payloads stop being interchangeable. */ +static void dc_cexpr_walk_closure_surface(dc_cexpr_walk *w, const zend_op_array *op_array); + static void dc_cexpr_walk_zval(dc_cexpr_walk *w, zval *val) { if (UNEXPECTED(dc_check_stack_limit())) { @@ -1131,8 +1133,8 @@ static void dc_cexpr_walk_zval(dc_cexpr_walk *w, zval *val) if (Z_TYPE_P(val) == IS_OBJECT) { if (Z_OBJCE_P(val) == zend_ce_closure) { + const zend_function *f = zend_get_closure_method_def(Z_OBJ_P(val)); if (w->needle) { - const zend_function *f = zend_get_closure_method_def(Z_OBJ_P(val)); if (!w->matched && dc_func_matches(f, w->needle)) { w->matched = true; w->matched_ord = w->ord; @@ -1141,6 +1143,13 @@ static void dc_cexpr_walk_zval(dc_cexpr_walk *w, zval *val) ZVAL_COPY(&w->found, val); } w->ord++; + /* The closure may itself declare closures in its attributes or + * in its parameter default values; they rank right after it, + * like in the engine's walk. */ + if (!dc_cexpr_walk_done(w) && f->type == ZEND_USER_FUNCTION + && !(f->common.fn_flags & ZEND_ACC_FAKE_CLOSURE)) { + dc_cexpr_walk_closure_surface(w, &f->op_array); + } return; } if (!zend_hash_index_add_empty_element(&w->seen, Z_OBJ_HANDLE_P(val))) { @@ -1256,100 +1265,107 @@ static zval *dc_cexpr_param_default(const zend_op_array *op_array, uint32_t para } #if PHP_VERSION_ID >= 80500 -/* Builds [class, site, attrIndex|null, ord, line]; takes ownership of site. */ -static void dc_cexpr_payload(zval *dst, zend_class_entry *ce, zend_string *site, zend_long attr_index, uint32_t ord, uint32_t line) +/* Walk the const-expr surface of an anonymous closure: its attributes + * (declaration order), then its parameter default values. */ +static void dc_cexpr_walk_closure_surface(dc_cexpr_walk *w, const zend_op_array *op_array) +{ + if (op_array->attributes) { + zend_attribute *attr; + ZEND_HASH_FOREACH_PTR(op_array->attributes, attr) { + dc_cexpr_walk_attr(w, attr, op_array->scope, true); + if (dc_cexpr_walk_done(w) || EG(exception)) { + return; + } + } ZEND_HASH_FOREACH_END(); + } + uint32_t nargs = op_array->num_args + ((op_array->fn_flags & ZEND_ACC_VARIADIC) ? 1 : 0); + for (uint32_t pi = 0; pi < nargs; pi++) { + zval *def = dc_cexpr_param_default(op_array, pi); + if (def && Z_TYPE_P(def) != IS_UNDEF) { + dc_cexpr_walk_const(w, def, op_array->scope, true); + if (dc_cexpr_walk_done(w) || EG(exception)) { + return; + } + } + } +} + +/* Builds [class, "@", line]; takes ownership of site. */ +static void dc_cexpr_payload(zval *dst, zend_class_entry *ce, zend_string *site, uint32_t rank, uint32_t line) { zval tmp; - array_init_size(dst, 5); + array_init_size(dst, 3); ZVAL_STR_COPY(&tmp, ce->name); zend_hash_index_add_new(Z_ARRVAL_P(dst), 0, &tmp); - ZVAL_STR(&tmp, site); + ZVAL_STR(&tmp, zend_strpprintf(0, "%s@%u", ZSTR_VAL(site), rank)); zend_hash_index_add_new(Z_ARRVAL_P(dst), 1, &tmp); - if (attr_index < 0) { - ZVAL_NULL(&tmp); - } else { - ZVAL_LONG(&tmp, attr_index); - } - zend_hash_index_add_new(Z_ARRVAL_P(dst), 2, &tmp); - ZVAL_LONG(&tmp, (zend_long) ord); - zend_hash_index_add_new(Z_ARRVAL_P(dst), 3, &tmp); + zend_string_release(site); ZVAL_LONG(&tmp, (zend_long) line); - zend_hash_index_add_new(Z_ARRVAL_P(dst), 4, &tmp); + zend_hash_index_add_new(Z_ARRVAL_P(dst), 2, &tmp); } -/* Walk one attribute list (entries matching `offset`) looking for the needle. - * On match fills *attr_index (ordinal among same-offset entries) and *ord. */ -static bool dc_cexpr_locate_in_attrs(HashTable *attributes, uint32_t offset, zend_class_entry *scope, const zend_function *needle, uint32_t *attr_index, uint32_t *ord) +/* Walk every attribute of one reflection element (all offsets, declaration + * order) with one shared walk, so ranks accumulate across the whole element. + * Returns false on an evaluation failure when clear_failure is unset. */ +static bool dc_cexpr_elem_attrs(dc_cexpr_walk *w, HashTable *attributes, zend_class_entry *scope, bool clear_failure) { + zend_attribute *attr; + if (!attributes) { - return false; + return true; } - uint32_t idx = 0; - zend_attribute *attr; ZEND_HASH_FOREACH_PTR(attributes, attr) { - if (attr->offset != offset) { - continue; - } - dc_cexpr_walk w; - dc_cexpr_walk_init(&w, needle, 0); - dc_cexpr_walk_attr(&w, attr, scope, true); - bool matched = w.matched; - *ord = w.matched_ord; - dc_cexpr_walk_dtor(&w); - if (UNEXPECTED(EG(exception))) { + if (!dc_cexpr_walk_attr(w, attr, scope, clear_failure) && !clear_failure) { return false; } - if (matched) { - *attr_index = idx; - return true; + if (dc_cexpr_walk_done(w) || EG(exception)) { + break; } - idx++; } ZEND_HASH_FOREACH_END(); - return false; -} - -static bool dc_cexpr_locate_in_value(zval *src, zend_class_entry *scope, const zend_function *needle, uint32_t *ord) -{ - dc_cexpr_walk w; - dc_cexpr_walk_init(&w, needle, 0); - dc_cexpr_walk_const(&w, src, scope, true); - bool matched = w.matched; - *ord = w.matched_ord; - dc_cexpr_walk_dtor(&w); - return matched && !EG(exception); + return true; } -/* Try to express an anonymous closure as a reference to the constant - * expression that declares it. Identity is exact: the closure's op_array - * shares its opcodes with the op_array embedded in the declaring AST. - * Sites are scanned in the same order the polyfill indexes them, so both - * implementations produce identical payloads. Promoted properties are - * skipped: their constructor parameter is the canonical surface. */ -/* Locate `target` as a closure declared in the constant expressions of an - * explicit class `ce`. For an anonymous closure ce is its own scope; for a - * first-class callable it is the declaring class, which differs from the - * target's scope on cross-class references. */ +/* Try to express a closure as a reference to the constant expressions of one + * reflection element of `ce`: [class, "@", line]. Identity is + * exact: the closure's op_array shares its opcodes with the op_array embedded + * in the declaring AST. The rank counts the element's closures in evaluation + * order: attribute arguments (declaration order, nested const-expr surfaces + * depth-first), then parameter defaults, then the constant or property default + * value itself. That order matches the engine's element walk, so for purely + * literal elements the reference equals ReflectionFunction::getConstExprId(). + * Promoted properties are skipped: their constructor parameter is the + * canonical surface. Elements are scanned in the same order the polyfill + * indexes them, so both implementations produce identical payloads. */ static bool dc_cexpr_locate_ce(const zend_function *target, zend_class_entry *ce, zval *payload) { const zend_function *needle = target; /* Internal functions have no line; resolution computes 0 for them, so the * staleness check matches. */ uint32_t line = target->type == ZEND_USER_FUNCTION ? target->op_array.line_start : 0; - uint32_t attr_index, ord; zend_string *name; + dc_cexpr_walk w; if (!ce) { return false; } +#define DC_ELEM_TRY(site_expr) do { \ + if (w.matched) { \ + uint32_t _ord = w.matched_ord; \ + dc_cexpr_walk_dtor(&w); \ + dc_cexpr_payload(payload, ce, (site_expr), _ord, line); \ + return true; \ + } \ + dc_cexpr_walk_dtor(&w); \ + if (UNEXPECTED(EG(exception))) { \ + return false; \ + } \ + } while (0) + /* class attributes */ - if (dc_cexpr_locate_in_attrs(ce->attributes, 0, ce, needle, &attr_index, &ord)) { - dc_cexpr_payload(payload, ce, ZSTR_EMPTY_ALLOC(), (zend_long) attr_index, ord, line); - return true; - } - if (UNEXPECTED(EG(exception))) { - return false; - } + dc_cexpr_walk_init(&w, needle, 0); + dc_cexpr_elem_attrs(&w, ce->attributes, ce, true); + DC_ELEM_TRY(ZSTR_EMPTY_ALLOC()); /* class constants and enum cases: attributes, then the value */ zend_class_constant *c; @@ -1357,97 +1373,64 @@ static bool dc_cexpr_locate_ce(const zend_function *target, zend_class_entry *ce if (c->ce != ce) { continue; } - if (dc_cexpr_locate_in_attrs(c->attributes, 0, ce, needle, &attr_index, &ord)) { - dc_cexpr_payload(payload, ce, zend_string_copy(name), (zend_long) attr_index, ord, line); - return true; - } - if (!EG(exception) && dc_cexpr_locate_in_value(&c->value, c->ce, needle, &ord)) { - dc_cexpr_payload(payload, ce, zend_string_copy(name), -1, ord, line); - return true; - } - if (UNEXPECTED(EG(exception))) { - return false; + dc_cexpr_walk_init(&w, needle, 0); + dc_cexpr_elem_attrs(&w, c->attributes, ce, true); + if (!w.matched && !EG(exception)) { + dc_cexpr_walk_const(&w, &c->value, c->ce, true); } + DC_ELEM_TRY(zend_string_copy(name)); } ZEND_HASH_FOREACH_END(); - /* properties: attributes, then the default value */ + /* properties: attributes, then the default value; hooks are elements of + * their own */ zend_property_info *prop; ZEND_HASH_FOREACH_STR_KEY_PTR(&ce->properties_info, name, prop) { if (prop->ce != ce || (prop->flags & ZEND_ACC_PROMOTED)) { continue; } - if (dc_cexpr_locate_in_attrs(prop->attributes, 0, ce, needle, &attr_index, &ord)) { - dc_cexpr_payload(payload, ce, zend_strpprintf(0, "$%s", ZSTR_VAL(name)), (zend_long) attr_index, ord, line); - return true; - } - zval *def = dc_cexpr_prop_default(ce, prop); - if (!EG(exception) && def && Z_TYPE_P(def) != IS_UNDEF && dc_cexpr_locate_in_value(def, prop->ce, needle, &ord)) { - dc_cexpr_payload(payload, ce, zend_strpprintf(0, "$%s", ZSTR_VAL(name)), -1, ord, line); - return true; - } - if (UNEXPECTED(EG(exception))) { - return false; + dc_cexpr_walk_init(&w, needle, 0); + dc_cexpr_elem_attrs(&w, prop->attributes, ce, true); + if (!w.matched && !EG(exception)) { + zval *def = dc_cexpr_prop_default(ce, prop); + if (def && Z_TYPE_P(def) != IS_UNDEF) { + dc_cexpr_walk_const(&w, def, prop->ce, true); + } } - /* property hooks: attributes, then per parameter attributes */ + DC_ELEM_TRY(zend_strpprintf(0, "$%s", ZSTR_VAL(name))); if (prop->hooks) { for (uint32_t hk = 0; hk < ZEND_PROPERTY_HOOK_COUNT; hk++) { const zend_function *hfn = prop->hooks[hk]; if (!hfn || hfn->type != ZEND_USER_FUNCTION) { continue; } - const char *hname = hk == ZEND_PROPERTY_HOOK_GET ? "get" : "set"; - if (dc_cexpr_locate_in_attrs(hfn->op_array.attributes, 0, ce, needle, &attr_index, &ord)) { - dc_cexpr_payload(payload, ce, zend_strpprintf(0, "$%s::%s()", ZSTR_VAL(name), hname), (zend_long) attr_index, ord, line); - return true; - } - if (UNEXPECTED(EG(exception))) { - return false; - } - uint32_t hook_params = hfn->common.num_args + ((hfn->common.fn_flags & ZEND_ACC_VARIADIC) ? 1 : 0); - for (uint32_t pi = 0; pi < hook_params; pi++) { - if (dc_cexpr_locate_in_attrs(hfn->op_array.attributes, pi + 1, ce, needle, &attr_index, &ord)) { - dc_cexpr_payload(payload, ce, zend_strpprintf(0, "$%s::%s()#%u", ZSTR_VAL(name), hname, pi), (zend_long) attr_index, ord, line); - return true; - } - if (UNEXPECTED(EG(exception))) { - return false; - } - } + dc_cexpr_walk_init(&w, needle, 0); + dc_cexpr_elem_attrs(&w, hfn->op_array.attributes, ce, true); + DC_ELEM_TRY(zend_strpprintf(0, "$%s::%s()", ZSTR_VAL(name), hk == ZEND_PROPERTY_HOOK_GET ? "get" : "set")); } } } ZEND_HASH_FOREACH_END(); - /* methods: attributes, then per parameter: attributes, then the default */ + /* methods: attributes (function and parameters), then parameter defaults */ zend_function *fn; ZEND_HASH_FOREACH_PTR(&ce->function_table, fn) { if (fn->common.scope != ce || fn->type != ZEND_USER_FUNCTION) { continue; } - const char *fname = ZSTR_VAL(fn->common.function_name); - if (dc_cexpr_locate_in_attrs(fn->op_array.attributes, 0, ce, needle, &attr_index, &ord)) { - dc_cexpr_payload(payload, ce, zend_strpprintf(0, "%s()", fname), (zend_long) attr_index, ord, line); - return true; - } - if (UNEXPECTED(EG(exception))) { - return false; - } - uint32_t num_params = fn->common.num_args + ((fn->common.fn_flags & ZEND_ACC_VARIADIC) ? 1 : 0); - for (uint32_t pi = 0; pi < num_params; pi++) { - if (dc_cexpr_locate_in_attrs(fn->op_array.attributes, pi + 1, ce, needle, &attr_index, &ord)) { - dc_cexpr_payload(payload, ce, zend_strpprintf(0, "%s()#%u", fname, pi), (zend_long) attr_index, ord, line); - return true; - } - zval *def = dc_cexpr_param_default(&fn->op_array, pi); - if (!EG(exception) && def && dc_cexpr_locate_in_value(def, ce, needle, &ord)) { - dc_cexpr_payload(payload, ce, zend_strpprintf(0, "%s()#%u", fname, pi), -1, ord, line); - return true; - } - if (UNEXPECTED(EG(exception))) { - return false; + dc_cexpr_walk_init(&w, needle, 0); + dc_cexpr_elem_attrs(&w, fn->op_array.attributes, ce, true); + if (!w.matched && !EG(exception)) { + uint32_t nargs = fn->common.num_args + ((fn->common.fn_flags & ZEND_ACC_VARIADIC) ? 1 : 0); + for (uint32_t pi = 0; pi < nargs && !dc_cexpr_walk_done(&w) && !EG(exception); pi++) { + zval *def = dc_cexpr_param_default(&fn->op_array, pi); + if (def && Z_TYPE_P(def) != IS_UNDEF) { + dc_cexpr_walk_const(&w, def, ce, true); + } } } + DC_ELEM_TRY(zend_strpprintf(0, "%s()", ZSTR_VAL(fn->common.function_name))); } ZEND_HASH_FOREACH_END(); +#undef DC_ELEM_TRY return false; } @@ -1678,9 +1661,8 @@ static void ZEND_FASTCALL dc_attr_new_instance_wrapper(INTERNAL_FUNCTION_PARAMET } #endif /* PHP_VERSION_ID >= 80500 */ -/* deepclone_from_array() counterpart: resolve a declaration-site reference - * back to a live Closure. The reference is the element-scoped, version- - * independent site-based (5-element) form. */ +/* deepclone_from_array() counterpart: resolve an element-scoped + * "@" declaration-site reference back to a live Closure. */ static void dc_cexpr_resolve(zval *value, HashTable *allowed_set, zval *retval) { if (Z_TYPE_P(value) != IS_ARRAY) { @@ -1688,35 +1670,22 @@ static void dc_cexpr_resolve(zval *value, HashTable *allowed_set, zval *retval) return; } HashTable *ht = Z_ARRVAL_P(value); - zval *zclass = zend_hash_index_find(ht, 0); - zval *zsite = zend_hash_index_find(ht, 1); - zval *zattr = zend_hash_index_find(ht, 2); - zval *zord = zend_hash_index_find(ht, 3); - zval *zline = zend_hash_index_find(ht, 4); - if (!zclass || !zsite || !zattr || !zord || !zline || zend_hash_num_elements(ht) != 5) { - zend_value_error("deepclone_from_array(): malformed payload, const-expr-closure value must have 5 elements"); + zval *zid = zend_hash_index_find(ht, 1); + zval *zline = zend_hash_index_find(ht, 2); + if (!zclass || !zid || !zline || zend_hash_num_elements(ht) != 3) { + zend_value_error("deepclone_from_array(): malformed payload, const-expr-closure value must have 3 elements"); return; } ZVAL_DEREF(zclass); - ZVAL_DEREF(zsite); - ZVAL_DEREF(zattr); - ZVAL_DEREF(zord); + ZVAL_DEREF(zid); ZVAL_DEREF(zline); if (Z_TYPE_P(zclass) != IS_STRING) { 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)); return; } - if (Z_TYPE_P(zsite) != IS_STRING) { - zend_value_error("deepclone_from_array(): malformed payload, const-expr-closure site must be of type string, %s given", zend_zval_value_name(zsite)); - return; - } - if (Z_TYPE_P(zattr) != IS_NULL && Z_TYPE_P(zattr) != IS_LONG) { - zend_value_error("deepclone_from_array(): malformed payload, const-expr-closure attribute index must be of type int or null, %s given", zend_zval_value_name(zattr)); - return; - } - if (Z_TYPE_P(zord) != IS_LONG) { - zend_value_error("deepclone_from_array(): malformed payload, const-expr-closure closure index must be of type int, %s given", zend_zval_value_name(zord)); + if (Z_TYPE_P(zid) != IS_STRING) { + zend_value_error("deepclone_from_array(): malformed payload, const-expr-closure id must be of type string, %s given", zend_zval_value_name(zid)); return; } if (Z_TYPE_P(zline) != IS_LONG) { @@ -1731,201 +1700,175 @@ static void dc_cexpr_resolve(zval *value, HashTable *allowed_set, zval *retval) return; } + const char *idstr = Z_STRVAL_P(zid); + size_t idlen = Z_STRLEN_P(zid); + const char *at = idlen ? zend_memrchr(idstr, '@', idlen) : NULL; + size_t site_len = at ? (size_t) (at - idstr) : 0; + size_t rank_len = at ? idlen - site_len - 1 : 0; + uint64_t rank = 0; + bool rank_ok = at && rank_len > 0 && (rank_len == 1 || idstr[site_len + 1] != '0'); + for (size_t i = 0; rank_ok && i < rank_len; i++) { + char ch = idstr[site_len + 1 + i]; + rank_ok = ch >= '0' && ch <= '9' && (rank = rank * 10 + (ch - '0')) <= UINT32_MAX; + } + if (!rank_ok) { + zend_value_error("deepclone_from_array(): malformed payload, const-expr-closure id must be of the form \"@\", \"%s\" given", idstr); + return; + } + const char *site = idstr; + zend_long line = Z_LVAL_P(zline); + uint32_t want_ord = (uint32_t) rank; + zend_class_entry *ce = zend_lookup_class(Z_STR_P(zclass)); if (!ce) { zend_value_error("deepclone_from_array(): malformed payload, const-expr-closure references unknown class \"%s\"", Z_STRVAL_P(zclass)); return; } - const char *site = Z_STRVAL_P(zsite); - size_t site_len = Z_STRLEN_P(zsite); - zend_long attr_index = Z_TYPE_P(zattr) == IS_LONG ? Z_LVAL_P(zattr) : -1; - bool attr_site = Z_TYPE_P(zattr) == IS_LONG; - zend_long want_ord = Z_LVAL_P(zord); - zend_long line = Z_LVAL_P(zline); - - if (want_ord < 0 || want_ord > UINT32_MAX) { - zend_value_error("deepclone_from_array(): malformed payload, const-expr-closure references unknown closure index " ZEND_LONG_FMT, want_ord); - return; +#if PHP_VERSION_ID >= 80600 + /* The engine resolves its own ids fastest. Its walk reads the raw constant + * expressions though, while this reference counts evaluated values, so fall + * back to the evaluating walk below when the engine does not know the id or + * resolves it to another line. */ + { + zend_function *from_cexpr = zend_hash_str_find_ptr(&zend_ce_closure->function_table, ZEND_STRL("fromconstexpr")); + if (from_cexpr) { + zval rv, params[2]; + ZVAL_UNDEF(&rv); + ZVAL_STR(¶ms[0], Z_STR_P(zclass)); + ZVAL_STR(¶ms[1], Z_STR_P(zid)); + zend_call_known_function(from_cexpr, NULL, zend_ce_closure, &rv, 2, params, NULL); + if (EG(exception)) { + if (!instanceof_function(EG(exception)->ce, zend_ce_value_error)) { + return; + } + zend_clear_exception(); + } else if (Z_TYPE(rv) == IS_OBJECT) { + const zend_function *ef = zend_get_closure_method_def(Z_OBJ(rv)); + uint32_t eline = ef->type == ZEND_USER_FUNCTION ? ef->op_array.line_start : 0; + if (line == (zend_long) eline) { + ZVAL_COPY_VALUE(retval, &rv); + return; + } + zval_ptr_dtor(&rv); + } else { + zval_ptr_dtor(&rv); + } + } } +#endif - /* Resolve the site to a target: its attribute list + evaluation scope, - * and for value sites the const-expr source zval. */ + /* Locate the named element: its attribute lists, for methods and hooks the + * op_array carrying parameter defaults, and for constants and properties + * the const-expr source value. */ HashTable *attributes = NULL; - uint32_t attr_offset = 0; zend_class_entry *scope = ce; + const zend_op_array *op = NULL; zval *const_src = NULL; - bool has_value_site = false; - if (0 == site_len) { + if (site_len == 0) { attributes = ce->attributes; - } else if ('$' == site[0]) { - size_t sep = 0; + } else if (site[0] == '$') { + const char *sep = NULL; for (size_t i = 1; i + 1 < site_len; i++) { - if (':' == site[i] && ':' == site[i + 1]) { - sep = i; + if (site[i] == ':' && site[i + 1] == ':') { + sep = site + i; break; } } if (sep) { #if PHP_VERSION_ID < 80400 - zend_value_error("deepclone_from_array(): malformed payload, const-expr-closure references unknown hook \"%s\"", site); + zend_value_error("deepclone_from_array(): malformed payload, const-expr-closure references unknown hook \"%.*s\"", (int) site_len, site); return; #else - /* property hook: "$prop::get()", "$prop::set()#N" */ - const zend_function *hfn = NULL; - size_t spec_len = site_len - sep - 2; - const char *spec = site + sep + 2; - uint64_t param = 0; - bool has_param = false; - bool valid = true; - if (spec_len > 5 && ')' == spec[4] && '#' == spec[5]) { - has_param = true; - valid = spec_len > 6 && ('0' != spec[6] || 7 == spec_len); - for (size_t i = 6; valid && i < spec_len; i++) { - valid = spec[i] >= '0' && spec[i] <= '9' && (param = param * 10 + (spec[i] - '0')) <= UINT32_MAX; - } - spec_len = 5; - } - uint32_t hook_kind = ZEND_PROPERTY_HOOK_COUNT; - if (valid && 5 == spec_len && 0 == memcmp(spec + 3, "()", 2)) { - if (0 == memcmp(spec, "get", 3)) { - hook_kind = ZEND_PROPERTY_HOOK_GET; - } else if (0 == memcmp(spec, "set", 3)) { - hook_kind = ZEND_PROPERTY_HOOK_SET; - } - } - if (hook_kind < ZEND_PROPERTY_HOOK_COUNT) { - zend_property_info *prop = zend_hash_str_find_ptr(&ce->properties_info, site + 1, sep - 1); - if (prop && prop->hooks && prop->hooks[hook_kind] && prop->hooks[hook_kind]->type == ZEND_USER_FUNCTION) { - hfn = prop->hooks[hook_kind]; - } - } - uint32_t hook_params = hfn ? hfn->common.num_args + ((hfn->common.fn_flags & ZEND_ACC_VARIADIC) ? 1 : 0) : 0; - if (!hfn || (has_param && param >= hook_params)) { - zend_value_error("deepclone_from_array(): malformed payload, const-expr-closure references unknown hook \"%s\"", site); + /* property hook: "$prop::get()" or "$prop::set()" */ + size_t spec_len = site_len - (size_t) (sep + 2 - site); + uint32_t hk = ZEND_PROPERTY_HOOK_COUNT; + if (spec_len == 5 && 0 == memcmp(sep + 2, "get()", 5)) { + hk = ZEND_PROPERTY_HOOK_GET; + } else if (spec_len == 5 && 0 == memcmp(sep + 2, "set()", 5)) { + hk = ZEND_PROPERTY_HOOK_SET; + } + zend_property_info *prop = hk < ZEND_PROPERTY_HOOK_COUNT + ? zend_hash_str_find_ptr(&ce->properties_info, site + 1, (size_t) (sep - site) - 1) : NULL; + const zend_function *hfn = prop && prop->hooks ? prop->hooks[hk] : NULL; + if (!hfn || hfn->type != ZEND_USER_FUNCTION) { + zend_value_error("deepclone_from_array(): malformed payload, const-expr-closure references unknown hook \"%.*s\"", (int) site_len, site); return; } attributes = hfn->op_array.attributes; scope = hfn->common.scope; - if (has_param) { - attr_offset = (uint32_t) param + 1; - const_src = dc_cexpr_param_default(&hfn->op_array, (uint32_t) param); - has_value_site = true; - } + op = &hfn->op_array; #endif } else { zend_property_info *prop = zend_hash_str_find_ptr(&ce->properties_info, site + 1, site_len - 1); if (!prop) { - zend_value_error("deepclone_from_array(): malformed payload, const-expr-closure references unknown property \"%s\"", site); + zend_value_error("deepclone_from_array(): malformed payload, const-expr-closure references unknown property \"%.*s\"", (int) site_len, site); return; } attributes = prop->attributes; scope = prop->ce; const_src = dc_cexpr_prop_default(ce, prop); - has_value_site = true; } - } else { - /* "name()#N" parameter site? */ - size_t paren = 0; - for (size_t i = 1; i + 1 < site_len; i++) { - if (')' == site[i] && '#' == site[i + 1]) { - paren = i; - break; - } + } else if (site_len >= 3 && '(' == site[site_len - 2] && ')' == site[site_len - 1]) { + zend_string *mname = zend_string_init(site, site_len - 2, 0); + zend_function *fn = zend_hash_find_ptr_lc(&ce->function_table, mname); + zend_string_release(mname); + if (!fn || fn->type != ZEND_USER_FUNCTION) { + zend_value_error("deepclone_from_array(): malformed payload, const-expr-closure references unknown method \"%.*s\"", (int) site_len, site); + return; } - if (paren) { - zend_function *fn = NULL; - uint64_t param = 0; - bool valid = paren >= 2 && '(' == site[paren - 1] && paren + 2 < site_len - && ('0' != site[paren + 2] || paren + 3 == site_len); - for (size_t i = paren + 2; valid && i < site_len; i++) { - valid = site[i] >= '0' && site[i] <= '9' && (param = param * 10 + (site[i] - '0')) <= UINT32_MAX; - } - if (valid) { - zend_string *mname = zend_string_init(site, paren - 1, 0); - fn = zend_hash_find_ptr_lc(&ce->function_table, mname); - zend_string_release(mname); - } - uint32_t num_params = fn && fn->type == ZEND_USER_FUNCTION - ? fn->common.num_args + ((fn->common.fn_flags & ZEND_ACC_VARIADIC) ? 1 : 0) : 0; - if (!valid || !fn || param >= num_params) { - zend_value_error("deepclone_from_array(): malformed payload, const-expr-closure references unknown parameter \"%s\"", site); - return; - } - attributes = fn->op_array.attributes; - attr_offset = (uint32_t) param + 1; - scope = fn->common.scope; - const_src = dc_cexpr_param_default(&fn->op_array, (uint32_t) param); - has_value_site = true; - } else if (site_len >= 3 && '(' == site[site_len - 2] && ')' == site[site_len - 1]) { - zend_string *mname = zend_string_init(site, site_len - 2, 0); - zend_function *fn = zend_hash_find_ptr_lc(&ce->function_table, mname); - zend_string_release(mname); - if (!fn || fn->type != ZEND_USER_FUNCTION) { - zend_value_error("deepclone_from_array(): malformed payload, const-expr-closure references unknown method \"%s\"", site); - return; - } - attributes = fn->op_array.attributes; - scope = fn->common.scope; - } else { - zend_class_constant *c = zend_hash_find_ptr(&ce->constants_table, Z_STR_P(zsite)); - if (!c) { - zend_value_error("deepclone_from_array(): malformed payload, const-expr-closure references unknown constant \"%s\"", site); - return; - } - attributes = c->attributes; - scope = c->ce; - const_src = &c->value; - has_value_site = true; + attributes = fn->op_array.attributes; + scope = fn->common.scope; + op = &fn->op_array; + } else { + zend_class_constant *c = zend_hash_str_find_ptr(&ce->constants_table, site, site_len); + if (!c) { + zend_value_error("deepclone_from_array(): malformed payload, const-expr-closure references unknown constant \"%.*s\"", (int) site_len, site); + return; } + attributes = c->attributes; + scope = c->ce; + const_src = &c->value; } + /* Enumerate the element's closures in the rank order used on the encoding + * side: attribute arguments in declaration order (nested const-expr + * surfaces depth-first), then parameter defaults, then the constant or + * property default value. */ dc_cexpr_walk w; - dc_cexpr_walk_init(&w, NULL, (uint32_t) want_ord); - - if (attr_site) { - zend_attribute *attr = NULL; - if (attr_index >= 0 && attributes) { - uint32_t idx = 0; - zend_attribute *a; - ZEND_HASH_FOREACH_PTR(attributes, a) { - if (a->offset != attr_offset) { - continue; - } - if (idx == (uint32_t) attr_index) { - attr = a; - break; - } - idx++; - } ZEND_HASH_FOREACH_END(); - } - if (!attr) { - dc_cexpr_walk_dtor(&w); - zend_value_error("deepclone_from_array(): malformed payload, const-expr-closure references unknown attribute index " ZEND_LONG_FMT, attr_index); - return; - } - if (!dc_cexpr_walk_attr(&w, attr, scope, false) || EG(exception)) { - dc_cexpr_walk_dtor(&w); - zval_ptr_dtor(&w.found); - zend_value_error("deepclone_from_array(): malformed payload, const-expr-closure evaluation failed for site \"%s\"", site); - return; - } - } else if (!has_value_site) { + dc_cexpr_walk_init(&w, NULL, want_ord); + + if (!dc_cexpr_elem_attrs(&w, attributes, scope, false) || EG(exception)) { dc_cexpr_walk_dtor(&w); - zend_value_error("deepclone_from_array(): malformed payload, const-expr-closure attribute index is required for site \"%s\"", site); + zval_ptr_dtor(&w.found); + zend_value_error("deepclone_from_array(): malformed payload, const-expr-closure evaluation failed for site \"%.*s\"", (int) site_len, site); return; - } else if (const_src && Z_TYPE_P(const_src) != IS_UNDEF + } + if (!dc_cexpr_walk_done(&w) && op) { + uint32_t nargs = op->num_args + ((op->fn_flags & ZEND_ACC_VARIADIC) ? 1 : 0); + for (uint32_t pi = 0; pi < nargs && !dc_cexpr_walk_done(&w); pi++) { + zval *def = dc_cexpr_param_default(op, pi); + if (def && Z_TYPE_P(def) != IS_UNDEF + && (!dc_cexpr_walk_const(&w, def, scope, false) || EG(exception))) { + dc_cexpr_walk_dtor(&w); + zval_ptr_dtor(&w.found); + zend_value_error("deepclone_from_array(): malformed payload, const-expr-closure evaluation failed for site \"%.*s\"", (int) site_len, site); + return; + } + } + } + if (!dc_cexpr_walk_done(&w) && const_src && Z_TYPE_P(const_src) != IS_UNDEF && (!dc_cexpr_walk_const(&w, const_src, scope, false) || EG(exception))) { dc_cexpr_walk_dtor(&w); zval_ptr_dtor(&w.found); - zend_value_error("deepclone_from_array(): malformed payload, const-expr-closure evaluation failed for site \"%s\"", site); + zend_value_error("deepclone_from_array(): malformed payload, const-expr-closure evaluation failed for site \"%.*s\"", (int) site_len, site); return; } dc_cexpr_walk_dtor(&w); if (Z_ISUNDEF(w.found)) { - zend_value_error("deepclone_from_array(): malformed payload, const-expr-closure references unknown closure index " ZEND_LONG_FMT, want_ord); + zend_value_error("deepclone_from_array(): malformed payload, const-expr-closure references unknown closure id \"%s\" in class \"%s\"", idstr, ZSTR_VAL(ce->name)); return; } @@ -2055,11 +1998,30 @@ static void dc_copy_value(dc_ctx *ctx, zval *src, zval *dst, zval *mask_dst) zend_value_error("deepclone_to_array(): class \"Closure\" is not allowed"); return; } - /* Anonymous const-expr closures use the site-based (5-element) - * reference, which is element-scoped and version-independent, so - * payloads interchange across PHP 8.5 and 8.6 and with the - * polyfill. The engine's element-scoped id (getConstExprId()) is a - * separate, native serialize()-only encoding not used here. */ +#if PHP_VERSION_ID >= 80600 + /* The engine derives the same element-scoped "@" + * reference without evaluating any constant expression; use it + * for anonymous closures. First-class callables keep the + * evaluating walk below: their payload line is the target + * function's, which the engine's site-based line is not. */ + if (!(func->common.fn_flags & ZEND_ACC_FAKE_CLOSURE)) { + zend_class_entry *site_ce; + zend_string *cexpr_id; + uint32_t cexpr_line; + if (zend_constexpr_closure_ref(Z_OBJ_P(src), &site_ce, &cexpr_id, &cexpr_line) == SUCCESS) { + zval tmp; + array_init_size(dst, 3); + ZVAL_STR_COPY(&tmp, site_ce->name); + zend_hash_index_add_new(Z_ARRVAL_P(dst), 0, &tmp); + ZVAL_STR(&tmp, cexpr_id); + zend_hash_index_add_new(Z_ARRVAL_P(dst), 1, &tmp); + ZVAL_LONG(&tmp, (zend_long) cexpr_line); + zend_hash_index_add_new(Z_ARRVAL_P(dst), 2, &tmp); + DC_MASK_CONSTEXPR_CLOSURE(mask_dst); + goto handle_value; + } + } +#endif zval payload; ZVAL_UNDEF(&payload); if (dc_cexpr_locate(func, &payload)) { diff --git a/tests/deepclone_attribute_provenance.phpt b/tests/deepclone_attribute_provenance.phpt index 387e0b5..51cb6cc 100644 --- a/tests/deepclone_attribute_provenance.phpt +++ b/tests/deepclone_attribute_provenance.phpt @@ -46,7 +46,7 @@ $x = (new ReflectionProperty(Order::class, 'x'))->getAttributes()[0]->getArgumen $d = deepclone_to_array($x); var_dump($d['mask'] === 1); // declaration-site reference, not by-name var_dump($d['prepared'][0] === 'Order'); // the DECLARING class, not Validators (the target's scope) -var_dump($d['prepared'][1] === '$x'); +var_dump($d['prepared'][1] === '$x@0'); $r = deepclone_from_array($d); var_dump($r instanceof Closure, $r() === true); diff --git a/tests/deepclone_constexpr_closures.phpt b/tests/deepclone_constexpr_closures.phpt index a2ce192..2b223bf 100644 --- a/tests/deepclone_constexpr_closures.phpt +++ b/tests/deepclone_constexpr_closures.phpt @@ -59,7 +59,7 @@ $rc = new ReflectionClass(Fix::class); $c = $rc->getAttributes()[0]->getArguments()[0]; $line = (new ReflectionFunction($c))->getStartLine(); $d = deepclone_to_array($c); -var_dump($d['prepared'] === [Fix::class, '', 0, 0, $line]); +var_dump($d['prepared'] === [Fix::class, '@0', $line]); var_dump($d['mask'] === 1); $r = deepclone_from_array($d); var_dump($r instanceof Closure, $r !== $c, $r() === 'class-secret'); @@ -67,14 +67,14 @@ var_dump($r instanceof Closure, $r !== $c, $r() === 'class-secret'); // ── Closure nested in an attribute argument array ── $c = $rc->getProperty('tagged')->getAttributes()[0]->getArguments()['cb'][1]['x']; $d = deepclone_to_array($c); -var_dump($d['prepared'][1] === '$tagged'); +var_dump($d['prepared'][1] === '$tagged@0'); var_dump(deepclone_from_array($d)(3) === 6); // ── Several closures in one attribute ── $args = (new ReflectionClassConstant(Fix::class, 'TAGGED'))->getAttributes()[0]->getArguments(); foreach (['multi-0', 'multi-1'] as $i => $expected) { $d = deepclone_to_array($args[$i]); - var_dump($d['prepared'][1] === 'TAGGED', $d['prepared'][3] === $i, deepclone_from_array($d)() === $expected); + var_dump($d['prepared'][1] === 'TAGGED@'.$i, deepclone_from_array($d)() === $expected); } // ── Repeated attribute, parameter attribute, parameter default ── @@ -95,10 +95,11 @@ var_dump(deepclone_from_array(deepclone_to_array(FixEnum::FILTER))() === 'enum-c // ── Trait method attribute ── var_dump(deepclone_from_array(deepclone_to_array((new ReflectionClass(FixTraitUser::class))->getMethod('traitTagged')->getAttributes()[0]->getArguments()[0]))() === 'trait-attr'); -// ── Promoted constructor property: parameter surface is canonical ── +// ── Promoted constructor property: parameter surface is canonical; the +// engine names the property surface instead, both resolve everywhere ── $c = (new ReflectionProperty(FixPromoted::class, 'promoted'))->getAttributes()[0]->getArguments()[0]; $d = deepclone_to_array($c); -var_dump($d['prepared'][1] === '__construct()#0', deepclone_from_array($d)() === 'promoted'); +var_dump($d['prepared'][1] === (PHP_VERSION_ID >= 80600 ? '$promoted@0' : '__construct()@0'), deepclone_from_array($d)() === 'promoted'); // ── Same-line closures are told apart by op_array identity ── $args = (new ReflectionClass(FixAmbiguous::class))->getAttributes()[0]->getArguments(); @@ -129,10 +130,10 @@ class FixHooked { } $c = (new ReflectionProperty(FixHooked::class, 'virtual'))->getHook(PropertyHookType::Get)->getAttributes()[0]->getArguments()[0]; $d = deepclone_to_array($c); -var_dump($d['prepared'][1] === '$virtual::get()', deepclone_from_array($d)() === 'get-hook-attr'); +var_dump($d['prepared'][1] === '$virtual::get()@0', deepclone_from_array($d)() === 'get-hook-attr'); $c = (new ReflectionProperty(FixHooked::class, 'stored'))->getHook(PropertyHookType::Set)->getParameters()[0]->getAttributes()[0]->getArguments()[0]; $d = deepclone_to_array($c); -var_dump($d['prepared'][1] === '$stored::set()#0', deepclone_from_array($d)() === 'set-hook-param-attr'); +var_dump($d['prepared'][1] === '$stored::set()@0', deepclone_from_array($d)() === 'set-hook-param-attr'); // ── Factory constant: outer round-trips, inner runtime closure refuses ── class FixFactory { public const FACTORY = static function (): Closure { return static function (): string { return 'inner'; }; }; } @@ -175,7 +176,7 @@ var_dump(deepclone_from_array($d, ['Closure', 'Fix'])() === 'class-secret'); // ── Stale payload ── $d = deepclone_to_array($rc->getAttributes()[0]->getArguments()[0]); -$d['prepared'][4]++; +$d['prepared'][2]++; try { deepclone_from_array($d); } catch (\ValueError $e) { @@ -207,8 +208,6 @@ bool(true) bool(true) bool(true) bool(true) -bool(true) -bool(true) string(35) "Type "Closure" is not instantiable." bool(true) bool(true) diff --git a/tests/deepclone_constexpr_closures_validation.phpt b/tests/deepclone_constexpr_closures_validation.phpt index bbe0694..299e28f 100644 --- a/tests/deepclone_constexpr_closures_validation.phpt +++ b/tests/deepclone_constexpr_closures_validation.phpt @@ -22,25 +22,22 @@ $line = (new ReflectionFunction((new ReflectionClass(Fix::class))->getAttributes $cases = [ 'foo', [Fix::class], - [42, '', 0, 0, $line], - ['No\Such\ClassAtAll', '', 0, 0, $line], - [Fix::class, 42, 0, 0, $line], - [Fix::class, '', 'x', 0, $line], - [Fix::class, '', 0, 'x', $line], - [Fix::class, '', 0, 0, 'x'], - [Fix::class, '$nope', 0, 0, $line], - [Fix::class, 'nope()', 0, 0, $line], - [Fix::class, 'NOPE', 0, 0, $line], - [Fix::class, 'tagged()#9', 0, 0, $line], - [Fix::class, 'tagged()#01', 0, 0, $line], - [Fix::class, '', 9, 0, $line], - [Fix::class, '', -1, 0, $line], - [Fix::class, '', 0, 9, $line], - [Fix::class, '', 0, -1, $line], - [Fix::class, '', null, 0, $line], - [Fix::class, 'tagged()', null, 0, $line], - [Fix::class, '$tagged::get()', 0, 0, $line], - [Fix::class, '$tagged::bad()', 0, 0, $line], + [Fix::class, '@0', $line, 'x'], + [42, '@0', $line], + [Fix::class, 0, $line], + [Fix::class, '@0', 'x'], + [Fix::class, 'nope', $line], + [Fix::class, '@', $line], + [Fix::class, '@01', $line], + [Fix::class, '@1x', $line], + ['No\\Such\\ClassAtAll', '@0', $line], + [Fix::class, '$nope@0', $line], + [Fix::class, 'nope()@0', $line], + [Fix::class, 'NOPE@0', $line], + [Fix::class, '$tagged::bad()@0', $line], + [Fix::class, '$tagged::get()@0', $line], + [Fix::class, '@9', $line], + [Fix::class, 'tagged()@9', $line], ]; foreach ($cases as $prepared) { @@ -54,23 +51,20 @@ foreach ($cases as $prepared) { ?> --EXPECT-- deepclone_from_array(): malformed payload, const-expr-closure value must be of type array, string given -deepclone_from_array(): malformed payload, const-expr-closure value must have 5 elements +deepclone_from_array(): malformed payload, const-expr-closure value must have 3 elements +deepclone_from_array(): malformed payload, const-expr-closure value must have 3 elements deepclone_from_array(): malformed payload, const-expr-closure class name must be of type string, int given -deepclone_from_array(): malformed payload, const-expr-closure references unknown class "No\Such\ClassAtAll" -deepclone_from_array(): malformed payload, const-expr-closure site must be of type string, int given -deepclone_from_array(): malformed payload, const-expr-closure attribute index must be of type int or null, string given -deepclone_from_array(): malformed payload, const-expr-closure closure index must be of type int, string given +deepclone_from_array(): malformed payload, const-expr-closure id must be of type string, int given deepclone_from_array(): malformed payload, const-expr-closure line must be of type int, string given +deepclone_from_array(): malformed payload, const-expr-closure id must be of the form "@", "nope" given +deepclone_from_array(): malformed payload, const-expr-closure id must be of the form "@", "@" given +deepclone_from_array(): malformed payload, const-expr-closure id must be of the form "@", "@01" given +deepclone_from_array(): malformed payload, const-expr-closure id must be of the form "@", "@1x" given +deepclone_from_array(): malformed payload, const-expr-closure references unknown class "No\Such\ClassAtAll" deepclone_from_array(): malformed payload, const-expr-closure references unknown property "$nope" deepclone_from_array(): malformed payload, const-expr-closure references unknown method "nope()" deepclone_from_array(): malformed payload, const-expr-closure references unknown constant "NOPE" -deepclone_from_array(): malformed payload, const-expr-closure references unknown parameter "tagged()#9" -deepclone_from_array(): malformed payload, const-expr-closure references unknown parameter "tagged()#01" -deepclone_from_array(): malformed payload, const-expr-closure references unknown attribute index 9 -deepclone_from_array(): malformed payload, const-expr-closure references unknown attribute index -1 -deepclone_from_array(): malformed payload, const-expr-closure references unknown closure index 9 -deepclone_from_array(): malformed payload, const-expr-closure references unknown closure index -1 -deepclone_from_array(): malformed payload, const-expr-closure attribute index is required for site "" -deepclone_from_array(): malformed payload, const-expr-closure attribute index is required for site "tagged()" -deepclone_from_array(): malformed payload, const-expr-closure references unknown hook "$tagged::get()" deepclone_from_array(): malformed payload, const-expr-closure references unknown hook "$tagged::bad()" +deepclone_from_array(): malformed payload, const-expr-closure references unknown hook "$tagged::get()" +deepclone_from_array(): malformed payload, const-expr-closure references unknown closure id "@9" in class "Fix" +deepclone_from_array(): malformed payload, const-expr-closure references unknown closure id "tagged()@9" in class "Fix" From 267b23bb843b308197eb7587f4a5790890e303b5 Mon Sep 17 00:00:00 2001 From: Nicolas Grekas Date: Sat, 11 Jul 2026 19:33:26 +0200 Subject: [PATCH 4/7] Store the const-expr closure staleness line relative to the declaring class An absolute line invalidated every cached reference in a file when anything above the class shifted (a new use import, another declaration). Anchoring the line to the declaring class line keeps the reference valid across such edits: the class and its closures move together. The declaring class line is recomputable by the polyfill too (ReflectionClass::getStartLine), so payloads stay byte-identical across both implementations. Also adjust to the engine 5-argument zend_constexpr_closure_ref() signature on PHP 8.6. --- deepclone.c | 37 +++++++++++++++---------- tests/deepclone_constexpr_closures.phpt | 5 ++-- 2 files changed, 25 insertions(+), 17 deletions(-) diff --git a/deepclone.c b/deepclone.c index 1de1d83..30c4d24 100644 --- a/deepclone.c +++ b/deepclone.c @@ -1290,8 +1290,10 @@ static void dc_cexpr_walk_closure_surface(dc_cexpr_walk *w, const zend_op_array } } -/* Builds [class, "@", line]; takes ownership of site. */ -static void dc_cexpr_payload(zval *dst, zend_class_entry *ce, zend_string *site, uint32_t rank, uint32_t line) +/* Builds [class, "@", line]; takes ownership of site. The line is + * relative to the declaring class, so an edit above the class leaves it + * unchanged (see dc_cexpr_locate_ce). */ +static void dc_cexpr_payload(zval *dst, zend_class_entry *ce, zend_string *site, uint32_t rank, zend_long line) { zval tmp; array_init_size(dst, 3); @@ -1339,9 +1341,11 @@ static bool dc_cexpr_elem_attrs(dc_cexpr_walk *w, HashTable *attributes, zend_cl static bool dc_cexpr_locate_ce(const zend_function *target, zend_class_entry *ce, zval *payload) { const zend_function *needle = target; - /* Internal functions have no line; resolution computes 0 for them, so the - * staleness check matches. */ - uint32_t line = target->type == ZEND_USER_FUNCTION ? target->op_array.line_start : 0; + /* Staleness line, relative to the declaring class so edits above the class + * do not invalidate the reference. Internal functions have no line; + * resolution computes 0 for them, so the check matches. */ + zend_long line = target->type == ZEND_USER_FUNCTION + ? (zend_long) target->op_array.line_start - (zend_long) ce->info.user.line_start : 0; zend_string *name; dc_cexpr_walk w; @@ -1560,8 +1564,7 @@ static zend_class_entry *dc_declaring_class(zval *src, const zend_function *func #if PHP_VERSION_ID >= 80600 zend_class_entry *ce; zend_string *id; - uint32_t line; - if (zend_constexpr_closure_ref(Z_OBJ_P(src), &ce, &id, &line) == SUCCESS) { + if (zend_constexpr_closure_ref(Z_OBJ_P(src), &ce, &id, NULL, NULL) == SUCCESS) { zend_string_release(id); return ce; } @@ -1745,8 +1748,9 @@ static void dc_cexpr_resolve(zval *value, HashTable *allowed_set, zval *retval) zend_clear_exception(); } else if (Z_TYPE(rv) == IS_OBJECT) { const zend_function *ef = zend_get_closure_method_def(Z_OBJ(rv)); - uint32_t eline = ef->type == ZEND_USER_FUNCTION ? ef->op_array.line_start : 0; - if (line == (zend_long) eline) { + zend_long eline = ef->type == ZEND_USER_FUNCTION + ? (zend_long) ef->op_array.line_start - (zend_long) ce->info.user.line_start : 0; + if (line == eline) { ZVAL_COPY_VALUE(retval, &rv); return; } @@ -1873,10 +1877,11 @@ static void dc_cexpr_resolve(zval *value, HashTable *allowed_set, zval *retval) } const zend_function *f = zend_get_closure_method_def(Z_OBJ(w.found)); - uint32_t found_line = f->type == ZEND_USER_FUNCTION ? f->op_array.line_start : 0; - if (line != (zend_long) found_line) { + zend_long found_line = f->type == ZEND_USER_FUNCTION + ? (zend_long) f->op_array.line_start - (zend_long) ce->info.user.line_start : 0; + if (line != found_line) { zval_ptr_dtor(&w.found); - zend_value_error("deepclone_from_array(): stale payload, const-expr-closure moved from line " ZEND_LONG_FMT " to line %u", line, found_line); + zend_value_error("deepclone_from_array(): stale payload, const-expr-closure moved from class-relative line " ZEND_LONG_FMT " to " ZEND_LONG_FMT, line, found_line); return; } @@ -2007,15 +2012,17 @@ static void dc_copy_value(dc_ctx *ctx, zval *src, zval *dst, zval *mask_dst) if (!(func->common.fn_flags & ZEND_ACC_FAKE_CLOSURE)) { zend_class_entry *site_ce; zend_string *cexpr_id; - uint32_t cexpr_line; - if (zend_constexpr_closure_ref(Z_OBJ_P(src), &site_ce, &cexpr_id, &cexpr_line) == SUCCESS) { + zend_long cexpr_line; + /* The engine returns the line already relative to the + * declaring class, matching dc_cexpr_locate_ce's own walk. */ + if (zend_constexpr_closure_ref(Z_OBJ_P(src), &site_ce, &cexpr_id, &cexpr_line, NULL) == SUCCESS) { zval tmp; array_init_size(dst, 3); ZVAL_STR_COPY(&tmp, site_ce->name); zend_hash_index_add_new(Z_ARRVAL_P(dst), 0, &tmp); ZVAL_STR(&tmp, cexpr_id); zend_hash_index_add_new(Z_ARRVAL_P(dst), 1, &tmp); - ZVAL_LONG(&tmp, (zend_long) cexpr_line); + ZVAL_LONG(&tmp, cexpr_line); zend_hash_index_add_new(Z_ARRVAL_P(dst), 2, &tmp); DC_MASK_CONSTEXPR_CLOSURE(mask_dst); goto handle_value; diff --git a/tests/deepclone_constexpr_closures.phpt b/tests/deepclone_constexpr_closures.phpt index 2b223bf..9a2d1a1 100644 --- a/tests/deepclone_constexpr_closures.phpt +++ b/tests/deepclone_constexpr_closures.phpt @@ -57,7 +57,8 @@ $rc = new ReflectionClass(Fix::class); // ── Wire format: class attribute ── $c = $rc->getAttributes()[0]->getArguments()[0]; -$line = (new ReflectionFunction($c))->getStartLine(); +// The stored line is relative to the declaring class. +$line = (new ReflectionFunction($c))->getStartLine() - $rc->getStartLine(); $d = deepclone_to_array($c); var_dump($d['prepared'] === [Fix::class, '@0', $line]); var_dump($d['mask'] === 1); @@ -180,7 +181,7 @@ $d['prepared'][2]++; try { deepclone_from_array($d); } catch (\ValueError $e) { - var_dump(str_contains($e->getMessage(), 'stale payload, const-expr-closure moved from line')); + var_dump(str_contains($e->getMessage(), 'stale payload, const-expr-closure moved from class-relative line')); } ?> --EXPECT-- From ea847902ed1077d2b6edacb9c32a34aed768740b Mon Sep 17 00:00:00 2001 From: Nicolas Grekas Date: Thu, 16 Jul 2026 16:53:29 +0200 Subject: [PATCH 5/7] Reference const-expr closures by "@" alone, verified by the engine code hash The engine now embeds a hash of the closure code in the id ("@#") and dropped the separate line field, so zend_constexpr_closure_ref() is 3-arg. Match that: the marker-1 payload is [class, id] (no line). On PHP 8.6 the engine id carries the hash and is verified by Closure::fromConstExpr on decode; the ext cannot recompute the hash (the closure source is discarded at compile time), so on 8.5, and for constant/property values on any version, the id is hash-less and resolves positionally. Decode strips an unverifiable hash before the value-walk, and a hash-bearing id the engine rejects is surfaced as stale rather than healed positionally. --- deepclone.c | 95 ++++++++----------- tests/deepclone_constexpr_closures.phpt | 41 +++++--- ...epclone_constexpr_closures_validation.phpt | 38 ++++---- 3 files changed, 84 insertions(+), 90 deletions(-) diff --git a/deepclone.c b/deepclone.c index 30c4d24..fe21024 100644 --- a/deepclone.c +++ b/deepclone.c @@ -1290,20 +1290,20 @@ static void dc_cexpr_walk_closure_surface(dc_cexpr_walk *w, const zend_op_array } } -/* Builds [class, "@", line]; takes ownership of site. The line is - * relative to the declaring class, so an edit above the class leaves it - * unchanged (see dc_cexpr_locate_ce). */ -static void dc_cexpr_payload(zval *dst, zend_class_entry *ce, zend_string *site, uint32_t rank, zend_long line) +/* Builds [class, "@"]; takes ownership of site. This value-walk + * reference carries no code hash (the ext cannot recompute the engine's hash + * of the closure's source, which is discarded at compile time), so it resolves + * positionally. On PHP 8.6 anonymous closures instead take the engine's + * hash-bearing id; see the encoder. */ +static void dc_cexpr_payload(zval *dst, zend_class_entry *ce, zend_string *site, uint32_t rank) { zval tmp; - array_init_size(dst, 3); + array_init_size(dst, 2); ZVAL_STR_COPY(&tmp, ce->name); zend_hash_index_add_new(Z_ARRVAL_P(dst), 0, &tmp); ZVAL_STR(&tmp, zend_strpprintf(0, "%s@%u", ZSTR_VAL(site), rank)); zend_hash_index_add_new(Z_ARRVAL_P(dst), 1, &tmp); zend_string_release(site); - ZVAL_LONG(&tmp, (zend_long) line); - zend_hash_index_add_new(Z_ARRVAL_P(dst), 2, &tmp); } /* Walk every attribute of one reflection element (all offsets, declaration @@ -1341,11 +1341,6 @@ static bool dc_cexpr_elem_attrs(dc_cexpr_walk *w, HashTable *attributes, zend_cl static bool dc_cexpr_locate_ce(const zend_function *target, zend_class_entry *ce, zval *payload) { const zend_function *needle = target; - /* Staleness line, relative to the declaring class so edits above the class - * do not invalidate the reference. Internal functions have no line; - * resolution computes 0 for them, so the check matches. */ - zend_long line = target->type == ZEND_USER_FUNCTION - ? (zend_long) target->op_array.line_start - (zend_long) ce->info.user.line_start : 0; zend_string *name; dc_cexpr_walk w; @@ -1357,7 +1352,7 @@ static bool dc_cexpr_locate_ce(const zend_function *target, zend_class_entry *ce if (w.matched) { \ uint32_t _ord = w.matched_ord; \ dc_cexpr_walk_dtor(&w); \ - dc_cexpr_payload(payload, ce, (site_expr), _ord, line); \ + dc_cexpr_payload(payload, ce, (site_expr), _ord); \ return true; \ } \ dc_cexpr_walk_dtor(&w); \ @@ -1564,7 +1559,7 @@ static zend_class_entry *dc_declaring_class(zval *src, const zend_function *func #if PHP_VERSION_ID >= 80600 zend_class_entry *ce; zend_string *id; - if (zend_constexpr_closure_ref(Z_OBJ_P(src), &ce, &id, NULL, NULL) == SUCCESS) { + if (zend_constexpr_closure_ref(Z_OBJ_P(src), &ce, &id) == SUCCESS) { zend_string_release(id); return ce; } @@ -1675,14 +1670,12 @@ static void dc_cexpr_resolve(zval *value, HashTable *allowed_set, zval *retval) HashTable *ht = Z_ARRVAL_P(value); zval *zclass = zend_hash_index_find(ht, 0); zval *zid = zend_hash_index_find(ht, 1); - zval *zline = zend_hash_index_find(ht, 2); - if (!zclass || !zid || !zline || zend_hash_num_elements(ht) != 3) { - zend_value_error("deepclone_from_array(): malformed payload, const-expr-closure value must have 3 elements"); + if (!zclass || !zid || zend_hash_num_elements(ht) != 2) { + zend_value_error("deepclone_from_array(): malformed payload, const-expr-closure value must have 2 elements"); return; } ZVAL_DEREF(zclass); ZVAL_DEREF(zid); - ZVAL_DEREF(zline); if (Z_TYPE_P(zclass) != IS_STRING) { 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)); return; @@ -1691,10 +1684,6 @@ static void dc_cexpr_resolve(zval *value, HashTable *allowed_set, zval *retval) zend_value_error("deepclone_from_array(): malformed payload, const-expr-closure id must be of type string, %s given", zend_zval_value_name(zid)); return; } - if (Z_TYPE_P(zline) != IS_LONG) { - zend_value_error("deepclone_from_array(): malformed payload, const-expr-closure line must be of type int, %s given", zend_zval_value_name(zline)); - return; - } /* Gate before zend_lookup_class(): the payload must not be able to * autoload, let alone evaluate, classes outside the allow-list. */ @@ -1705,9 +1694,17 @@ static void dc_cexpr_resolve(zval *value, HashTable *allowed_set, zval *retval) const char *idstr = Z_STRVAL_P(zid); size_t idlen = Z_STRLEN_P(zid); - const char *at = idlen ? zend_memrchr(idstr, '@', idlen) : NULL; + /* An engine-produced id may carry a "#" code fingerprint after the + * rank. The ext cannot recompute it (the closure's source is discarded at + * compile time), so on the value-walk below the hash is stripped and the + * reference resolves positionally; on 8.6 the hash-bearing id is verified + * by fromConstExpr first. */ + const char *sharp = idlen ? zend_memrchr(idstr, '#', idlen) : NULL; + bool has_hash = sharp != NULL; + size_t coreid_len = has_hash ? (size_t) (sharp - idstr) : idlen; + const char *at = coreid_len ? zend_memrchr(idstr, '@', coreid_len) : NULL; size_t site_len = at ? (size_t) (at - idstr) : 0; - size_t rank_len = at ? idlen - site_len - 1 : 0; + size_t rank_len = at ? coreid_len - site_len - 1 : 0; uint64_t rank = 0; bool rank_ok = at && rank_len > 0 && (rank_len == 1 || idstr[site_len + 1] != '0'); for (size_t i = 0; rank_ok && i < rank_len; i++) { @@ -1719,7 +1716,6 @@ static void dc_cexpr_resolve(zval *value, HashTable *allowed_set, zval *retval) return; } const char *site = idstr; - zend_long line = Z_LVAL_P(zline); uint32_t want_ord = (uint32_t) rank; zend_class_entry *ce = zend_lookup_class(Z_STR_P(zclass)); @@ -1729,10 +1725,12 @@ static void dc_cexpr_resolve(zval *value, HashTable *allowed_set, zval *retval) } #if PHP_VERSION_ID >= 80600 - /* The engine resolves its own ids fastest. Its walk reads the raw constant - * expressions though, while this reference counts evaluated values, so fall - * back to the evaluating walk below when the engine does not know the id or - * resolves it to another line. */ + /* The engine resolves its own ids, verifying the "#" fingerprint. Its + * walk reads the raw constant expressions, while this reference counts + * evaluated values, so a hash-less id the engine does not know (a closure + * in a constant or property value) falls through to the evaluating walk. A + * hash-bearing id is fully the engine's to judge: if it rejects one, the + * reference is stale, so we surface that instead of healing positionally. */ { zend_function *from_cexpr = zend_hash_str_find_ptr(&zend_ce_closure->function_table, ZEND_STRL("fromconstexpr")); if (from_cexpr) { @@ -1742,21 +1740,14 @@ static void dc_cexpr_resolve(zval *value, HashTable *allowed_set, zval *retval) ZVAL_STR(¶ms[1], Z_STR_P(zid)); zend_call_known_function(from_cexpr, NULL, zend_ce_closure, &rv, 2, params, NULL); if (EG(exception)) { - if (!instanceof_function(EG(exception)->ce, zend_ce_value_error)) { + if (has_hash || !instanceof_function(EG(exception)->ce, zend_ce_value_error)) { return; } zend_clear_exception(); - } else if (Z_TYPE(rv) == IS_OBJECT) { - const zend_function *ef = zend_get_closure_method_def(Z_OBJ(rv)); - zend_long eline = ef->type == ZEND_USER_FUNCTION - ? (zend_long) ef->op_array.line_start - (zend_long) ce->info.user.line_start : 0; - if (line == eline) { - ZVAL_COPY_VALUE(retval, &rv); - return; - } - zval_ptr_dtor(&rv); } else { - zval_ptr_dtor(&rv); + ZEND_ASSERT(Z_TYPE(rv) == IS_OBJECT); + ZVAL_COPY_VALUE(retval, &rv); + return; } } } @@ -1876,15 +1867,9 @@ static void dc_cexpr_resolve(zval *value, HashTable *allowed_set, zval *retval) return; } - const zend_function *f = zend_get_closure_method_def(Z_OBJ(w.found)); - zend_long found_line = f->type == ZEND_USER_FUNCTION - ? (zend_long) f->op_array.line_start - (zend_long) ce->info.user.line_start : 0; - if (line != found_line) { - zval_ptr_dtor(&w.found); - zend_value_error("deepclone_from_array(): stale payload, const-expr-closure moved from class-relative line " ZEND_LONG_FMT " to " ZEND_LONG_FMT, line, found_line); - return; - } - + /* This value-walk resolves positionally: a hash-less id carries no + * staleness check (the ext cannot recompute the engine's code hash), so + * the rank alone selects the closure. */ ZVAL_COPY_VALUE(retval, &w.found); } @@ -2012,18 +1997,16 @@ static void dc_copy_value(dc_ctx *ctx, zval *src, zval *dst, zval *mask_dst) if (!(func->common.fn_flags & ZEND_ACC_FAKE_CLOSURE)) { zend_class_entry *site_ce; zend_string *cexpr_id; - zend_long cexpr_line; - /* The engine returns the line already relative to the - * declaring class, matching dc_cexpr_locate_ce's own walk. */ - if (zend_constexpr_closure_ref(Z_OBJ_P(src), &site_ce, &cexpr_id, &cexpr_line, NULL) == SUCCESS) { + /* The engine id already carries a "#" of the closure's + * code, so the reference is verified on decode without a + * separate line field. */ + if (zend_constexpr_closure_ref(Z_OBJ_P(src), &site_ce, &cexpr_id) == SUCCESS) { zval tmp; - array_init_size(dst, 3); + array_init_size(dst, 2); ZVAL_STR_COPY(&tmp, site_ce->name); zend_hash_index_add_new(Z_ARRVAL_P(dst), 0, &tmp); ZVAL_STR(&tmp, cexpr_id); zend_hash_index_add_new(Z_ARRVAL_P(dst), 1, &tmp); - ZVAL_LONG(&tmp, cexpr_line); - zend_hash_index_add_new(Z_ARRVAL_P(dst), 2, &tmp); DC_MASK_CONSTEXPR_CLOSURE(mask_dst); goto handle_value; } diff --git a/tests/deepclone_constexpr_closures.phpt b/tests/deepclone_constexpr_closures.phpt index 9a2d1a1..59b5608 100644 --- a/tests/deepclone_constexpr_closures.phpt +++ b/tests/deepclone_constexpr_closures.phpt @@ -55,12 +55,17 @@ class FixTraitUser { use FixTrait; } $rc = new ReflectionClass(Fix::class); +// The reference is [class, "@"]; on PHP 8.6 the engine suffixes the +// rank of an anonymous closure with a "#" of its code, which the ext +// propagates. This helper reads the rank id with any such hash stripped. +$refId = static fn ($d) => preg_replace('/#[0-9a-f]{8}$/', '', $d['prepared'][1]); + // ── Wire format: class attribute ── $c = $rc->getAttributes()[0]->getArguments()[0]; -// The stored line is relative to the declaring class. -$line = (new ReflectionFunction($c))->getStartLine() - $rc->getStartLine(); $d = deepclone_to_array($c); -var_dump($d['prepared'] === [Fix::class, '@0', $line]); +var_dump($d['prepared'][0] === Fix::class); +var_dump($refId($d) === '@0'); +var_dump(count($d['prepared']) === 2); var_dump($d['mask'] === 1); $r = deepclone_from_array($d); var_dump($r instanceof Closure, $r !== $c, $r() === 'class-secret'); @@ -68,14 +73,14 @@ var_dump($r instanceof Closure, $r !== $c, $r() === 'class-secret'); // ── Closure nested in an attribute argument array ── $c = $rc->getProperty('tagged')->getAttributes()[0]->getArguments()['cb'][1]['x']; $d = deepclone_to_array($c); -var_dump($d['prepared'][1] === '$tagged@0'); +var_dump($refId($d) === '$tagged@0'); var_dump(deepclone_from_array($d)(3) === 6); // ── Several closures in one attribute ── $args = (new ReflectionClassConstant(Fix::class, 'TAGGED'))->getAttributes()[0]->getArguments(); foreach (['multi-0', 'multi-1'] as $i => $expected) { $d = deepclone_to_array($args[$i]); - var_dump($d['prepared'][1] === 'TAGGED@'.$i, deepclone_from_array($d)() === $expected); + var_dump($refId($d) === 'TAGGED@'.$i, deepclone_from_array($d)() === $expected); } // ── Repeated attribute, parameter attribute, parameter default ── @@ -100,7 +105,7 @@ var_dump(deepclone_from_array(deepclone_to_array((new ReflectionClass(FixTraitUs // engine names the property surface instead, both resolve everywhere ── $c = (new ReflectionProperty(FixPromoted::class, 'promoted'))->getAttributes()[0]->getArguments()[0]; $d = deepclone_to_array($c); -var_dump($d['prepared'][1] === (PHP_VERSION_ID >= 80600 ? '$promoted@0' : '__construct()@0'), deepclone_from_array($d)() === 'promoted'); +var_dump($refId($d) === (PHP_VERSION_ID >= 80600 ? '$promoted@0' : '__construct()@0'), deepclone_from_array($d)() === 'promoted'); // ── Same-line closures are told apart by op_array identity ── $args = (new ReflectionClass(FixAmbiguous::class))->getAttributes()[0]->getArguments(); @@ -131,10 +136,10 @@ class FixHooked { } $c = (new ReflectionProperty(FixHooked::class, 'virtual'))->getHook(PropertyHookType::Get)->getAttributes()[0]->getArguments()[0]; $d = deepclone_to_array($c); -var_dump($d['prepared'][1] === '$virtual::get()@0', deepclone_from_array($d)() === 'get-hook-attr'); +var_dump($refId($d) === '$virtual::get()@0', deepclone_from_array($d)() === 'get-hook-attr'); $c = (new ReflectionProperty(FixHooked::class, 'stored'))->getHook(PropertyHookType::Set)->getParameters()[0]->getAttributes()[0]->getArguments()[0]; $d = deepclone_to_array($c); -var_dump($d['prepared'][1] === '$stored::set()@0', deepclone_from_array($d)() === 'set-hook-param-attr'); +var_dump($refId($d) === '$stored::set()@0', deepclone_from_array($d)() === 'set-hook-param-attr'); // ── Factory constant: outer round-trips, inner runtime closure refuses ── class FixFactory { public const FACTORY = static function (): Closure { return static function (): string { return 'inner'; }; }; } @@ -176,12 +181,20 @@ try { var_dump(deepclone_from_array($d, ['Closure', 'Fix'])() === 'class-secret'); // ── Stale payload ── +// On PHP 8.6 the id carries a "#" of the closure's code; tampering it is +// rejected. On 8.5 the ext cannot compute the hash, so references resolve +// positionally with no staleness check. $d = deepclone_to_array($rc->getAttributes()[0]->getArguments()[0]); -$d['prepared'][2]++; -try { - deepclone_from_array($d); -} catch (\ValueError $e) { - var_dump(str_contains($e->getMessage(), 'stale payload, const-expr-closure moved from class-relative line')); +if (PHP_VERSION_ID >= 80600) { + $d['prepared'][1] = substr($d['prepared'][1], 0, -1) . dechex(hexdec(substr($d['prepared'][1], -1)) ^ 1); + try { + deepclone_from_array($d); + echo "resolved!?\n"; + } catch (\ValueError $e) { + var_dump(str_contains($e->getMessage(), 'changed')); + } +} else { + var_dump(deepclone_from_array($d)() === 'class-secret'); } ?> --EXPECT-- @@ -209,6 +222,8 @@ bool(true) bool(true) bool(true) bool(true) +bool(true) +bool(true) string(35) "Type "Closure" is not instantiable." bool(true) bool(true) diff --git a/tests/deepclone_constexpr_closures_validation.phpt b/tests/deepclone_constexpr_closures_validation.phpt index 299e28f..c8d1685 100644 --- a/tests/deepclone_constexpr_closures_validation.phpt +++ b/tests/deepclone_constexpr_closures_validation.phpt @@ -17,27 +17,24 @@ class Fix { public function tagged(int $x = 0): void {} } -$line = (new ReflectionFunction((new ReflectionClass(Fix::class))->getAttributes()[0]->getArguments()[0]))->getStartLine(); - $cases = [ 'foo', [Fix::class], - [Fix::class, '@0', $line, 'x'], - [42, '@0', $line], - [Fix::class, 0, $line], - [Fix::class, '@0', 'x'], - [Fix::class, 'nope', $line], - [Fix::class, '@', $line], - [Fix::class, '@01', $line], - [Fix::class, '@1x', $line], - ['No\\Such\\ClassAtAll', '@0', $line], - [Fix::class, '$nope@0', $line], - [Fix::class, 'nope()@0', $line], - [Fix::class, 'NOPE@0', $line], - [Fix::class, '$tagged::bad()@0', $line], - [Fix::class, '$tagged::get()@0', $line], - [Fix::class, '@9', $line], - [Fix::class, 'tagged()@9', $line], + [Fix::class, '@0', 1], + [42, '@0'], + [Fix::class, 0], + [Fix::class, 'nope'], + [Fix::class, '@'], + [Fix::class, '@01'], + [Fix::class, '@1x'], + ['No\\Such\\ClassAtAll', '@0'], + [Fix::class, '$nope@0'], + [Fix::class, 'nope()@0'], + [Fix::class, 'NOPE@0'], + [Fix::class, '$tagged::bad()@0'], + [Fix::class, '$tagged::get()@0'], + [Fix::class, '@9'], + [Fix::class, 'tagged()@9'], ]; foreach ($cases as $prepared) { @@ -51,11 +48,10 @@ foreach ($cases as $prepared) { ?> --EXPECT-- deepclone_from_array(): malformed payload, const-expr-closure value must be of type array, string given -deepclone_from_array(): malformed payload, const-expr-closure value must have 3 elements -deepclone_from_array(): malformed payload, const-expr-closure value must have 3 elements +deepclone_from_array(): malformed payload, const-expr-closure value must have 2 elements +deepclone_from_array(): malformed payload, const-expr-closure value must have 2 elements deepclone_from_array(): malformed payload, const-expr-closure class name must be of type string, int given deepclone_from_array(): malformed payload, const-expr-closure id must be of type string, int given -deepclone_from_array(): malformed payload, const-expr-closure line must be of type int, string given deepclone_from_array(): malformed payload, const-expr-closure id must be of the form "@", "nope" given deepclone_from_array(): malformed payload, const-expr-closure id must be of the form "@", "@" given deepclone_from_array(): malformed payload, const-expr-closure id must be of the form "@", "@01" given From b02dd72f63cb21c130aafff682ed4b6b58bfde77 Mon Sep 17 00:00:00 2001 From: Nicolas Grekas Date: Fri, 17 Jul 2026 15:26:37 +0200 Subject: [PATCH 6/7] Serialize const-expr closures through Closure::__serialize()/unserialize() on PHP 8.6 The engine now exposes a const-expr closure's [class, id] declaration-site reference through Closure::__serialize() and resolves it back through unserialize(), replacing the reflection/reconstruction API (getConstExprClass, getConstExprId, fromConstExpr). Encode reads the reference from __serialize() for the positions the engine addresses (attribute arguments and parameter defaults) and falls back to the value-walk for constant and property default values. Decode synthesizes the serialized form and unserializes it with the allow-list (passing allowed classes), before the rank parse so the engine's name-keyed first-class-callable ids resolve. A stale or unknown hash-bearing reference now surfaces the engine's own exception instead of a ValueError. --- deepclone.c | 275 +++++++++++------- tests/deepclone_attribute_provenance.phpt | 2 +- tests/deepclone_constexpr_closures.phpt | 6 +- ...epclone_constexpr_closures_native_fcc.phpt | 2 +- 4 files changed, 182 insertions(+), 103 deletions(-) diff --git a/deepclone.c b/deepclone.c index fe21024..c5204a9 100644 --- a/deepclone.c +++ b/deepclone.c @@ -1328,13 +1328,13 @@ static bool dc_cexpr_elem_attrs(dc_cexpr_walk *w, HashTable *attributes, zend_cl } /* Try to express a closure as a reference to the constant expressions of one - * reflection element of `ce`: [class, "@", line]. Identity is - * exact: the closure's op_array shares its opcodes with the op_array embedded - * in the declaring AST. The rank counts the element's closures in evaluation - * order: attribute arguments (declaration order, nested const-expr surfaces + * reflection element of `ce`: [class, "@"]. Identity is exact: the + * closure's op_array shares its opcodes with the op_array embedded in the + * declaring AST. The rank counts the element's closures in evaluation order: + * attribute arguments (declaration order, nested const-expr surfaces * depth-first), then parameter defaults, then the constant or property default * value itself. That order matches the engine's element walk, so for purely - * literal elements the reference equals ReflectionFunction::getConstExprId(). + * literal elements the reference equals the engine's own hash-less id. * Promoted properties are skipped: their constructor parameter is the * canonical surface. Elements are scanned in the same order the polyfill * indexes them, so both implementations produce identical payloads. */ @@ -1447,8 +1447,8 @@ static bool dc_cexpr_locate(const zend_function *target, zval *payload) * A first-class callable declared in a constant expression of class A but * referencing a method of class B (e.g. #[When(Validators::check(...))]) has * no link back to A on its closure object: its scope is B. PHP 8.6 records the - * declaring class as engine provenance (ReflectionFunction::getConstExprClass); - * 8.5 does not. To recover it without that API, we instrument + * declaring class on the closure and exposes it through Closure::__serialize(); + * 8.5 does not. To recover it there, we instrument * ReflectionAttribute::getArguments() and ::newInstance() (the paths frameworks * use to read attribute metadata) and record, for every cross-class FCC they * produce, a name-keyed map from the target to its declaring class. It is built @@ -1548,22 +1548,11 @@ static zend_class_entry *dc_provenance_lookup(zend_class_entry *target_ce, zend_ return zend_lookup_class_ex(decl, NULL, ZEND_FETCH_CLASS_NO_AUTOLOAD); } -/* The class whose constant expression declares this first-class callable. On - * PHP 8.6 the engine records it (zend_constexpr_closure_ref), so it is exact - * and needs no capture; on 8.5 it comes from the ReflectionAttribute-captured - * index. Either way it feeds the same site-based (5-element) reference, which - * is interchangeable with the polyfill — unlike the engine-id form, whose fcc - * line userland cannot reproduce. */ -static zend_class_entry *dc_declaring_class(zval *src, const zend_function *func) +/* The class whose constant expression declares this first-class callable, taken + * from the ReflectionAttribute-captured index. Used only on PHP 8.5; on 8.6 the + * engine addresses such closures directly through Closure::__serialize(). */ +static zend_class_entry *dc_declaring_class(const zend_function *func) { -#if PHP_VERSION_ID >= 80600 - zend_class_entry *ce; - zend_string *id; - if (zend_constexpr_closure_ref(Z_OBJ_P(src), &ce, &id) == SUCCESS) { - zend_string_release(id); - return ce; - } -#endif return func->common.function_name ? dc_provenance_lookup(func->common.scope, func->common.function_name) : NULL; @@ -1659,6 +1648,94 @@ static void ZEND_FASTCALL dc_attr_new_instance_wrapper(INTERNAL_FUNCTION_PARAMET } #endif /* PHP_VERSION_ID >= 80500 */ +#if PHP_VERSION_ID >= 80600 +/* Read a const-expr closure's declaration-site reference straight out of the + * engine's native Closure::__serialize(), which yields + * [ [], ["const-expr", [class, id]] ] + * for any closure the engine addresses. Only called for closures the engine has + * flagged ZEND_ACC2_CONSTEXPR_CLOSURE, so __serialize() does not throw; a + * malformed shape is treated as "not a reference" (false, exception cleared). On + * success `out` receives a fresh [class, id] array. */ +static bool dc_closure_native_ref(zval *closure, zval *out) +{ + zend_function *fn = zend_hash_str_find_ptr(&zend_ce_closure->function_table, ZEND_STRL("__serialize")); + if (!fn) { + return false; + } + zval ser; + ZVAL_UNDEF(&ser); + zend_call_known_instance_method_with_0_params(fn, Z_OBJ_P(closure), &ser); + if (EG(exception)) { + zend_clear_exception(); + zval_ptr_dtor(&ser); + return false; + } + bool ok = false; + zval *tagged = Z_TYPE(ser) == IS_ARRAY ? zend_hash_index_find(Z_ARRVAL(ser), 1) : NULL; + zval *ref = (tagged && Z_TYPE_P(tagged) == IS_ARRAY) ? zend_hash_index_find(Z_ARRVAL_P(tagged), 1) : NULL; + if (ref && Z_TYPE_P(ref) == IS_ARRAY && zend_hash_num_elements(Z_ARRVAL_P(ref)) == 2) { + zval *zclass = zend_hash_index_find(Z_ARRVAL_P(ref), 0); + zval *zid = zend_hash_index_find(Z_ARRVAL_P(ref), 1); + if (zclass && Z_TYPE_P(zclass) == IS_STRING && zid && Z_TYPE_P(zid) == IS_STRING) { + zval tmp; + array_init_size(out, 2); + ZVAL_STR_COPY(&tmp, Z_STR_P(zclass)); + zend_hash_index_add_new(Z_ARRVAL_P(out), 0, &tmp); + ZVAL_STR_COPY(&tmp, Z_STR_P(zid)); + zend_hash_index_add_new(Z_ARRVAL_P(out), 1, &tmp); + ok = true; + } + } + zval_ptr_dtor(&ser); + return ok; +} + +/* deepclone_from_array() counterpart of dc_closure_native_ref(): resolve a + * [class, id] reference through the engine's own Closure unserialization, which + * re-evaluates the reference bounded to what the class declares and verifies any + * "#" fingerprint. The serialized form is synthesized directly (the class + * and id are length-prefixed, so no escaping is needed). allowed_set gates the + * Closure the payload instantiates. Returns true on success (retval set); on a + * throw returns false with the exception left pending for the caller to weigh + * (a stale hash to surface vs an unknown id to heal positionally). */ +static bool dc_closure_native_resolve(zend_string *zclass, zend_string *zid, HashTable *allowed_set, zval *retval) +{ + ZVAL_UNDEF(retval); + smart_str buf = {0}; + smart_str_appendl(&buf, ZEND_STRL("O:7:\"Closure\":2:{i:0;a:0:{}i:1;a:2:{i:0;s:10:\"const-expr\";i:1;a:2:{i:0;s:")); + smart_str_append_long(&buf, (zend_long) ZSTR_LEN(zclass)); + smart_str_appendl(&buf, ZEND_STRL(":\"")); + smart_str_append(&buf, zclass); + smart_str_appendl(&buf, ZEND_STRL("\";i:1;s:")); + smart_str_append_long(&buf, (zend_long) ZSTR_LEN(zid)); + smart_str_appendl(&buf, ZEND_STRL(":\"")); + smart_str_append(&buf, zid); + smart_str_appendl(&buf, ZEND_STRL("\";}}}")); + smart_str_0(&buf); + + php_unserialize_data_t var_hash; + PHP_VAR_UNSERIALIZE_INIT(var_hash); + if (allowed_set) { + php_var_unserialize_set_allowed_classes(var_hash, allowed_set); + } + const unsigned char *p = (const unsigned char *) ZSTR_VAL(buf.s); + const unsigned char *end = p + ZSTR_LEN(buf.s); + /* The engine defers the reference's resolution (and any "not found"/"changed" + * throw) to the delayed-callback pass run by DESTROY, so the exception and the + * final object type are only settled afterwards. */ + bool ok = php_var_unserialize(retval, &p, end, &var_hash); + PHP_VAR_UNSERIALIZE_DESTROY(var_hash); + smart_str_free(&buf); + ok = ok && !EG(exception) + && Z_TYPE_P(retval) == IS_OBJECT && Z_OBJCE_P(retval) == zend_ce_closure; + if (!ok) { + zval_ptr_dtor(retval); + ZVAL_UNDEF(retval); + } + return ok; +} +#endif /* PHP_VERSION_ID >= 80600 */ + /* deepclone_from_array() counterpart: resolve an element-scoped * "@" declaration-site reference back to a live Closure. */ static void dc_cexpr_resolve(zval *value, HashTable *allowed_set, zval *retval) @@ -1686,21 +1763,50 @@ static void dc_cexpr_resolve(zval *value, HashTable *allowed_set, zval *retval) } /* Gate before zend_lookup_class(): the payload must not be able to - * autoload, let alone evaluate, classes outside the allow-list. */ + * autoload, let alone evaluate, classes outside the allow-list. Closure is + * gated too, matching deepclone_to_array(): resolving a reference mints one. */ if (!dc_class_allowed(allowed_set, Z_STR_P(zclass))) { zend_value_error("deepclone_from_array(): class \"%s\" is not allowed", Z_STRVAL_P(zclass)); return; } + if (!dc_class_allowed(allowed_set, zend_ce_closure->name)) { + zend_value_error("deepclone_from_array(): class \"Closure\" is not allowed"); + return; + } const char *idstr = Z_STRVAL_P(zid); size_t idlen = Z_STRLEN_P(zid); /* An engine-produced id may carry a "#" code fingerprint after the * rank. The ext cannot recompute it (the closure's source is discarded at * compile time), so on the value-walk below the hash is stripped and the - * reference resolves positionally; on 8.6 the hash-bearing id is verified - * by fromConstExpr first. */ + * reference resolves positionally. */ const char *sharp = idlen ? zend_memrchr(idstr, '#', idlen) : NULL; bool has_hash = sharp != NULL; + +#if PHP_VERSION_ID >= 80600 + /* Resolve through the engine's own unserialization first: it reads the raw + * constant expressions and verifies the "#" fingerprint, and it alone + * understands the engine's first-class-callable ids (a "@" the + * rank parse below would reject). A hash-bearing id is fully the engine's to + * judge — a rejection means the reference is stale, surfaced rather than + * healed positionally. A hash-less id it does not know (a closure counted + * among a constant or property's evaluated values, or one written by an + * older ext) falls through to the value-walk. */ + { + zval rv; + if (dc_closure_native_resolve(Z_STR_P(zclass), Z_STR_P(zid), allowed_set, &rv)) { + ZVAL_COPY_VALUE(retval, &rv); + return; + } + if (EG(exception)) { + if (has_hash) { + return; + } + zend_clear_exception(); + } + } +#endif + size_t coreid_len = has_hash ? (size_t) (sharp - idstr) : idlen; const char *at = coreid_len ? zend_memrchr(idstr, '@', coreid_len) : NULL; size_t site_len = at ? (size_t) (at - idstr) : 0; @@ -1724,35 +1830,6 @@ static void dc_cexpr_resolve(zval *value, HashTable *allowed_set, zval *retval) return; } -#if PHP_VERSION_ID >= 80600 - /* The engine resolves its own ids, verifying the "#" fingerprint. Its - * walk reads the raw constant expressions, while this reference counts - * evaluated values, so a hash-less id the engine does not know (a closure - * in a constant or property value) falls through to the evaluating walk. A - * hash-bearing id is fully the engine's to judge: if it rejects one, the - * reference is stale, so we surface that instead of healing positionally. */ - { - zend_function *from_cexpr = zend_hash_str_find_ptr(&zend_ce_closure->function_table, ZEND_STRL("fromconstexpr")); - if (from_cexpr) { - zval rv, params[2]; - ZVAL_UNDEF(&rv); - ZVAL_STR(¶ms[0], Z_STR_P(zclass)); - ZVAL_STR(¶ms[1], Z_STR_P(zid)); - zend_call_known_function(from_cexpr, NULL, zend_ce_closure, &rv, 2, params, NULL); - if (EG(exception)) { - if (has_hash || !instanceof_function(EG(exception)->ce, zend_ce_value_error)) { - return; - } - zend_clear_exception(); - } else { - ZEND_ASSERT(Z_TYPE(rv) == IS_OBJECT); - ZVAL_COPY_VALUE(retval, &rv); - return; - } - } - } -#endif - /* Locate the named element: its attribute lists, for methods and hooks the * op_array carrying parameter defaults, and for constants and properties * the const-expr source value. */ @@ -1971,13 +2048,43 @@ static void dc_copy_value(dc_ctx *ctx, zval *src, zval *dst, zval *mask_dst) if (Z_OBJCE_P(src) == zend_ce_closure) { const zend_function *func = zend_get_closure_method_def(Z_OBJ_P(src)); +#if PHP_VERSION_ID >= 80600 + /* The engine flags every const-expr closure with ZEND_ACC2_CONSTEXPR_CLOSURE + * and serializes the ones it addresses (attribute arguments and parameter + * defaults) through Closure::__serialize(), which yields a [class, id] + * reference fingerprinted against the closure's code. Store that directly: + * it round-trips through the engine's own unserialization (see + * dc_cexpr_resolve), resolves only to what the class declares, and needs no + * allow_named_closures opt-in. Closures the engine flags but does not + * address (a constant or property default value) leave __serialize() + * throwing; dc_closure_native_ref() swallows that and they fall through to + * the value-walk below, which reaches those positions too. The allow-list + * is checked first, before any constant expression is evaluated. */ + if (func && (func->common.fn_flags2 & ZEND_ACC2_CONSTEXPR_CLOSURE)) { + if (!dc_class_allowed(ctx->allowed_ht, zend_ce_closure->name)) { + zend_value_error("deepclone_to_array(): class \"Closure\" is not allowed"); + return; + } + if (dc_closure_native_ref(src, dst)) { + DC_MASK_CONSTEXPR_CLOSURE(mask_dst); + goto handle_value; + } + if (UNEXPECTED(EG(exception))) { + return; + } + } +#endif #if PHP_VERSION_ID >= 80500 - /* Const-expr declaration-site reference. This covers anonymous static - * closures and first-class callables over a method of their own + /* Const-expr declaration-site reference, walked from the constant + * expressions. On 8.6 this backs the __serialize() path above for the + * positions the engine does not address (constant and property default + * values); on 8.5, where there is no engine provenance, it is the only + * path. This covers anonymous + * static closures and first-class callables over a method of their own * declaring class (e.g. #[When(self::isStrict(...))]). It is attempted * before the by-name encoding so that such closures serialize as a - * declaration-site reference — resolvable only to what the class - * itself declares — and therefore round-trip without requiring the + * declaration-site reference — resolvable only to what the class itself + * declares — and therefore round-trip without requiring the * allow_named_closures opt-in. The allow-list is checked first so that * disallowing Closure is reported before any const-expr of the scope * class is evaluated. */ @@ -1988,30 +2095,6 @@ static void dc_copy_value(dc_ctx *ctx, zval *src, zval *dst, zval *mask_dst) zend_value_error("deepclone_to_array(): class \"Closure\" is not allowed"); return; } -#if PHP_VERSION_ID >= 80600 - /* The engine derives the same element-scoped "@" - * reference without evaluating any constant expression; use it - * for anonymous closures. First-class callables keep the - * evaluating walk below: their payload line is the target - * function's, which the engine's site-based line is not. */ - if (!(func->common.fn_flags & ZEND_ACC_FAKE_CLOSURE)) { - zend_class_entry *site_ce; - zend_string *cexpr_id; - /* The engine id already carries a "#" of the closure's - * code, so the reference is verified on decode without a - * separate line field. */ - if (zend_constexpr_closure_ref(Z_OBJ_P(src), &site_ce, &cexpr_id) == SUCCESS) { - zval tmp; - array_init_size(dst, 2); - ZVAL_STR_COPY(&tmp, site_ce->name); - zend_hash_index_add_new(Z_ARRVAL_P(dst), 0, &tmp); - ZVAL_STR(&tmp, cexpr_id); - zend_hash_index_add_new(Z_ARRVAL_P(dst), 1, &tmp); - DC_MASK_CONSTEXPR_CLOSURE(mask_dst); - goto handle_value; - } - } -#endif zval payload; ZVAL_UNDEF(&payload); if (dc_cexpr_locate(func, &payload)) { @@ -2024,11 +2107,10 @@ static void dc_copy_value(dc_ctx *ctx, zval *src, zval *dst, zval *mask_dst) } /* Cross-class first-class callable: the declaring class is not * the closure's scope, so the locate above (which walks the - * scope) misses. On 8.5 there is no engine provenance; fall back - * to a declaring class captured from ReflectionAttribute, if - * any, and locate the site there. */ + * scope) misses. Fall back to a declaring class captured from + * ReflectionAttribute, if any, and locate the site there. */ if (func->common.function_name) { - zend_class_entry *decl = dc_declaring_class(src, func); + zend_class_entry *decl = dc_declaring_class(func); if (decl && decl != func->common.scope && dc_cexpr_locate_ce(func, decl, &payload)) { ZVAL_COPY_VALUE(dst, &payload); DC_MASK_CONSTEXPR_CLOSURE(mask_dst); @@ -2042,15 +2124,14 @@ static void dc_copy_value(dc_ctx *ctx, zval *src, zval *dst, zval *mask_dst) } /* Global-function first-class callable (no scope, internal or user): - * the declaring class comes from the engine (8.6) or captured - * provenance (8.5). Same + * the declaring class comes from captured provenance. Same * declaration-site reference and Closure gating as above; unresolved * ones fall through to the by-name path. */ if (func && (func->common.fn_flags & ZEND_ACC_FAKE_CLOSURE) && !func->common.scope && func->common.function_name) { zval *this_ptr = zend_get_closure_this_ptr(src); if (!this_ptr || Z_TYPE_P(this_ptr) != IS_OBJECT) { - zend_class_entry *decl = dc_declaring_class(src, func); + zend_class_entry *decl = dc_declaring_class(func); if (decl) { if (!dc_class_allowed(ctx->allowed_ht, zend_ce_closure->name)) { zend_value_error("deepclone_to_array(): class \"Closure\" is not allowed"); @@ -5439,15 +5520,13 @@ PHP_MINIT_FUNCTION(deepclone) #if PHP_VERSION_ID >= 80500 /* Cross-class first-class-callable provenance: on PHP 8.5 the engine records * no declaring-class provenance for const-expr FCCs, so we recover it by - * instrumenting ReflectionAttribute. When the engine exposes it natively - * (ReflectionFunction::getConstExprClass, the serializable-closures patch), - * the engine-id path resolves cross-class FCCs directly and this is left - * off. There is no INI knob: it is simply how deepclone behaves on a build - * without native provenance. */ - zend_class_entry *rf_ce = zend_hash_str_find_ptr(CG(class_table), - "reflectionfunction", sizeof("reflectionfunction") - 1); - bool native_provenance = rf_ce && zend_hash_str_exists(&rf_ce->function_table, - "getconstexprclass", sizeof("getconstexprclass") - 1); + * instrumenting ReflectionAttribute. When the engine serializes const-expr + * closures natively (Closure::__serialize, the serializable-closures patch), + * the __serialize path resolves them directly and this is left off. There is + * no INI knob: it is simply how deepclone behaves on a build without native + * support. */ + bool native_provenance = zend_hash_str_exists(&zend_ce_closure->function_table, + "__serialize", sizeof("__serialize") - 1); DC_G(capture_attribute_closures) = !native_provenance; if (DC_G(capture_attribute_closures)) { diff --git a/tests/deepclone_attribute_provenance.phpt b/tests/deepclone_attribute_provenance.phpt index 51cb6cc..344e794 100644 --- a/tests/deepclone_attribute_provenance.phpt +++ b/tests/deepclone_attribute_provenance.phpt @@ -5,7 +5,7 @@ deepclone --SKIPIF-- --FILE-- " of the closure's code; tampering it is -// rejected. On 8.5 the ext cannot compute the hash, so references resolve -// positionally with no staleness check. +// rejected by the engine's own unserialization. On 8.5 the ext cannot compute +// the hash, so references resolve positionally with no staleness check. $d = deepclone_to_array($rc->getAttributes()[0]->getArguments()[0]); if (PHP_VERSION_ID >= 80600) { $d['prepared'][1] = substr($d['prepared'][1], 0, -1) . dechex(hexdec(substr($d['prepared'][1], -1)) ^ 1); try { deepclone_from_array($d); echo "resolved!?\n"; - } catch (\ValueError $e) { + } catch (\Exception $e) { var_dump(str_contains($e->getMessage(), 'changed')); } } else { diff --git a/tests/deepclone_constexpr_closures_native_fcc.phpt b/tests/deepclone_constexpr_closures_native_fcc.phpt index 168db98..1cfcaf3 100644 --- a/tests/deepclone_constexpr_closures_native_fcc.phpt +++ b/tests/deepclone_constexpr_closures_native_fcc.phpt @@ -5,7 +5,7 @@ deepclone --SKIPIF-- --FILE-- Date: Sat, 18 Jul 2026 10:09:54 +0200 Subject: [PATCH 7/7] Split the const-expr closure reference into four fields Follow the engine's marker-1 wire format: the reference carried under mask 1 is now [class, site, key, hash] instead of [class, "@"] with an optional "#" suffix. key is an integer rank for an anonymous closure or the callable's name for a first-class callable; hash is the engine's 32-bit code hash (0 for the value-walk, which cannot recompute it, and for first-class callables). On PHP 8.6 the reference is copied straight from Closure::__serialize() and resolved by synthesizing the engine's own a:4:{...} payload; the value-walk decode reads the four fields and resolves an anonymous rank positionally, rejecting a string key it cannot place. Byte-identical to the engine and the polyfill on 8.6, and interchangeable with 8.5. --- deepclone.c | 159 +++++++++--------- tests/deepclone_attribute_provenance.phpt | 2 +- tests/deepclone_constexpr_closures.phpt | 19 ++- ...epclone_constexpr_closures_validation.phpt | 54 +++--- 4 files changed, 124 insertions(+), 110 deletions(-) diff --git a/deepclone.c b/deepclone.c index c5204a9..e27d02c 100644 --- a/deepclone.c +++ b/deepclone.c @@ -1290,20 +1290,23 @@ static void dc_cexpr_walk_closure_surface(dc_cexpr_walk *w, const zend_op_array } } -/* Builds [class, "@"]; takes ownership of site. This value-walk - * reference carries no code hash (the ext cannot recompute the engine's hash - * of the closure's source, which is discarded at compile time), so it resolves +/* Builds [class, site, rank, hash]; takes ownership of site. This value-walk + * reference carries a zero hash (the ext cannot recompute the engine's hash of + * the closure's source, which is discarded at compile time), so it resolves * positionally. On PHP 8.6 anonymous closures instead take the engine's - * hash-bearing id; see the encoder. */ + * hash-bearing reference; see the encoder. */ static void dc_cexpr_payload(zval *dst, zend_class_entry *ce, zend_string *site, uint32_t rank) { zval tmp; - array_init_size(dst, 2); + array_init_size(dst, 4); ZVAL_STR_COPY(&tmp, ce->name); zend_hash_index_add_new(Z_ARRVAL_P(dst), 0, &tmp); - ZVAL_STR(&tmp, zend_strpprintf(0, "%s@%u", ZSTR_VAL(site), rank)); + ZVAL_STR(&tmp, site); zend_hash_index_add_new(Z_ARRVAL_P(dst), 1, &tmp); - zend_string_release(site); + ZVAL_LONG(&tmp, rank); + zend_hash_index_add_new(Z_ARRVAL_P(dst), 2, &tmp); + ZVAL_LONG(&tmp, 0); + zend_hash_index_add_new(Z_ARRVAL_P(dst), 3, &tmp); } /* Walk every attribute of one reflection element (all offsets, declaration @@ -1328,7 +1331,7 @@ static bool dc_cexpr_elem_attrs(dc_cexpr_walk *w, HashTable *attributes, zend_cl } /* Try to express a closure as a reference to the constant expressions of one - * reflection element of `ce`: [class, "@"]. Identity is exact: the + * reflection element of `ce`: [class, site, rank, 0]. Identity is exact: the * closure's op_array shares its opcodes with the op_array embedded in the * declaring AST. The rank counts the element's closures in evaluation order: * attribute arguments (declaration order, nested const-expr surfaces @@ -1651,11 +1654,11 @@ static void ZEND_FASTCALL dc_attr_new_instance_wrapper(INTERNAL_FUNCTION_PARAMET #if PHP_VERSION_ID >= 80600 /* Read a const-expr closure's declaration-site reference straight out of the * engine's native Closure::__serialize(), which yields - * [ [], ["const-expr", [class, id]] ] + * [ [], ["const-expr", [class, site, key, hash]] ] * for any closure the engine addresses. Only called for closures the engine has * flagged ZEND_ACC2_CONSTEXPR_CLOSURE, so __serialize() does not throw; a * malformed shape is treated as "not a reference" (false, exception cleared). On - * success `out` receives a fresh [class, id] array. */ + * success `out` receives the [class, site, key, hash] reference. */ static bool dc_closure_native_ref(zval *closure, zval *out) { zend_function *fn = zend_hash_str_find_ptr(&zend_ce_closure->function_table, ZEND_STRL("__serialize")); @@ -1673,16 +1676,16 @@ static bool dc_closure_native_ref(zval *closure, zval *out) bool ok = false; zval *tagged = Z_TYPE(ser) == IS_ARRAY ? zend_hash_index_find(Z_ARRVAL(ser), 1) : NULL; zval *ref = (tagged && Z_TYPE_P(tagged) == IS_ARRAY) ? zend_hash_index_find(Z_ARRVAL_P(tagged), 1) : NULL; - if (ref && Z_TYPE_P(ref) == IS_ARRAY && zend_hash_num_elements(Z_ARRVAL_P(ref)) == 2) { + if (ref && Z_TYPE_P(ref) == IS_ARRAY && zend_hash_num_elements(Z_ARRVAL_P(ref)) == 4) { zval *zclass = zend_hash_index_find(Z_ARRVAL_P(ref), 0); - zval *zid = zend_hash_index_find(Z_ARRVAL_P(ref), 1); - if (zclass && Z_TYPE_P(zclass) == IS_STRING && zid && Z_TYPE_P(zid) == IS_STRING) { - zval tmp; - array_init_size(out, 2); - ZVAL_STR_COPY(&tmp, Z_STR_P(zclass)); - zend_hash_index_add_new(Z_ARRVAL_P(out), 0, &tmp); - ZVAL_STR_COPY(&tmp, Z_STR_P(zid)); - zend_hash_index_add_new(Z_ARRVAL_P(out), 1, &tmp); + zval *zsite = zend_hash_index_find(Z_ARRVAL_P(ref), 1); + zval *zkey = zend_hash_index_find(Z_ARRVAL_P(ref), 2); + zval *zhash = zend_hash_index_find(Z_ARRVAL_P(ref), 3); + if (zclass && Z_TYPE_P(zclass) == IS_STRING + && zsite && Z_TYPE_P(zsite) == IS_STRING + && zkey && (Z_TYPE_P(zkey) == IS_LONG || Z_TYPE_P(zkey) == IS_STRING) + && zhash && Z_TYPE_P(zhash) == IS_LONG) { + ZVAL_COPY(out, ref); ok = true; } } @@ -1691,26 +1694,41 @@ static bool dc_closure_native_ref(zval *closure, zval *out) } /* deepclone_from_array() counterpart of dc_closure_native_ref(): resolve a - * [class, id] reference through the engine's own Closure unserialization, which - * re-evaluates the reference bounded to what the class declares and verifies any - * "#" fingerprint. The serialized form is synthesized directly (the class - * and id are length-prefixed, so no escaping is needed). allowed_set gates the - * Closure the payload instantiates. Returns true on success (retval set); on a - * throw returns false with the exception left pending for the caller to weigh - * (a stale hash to surface vs an unknown id to heal positionally). */ -static bool dc_closure_native_resolve(zend_string *zclass, zend_string *zid, HashTable *allowed_set, zval *retval) + * [class, site, key, hash] reference through the engine's own Closure + * unserialization, which re-evaluates the reference bounded to what the class + * declares and verifies a non-zero hash. The serialized form is synthesized + * directly (strings are length-prefixed, so no escaping is needed); the key is a + * rank (int) or a callable name (string). allowed_set gates the Closure the + * payload instantiates. Returns true on success (retval set); on a throw returns + * false with the exception left pending for the caller to weigh (a stale hash to + * surface vs an unknown reference to heal positionally). */ +static bool dc_closure_native_resolve(zend_string *zclass, zend_string *zsite, zval *zkey, zend_long hash, HashTable *allowed_set, zval *retval) { ZVAL_UNDEF(retval); smart_str buf = {0}; - smart_str_appendl(&buf, ZEND_STRL("O:7:\"Closure\":2:{i:0;a:0:{}i:1;a:2:{i:0;s:10:\"const-expr\";i:1;a:2:{i:0;s:")); + smart_str_appendl(&buf, ZEND_STRL("O:7:\"Closure\":2:{i:0;a:0:{}i:1;a:2:{i:0;s:10:\"const-expr\";i:1;a:4:{i:0;s:")); smart_str_append_long(&buf, (zend_long) ZSTR_LEN(zclass)); smart_str_appendl(&buf, ZEND_STRL(":\"")); smart_str_append(&buf, zclass); smart_str_appendl(&buf, ZEND_STRL("\";i:1;s:")); - smart_str_append_long(&buf, (zend_long) ZSTR_LEN(zid)); + smart_str_append_long(&buf, (zend_long) ZSTR_LEN(zsite)); smart_str_appendl(&buf, ZEND_STRL(":\"")); - smart_str_append(&buf, zid); - smart_str_appendl(&buf, ZEND_STRL("\";}}}")); + smart_str_append(&buf, zsite); + smart_str_appendl(&buf, ZEND_STRL("\";i:2;")); + if (Z_TYPE_P(zkey) == IS_LONG) { + smart_str_appendl(&buf, ZEND_STRL("i:")); + smart_str_append_long(&buf, Z_LVAL_P(zkey)); + smart_str_appendc(&buf, ';'); + } else { + smart_str_appendl(&buf, ZEND_STRL("s:")); + smart_str_append_long(&buf, (zend_long) Z_STRLEN_P(zkey)); + smart_str_appendl(&buf, ZEND_STRL(":\"")); + smart_str_append(&buf, Z_STR_P(zkey)); + smart_str_appendl(&buf, ZEND_STRL("\";")); + } + smart_str_appendl(&buf, ZEND_STRL("i:3;i:")); + smart_str_append_long(&buf, hash); + smart_str_appendl(&buf, ZEND_STRL(";}}}")); smart_str_0(&buf); php_unserialize_data_t var_hash; @@ -1736,8 +1754,8 @@ static bool dc_closure_native_resolve(zend_string *zclass, zend_string *zid, Has } #endif /* PHP_VERSION_ID >= 80600 */ -/* deepclone_from_array() counterpart: resolve an element-scoped - * "@" declaration-site reference back to a live Closure. */ +/* deepclone_from_array() counterpart: resolve a [class, site, key, hash] + * declaration-site reference back to a live Closure. */ static void dc_cexpr_resolve(zval *value, HashTable *allowed_set, zval *retval) { if (Z_TYPE_P(value) != IS_ARRAY) { @@ -1746,19 +1764,21 @@ static void dc_cexpr_resolve(zval *value, HashTable *allowed_set, zval *retval) } HashTable *ht = Z_ARRVAL_P(value); zval *zclass = zend_hash_index_find(ht, 0); - zval *zid = zend_hash_index_find(ht, 1); - if (!zclass || !zid || zend_hash_num_elements(ht) != 2) { - zend_value_error("deepclone_from_array(): malformed payload, const-expr-closure value must have 2 elements"); + zval *zsite = zend_hash_index_find(ht, 1); + zval *zkey = zend_hash_index_find(ht, 2); + zval *zhash = zend_hash_index_find(ht, 3); + if (!zclass || !zsite || !zkey || !zhash || zend_hash_num_elements(ht) != 4) { + zend_value_error("deepclone_from_array(): malformed payload, const-expr-closure value must have 4 elements"); return; } ZVAL_DEREF(zclass); - ZVAL_DEREF(zid); - if (Z_TYPE_P(zclass) != IS_STRING) { - 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)); - return; - } - if (Z_TYPE_P(zid) != IS_STRING) { - zend_value_error("deepclone_from_array(): malformed payload, const-expr-closure id must be of type string, %s given", zend_zval_value_name(zid)); + ZVAL_DEREF(zsite); + ZVAL_DEREF(zkey); + ZVAL_DEREF(zhash); + if (Z_TYPE_P(zclass) != IS_STRING || Z_TYPE_P(zsite) != IS_STRING + || (Z_TYPE_P(zkey) != IS_LONG && Z_TYPE_P(zkey) != IS_STRING) + || Z_TYPE_P(zhash) != IS_LONG) { + zend_value_error("deepclone_from_array(): malformed payload, const-expr-closure reference must be [string class, string site, int|string key, int hash]"); return; } @@ -1774,27 +1794,22 @@ static void dc_cexpr_resolve(zval *value, HashTable *allowed_set, zval *retval) return; } - const char *idstr = Z_STRVAL_P(zid); - size_t idlen = Z_STRLEN_P(zid); - /* An engine-produced id may carry a "#" code fingerprint after the - * rank. The ext cannot recompute it (the closure's source is discarded at - * compile time), so on the value-walk below the hash is stripped and the - * reference resolves positionally. */ - const char *sharp = idlen ? zend_memrchr(idstr, '#', idlen) : NULL; - bool has_hash = sharp != NULL; + /* The ext cannot recompute the engine's code hash (the closure's source is + * discarded at compile time), so its own value-walk references carry hash 0 + * and resolve positionally. A non-zero hash comes from the engine. */ + bool has_hash = Z_LVAL_P(zhash) != 0; #if PHP_VERSION_ID >= 80600 /* Resolve through the engine's own unserialization first: it reads the raw - * constant expressions and verifies the "#" fingerprint, and it alone - * understands the engine's first-class-callable ids (a "@" the - * rank parse below would reject). A hash-bearing id is fully the engine's to - * judge — a rejection means the reference is stale, surfaced rather than - * healed positionally. A hash-less id it does not know (a closure counted - * among a constant or property's evaluated values, or one written by an - * older ext) falls through to the value-walk. */ + * constant expressions, verifies the hash, and alone understands a + * first-class-callable name key (the value-walk below is by rank only). A + * non-zero hash is the engine's to judge — a rejection means the reference is + * stale, surfaced rather than healed positionally. A hash-less reference it + * does not know (a closure counted among a constant or property's evaluated + * values, or one written by an older ext) falls through to the value-walk. */ { zval rv; - if (dc_closure_native_resolve(Z_STR_P(zclass), Z_STR_P(zid), allowed_set, &rv)) { + if (dc_closure_native_resolve(Z_STR_P(zclass), Z_STR_P(zsite), zkey, Z_LVAL_P(zhash), allowed_set, &rv)) { ZVAL_COPY_VALUE(retval, &rv); return; } @@ -1807,22 +1822,16 @@ static void dc_cexpr_resolve(zval *value, HashTable *allowed_set, zval *retval) } #endif - size_t coreid_len = has_hash ? (size_t) (sharp - idstr) : idlen; - const char *at = coreid_len ? zend_memrchr(idstr, '@', coreid_len) : NULL; - size_t site_len = at ? (size_t) (at - idstr) : 0; - size_t rank_len = at ? coreid_len - site_len - 1 : 0; - uint64_t rank = 0; - bool rank_ok = at && rank_len > 0 && (rank_len == 1 || idstr[site_len + 1] != '0'); - for (size_t i = 0; rank_ok && i < rank_len; i++) { - char ch = idstr[site_len + 1 + i]; - rank_ok = ch >= '0' && ch <= '9' && (rank = rank * 10 + (ch - '0')) <= UINT32_MAX; - } - if (!rank_ok) { - zend_value_error("deepclone_from_array(): malformed payload, const-expr-closure id must be of the form \"@\", \"%s\" given", idstr); + /* The value-walk resolves an anonymous closure by position; a first-class + * callable name key (string) is not positionally resolvable here (on 8.6 the + * engine resolves it above). */ + if (Z_TYPE_P(zkey) != IS_LONG || Z_LVAL_P(zkey) < 0 || Z_LVAL_P(zkey) > UINT32_MAX) { + zend_value_error("deepclone_from_array(): malformed payload, const-expr-closure references unknown closure at site \"%s\" of class \"%s\"", Z_STRVAL_P(zsite), Z_STRVAL_P(zclass)); return; } - const char *site = idstr; - uint32_t want_ord = (uint32_t) rank; + const char *site = Z_STRVAL_P(zsite); + size_t site_len = Z_STRLEN_P(zsite); + uint32_t want_ord = (uint32_t) Z_LVAL_P(zkey); zend_class_entry *ce = zend_lookup_class(Z_STR_P(zclass)); if (!ce) { @@ -1940,11 +1949,11 @@ static void dc_cexpr_resolve(zval *value, HashTable *allowed_set, zval *retval) dc_cexpr_walk_dtor(&w); if (Z_ISUNDEF(w.found)) { - zend_value_error("deepclone_from_array(): malformed payload, const-expr-closure references unknown closure id \"%s\" in class \"%s\"", idstr, ZSTR_VAL(ce->name)); + zend_value_error("deepclone_from_array(): malformed payload, const-expr-closure references unknown closure \"%.*s@%u\" in class \"%s\"", (int) site_len, site, want_ord, ZSTR_VAL(ce->name)); return; } - /* This value-walk resolves positionally: a hash-less id carries no + /* This value-walk resolves positionally: a hash-less reference carries no * staleness check (the ext cannot recompute the engine's code hash), so * the rank alone selects the closure. */ ZVAL_COPY_VALUE(retval, &w.found); diff --git a/tests/deepclone_attribute_provenance.phpt b/tests/deepclone_attribute_provenance.phpt index 344e794..632ddfe 100644 --- a/tests/deepclone_attribute_provenance.phpt +++ b/tests/deepclone_attribute_provenance.phpt @@ -46,7 +46,7 @@ $x = (new ReflectionProperty(Order::class, 'x'))->getAttributes()[0]->getArgumen $d = deepclone_to_array($x); var_dump($d['mask'] === 1); // declaration-site reference, not by-name var_dump($d['prepared'][0] === 'Order'); // the DECLARING class, not Validators (the target's scope) -var_dump($d['prepared'][1] === '$x@0'); +var_dump($d['prepared'][1] === '$x' && $d['prepared'][2] === 0); // [class, site, key, hash] $r = deepclone_from_array($d); var_dump($r instanceof Closure, $r() === true); diff --git a/tests/deepclone_constexpr_closures.phpt b/tests/deepclone_constexpr_closures.phpt index 9585722..082c1e1 100644 --- a/tests/deepclone_constexpr_closures.phpt +++ b/tests/deepclone_constexpr_closures.phpt @@ -55,17 +55,17 @@ class FixTraitUser { use FixTrait; } $rc = new ReflectionClass(Fix::class); -// The reference is [class, "@"]; on PHP 8.6 the engine suffixes the -// rank of an anonymous closure with a "#" of its code, which the ext -// propagates. This helper reads the rank id with any such hash stripped. -$refId = static fn ($d) => preg_replace('/#[0-9a-f]{8}$/', '', $d['prepared'][1]); +// The reference is [class, site, key, hash]: an anonymous closure's key is an +// int rank and the hash guards its code; a first-class callable's key is the +// callable name and the hash is 0. This helper renders "@". +$refId = static fn ($d) => $d['prepared'][1] . '@' . $d['prepared'][2]; // ── Wire format: class attribute ── $c = $rc->getAttributes()[0]->getArguments()[0]; $d = deepclone_to_array($c); var_dump($d['prepared'][0] === Fix::class); var_dump($refId($d) === '@0'); -var_dump(count($d['prepared']) === 2); +var_dump(count($d['prepared']) === 4); var_dump($d['mask'] === 1); $r = deepclone_from_array($d); var_dump($r instanceof Closure, $r !== $c, $r() === 'class-secret'); @@ -181,12 +181,13 @@ try { var_dump(deepclone_from_array($d, ['Closure', 'Fix'])() === 'class-secret'); // ── Stale payload ── -// On PHP 8.6 the id carries a "#" of the closure's code; tampering it is -// rejected by the engine's own unserialization. On 8.5 the ext cannot compute -// the hash, so references resolve positionally with no staleness check. +// On PHP 8.6 the reference carries a code hash of the anonymous closure; +// tampering it is rejected by the engine's own unserialization. On 8.5 the ext +// cannot compute the hash (it passes 0), so references resolve positionally +// with no staleness check. $d = deepclone_to_array($rc->getAttributes()[0]->getArguments()[0]); if (PHP_VERSION_ID >= 80600) { - $d['prepared'][1] = substr($d['prepared'][1], 0, -1) . dechex(hexdec(substr($d['prepared'][1], -1)) ^ 1); + $d['prepared'][3] ^= 1; try { deepclone_from_array($d); echo "resolved!?\n"; diff --git a/tests/deepclone_constexpr_closures_validation.phpt b/tests/deepclone_constexpr_closures_validation.phpt index c8d1685..14c3ae2 100644 --- a/tests/deepclone_constexpr_closures_validation.phpt +++ b/tests/deepclone_constexpr_closures_validation.phpt @@ -17,24 +17,28 @@ class Fix { public function tagged(int $x = 0): void {} } +// The reference is [class, site, key, hash]. A hash of 0 means "unverified"; the +// cases below all pass 0, so on PHP 8.6 the engine's own resolution throws and +// the ext heals positionally through its value-walk, surfacing these messages on +// every version. $cases = [ 'foo', [Fix::class], - [Fix::class, '@0', 1], - [42, '@0'], - [Fix::class, 0], - [Fix::class, 'nope'], - [Fix::class, '@'], - [Fix::class, '@01'], - [Fix::class, '@1x'], - ['No\\Such\\ClassAtAll', '@0'], - [Fix::class, '$nope@0'], - [Fix::class, 'nope()@0'], - [Fix::class, 'NOPE@0'], - [Fix::class, '$tagged::bad()@0'], - [Fix::class, '$tagged::get()@0'], - [Fix::class, '@9'], - [Fix::class, 'tagged()@9'], + [Fix::class, '', 0], + [Fix::class, '', 0, 0, 0], + [42, '', 0, 0], + [Fix::class, 0, 0, 0], + [Fix::class, '', [], 0], + [Fix::class, '', 0, '0'], + ['No\\Such\\ClassAtAll', '', 0, 0], + [Fix::class, '$nope', 0, 0], + [Fix::class, 'nope()', 0, 0], + [Fix::class, 'NOPE', 0, 0], + [Fix::class, '$tagged::bad()', 0, 0], + [Fix::class, '$tagged::get()', 0, 0], + [Fix::class, '', 'x', 0], + [Fix::class, '', 9, 0], + [Fix::class, 'tagged()', 9, 0], ]; foreach ($cases as $prepared) { @@ -48,19 +52,19 @@ foreach ($cases as $prepared) { ?> --EXPECT-- deepclone_from_array(): malformed payload, const-expr-closure value must be of type array, string given -deepclone_from_array(): malformed payload, const-expr-closure value must have 2 elements -deepclone_from_array(): malformed payload, const-expr-closure value must have 2 elements -deepclone_from_array(): malformed payload, const-expr-closure class name must be of type string, int given -deepclone_from_array(): malformed payload, const-expr-closure id must be of type string, int given -deepclone_from_array(): malformed payload, const-expr-closure id must be of the form "@", "nope" given -deepclone_from_array(): malformed payload, const-expr-closure id must be of the form "@", "@" given -deepclone_from_array(): malformed payload, const-expr-closure id must be of the form "@", "@01" given -deepclone_from_array(): malformed payload, const-expr-closure id must be of the form "@", "@1x" given +deepclone_from_array(): malformed payload, const-expr-closure value must have 4 elements +deepclone_from_array(): malformed payload, const-expr-closure value must have 4 elements +deepclone_from_array(): malformed payload, const-expr-closure value must have 4 elements +deepclone_from_array(): malformed payload, const-expr-closure reference must be [string class, string site, int|string key, int hash] +deepclone_from_array(): malformed payload, const-expr-closure reference must be [string class, string site, int|string key, int hash] +deepclone_from_array(): malformed payload, const-expr-closure reference must be [string class, string site, int|string key, int hash] +deepclone_from_array(): malformed payload, const-expr-closure reference must be [string class, string site, int|string key, int hash] deepclone_from_array(): malformed payload, const-expr-closure references unknown class "No\Such\ClassAtAll" deepclone_from_array(): malformed payload, const-expr-closure references unknown property "$nope" deepclone_from_array(): malformed payload, const-expr-closure references unknown method "nope()" deepclone_from_array(): malformed payload, const-expr-closure references unknown constant "NOPE" deepclone_from_array(): malformed payload, const-expr-closure references unknown hook "$tagged::bad()" deepclone_from_array(): malformed payload, const-expr-closure references unknown hook "$tagged::get()" -deepclone_from_array(): malformed payload, const-expr-closure references unknown closure id "@9" in class "Fix" -deepclone_from_array(): malformed payload, const-expr-closure references unknown closure id "tagged()@9" in class "Fix" +deepclone_from_array(): malformed payload, const-expr-closure references unknown closure at site "" of class "Fix" +deepclone_from_array(): malformed payload, const-expr-closure references unknown closure "@9" in class "Fix" +deepclone_from_array(): malformed payload, const-expr-closure references unknown closure "tagged()@9" in class "Fix"