diff --git a/CHANGELOG.md b/CHANGELOG.md index 81339aa..75bc0a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 the polyfill do: `null` on a non-nullable one and a scalar on one typed with a backed enum throw a `TypeError` instead of leaving it uninitialized or casting the scalar, which `deepclone_hydrate()` still does. +- Named closures are created like `Closure::fromCallable()` does: over an + inherited method, they got the scope of the class of their object, where + the private properties of the parent class aren't reachable, and over a + static method, `deepclone_to_array()` exported the class that declares it + instead of the one it's called on, which `static::` resolves to. +- `deepclone_from_array()` rejects named closures over a non-static method + without an object, or over a method of a class that their object or class + doesn't extend, instead of creating them, and throws when it doesn't find + the function of a top-level one, instead of returning `null`. It creates the + ones over a method that `__call()` or `__callStatic()` handles, instead of + returning `null`. +- On PHP 8.4+, `deepclone_from_array()` checks the shape of the named closures + that lazy objects hold right away, instead of when these are first used. ## [0.8.6] - 2026-09-29 diff --git a/deepclone.c b/deepclone.c index 96e9792..0ae02da 100644 --- a/deepclone.c +++ b/deepclone.c @@ -196,6 +196,19 @@ static zend_always_inline zend_object *dc_closure_this(zval *closure) return Z_TYPE_P((zval *) this_ptr) == IS_OBJECT ? Z_OBJ_P((zval *) this_ptr) : NULL; } +/* The class a closure is called on, eg CM2 for CM2::m(...) where m() is + * declared by its parent CM */ +static zend_class_entry *dc_closure_called_scope(zval *closure) +{ + zend_class_entry *called_scope = NULL; + zend_function *func; + zend_object *this_obj; + + Z_OBJ_HT_P(closure)->get_closure(Z_OBJ_P(closure), &called_scope, &func, &this_obj, true); + + return called_scope; +} + /* zend_create_fake_closure() taking $this in whichever flavor the engine * expects; the engine adds its own reference to the object either way. */ static zend_always_inline void dc_create_fake_closure(zval *res, zend_function *func, @@ -2568,9 +2581,13 @@ static void dc_copy_value(dc_ctx *ctx, zval *src, zval *dst, zval *mask_dst) zval_ptr_dtor(&this_zval); zval_ptr_dtor(&scratch_mask); } else { - zend_class_entry *called_scope = func->common.scope; + /* Static methods are called on a class that can be a child + * of the one declaring them, which static:: resolves to */ + zend_class_entry *called_scope = func->common.scope ? dc_closure_called_scope(src) : NULL; if (called_scope) { ZVAL_STR_COPY(slot0, called_scope->name); + } else if (func->common.scope) { + ZVAL_STR_COPY(slot0, func->common.scope->name); } else { ZVAL_NULL(slot0); } @@ -3826,6 +3843,50 @@ PHP_FUNCTION(deepclone_to_array) static void dc_resolve(zval *value, zval *mask, zval *objects, uint32_t num_objects, HashTable *refs, HashTable *allowed_set, zval *retval); +/* Check the shape of the value of a named closure, [obj_or_class_or_null, + * method] or [[obj_or_class_or_null, method], class, method] for non-public + * methods, and fetch its parts. Throws and returns false when malformed. */ +static bool dc_named_closure_parts(zval *value, zval **zobj, zval **zname, zend_string **priv_class, zend_string **priv_method) +{ + if (Z_TYPE_P(value) != IS_ARRAY) { + zend_value_error("deepclone_from_array(): malformed payload, named-closure value must be of type array, %s given", zend_zval_value_name(value)); + return false; + } + zval *elem0 = zend_hash_index_find(Z_ARRVAL_P(value), 0); + zval *elem1 = zend_hash_index_find(Z_ARRVAL_P(value), 1); + if (!elem0 || !elem1) { + zend_value_error("deepclone_from_array(): malformed payload, named-closure value must have at least 2 elements"); + return false; + } + + zval *callable_arr = value; + *priv_class = *priv_method = NULL; + + if (Z_TYPE_P(elem0) == IS_ARRAY) { + callable_arr = elem0; + if (Z_TYPE_P(elem1) != IS_STRING) { + zend_value_error("deepclone_from_array(): malformed payload, named-closure private class name must be of type string, %s given", zend_zval_value_name(elem1)); + return false; + } + zval *elem2 = zend_hash_index_find(Z_ARRVAL_P(value), 2); + if (!elem2 || Z_TYPE_P(elem2) != IS_STRING) { + zend_value_error("deepclone_from_array(): malformed payload, named-closure private method name must be of type string"); + return false; + } + *priv_class = Z_STR_P(elem1); + *priv_method = Z_STR_P(elem2); + } + + *zobj = zend_hash_index_find(Z_ARRVAL_P(callable_arr), 0); + *zname = zend_hash_index_find(Z_ARRVAL_P(callable_arr), 1); + if (!*zobj || !*zname || Z_TYPE_P(*zname) != IS_STRING) { + zend_value_error("deepclone_from_array(): malformed payload, named-closure callable must be [obj_or_class_or_null, string]"); + return false; + } + + return true; +} + /* Check a mask that matches no value, or a refMasks entry that matches no * reference, as if it matched null, like the polyfill does: markers reject * it, unknown masks let it pass. Nothing is added to the payload. */ @@ -3948,47 +4009,12 @@ static void dc_resolve(zval *value, zval *mask, zval *objects, uint32_t num_obje if (DC_MASK_IS_NAMED_CLOSURE(mask)) { /* Named closure: value is [obj_or_class, method] or [[callable], class, method] */ - if (Z_TYPE_P(value) != IS_ARRAY) { - zend_value_error("deepclone_from_array(): malformed payload, named-closure value must be of type array, %s given", zend_zval_value_name(value)); - return; - } - zval *arr = value; - zval *elem0 = zend_hash_index_find(Z_ARRVAL_P(arr), 0); - zval *elem1 = zend_hash_index_find(Z_ARRVAL_P(arr), 1); - if (!elem0 || !elem1) { - zend_value_error("deepclone_from_array(): malformed payload, named-closure value must have at least 2 elements"); - return; - } - - zval *callable_arr; - bool is_private = false; - zend_string *priv_class = NULL, *priv_method = NULL; - - if (Z_TYPE_P(elem0) == IS_ARRAY) { - /* Private method: [[obj, name], class, method] */ - callable_arr = elem0; - is_private = true; - if (Z_TYPE_P(elem1) != IS_STRING) { - zend_value_error("deepclone_from_array(): malformed payload, named-closure private class name must be of type string, %s given", zend_zval_value_name(elem1)); - return; - } - priv_class = Z_STR_P(elem1); - zval *elem2 = zend_hash_index_find(Z_ARRVAL_P(arr), 2); - if (!elem2 || Z_TYPE_P(elem2) != IS_STRING) { - zend_value_error("deepclone_from_array(): malformed payload, named-closure private method name must be of type string"); - return; - } - priv_method = Z_STR_P(elem2); - } else { - callable_arr = arr; - } - - zval *zobj = zend_hash_index_find(Z_ARRVAL_P(callable_arr), 0); - zval *zname = zend_hash_index_find(Z_ARRVAL_P(callable_arr), 1); - if (!zobj || !zname || Z_TYPE_P(zname) != IS_STRING) { - zend_value_error("deepclone_from_array(): malformed payload, named-closure callable must be [obj_or_class_or_null, string]"); + zval *zobj, *zname; + zend_string *priv_class, *priv_method; + if (!dc_named_closure_parts(value, &zobj, &zname, &priv_class, &priv_method)) { return; } + bool is_private = priv_class != NULL; zval resolved_obj; if (Z_TYPE_P(zobj) == IS_LONG) { @@ -4020,44 +4046,76 @@ static void dc_resolve(zval *value, zval *mask, zval *objects, uint32_t num_obje ZVAL_COPY(&resolved_obj, zobj); } - if (is_private) { - zend_class_entry *ce = zend_lookup_class(priv_class); - if (ce) { - zend_function *func = zend_hash_find_ptr_lc(&ce->function_table, priv_method); - if (func) { - dc_create_fake_closure(retval, func, ce, ce, - (Z_TYPE(resolved_obj) == IS_OBJECT) ? Z_OBJ(resolved_obj) : NULL); - } - } - } else { - zend_string *name = Z_STR_P(zname); + /* Like Closure::fromCallable(): the method is looked up on the class + * of the object, or on the named class, or on the class that declares + * it for the non-public ones, and the closure gets the scope of the + * class that declares it and is called on the class of the object, + * or on the named class */ + zend_class_entry *called_scope = NULL; + zend_function *func = NULL; + zend_string *name = is_private ? priv_method : Z_STR_P(zname); + + if (Z_TYPE(resolved_obj) == IS_OBJECT) { + called_scope = Z_OBJCE(resolved_obj); + } else if (Z_TYPE(resolved_obj) == IS_STRING) { + called_scope = zend_lookup_class(Z_STR(resolved_obj)); + } else if (Z_TYPE(resolved_obj) != IS_NULL) { + zval_ptr_dtor(&resolved_obj); + zend_value_error("deepclone_from_array(): malformed payload, named-closure callable must be [obj_or_class_or_null, string]"); + return; + } - if (Z_TYPE(resolved_obj) == IS_NULL) { - zend_function *func = zend_hash_find_ptr_lc(CG(function_table), name); - if (func) { - dc_create_fake_closure(retval, func, NULL, NULL, NULL); - } - } else if (Z_TYPE(resolved_obj) == IS_OBJECT) { - zend_class_entry *ce = Z_OBJCE(resolved_obj); - zend_function *func = zend_hash_find_ptr_lc(&ce->function_table, name); - if (func) { - dc_create_fake_closure(retval, func, ce, ce, Z_OBJ(resolved_obj)); - } - } else if (Z_TYPE(resolved_obj) == IS_STRING) { - zend_class_entry *ce = zend_lookup_class(Z_STR(resolved_obj)); - if (ce) { - zend_function *func = zend_hash_find_ptr_lc(&ce->function_table, name); - if (func) { - dc_create_fake_closure(retval, func, ce, ce, NULL); - } - } + if (is_private) { + zend_class_entry *scope = zend_lookup_class(priv_class); + func = scope ? zend_hash_find_ptr_lc(&scope->function_table, name) : NULL; + if (func && !called_scope) { + called_scope = func->common.scope; + } + } else if (called_scope) { + func = zend_hash_find_ptr_lc(&called_scope->function_table, name); + } else if (Z_TYPE(resolved_obj) == IS_NULL) { + func = zend_hash_find_ptr_lc(CG(function_table), name); + } + + if (!func && called_scope && !is_private + && (Z_TYPE(resolved_obj) == IS_OBJECT ? called_scope->__call : called_scope->__callstatic)) { + /* A method that __call() or __callStatic() handles */ + zval callable; + array_init_size(&callable, 2); + if (Z_TYPE(resolved_obj) == IS_OBJECT) { + Z_ADDREF(resolved_obj); + add_next_index_zval(&callable, &resolved_obj); + } else { + add_next_index_str(&callable, zend_string_copy(called_scope->name)); } + add_next_index_str(&callable, zend_string_copy(name)); + zend_call_method_with_1_params(NULL, zend_ce_closure, NULL, "fromcallable", retval, &callable); + zval_ptr_dtor(&callable); + zval_ptr_dtor(&resolved_obj); + return; } - if (Z_ISUNDEF_P(retval)) { + if (!func) { zval_ptr_dtor(&resolved_obj); zend_value_error("deepclone_from_array(): malformed payload, named-closure function or method not found"); return; } + if (func->common.scope) { + if (!instanceof_function(called_scope, func->common.scope)) { + zval_ptr_dtor(&resolved_obj); + zend_value_error("deepclone_from_array(): malformed payload, named-closure method %s::%s() cannot be called on %s", + ZSTR_VAL(func->common.scope->name), ZSTR_VAL(func->common.function_name), ZSTR_VAL(called_scope->name)); + return; + } + if (!(func->common.fn_flags & ZEND_ACC_STATIC) && Z_TYPE(resolved_obj) != IS_OBJECT) { + zval_ptr_dtor(&resolved_obj); + zend_value_error("deepclone_from_array(): malformed payload, named-closure method %s::%s() is not static", + ZSTR_VAL(func->common.scope->name), ZSTR_VAL(func->common.function_name)); + return; + } + } + + dc_create_fake_closure(retval, func, func->common.scope, called_scope, + Z_TYPE(resolved_obj) == IS_OBJECT && !(func->common.fn_flags & ZEND_ACC_STATIC) ? Z_OBJ(resolved_obj) : NULL); zval_ptr_dtor(&resolved_obj); return; } @@ -4382,19 +4440,26 @@ static bool dc_class_can_be_ghost(const zend_class_entry *ce) return true; } -/* Eagerly enforce the allow-list on const-expr-closure markers inside a - * deferred slot, replicating the gate dc_cexpr_resolve() applies before - * zend_lookup_class(). Without this, lazy mode would delay the "class not - * allowed" error to an arbitrary later point in the program. Only - * well-shaped entries are checked: shape errors keep failing at resolve - * time, exactly like the eager path reports them. */ -static void dc_lazy_gate_cexpr(zval *value, zval *mask, HashTable *allowed_set) +/* Eagerly check the closure markers inside a deferred slot: the shape of + * named closures, and the allow-list on const-expr closures, replicating the + * gate dc_cexpr_resolve() applies before zend_lookup_class(). Without this, + * lazy mode would delay these errors about the payload itself to an + * arbitrary later point in the program. What depends on what the payload + * references, eg a function that doesn't exist, fails on first use. */ +static void dc_lazy_gate(zval *value, zval *mask, HashTable *allowed_set) { if (UNEXPECTED(dc_check_stack_limit())) { return; } + if (DC_MASK_IS_NAMED_CLOSURE(mask)) { + /* Its shape doesn't depend on what the payload references */ + zval *zobj, *zname; + zend_string *priv_class, *priv_method; + dc_named_closure_parts(value, &zobj, &zname, &priv_class, &priv_method); + return; + } if (DC_MASK_IS_CONSTEXPR_CLOSURE(mask)) { - if (Z_TYPE_P(value) == IS_ARRAY) { + if (allowed_set && Z_TYPE_P(value) == IS_ARRAY) { zval *zclass = zend_hash_index_find(Z_ARRVAL_P(value), 0); if (zclass) { ZVAL_DEREF(zclass); @@ -4417,7 +4482,7 @@ static void dc_lazy_gate_cexpr(zval *value, zval *mask, HashTable *allowed_set) ? zend_hash_find(Z_ARRVAL_P(value), mkey) : zend_hash_index_find(Z_ARRVAL_P(value), midx); if (!slot) continue; - dc_lazy_gate_cexpr(slot, mval, allowed_set); + dc_lazy_gate(slot, mval, allowed_set); if (UNEXPECTED(EG(exception))) { return; } @@ -4579,8 +4644,8 @@ static bool dc_lazy_index_build(dc_lazy_ctx *ctx, HashTable *properties_ht, Hash } zval *marker = resolve_ids ? zend_hash_index_find(resolve_ids, obj_id) : NULL; - if (marker && allowed_set) { - dc_lazy_gate_cexpr(prop_val, marker, allowed_set); + if (marker) { + dc_lazy_gate(prop_val, marker, allowed_set); if (UNEXPECTED(EG(exception))) { goto prop_err; } @@ -5462,9 +5527,9 @@ PHP_FUNCTION(deepclone_from_array) || lazy_ctx->states[sid].props != NULL) { continue; } - if (st_mask && allowed_set) { - /* Same eager const-expr gate as deferred slots. */ - dc_lazy_gate_cexpr(st_props, st_mask, allowed_set); + if (st_mask) { + /* Same eager gate as deferred slots. */ + dc_lazy_gate(st_props, st_mask, allowed_set); if (UNEXPECTED(EG(exception))) { goto cleanup; } diff --git a/tests/deepclone_named_closures_like_from_callable.phpt b/tests/deepclone_named_closures_like_from_callable.phpt new file mode 100644 index 0000000..5872a54 --- /dev/null +++ b/tests/deepclone_named_closures_like_from_callable.phpt @@ -0,0 +1,93 @@ +--TEST-- +deepclone_from_array() creates named closures like Closure::fromCallable() does +--EXTENSIONS-- +deepclone +--FILE-- +secret; } + protected function prot() { return static::class; } + public function getProt() { return $this->prot(...); } +} + +class Child extends Base +{ +} + +class Other +{ +} + +function roundtrip($value) +{ + return deepclone_from_array(deepclone_to_array($value, null, true), null, true); +} + +// Called on the child class, with the scope of the declaring one +var_dump(roundtrip(Child::create(...))()); +var_dump(roundtrip((new Child())->reveal(...))()); +var_dump(roundtrip((new Child())->getProt())()); + +$r = new ReflectionFunction(roundtrip((new Child())->reveal(...))); +var_dump($r->getClosureScopeClass()->name, $r->getClosureCalledClass()->name); + +// Methods that __call() and __callStatic() handle +class Magic +{ + public function __call($name, $args) { return "call $name"; } + public static function __callStatic($name, $args) { return "static $name ".static::class; } +} + +class ChildMagic extends Magic +{ +} + +var_dump(roundtrip((new Magic())->foo(...))()); +var_dump(roundtrip(ChildMagic::bar(...))()); + +$payloads = [ + 'top-level function not found' => ['classes' => '', 'objectMeta' => 0, 'prepared' => [null, 'no_such_function'], 'mask' => 0], + 'non-static method without object' => ['classes' => '', 'objectMeta' => 0, 'prepared' => ['Base', 'reveal'], 'mask' => 0], + 'method on an unrelated object' => ['classes' => 'Other', 'objectMeta' => 1, 'prepared' => [[[0, 'prot'], 'Base', 'prot']], 'mask' => [0]], + 'method on an unrelated class' => ['classes' => '', 'objectMeta' => 0, 'prepared' => [[['Other', 'create'], 'Base', 'create']], 'mask' => [0]], +]; +foreach ($payloads as $label => $payload) { + try { + deepclone_from_array($payload, null, true); + echo "$label: accepted\n"; + } catch (ValueError $e) { + echo "$label: ", $e->getMessage(), "\n"; + } +} + +// Their shape is checked right away, even when the objects holding them are created lazily +class Holder +{ + public $f; +} + +try { + deepclone_from_array(['classes' => 'Holder', 'objectMeta' => 1, 'prepared' => 0, 'properties' => ['stdClass' => ['f' => [5]]], 'resolve' => ['stdClass' => ['f' => [0]]]], null, true); + echo "accepted\n"; +} catch (ValueError $e) { + echo $e->getMessage(), "\n"; +} +?> +--EXPECT-- +string(5) "Child" +string(4) "base" +string(5) "Child" +string(4) "Base" +string(5) "Child" +string(8) "call foo" +string(21) "static bar ChildMagic" +top-level function not found: deepclone_from_array(): malformed payload, named-closure function or method not found +non-static method without object: deepclone_from_array(): malformed payload, named-closure method Base::reveal() is not static +method on an unrelated object: deepclone_from_array(): malformed payload, named-closure method Base::prot() cannot be called on Other +method on an unrelated class: deepclone_from_array(): malformed payload, named-closure method Base::create() cannot be called on Other +deepclone_from_array(): malformed payload, named-closure value must be of type array, int given