diff --git a/CHANGELOG.md b/CHANGELOG.md index 2fd4640..cc069d9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,6 +39,26 @@ 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 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 + 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. + - 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..e27d02c 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,109 @@ 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, 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 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, 5); + 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, site); zend_hash_index_add_new(Z_ARRVAL_P(dst), 1, &tmp); - if (attr_index < 0) { - ZVAL_NULL(&tmp); - } else { - ZVAL_LONG(&tmp, attr_index); - } + ZVAL_LONG(&tmp, rank); zend_hash_index_add_new(Z_ARRVAL_P(dst), 2, &tmp); - ZVAL_LONG(&tmp, (zend_long) ord); + ZVAL_LONG(&tmp, 0); zend_hash_index_add_new(Z_ARRVAL_P(dst), 3, &tmp); - ZVAL_LONG(&tmp, (zend_long) line); - zend_hash_index_add_new(Z_ARRVAL_P(dst), 4, &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, 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 + * 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 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. */ 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); \ + 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 +1375,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; } @@ -1465,8 +1450,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 @@ -1566,6 +1551,16 @@ 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, 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) +{ + 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,8 +1651,111 @@ 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. */ +#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, 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 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")); + 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)) == 4) { + zval *zclass = zend_hash_index_find(Z_ARRVAL_P(ref), 0); + 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; + } + } + zval_ptr_dtor(&ser); + return ok; +} + +/* deepclone_from_array() counterpart of dc_closure_native_ref(): resolve a + * [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: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(zsite)); + 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; + 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 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) { @@ -1667,252 +1765,197 @@ 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 *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 *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(zsite); - ZVAL_DEREF(zattr); - ZVAL_DEREF(zord); - 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)); - 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)); + 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; } /* 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; } - - 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)); + if (!dc_class_allowed(allowed_set, zend_ce_closure->name)) { + zend_value_error("deepclone_from_array(): class \"Closure\" is not allowed"); return; } + /* 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, 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(zsite), zkey, Z_LVAL_P(zhash), allowed_set, &rv)) { + ZVAL_COPY_VALUE(retval, &rv); + return; + } + if (EG(exception)) { + if (has_hash) { + return; + } + zend_clear_exception(); + } + } +#endif + + /* 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 = 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); + uint32_t want_ord = (uint32_t) Z_LVAL_P(zkey); - 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); + 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; } - /* 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); - return; - } - - 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) { - 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(): 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 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); } @@ -2014,13 +2057,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. */ @@ -2043,11 +2116,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. */ - 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); + * 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(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 +2133,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 can only come from captured provenance. 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 - && 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(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"); @@ -2163,8 +2234,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 ──────────────── */ @@ -4286,7 +4363,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 ── */ @@ -5452,15 +5529,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 387e0b5..632ddfe 100644 --- a/tests/deepclone_attribute_provenance.phpt +++ b/tests/deepclone_attribute_provenance.phpt @@ -5,7 +5,7 @@ deepclone --SKIPIF-- --FILE-- 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' && $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 a2ce192..082c1e1 100644 --- a/tests/deepclone_constexpr_closures.phpt +++ b/tests/deepclone_constexpr_closures.phpt @@ -55,11 +55,17 @@ class FixTraitUser { use FixTrait; } $rc = new ReflectionClass(Fix::class); +// 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]; -$line = (new ReflectionFunction($c))->getStartLine(); $d = deepclone_to_array($c); -var_dump($d['prepared'] === [Fix::class, '', 0, 0, $line]); +var_dump($d['prepared'][0] === Fix::class); +var_dump($refId($d) === '@0'); +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'); @@ -67,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'); +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', $d['prepared'][3] === $i, deepclone_from_array($d)() === $expected); + var_dump($refId($d) === 'TAGGED@'.$i, deepclone_from_array($d)() === $expected); } // ── Repeated attribute, parameter attribute, parameter default ── @@ -95,10 +101,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($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(); @@ -129,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()', 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'; }; }; } @@ -174,12 +181,21 @@ try { var_dump(deepclone_from_array($d, ['Closure', 'Fix'])() === 'class-secret'); // ── Stale payload ── +// 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]); -$d['prepared'][4]++; -try { - deepclone_from_array($d); -} catch (\ValueError $e) { - var_dump(str_contains($e->getMessage(), 'stale payload, const-expr-closure moved from line')); +if (PHP_VERSION_ID >= 80600) { + $d['prepared'][3] ^= 1; + try { + deepclone_from_array($d); + echo "resolved!?\n"; + } catch (\Exception $e) { + var_dump(str_contains($e->getMessage(), 'changed')); + } +} else { + var_dump(deepclone_from_array($d)() === 'class-secret'); } ?> --EXPECT-- diff --git a/tests/deepclone_constexpr_closures_native_fcc.phpt b/tests/deepclone_constexpr_closures_native_fcc.phpt new file mode 100644 index 0000000..1cfcaf3 --- /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..14c3ae2 100644 --- a/tests/deepclone_constexpr_closures_validation.phpt +++ b/tests/deepclone_constexpr_closures_validation.phpt @@ -17,30 +17,28 @@ class Fix { public function tagged(int $x = 0): void {} } -$line = (new ReflectionFunction((new ReflectionClass(Fix::class))->getAttributes()[0]->getArguments()[0]))->getStartLine(); - +// 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], - [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], + [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) { @@ -54,23 +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 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 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 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 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 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" 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 ==