From a6d3f6704b7c4458764e5b49d193a540e2fa525c Mon Sep 17 00:00:00 2001 From: Nicolas Grekas Date: Thu, 11 Jun 2026 12:10:29 +0200 Subject: [PATCH] Gate closures over named callables behind an allow_named_closures option A by-name closure payload (a first-class callable over a named function or method) lets deepclone_from_array() mint a Closure over any function or method of that name, with no visibility or staticness restriction and including internal functions such as system() -- a stronger primitive than anything unserialize() exposes, and a one-step gadget when paired with an object whose __wakeup/__unserialize/destructor invokes a stored callback. Both deepclone_to_array() and deepclone_from_array() gain a third parameter, bool $allow_named_closures = false, that both ends must enable: with it off (the default) to_array refuses to encode such a closure, and from_array rejects any payload carrying a by-name closure marker before instantiating anything. So the attribute-cache use case does not regress, first-class callables over a method of their own declaring class declared in a constant expression (e.g. #[When(self::isStrict(...))]) are now encoded as a declaration-site reference (mask LONG 1), like anonymous const-expr closures, instead of by name. They resolve only to a closure the named class itself declares and round-trip without the opt-in. References whose declaring class cannot be derived from the closure (cross-class or global-function callables, inherited methods, runtime-created callables) keep the by-name form and need the opt-in. allowed_classes still gates Closure for both forms. --- CHANGELOG.md | 30 ++++ README.md | 26 ++- SECURITY.md | 22 +++ deepclone.c | 162 ++++++++++++++---- deepclone.stub.php | 4 +- deepclone_arginfo.h | 4 +- tests/deepclone_allowed_classes.phpt | 8 +- tests/deepclone_closures.phpt | 16 +- tests/deepclone_from_array.phpt | 9 +- tests/deepclone_from_array_lazy.phpt | 14 +- tests/deepclone_from_array_lazy_errors.phpt | 12 +- tests/deepclone_from_array_lazy_gc.phpt | 14 +- tests/deepclone_from_array_lazy_refs.phpt | 6 +- tests/deepclone_from_array_lazy_states.phpt | 28 +-- .../deepclone_from_array_refid_overflow.phpt | 10 +- tests/deepclone_named_closure_optin.phpt | 109 ++++++++++++ tests/deepclone_to_array.phpt | 2 +- 17 files changed, 386 insertions(+), 90 deletions(-) create mode 100644 tests/deepclone_named_closure_optin.phpt diff --git a/CHANGELOG.md b/CHANGELOG.md index a929813..2fd4640 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,36 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- `deepclone_to_array()` and `deepclone_from_array()` gained a third + parameter, `bool $allow_named_closures = false`, gating the by-name + encoding of closures over named callables (first-class callables such as + `strlen(...)`, `$obj->method(...)`, `Cls::method(...)`, and + `Closure::fromCallable()`). Both ends must enable it: with it off (the + default) `deepclone_to_array()` refuses to encode such a closure and + `deepclone_from_array()` rejects any payload carrying a by-name closure + marker, before instantiating anything. A by-name payload can mint a + `Closure` over any function or method of that name, including internal + functions like `system()`, so it is restricted to ends that trust each + other. See SECURITY.md. +- On PHP 8.5+, first-class callables over a method of their own declaring + class declared in a constant expression (e.g. + `#[When(self::isStrict(...))]`) now serialize as a reference to their + declaration site (the same code-free, `allowed_classes`-gated payload as + anonymous const-expr closures), instead of by name. They round-trip + without `$allow_named_closures`, restoring the attribute-cache use case for + first-class-callable arguments. References whose declaring class cannot be + derived from the closure (cross-class or global-function callables, + inherited methods, runtime-created callables) still take the by-name path + and require the opt-in. + +### Changed + +- **BC break.** Closures over named callables no longer serialize or resolve + by default; they now require `$allow_named_closures` on both + `deepclone_to_array()` and `deepclone_from_array()` (see Added). Code that + relied on the previous unconditional by-name behavior must pass the flag. + Closures declared in constant expressions are unaffected. + - 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/README.md b/README.md index 7e221f6..f7de65b 100644 --- a/README.md +++ b/README.md @@ -78,8 +78,8 @@ deepclone_hydrate($existingUser, ['name' => 'Bob']); ## API ```php -function deepclone_to_array(mixed $value, ?array $allowed_classes = null): array; -function deepclone_from_array(array $data, ?array $allowed_classes = null): mixed; +function deepclone_to_array(mixed $value, ?array $allowed_classes = null, bool $allow_named_closures = false): array; +function deepclone_from_array(array $data, ?array $allowed_classes = null, bool $allow_named_closures = false): mixed; function deepclone_hydrate(object|string $object_or_class, array $vars = [], int $flags = 0): object; ``` @@ -87,6 +87,21 @@ function deepclone_hydrate(object|string $object_or_class, array $vars = [], int (`null` = allow all, `[]` = allow none). Case-insensitive, matching `unserialize()`'s `allowed_classes` option. +`$allow_named_closures` controls the by-name encoding of closures over named +callables (first-class callables such as `strlen(...)`, `$obj->method(...)` +or `Cls::method(...)`, and `Closure::fromCallable()`). It defaults to +`false`, and **both ends must enable it**: `deepclone_to_array()` refuses to +encode such a closure unless it is set, and `deepclone_from_array()` refuses +to resolve a by-name closure payload unless it is set. The reason is that a +by-name payload can mint a `Closure` over *any* function or method of that +name, including internal functions like `system()`, so it should only travel +between ends that trust each other. Closures declared in constant +expressions (anonymous static closures and first-class callables over a +method of their own declaring class, e.g. `#[When(self::isStrict(...))]`) +are **not** affected: they serialize as a reference to their declaration +site, resolvable only to what the named class itself declares, and round-trip +without this option. + ### Lazy hydration of closure-bearing nodes (PHP 8.4+) `deepclone_from_array()` creates the object nodes that are expensive to @@ -257,7 +272,12 @@ $s->__unserialize([[$obj1, 'info1', $obj2, 'info2'], []]); - Cycles in the object graph - Private/protected properties across inheritance - `__serialize` / `__unserialize` / `__sleep` / `__wakeup` semantics -- Named closures (first-class callables like `strlen(...)`) +- Closures declared in constant expressions (anonymous static closures and + first-class callables over a method of their declaring class, as found in + attribute arguments and parameter defaults), as a reference to their + declaration site +- Closures over named callables (first-class callables like `strlen(...)`), + by name, when `$allow_named_closures` is enabled on both ends - Enum values - Copy-on-write for strings and scalar arrays diff --git a/SECURITY.md b/SECURITY.md index dbddfde..a9b6511 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -30,3 +30,25 @@ and throws `\ValueError` on malformed input. It instantiates classes named in the payload via the standard PHP class loader. Treat its input the same way you would treat input to `unserialize()`: only call it on payloads from trusted sources. + +### Closures + +A by-name closure payload (a first-class callable over a named function or +method, e.g. `strlen(...)` or `Cls::method(...)`) is a stronger primitive +than anything `unserialize()` exposes: resolving it mints a `Closure` over +the named callable, with no visibility or staticness restriction, including +internal functions such as `system()`. Creating the closure does not call it, +but a payload pairing such a closure with an object whose `__wakeup`, +`__unserialize` or destructor invokes a stored callback turns it into a +one-step gadget. This encoding is therefore **off by default** and gated by +the `$allow_named_closures` flag, which both `deepclone_to_array()` and +`deepclone_from_array()` must set: a default `deepclone_from_array()` call +rejects any payload carrying a by-name closure before instantiating anything. +Enable it only between ends that trust each other. + +Closures declared in constant expressions (the attribute-cache use case) +carry no such risk and need no opt-in: they serialize as a reference to their +declaration site and resolve only to a closure the named class itself +declares, the same bounded capability as unserializing a class or enum-case +name. The `$allowed_classes` filter still applies to both forms: omit +`Closure` from the list and no closure resolves at all. diff --git a/deepclone.c b/deepclone.c index e91d764..150e120 100644 --- a/deepclone.c +++ b/deepclone.c @@ -265,6 +265,7 @@ struct _dc_ctx { uint32_t next_obj_id; uint32_t objects_count; bool is_static; + bool allow_named_closures; /* opt-in: encode closures over named callables by name */ HashTable *allowed_ht; /* allowed class names set (or NULL = all) */ /* Output structures built incrementally during traversal */ @@ -311,6 +312,7 @@ static void dc_ctx_init(dc_ctx *ctx) { ctx->next_obj_id = 0; ctx->objects_count = 0; ctx->is_static = 1; + ctx->allow_named_closures = false; ctx->allowed_ht = NULL; zend_hash_init(&ctx->scope_cache, 4, NULL, ZVAL_PTR_DTOR, 0); zend_hash_init(&ctx->class_info, 4, NULL, NULL, 0); @@ -1782,10 +1784,54 @@ static void dc_copy_value(dc_ctx *ctx, zval *src, zval *dst, zval *mask_dst) } } - /* ── Named closure ──────────────────────────── */ + /* ── Closures ───────────────────────────────── */ 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 >= 80500 + /* Const-expr declaration-site reference. 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 + * 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. */ + if (func && func->type == ZEND_USER_FUNCTION && func->common.scope) { + zval *this_ptr = zend_get_closure_this_ptr(src); + if (!this_ptr || Z_TYPE_P(this_ptr) != IS_OBJECT) { + if (!dc_class_allowed(ctx->allowed_ht, zend_ce_closure->name)) { + zend_value_error("deepclone_to_array(): class \"Closure\" is not allowed"); + return; + } + zval payload; + ZVAL_UNDEF(&payload); + if (dc_cexpr_locate(func, &payload)) { + ZVAL_COPY_VALUE(dst, &payload); + DC_MASK_CONSTEXPR_CLOSURE(mask_dst); + goto handle_value; + } + if (UNEXPECTED(EG(exception))) { + return; + } + } + } +#endif + + /* Named closure: a first-class callable that is not addressable as a + * declaration-site reference (one created at runtime, or whose target + * lives outside its declaring class, or over an internal/global + * function). Encoding it stores the callable by name, which lets + * deepclone_from_array() mint a Closure over any function or method of + * that name — including internal functions such as system(). It is + * therefore gated behind the allow_named_closures opt-in, which both + * ends must enable. */ if (func && (func->common.fn_flags & ZEND_ACC_FAKE_CLOSURE)) { + if (!ctx->allow_named_closures) { + zend_value_error("deepclone_to_array(): serializing a closure over the named callable \"%s\" requires enabling the allow_named_closures option", ZSTR_VAL(func->common.function_name)); + return; + } if (!dc_class_allowed(ctx->allowed_ht, zend_ce_closure->name)) { zend_value_error("deepclone_to_array(): class \"Closure\" is not allowed"); return; @@ -1846,32 +1892,8 @@ static void dc_copy_value(dc_ctx *ctx, zval *src, zval *dst, zval *mask_dst) DC_MASK_NAMED_CLOSURE(mask_dst); goto handle_value; } - -#if PHP_VERSION_ID >= 80500 - /* Anonymous closure: reference its const-expr declaration site. - * The allow-list is checked first so that disallowing Closure is - * reported before any const-expr of the scope class is evaluated. */ - if (func && func->type == ZEND_USER_FUNCTION && func->common.scope) { - zval *this_ptr = zend_get_closure_this_ptr(src); - if (!this_ptr || Z_TYPE_P(this_ptr) != IS_OBJECT) { - if (!dc_class_allowed(ctx->allowed_ht, zend_ce_closure->name)) { - zend_value_error("deepclone_to_array(): class \"Closure\" is not allowed"); - return; - } - zval payload; - ZVAL_UNDEF(&payload); - if (dc_cexpr_locate(func, &payload)) { - ZVAL_COPY_VALUE(dst, &payload); - DC_MASK_CONSTEXPR_CLOSURE(mask_dst); - goto handle_value; - } - if (UNEXPECTED(EG(exception))) { - return; - } - } - } -#endif - /* Other anonymous closure — fall through to regular object handling */ + /* Other closure (runtime anonymous, arrow fn) — fall through to + * regular object handling, which refuses it as non-instantiable. */ } /* ── Regular object processing ──────────────── */ @@ -2722,11 +2744,13 @@ PHP_FUNCTION(deepclone_to_array) { zval *value; HashTable *allowed_ht = NULL; + bool allow_named_closures = false; - ZEND_PARSE_PARAMETERS_START(1, 2) + ZEND_PARSE_PARAMETERS_START(1, 3) Z_PARAM_ZVAL(value) Z_PARAM_OPTIONAL Z_PARAM_ARRAY_HT_OR_NULL(allowed_ht) + Z_PARAM_BOOL(allow_named_closures) ZEND_PARSE_PARAMETERS_END(); /* Reject resources at the top level just like the walker does mid-tree. @@ -2755,6 +2779,7 @@ PHP_FUNCTION(deepclone_to_array) dc_ctx ctx; dc_ctx_init(&ctx); + ctx.allow_named_closures = allow_named_closures; if (allowed_ht) { ctx.allowed_ht = dc_build_allowed_set(allowed_ht, "deepclone_to_array"); if (!ctx.allowed_ht) { @@ -3767,11 +3792,77 @@ static bool dc_mask_has_closure(zval *mask) return false; } +/* Like dc_mask_has_closure() but matches only the named-closure marker + * (LONG(0)), ignoring const-expr-closure references (LONG(1)). */ +static bool dc_mask_has_named_closure(zval *mask) +{ + if (mask == NULL) { + return false; + } + if (DC_MASK_IS_NAMED_CLOSURE(mask)) { + return true; + } + if (Z_TYPE_P(mask) != IS_ARRAY) { + return false; + } + zval *v; + ZEND_HASH_FOREACH_VAL(Z_ARRVAL_P(mask), v) { + if (dc_mask_has_named_closure(v)) { + return true; + } + } ZEND_HASH_FOREACH_END(); + return false; +} + +/* Scan the four payload regions that can carry closure markers — the top + * mask, the resolve table, the reference masks and the replayed state masks — + * for a named-closure marker. Mirrors the region set used by the + * allowed_classes "Closure" gate below. */ +static bool dc_payload_has_named_closure(zval *zmask, zval *zresolve, zval *zref_masks, zval *zstates) +{ + if (dc_mask_has_named_closure(zmask)) { + return true; + } + if (zresolve) { + zval *scope; + ZEND_HASH_FOREACH_VAL(Z_ARRVAL_P(zresolve), scope) { + if (Z_TYPE_P(scope) != IS_ARRAY) continue; + zval *name; + ZEND_HASH_FOREACH_VAL(Z_ARRVAL_P(scope), name) { + if (dc_mask_has_named_closure(name)) { + return true; + } + } ZEND_HASH_FOREACH_END(); + } ZEND_HASH_FOREACH_END(); + } + if (zref_masks) { + zval *rmask; + ZEND_HASH_FOREACH_VAL(Z_ARRVAL_P(zref_masks), rmask) { + if (dc_mask_has_named_closure(rmask)) { + return true; + } + } ZEND_HASH_FOREACH_END(); + } + if (zstates) { + zval *state; + ZEND_HASH_FOREACH_VAL(Z_ARRVAL_P(zstates), state) { + if (Z_TYPE_P(state) == IS_ARRAY) { + zval *smask = zend_hash_index_find(Z_ARRVAL_P(state), 2); + if (smask && dc_mask_has_named_closure(smask)) { + return true; + } + } + } ZEND_HASH_FOREACH_END(); + } + return false; +} + PHP_FUNCTION(deepclone_from_array) { HashTable *data_ht; HashTable *allowed_ht = NULL; HashTable *allowed_set = NULL; + bool allow_named_closures = false; HashTable refs_local; HashTable *refs = NULL; zend_string **class_names = NULL; @@ -3795,10 +3886,11 @@ PHP_FUNCTION(deepclone_from_array) * any early-exit path. Only one is live at a time. */ zend_string *numeric_prop_tmp = NULL; - ZEND_PARSE_PARAMETERS_START(1, 2) + ZEND_PARSE_PARAMETERS_START(1, 3) Z_PARAM_ARRAY_HT(data_ht) Z_PARAM_OPTIONAL Z_PARAM_ARRAY_HT_OR_NULL(allowed_ht) + Z_PARAM_BOOL(allow_named_closures) ZEND_PARSE_PARAMETERS_END(); /* Static value: return data['value'] */ @@ -3914,6 +4006,18 @@ PHP_FUNCTION(deepclone_from_array) } } + /* Named closures (the by-name marker, LONG(0)) let a payload mint a + * Closure over any function or method by name; they resolve only when the + * caller opts in via allow_named_closures, which the producer must also + * have set. Const-expr-closure references (LONG(1)) are unaffected: they + * resolve only to closures the named class itself declares. The scan runs + * before any object is instantiated, so a payload carrying a named closure + * 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"); + } + /* ── Build objectMeta ── */ if (Z_TYPE_P(zobject_meta) == IS_LONG) { zend_long n = Z_LVAL_P(zobject_meta); diff --git a/deepclone.stub.php b/deepclone.stub.php index e2b8569..d252864 100644 --- a/deepclone.stub.php +++ b/deepclone.stub.php @@ -47,9 +47,9 @@ private function hydrate(object $object): void {} */ const DEEPCLONE_HYDRATE_PRESERVE_REFS = UNKNOWN; - function deepclone_to_array(mixed $value, ?array $allowed_classes = null): array {} + function deepclone_to_array(mixed $value, ?array $allowed_classes = null, bool $allow_named_closures = false): array {} - function deepclone_from_array(array $data, ?array $allowed_classes = null): mixed {} + function deepclone_from_array(array $data, ?array $allowed_classes = null, bool $allow_named_closures = false): mixed {} function deepclone_hydrate(object|string $object_or_class, array $vars = [], int $flags = 0): object {} } diff --git a/deepclone_arginfo.h b/deepclone_arginfo.h index da7a984..7b88908 100644 --- a/deepclone_arginfo.h +++ b/deepclone_arginfo.h @@ -1,14 +1,16 @@ /* This is a generated file, edit deepclone.stub.php instead. - * Stub hash: bde61513c175dd9130f054c427cfaad2e233ff4f */ + * Stub hash: ec11b6f3b9b69f77cbddece18176eab2ffd1007a */ ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_deepclone_to_array, 0, 1, IS_ARRAY, 0) ZEND_ARG_TYPE_INFO(0, value, IS_MIXED, 0) ZEND_ARG_TYPE_INFO_WITH_DEFAULT_VALUE(0, allowed_classes, IS_ARRAY, 1, "null") + ZEND_ARG_TYPE_INFO_WITH_DEFAULT_VALUE(0, allow_named_closures, _IS_BOOL, 0, "false") ZEND_END_ARG_INFO() ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_deepclone_from_array, 0, 1, IS_MIXED, 0) ZEND_ARG_TYPE_INFO(0, data, IS_ARRAY, 0) ZEND_ARG_TYPE_INFO_WITH_DEFAULT_VALUE(0, allowed_classes, IS_ARRAY, 1, "null") + ZEND_ARG_TYPE_INFO_WITH_DEFAULT_VALUE(0, allow_named_closures, _IS_BOOL, 0, "false") ZEND_END_ARG_INFO() ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_deepclone_hydrate, 0, 1, IS_OBJECT, 0) diff --git a/tests/deepclone_allowed_classes.phpt b/tests/deepclone_allowed_classes.phpt index caea6e6..e7bdfc1 100644 --- a/tests/deepclone_allowed_classes.phpt +++ b/tests/deepclone_allowed_classes.phpt @@ -47,12 +47,12 @@ try { } // ── to_array: Closure allowed ── -$d = deepclone_to_array(strlen(...), ['Closure']); +$d = deepclone_to_array(strlen(...), ['Closure'], true); var_dump(isset($d['mask'])); // ── to_array: Closure rejected ── try { - deepclone_to_array(strlen(...), []); + deepclone_to_array(strlen(...), [], true); } catch (ValueError $e) { var_dump(str_contains($e->getMessage(), '"Closure" is not allowed')); } @@ -78,9 +78,9 @@ try { } // ── from_array: Closure in mask rejected ── -$d = deepclone_to_array(strlen(...)); +$d = deepclone_to_array(strlen(...), allow_named_closures: true); try { - deepclone_from_array($d, ['stdClass']); + deepclone_from_array($d, ['stdClass'], true); } catch (ValueError $e) { var_dump(str_contains($e->getMessage(), '"Closure" is not allowed')); } diff --git a/tests/deepclone_closures.phpt b/tests/deepclone_closures.phpt index 581408d..50190ca 100644 --- a/tests/deepclone_closures.phpt +++ b/tests/deepclone_closures.phpt @@ -13,33 +13,33 @@ class ClosureTest { } // ── Global function ── -$d = deepclone_to_array(strlen(...)); +$d = deepclone_to_array(strlen(...), allow_named_closures: true); var_dump($d['mask'] === 0); var_dump($d['prepared'][0] === null); var_dump($d['prepared'][1] === 'strlen'); -$clone = deepclone_from_array($d); +$clone = deepclone_from_array($d, allow_named_closures: true); var_dump($clone('hello') === 5); // ── Static method ── -$d = deepclone_to_array(ClosureTest::staticMethod(...)); +$d = deepclone_to_array(ClosureTest::staticMethod(...), allow_named_closures: true); var_dump($d['prepared'][0] === 'ClosureTest'); var_dump($d['prepared'][1] === 'staticMethod'); -$clone = deepclone_from_array($d); +$clone = deepclone_from_array($d, allow_named_closures: true); var_dump($clone() === 'static'); // ── Instance method ── $obj = new ClosureTest(); -$d = deepclone_to_array($obj->instanceMethod(...)); +$d = deepclone_to_array($obj->instanceMethod(...), allow_named_closures: true); var_dump($d['classes'] === 'ClosureTest'); -$clone = deepclone_from_array($d); +$clone = deepclone_from_array($d, allow_named_closures: true); var_dump($clone() === 'instance'); // ── Private method ── $obj = new ClosureTest(); $fn = $obj->getPrivateClosure(); -$d = deepclone_to_array($fn); +$d = deepclone_to_array($fn, allow_named_closures: true); var_dump($d['mask'] === 0); // named closure marker -$clone = deepclone_from_array($d); +$clone = deepclone_from_array($d, allow_named_closures: true); var_dump($clone() === 'private'); echo "Done\n"; diff --git a/tests/deepclone_from_array.phpt b/tests/deepclone_from_array.phpt index 1e1a5ce..bc93c4c 100644 --- a/tests/deepclone_from_array.phpt +++ b/tests/deepclone_from_array.phpt @@ -65,7 +65,14 @@ $clone = deepclone_from_array(deepclone_to_array($v)); var_dump($clone[0] === $clone); // ── Named closure (global function) ── -$clone = deepclone_from_array(deepclone_to_array(strlen(...))); +// Serializing/resolving a runtime named callable requires the opt-in on both ends. +try { + deepclone_to_array(strlen(...)); + echo "no throw\n"; +} catch (ValueError $e) { + var_dump($e->getMessage() === 'deepclone_to_array(): serializing a closure over the named callable "strlen" requires enabling the allow_named_closures option'); +} +$clone = deepclone_from_array(deepclone_to_array(strlen(...), allow_named_closures: true), allow_named_closures: true); var_dump($clone('hello') === 5); // ── Wide fixture (50 objects) ── diff --git a/tests/deepclone_from_array_lazy.phpt b/tests/deepclone_from_array_lazy.phpt index a4f7594..fd7ad7f 100644 --- a/tests/deepclone_from_array_lazy.phpt +++ b/tests/deepclone_from_array_lazy.phpt @@ -42,10 +42,10 @@ function uninit(object $o): bool return (new ReflectionClass($o))->isUninitializedLazyObject($o); } -$payload = deepclone_to_array(new Outer('hello', new Inner(42, strlen(...)))); +$payload = deepclone_to_array(new Outer('hello', new Inner(42, strlen(...))), allow_named_closures: true); // ── Root comes back as an uninitialized ghost ── -$copy = deepclone_from_array($payload); +$copy = deepclone_from_array($payload, allow_named_closures: true); var_dump(uninit($copy)); // ── var_dump does not initialize ── @@ -69,7 +69,7 @@ var_dump(uninit($copy->getInner())); // ── getLazyInitializer() returns a Closure over the shared context; // calling it hydrates ── -$lazy = deepclone_from_array($payload); +$lazy = deepclone_from_array($payload, allow_named_closures: true); $init = (new ReflectionClass(Outer::class))->getLazyInitializer($lazy); var_dump($init instanceof Closure); $init($lazy); @@ -77,18 +77,18 @@ var_dump(uninit($lazy), $lazy->getName()); // ── The Closure is created once per call: identical across all ghosts of // one call, distinct across calls ── -$lazy = deepclone_from_array($payload); +$lazy = deepclone_from_array($payload, allow_named_closures: true); $initOuter = (new ReflectionClass(Outer::class))->getLazyInitializer($lazy); $initInner = (new ReflectionClass(Inner::class))->getLazyInitializer($lazy->getInner()); var_dump($initOuter === $initInner); -var_dump($initOuter === (new ReflectionClass(Outer::class))->getLazyInitializer(deepclone_from_array($payload))); +var_dump($initOuter === (new ReflectionClass(Outer::class))->getLazyInitializer(deepclone_from_array($payload, allow_named_closures: true))); // ── Nodes without closure markers gain nothing from deferral: they always // hydrate eagerly (plain value slots are cheaper to hydrate than to // ghost) ── class ScalarOnly { public int $s = 0; public string $t = ''; } $s = new ScalarOnly; $s->s = 5; $s->t = 'x'; -$s2 = deepclone_from_array(deepclone_to_array([$s, new Inner(3, trim(...))])); +$s2 = deepclone_from_array(deepclone_to_array([$s, new Inner(3, trim(...))], allow_named_closures: true), allow_named_closures: true); var_dump(uninit($s2[0]), $s2[0]->s); var_dump(uninit($s2[1]), $s2[1]->n); @@ -100,7 +100,7 @@ $o2 = deepclone_from_array(deepclone_to_array($o)); var_dump($o2->x->y); // ── clone initializes the ghost and produces a hydrated copy ── -$lazy = deepclone_from_array($payload); +$lazy = deepclone_from_array($payload, allow_named_closures: true); $clone = clone $lazy; var_dump(uninit($lazy)); var_dump($clone->getName(), ($clone->fn)('ok')); diff --git a/tests/deepclone_from_array_lazy_errors.phpt b/tests/deepclone_from_array_lazy_errors.phpt index d3c8904..0599a50 100644 --- a/tests/deepclone_from_array_lazy_errors.phpt +++ b/tests/deepclone_from_array_lazy_errors.phpt @@ -38,22 +38,22 @@ function ghostPayload(array $extraProps = [], array $extraResolve = []): array } // ── allowed_classes filtering stays eager ── -expectError(fn () => deepclone_from_array(ghostPayload(), ['Other'])); +expectError(fn () => deepclone_from_array(ghostPayload(), ['Other'], true)); // ── Structural validation of deferred slots stays eager: // scope that is not a parent of the (would-be lazy) object ── expectError(fn () => deepclone_from_array( - ghostPayload(['Other' => ['x' => [0 => 1]]]))); + ghostPayload(['Other' => ['x' => [0 => 1]]]), null, true)); // unknown declared property on a (would-be lazy) object ── expectError(fn () => deepclone_from_array( - ghostPayload(['Node' => ['nope' => [0 => 1]]]))); + ghostPayload(['Node' => ['nope' => [0 => 1]]]), null, true)); // ── Value-level resolution errors defer to first access; the engine // reverts the ghost, which stays uninitialized and retries deterministically ── $obj = deepclone_from_array( ghostPayload(['stdClass' => ['w' => [0 => [null, 'no_such_function_xyz']]]], - ['stdClass' => ['w' => [0 => 0]]])); + ['stdClass' => ['w' => [0 => 0]]]), null, true); $rc = new ReflectionClass(Node::class); var_dump($rc->isUninitializedLazyObject($obj)); expectError(fn () => $obj->w); @@ -67,7 +67,7 @@ expectError(fn () => new DeepClone\HydrationContext); $lazy = deepclone_from_array(deepclone_to_array((function () { $n = new Node; $n->v = strtoupper(...); return $n; -})())); +})(), null, true), null, true); $init = $rc->getLazyInitializer($lazy); var_dump($init instanceof Closure); expectError(fn () => serialize($init)); @@ -88,7 +88,7 @@ var_dump(($lazy->v)('ok')); $lazy2 = deepclone_from_array(deepclone_to_array((function () { $n = new Node; $n->v = strtoupper(...); return $n; -})())); +})(), null, true), null, true); $init2 = $rc->getLazyInitializer($lazy2); $rc->markLazyObjectAsInitialized($lazy2); $lazy2->v = 'mine'; diff --git a/tests/deepclone_from_array_lazy_gc.phpt b/tests/deepclone_from_array_lazy_gc.phpt index dfe9514..e466dcb 100644 --- a/tests/deepclone_from_array_lazy_gc.phpt +++ b/tests/deepclone_from_array_lazy_gc.phpt @@ -19,14 +19,14 @@ function build(): array $a->cb = strlen(...); $b->cb = strrev(...); // makes both nodes ghosts $a->peer = $b; $b->peer = $a; $a->v = 'av'; $b->v = 'bv'; - return deepclone_to_array($a); + return deepclone_to_array($a, allow_named_closures: true); } // ── A half-lazy graph that goes out of scope is a context↔ghost cycle; // the GC must reclaim it ── $weak = null; (function () use (&$weak) { - $a = deepclone_from_array(build()); + $a = deepclone_from_array(build(), allow_named_closures: true); $weak = WeakReference::create($a); $a->v; // hydrate the root, leave the peer lazy })(); @@ -35,7 +35,7 @@ var_dump($weak->get()); // ── Fully-lazy abandoned graph too ── (function () use (&$weak) { - $a = deepclone_from_array(build()); + $a = deepclone_from_array(build(), allow_named_closures: true); $weak = WeakReference::create($a); })(); gc_collect_cycles(); @@ -51,10 +51,10 @@ class Dtor { $src = [new Dtor, new Dtor]; $src[0]->x = 1; $src[0]->cb = strlen(...); $src[1]->x = 2; $src[1]->cb = strrev(...); -$payload = deepclone_to_array($src); +$payload = deepclone_to_array($src, allow_named_closures: true); unset($src); // "dtor 1", "dtor 2": the sources go away (function () use ($payload) { - [$a, $b] = deepclone_from_array($payload); + [$a, $b] = deepclone_from_array($payload, allow_named_closures: true); var_dump($a->x); // initializes $a only })(); // The still-uninitialized ghost of $b pins the whole graph (the context @@ -83,12 +83,12 @@ class U { } $w = new W; $w->child = new Node; $w->child->v = 'cw'; $w->child->cb = strlen(...); -$w2 = deepclone_from_array(deepclone_to_array($w)); +$w2 = deepclone_from_array(deepclone_to_array($w, allow_named_closures: true), allow_named_closures: true); var_dump((new ReflectionClass(W::class))->isUninitializedLazyObject($w2)); var_dump($w2->seen); $u = new U; $u->child = new Node; $u->child->v = 'cu'; $u->child->cb = strlen(...); -$u2 = deepclone_from_array(deepclone_to_array($u)); +$u2 = deepclone_from_array(deepclone_to_array($u, allow_named_closures: true), allow_named_closures: true); var_dump((new ReflectionClass(U::class))->isUninitializedLazyObject($u2)); var_dump($u2->seen); diff --git a/tests/deepclone_from_array_lazy_refs.phpt b/tests/deepclone_from_array_lazy_refs.phpt index dc8a502..7e4daf1 100644 --- a/tests/deepclone_from_array_lazy_refs.phpt +++ b/tests/deepclone_from_array_lazy_refs.phpt @@ -23,11 +23,11 @@ function buildNodes(): array $shared = 'initial'; $a->v = &$shared; $b->v = &$shared; $a->peer = $b; $b->peer = $a; - return deepclone_to_array($a); + return deepclone_to_array($a, allow_named_closures: true); } foreach (['root-first', 'peer-first'] as $order) { - $root = deepclone_from_array(buildNodes()); + $root = deepclone_from_array(buildNodes(), allow_named_closures: true); var_dump((new ReflectionClass(Node::class))->isUninitializedLazyObject($root)); if ($order === 'peer-first') { $r = &$root->peer->v; // hydrates root (peer read), then peer (v read) @@ -44,7 +44,7 @@ $a->cb = strlen(...); $b->cb = strrev(...); $shared = 1; $a->a = &$shared; $b->a = &$shared; $a->peer = $b; $b->peer = $a; -$root = deepclone_from_array(deepclone_to_array($a)); +$root = deepclone_from_array(deepclone_to_array($a, allow_named_closures: true), allow_named_closures: true); var_dump((new ReflectionClass(Typed::class))->isUninitializedLazyObject($root)); $w = &$root->peer->a; $w = 11; diff --git a/tests/deepclone_from_array_lazy_states.phpt b/tests/deepclone_from_array_lazy_states.phpt index 41405f0..802133d 100644 --- a/tests/deepclone_from_array_lazy_states.phpt +++ b/tests/deepclone_from_array_lazy_states.phpt @@ -27,7 +27,7 @@ class U { } } $u = new U; $u->cb = strlen(...); -$copy = deepclone_from_array(deepclone_to_array($u)); +$copy = deepclone_from_array(deepclone_to_array($u, allow_named_closures: true), allow_named_closures: true); var_dump(uninit($copy)); var_dump($copy->seen); // first touch runs __unserialize var_dump(uninit($copy)); @@ -39,7 +39,7 @@ class W { public function __wakeup(): void { $this->seen = 'woke:' . ($this->cb)('xy'); } } $w = new W; $w->cb = strrev(...); -$copy = deepclone_from_array(deepclone_to_array($w)); +$copy = deepclone_from_array(deepclone_to_array($w, allow_named_closures: true), allow_named_closures: true); var_dump(uninit($copy)); var_dump($copy->seen); @@ -56,9 +56,9 @@ class EagerU { } $g = new U; $g->cb = strlen(...); $e = new EagerU; $e->peer = $g; -$payload = deepclone_to_array($e); +$payload = deepclone_to_array($e, allow_named_closures: true); foreach ([$payload, ['states' => array_reverse($payload['states'], true)] + $payload] as $p) { - var_dump(deepclone_from_array($p)->got); + var_dump(deepclone_from_array($p, allow_named_closures: true)->got); } // ── Nested deferral: a deferred-state ghost whose __unserialize touches a @@ -78,9 +78,9 @@ class Pair { $a = new Pair; $b = new Pair; $a->cb = strlen(...); $b->cb = strrev(...); $a->peer = $b; $b->peer = 'leaf-b'; -$payload = deepclone_to_array($a); +$payload = deepclone_to_array($a, allow_named_closures: true); foreach ([$payload, ['states' => array_reverse($payload['states'], true)] + $payload] as $p) { - $copy = deepclone_from_array($p); + $copy = deepclone_from_array($p, allow_named_closures: true); var_dump(uninit($copy)); var_dump($copy->seen); // hydrates $a; reading peer->seen nests into $b var_dump($copy->peer->seen); @@ -97,9 +97,9 @@ class Combo { $this->seen .= 'ran:' . ($this->cb)('abc'); } } -$payload = deepclone_to_array((function () { $c = new Combo; $c->cb = strlen(...); return $c; })()); +$payload = deepclone_to_array((function () { $c = new Combo; $c->cb = strlen(...); return $c; })(), allow_named_closures: true); $payload['properties'] = ['stdClass' => ['seen' => [0 => 'slot-written+']]]; -$copy = deepclone_from_array($payload); +$copy = deepclone_from_array($payload, allow_named_closures: true); var_dump(uninit($copy)); var_dump($copy->seen); // slot value first, then the appending hook @@ -110,13 +110,13 @@ class V { public function __unserialize(array $d): void { $this->cb = $d['cb']; } } $v = new V; $v->cb = strlen(...); -$payload = deepclone_to_array($v); +$payload = deepclone_to_array($v, allow_named_closures: true); // flagged for replay but no states entry $broken = $payload; unset($broken['states']); try { - deepclone_from_array($broken); + deepclone_from_array($broken, allow_named_closures: true); echo "no error?!\n"; } catch (ValueError $err) { echo $err->getMessage(), "\n"; @@ -140,11 +140,11 @@ class Toucher { } $g = new NoisyWakeup; $g->cb = strlen(...); $t = new Toucher; $t->peer = $g; -$broken = deepclone_to_array($t); +$broken = deepclone_to_array($t, allow_named_closures: true); // drop the ghost's __wakeup entry (the only int entry), keep its flag $broken['states'] = array_filter($broken['states'], fn ($e) => !is_int($e)); try { - deepclone_from_array($broken); + deepclone_from_array($broken, allow_named_closures: true); echo "no error?!\n"; } catch (ValueError $err) { echo $err->getMessage(), "\n"; @@ -154,7 +154,7 @@ try { $broken = $payload; $broken['states'][] = $broken['states'][1]; try { - deepclone_from_array($broken); + deepclone_from_array($broken, allow_named_closures: true); echo "no error?!\n"; } catch (ValueError $err) { echo $err->getMessage(), "\n"; @@ -170,7 +170,7 @@ function deepmap(array $a): array } return $r; } -$ghost = deepclone_from_array(deepmap($payload)); +$ghost = deepclone_from_array(deepmap($payload), allow_named_closures: true); var_dump(uninit($ghost)); foreach ([1, 2] as $try) { try { diff --git a/tests/deepclone_from_array_refid_overflow.phpt b/tests/deepclone_from_array_refid_overflow.phpt index ae5b5e7..dc72095 100644 --- a/tests/deepclone_from_array_refid_overflow.phpt +++ b/tests/deepclone_from_array_refid_overflow.phpt @@ -5,9 +5,9 @@ deepclone --FILE-- getMessage(), "\n"; @@ -33,7 +33,8 @@ check('object-ref min', // --EXPECTF-- line matches it with %i. check('named-closure min', ['classes' => '', 'objectMeta' => 0, - 'prepared' => [PHP_INT_MIN, 'method'], 'mask' => 0]); + 'prepared' => [PHP_INT_MIN, 'method'], 'mask' => 0], + allowNamedClosures: true); // A non-overflowing negative id still produces the ordinary "unknown ref id" // error, confirming only the ZEND_LONG_MIN wraparound is special-cased. @@ -44,7 +45,8 @@ check('object-ref -1', 'prepared' => [0 => -1], 'mask' => [0 => true]]); check('named-closure -1', ['classes' => '', 'objectMeta' => 0, - 'prepared' => [-1, 'method'], 'mask' => 0]); + 'prepared' => [-1, 'method'], 'mask' => 0], + allowNamedClosures: true); echo "Done\n"; ?> diff --git a/tests/deepclone_named_closure_optin.phpt b/tests/deepclone_named_closure_optin.phpt new file mode 100644 index 0000000..bf8baab --- /dev/null +++ b/tests/deepclone_named_closure_optin.phpt @@ -0,0 +1,109 @@ +--TEST-- +deepclone gates closures over named callables behind the allow_named_closures option +--EXTENSIONS-- +deepclone +--SKIPIF-- + +--FILE-- +getMessage(), "\n"; } +} + +$rp = new ReflectionProperty(Order::class, 'a'); +$fcc = $rp->getAttributes()[0]->getArguments()[0]; // self::isStrict(...) +$anon = (new ReflectionProperty(Order::class, 'b'))->getAttributes()[0]->getArguments()[0]; + +echo "== 1. own-method FCC in an attribute uses the const-expr path, no opt-in needed ==\n"; +$d = deepclone_to_array($fcc); +var_dump($d['mask'] === 1); // 1 = const-expr reference (safe) +var_dump($d['prepared'][0] === Order::class); +$r = deepclone_from_array($d); +var_dump($r instanceof Closure, $r() === true); + +echo "== 2. anonymous closure in an attribute is unaffected ==\n"; +$d = deepclone_to_array($anon); +var_dump($d['mask'] === 1); +var_dump(deepclone_from_array($d)() === 'anon'); + +echo "== 3. a runtime named closure refuses to_array without the opt-in ==\n"; +show('strlen', fn () => deepclone_to_array(strlen(...))); +show('Helper::pub', fn () => deepclone_to_array(Helper::pub(...))); + +echo "== 4. with the opt-in on both ends it round-trips by name ==\n"; +$d = deepclone_to_array(strlen(...), allow_named_closures: true); +var_dump($d['mask'] === 0); // 0 = by-name reference +var_dump($d['prepared'] === [null, 'strlen']); +$r = deepclone_from_array($d, allow_named_closures: true); +var_dump($r('hello') === 5); + +echo "== 5. a by-name payload is refused by from_array without the opt-in ==\n"; +$d = deepclone_to_array(trim(...), allow_named_closures: true); +show('from_array', fn () => deepclone_from_array($d)); + +echo "== 6. a hostile system() payload is refused by default ==\n"; +$evil = deepclone_to_array(\Closure::fromCallable('system'), allow_named_closures: true); +show('system', fn () => deepclone_from_array($evil)); + +echo "== 7. a named closure nested in an object graph is refused wholesale (before instantiation) ==\n"; +$h = new Holder(); +$h->cb = strlen(...); +$d = deepclone_to_array($h, allow_named_closures: true); +show('nested', fn () => deepclone_from_array($d)); +$r = deepclone_from_array($d, allow_named_closures: true); +var_dump($r instanceof Holder, ($r->cb)('abcd') === 4); + +echo "== 8. allowed_classes still gates Closure even with the opt-in ==\n"; +show('Closure excluded', fn () => deepclone_from_array($d, ['Holder'], true)); +$r = deepclone_from_array($d, ['Holder', 'Closure'], true); +var_dump(($r->cb)('ab') === 2); + +echo "Done\n"; +?> +--EXPECT-- +== 1. own-method FCC in an attribute uses the const-expr path, no opt-in needed == +bool(true) +bool(true) +bool(true) +bool(true) +== 2. anonymous closure in an attribute is unaffected == +bool(true) +bool(true) +== 3. a runtime named closure refuses to_array without the opt-in == +strlen: ValueError: deepclone_to_array(): serializing a closure over the named callable "strlen" requires enabling the allow_named_closures option +Helper::pub: ValueError: deepclone_to_array(): serializing a closure over the named callable "pub" requires enabling the allow_named_closures option +== 4. with the opt-in on both ends it round-trips by name == +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 +== 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 +== 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 +bool(true) +bool(true) +== 8. allowed_classes still gates Closure even with the opt-in == +Closure excluded: ValueError: deepclone_from_array(): class "Closure" is not allowed +bool(true) +Done diff --git a/tests/deepclone_to_array.phpt b/tests/deepclone_to_array.phpt index c4e1e74..3691b85 100644 --- a/tests/deepclone_to_array.phpt +++ b/tests/deepclone_to_array.phpt @@ -77,7 +77,7 @@ var_dump(array_key_exists('value', $d)); var_dump($d['value'] === [[123]]); // ── Named closure (global function) ── -$d = deepclone_to_array(strlen(...)); +$d = deepclone_to_array(strlen(...), allow_named_closures: true); var_dump($d['prepared'] === [null, 'strlen']); var_dump($d['mask'] === 0);