From 178e0f26eb436aa4648653a74282e773d572f61a Mon Sep 17 00:00:00 2001 From: Nicolas Grekas Date: Wed, 14 Oct 2026 09:17:07 +0200 Subject: [PATCH] Fix deepclone_from_array() issues reported in symfony/polyfill#717 --- CHANGELOG.md | 14 ++ deepclone.c | 238 +++++++++++++------ tests/deepclone_from_array_lazy_markers.phpt | 107 +++++++++ tests/deepclone_from_array_wakeup_flags.phpt | 44 ++++ 4 files changed, 330 insertions(+), 73 deletions(-) create mode 100644 tests/deepclone_from_array_lazy_markers.phpt create mode 100644 tests/deepclone_from_array_wakeup_flags.phpt diff --git a/CHANGELOG.md b/CHANGELOG.md index 63b127b..1bfd724 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,20 @@ All notable changes to this extension will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Fixed + +- `deepclone_from_array()` read the wakeup flags of `"objectMeta"` as 32-bit + integers, so `4294967295` read as `-1` and `-4294967296` as no flag. Only + their sign matters now. +- When `deepclone_from_array()` returns lazy ghosts, it checks the markers of + their properties and states right away, like for other objects. Unknown + object or reference ids, values of the wrong type and malformed closures were + reported on first access before. +- A const-expr closure with a negative or too large closure index is reported + before its class is looked up, like its other malformed parts. + ## [0.8.9] - 2026-10-08 ### Added diff --git a/deepclone.c b/deepclone.c index c56f7f2..b6b5639 100644 --- a/deepclone.c +++ b/deepclone.c @@ -2104,54 +2104,73 @@ 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. */ -static void dc_cexpr_resolve(zval *value, HashTable *allowed_set, zval *retval) +/* Check the shape of the value of a const-expr closure, [class, site, + * attribute index, closure index, line], its class against the allow-list and + * the range of its closure index, and fetch its parts. Throws and returns + * false when malformed. */ +static bool dc_cexpr_parts(zval *value, HashTable *allowed_set, zval **zclass, zval **zsite, zval **zattr, zval **zord, zval **zline) { if (Z_TYPE_P(value) != IS_ARRAY) { zend_value_error("deepclone_from_array(): malformed payload, const-expr-closure value must be of type array, %s given", zend_zval_value_name(value)); - return; + return false; } 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) { + *zclass = zend_hash_index_find(ht, 0); + *zsite = zend_hash_index_find(ht, 1); + *zattr = zend_hash_index_find(ht, 2); + *zord = zend_hash_index_find(ht, 3); + *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"); - return; + return false; } - 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; + 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 false; } - 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(*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 false; } - 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(*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 false; } - 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(*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 false; } - if (Z_TYPE_P(zline) != IS_LONG) { - zend_value_error("deepclone_from_array(): malformed payload, const-expr-closure line must be of type int, %s given", zend_zval_value_name(zline)); - return; + if (Z_TYPE_P(*zline) != IS_LONG) { + zend_value_error("deepclone_from_array(): malformed payload, const-expr-closure line must be of type int, %s given", zend_zval_value_name(*zline)); + return false; } /* Gate before zend_lookup_class(): the payload must not be able to * autoload, let alone evaluate, classes outside the allow-list. */ - if (!dc_class_allowed(allowed_set, Z_STR_P(zclass))) { - zend_value_error("deepclone_from_array(): class \"%s\" is not allowed", Z_STRVAL_P(zclass)); + 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 false; + } + + if (Z_LVAL_P(*zord) < 0 || Z_LVAL_P(*zord) > UINT32_MAX) { + zend_value_error("deepclone_from_array(): malformed payload, const-expr-closure references unknown closure index " ZEND_LONG_FMT, Z_LVAL_P(*zord)); + return false; + } + + return true; +} + +/* deepclone_from_array() counterpart: resolve a declaration-site reference + * back to a live Closure. */ +static void dc_cexpr_resolve(zval *value, HashTable *allowed_set, zval *retval) +{ + zval *zclass, *zsite, *zattr, *zord, *zline; + if (!dc_cexpr_parts(value, allowed_set, &zclass, &zsite, &zattr, &zord, &zline)) { return; } @@ -2168,11 +2187,6 @@ static void dc_cexpr_resolve(zval *value, HashTable *allowed_set, zval *retval) zend_long want_ord = Z_LVAL_P(zord); zend_long line = Z_LVAL_P(zline); - if (want_ord < 0 || want_ord > UINT32_MAX) { - zend_value_error("deepclone_from_array(): malformed payload, const-expr-closure references unknown closure index " ZEND_LONG_FMT, want_ord); - return; - } - /* Resolve the site to a target: its attribute list + evaluation scope, * and for value sites the const-expr source zval. */ HashTable *attributes = NULL; @@ -4292,13 +4306,14 @@ static void dc_resolve(zval *value, zval *mask, zval *objects, uint32_t num_obje * reference cycle is visible to the GC through dc_lazy_ctx_get_gc() and the * engine's lazy-object get_gc, so abandoned graphs are collectable. * - * Structural validation (object ids, scopes, declared-property matches) and - * the const-expr-closure allow-list gate stay eager: malformed payloads keep + * Structural validation (object ids, scopes, declared-property matches, the + * shape of the markers and the object and ref ids they reference) and the + * const-expr-closure allow-list gate stay eager: malformed payloads keep * failing inside deepclone_from_array(). What can surface lazily, at first - * access, are value-level resolution errors (unknown class or enum case, - * stale const-expr line, type errors), like any native lazy-object - * initializer. A failing initializer is reverted by the engine and the - * ghost stays uninitialized and retryable. */ + * access, are the errors that depend on what the payload references (unknown + * function, class or enum case, stale const-expr line, type errors), like any + * native lazy-object initializer. A failing initializer is reverted by the + * engine and the ghost stays uninitialized and retryable. */ typedef struct { zend_class_entry *scope_ce; /* resolved scope (zend_standard_class_def for public scope) */ @@ -4450,38 +4465,102 @@ static bool dc_class_can_be_ghost(const zend_class_entry *ce) return true; } -/* 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) +/* Check the hard-ref id of a value, or of a slot of an array mask, like + * dc_resolve() does, against the "refs" of the payload, which are the keys of + * the refs table it resolves against. */ +static bool dc_lazy_gate_ref_id(zval *value, bool slot, HashTable *refs) +{ + ZVAL_DEREF(value); + if (Z_TYPE_P(value) != IS_LONG) { + if (slot) { + zend_value_error("deepclone_from_array(): malformed payload, hard-ref slot must be of type int, %s given", zend_zval_value_name(value)); + } else { + zend_value_error("deepclone_from_array(): malformed payload, hard-ref value must be of type int, %s given", zend_zval_value_name(value)); + } + return false; + } + if (Z_LVAL_P(value) <= ZEND_LONG_MIN || Z_LVAL_P(value) >= 0) { + zend_value_error("deepclone_from_array(): malformed payload, ref id out of range"); + return false; + } + if (!refs || !zend_hash_index_exists(refs, -Z_LVAL_P(value))) { + zend_value_error("deepclone_from_array(): malformed payload, unknown ref id " ZEND_LONG_FMT, -Z_LVAL_P(value)); + return false; + } + return true; +} + +/* Whether value is an object id that mask marks, the most common marker, which + * spares a call to dc_lazy_gate() */ +#define DC_LAZY_GATE_IS_OBJ_ID(value, mask, num_objects) \ + (DC_MASK_IS_OBJ_REF(mask) && Z_TYPE_P(value) == IS_LONG && (zend_ulong) Z_LVAL_P(value) < (num_objects)) + +/* Eagerly check the markers inside a deferred slot, with the errors of + * dc_resolve(): the shape of their values, the object and ref ids they + * reference, and the allow-list on const-expr closures. 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 or an enum case that doesn't exist, fails on first use. */ +static void dc_lazy_gate(zval *value, zval *mask, uint32_t num_objects, HashTable *refs, HashTable *allowed_set) { if (UNEXPECTED(dc_check_stack_limit())) { return; } + ZVAL_DEREF(value); + + if (DC_MASK_IS_OBJ_REF(mask)) { + if (Z_TYPE_P(value) != IS_LONG) { + zend_value_error("deepclone_from_array(): malformed payload, object reference value must be of type int, %s given", zend_zval_value_name(value)); + } else if (Z_LVAL_P(value) >= 0) { + if ((zend_ulong) Z_LVAL_P(value) >= num_objects) { + zend_value_error("deepclone_from_array(): malformed payload, unknown object id " ZEND_LONG_FMT, Z_LVAL_P(value)); + } + } else if (Z_LVAL_P(value) <= ZEND_LONG_MIN) { + zend_value_error("deepclone_from_array(): malformed payload, ref id out of range"); + } else if (!refs || !zend_hash_index_exists(refs, -Z_LVAL_P(value))) { + zend_value_error("deepclone_from_array(): malformed payload, unknown ref id " ZEND_LONG_FMT, -Z_LVAL_P(value)); + } + return; + } + if (DC_MASK_IS_HARD_REF(mask)) { + dc_lazy_gate_ref_id(value, false, refs); + 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); + if (!dc_named_closure_parts(value, &zobj, &zname, &priv_class, &priv_method)) { + return; + } + if (Z_TYPE_P(zobj) == IS_LONG) { + zend_long id = Z_LVAL_P(zobj); + if (id >= 0 ? (zend_ulong) id >= num_objects + : id <= ZEND_LONG_MIN || !refs || !zend_hash_index_exists(refs, -id)) { + zend_value_error("deepclone_from_array(): malformed payload, named-closure references unknown id " ZEND_LONG_FMT, id); + } + } else if (Z_TYPE_P(zobj) != IS_OBJECT && Z_TYPE_P(zobj) != IS_STRING && Z_TYPE_P(zobj) != IS_NULL) { + zend_value_error("deepclone_from_array(): malformed payload, named-closure callable must be [obj_or_class_or_null, string]"); + } return; } if (DC_MASK_IS_CONSTEXPR_CLOSURE(mask)) { - 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); - if (Z_TYPE_P(zclass) == IS_STRING - && !dc_class_allowed(allowed_set, Z_STR_P(zclass))) { - zend_value_error("deepclone_from_array(): class \"%s\" is not allowed", Z_STRVAL_P(zclass)); - } - } + zval *zclass, *zsite, *zattr, *zord, *zline; + dc_cexpr_parts(value, allowed_set, &zclass, &zsite, &zattr, &zord, &zline); + return; + } + if (Z_TYPE_P(mask) == IS_STRING && ZSTR_LEN(Z_STR_P(mask)) == 1 && ZSTR_VAL(Z_STR_P(mask))[0] == 'e') { + if (Z_TYPE_P(value) != IS_STRING) { + zend_value_error("deepclone_from_array(): malformed payload, enum value must be of type string, %s given", zend_zval_value_name(value)); + } else if (!strstr(Z_STRVAL_P(value), "::")) { + zend_value_error("deepclone_from_array(): malformed payload, enum value must match \"Class::Case\""); } return; } - if (Z_TYPE_P(mask) != IS_ARRAY || Z_TYPE_P(value) != IS_ARRAY) { + if (Z_TYPE_P(mask) != IS_ARRAY) { + return; + } + if (Z_TYPE_P(value) != IS_ARRAY) { + zend_value_error("deepclone_from_array(): malformed payload, array-mask value must be of type array, %s given", zend_zval_value_name(value)); return; } zend_string *mkey; @@ -4491,10 +4570,21 @@ static void dc_lazy_gate(zval *value, zval *mask, HashTable *allowed_set) zval *slot = mkey ? zend_hash_find(Z_ARRVAL_P(value), mkey) : zend_hash_index_find(Z_ARRVAL_P(value), midx); - if (!slot) continue; - dc_lazy_gate(slot, mval, allowed_set); - if (UNEXPECTED(EG(exception))) { - return; + zval null_val; + if (!slot) { + /* Checked as if it were null, like dc_resolve_unmatched_mask() */ + ZVAL_NULL(&null_val); + slot = &null_val; + } + if (Z_TYPE_P(mval) == IS_FALSE) { + if (!dc_lazy_gate_ref_id(slot, true, refs)) { + return; + } + } else if (!DC_LAZY_GATE_IS_OBJ_ID(slot, mval, num_objects)) { + dc_lazy_gate(slot, mval, num_objects, refs, allowed_set); + if (UNEXPECTED(EG(exception))) { + return; + } } } ZEND_HASH_FOREACH_END(); } @@ -4513,7 +4603,7 @@ static void dc_lazy_gate(zval *value, zval *mask, HashTable *allowed_set) * (non-string scope keys, non-array levels) are skipped consistently by * both passes. */ static bool dc_lazy_index_build(dc_lazy_ctx *ctx, HashTable *properties_ht, HashTable *resolve_ht, - zend_string **class_names, zend_class_entry **class_ces, uint32_t num_classes, + HashTable *refs_ht, zend_string **class_names, zend_class_entry **class_ces, uint32_t num_classes, const bool *is_ghost, const uint32_t *lazy_slot_counts) { HashTable *allowed_set = ctx->allowed_set; @@ -4654,8 +4744,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) { - dc_lazy_gate(prop_val, marker, allowed_set); + if (marker && !DC_LAZY_GATE_IS_OBJ_ID(prop_val, marker, num_objects)) { + dc_lazy_gate(prop_val, marker, num_objects, refs_ht, allowed_set); if (UNEXPECTED(EG(exception))) { goto prop_err; } @@ -5203,7 +5293,8 @@ PHP_FUNCTION(deepclone_from_array) if (!obj_wakeups) { obj_wakeups = ecalloc(num_objects, sizeof(int)); } - obj_wakeups[id] = (int) Z_LVAL_P(wk); + /* Only the sign matters, which a cast to int could flip */ + obj_wakeups[id] = ZEND_NORMALIZE_BOOL(Z_LVAL_P(wk)); } } else if (Z_TYPE_P(meta) == IS_LONG) { cidx_val = Z_LVAL_P(meta); @@ -5488,7 +5579,7 @@ PHP_FUNCTION(deepclone_from_array) if (!dc_lazy_index_build(lazy_ctx, zproperties ? Z_ARRVAL_P(zproperties) : NULL, zresolve ? Z_ARRVAL_P(zresolve) : NULL, - class_names, class_ces, num_classes, + zrefs ? Z_ARRVAL_P(zrefs) : NULL, class_names, class_ces, num_classes, is_ghost, lazy_slot_counts)) { goto cleanup; } @@ -5539,7 +5630,8 @@ PHP_FUNCTION(deepclone_from_array) } if (st_mask) { /* Same eager gate as deferred slots. */ - dc_lazy_gate(st_props, st_mask, allowed_set); + dc_lazy_gate(st_props, st_mask, num_objects, + zrefs ? Z_ARRVAL_P(zrefs) : NULL, allowed_set); if (UNEXPECTED(EG(exception))) { goto cleanup; } diff --git a/tests/deepclone_from_array_lazy_markers.phpt b/tests/deepclone_from_array_lazy_markers.phpt new file mode 100644 index 0000000..67449e8 --- /dev/null +++ b/tests/deepclone_from_array_lazy_markers.phpt @@ -0,0 +1,107 @@ +--TEST-- +deepclone_from_array() lazy hydration: malformed markers of deferred slots fail eagerly, like in eager slots +--EXTENSIONS-- +deepclone +--SKIPIF-- + +--FILE-- + $c, + 'objectMeta' => 1, + 'prepared' => 0, + 'properties' => ['stdClass' => ['v' => [[null, 'strlen']], 'w' => [$value]]], + 'resolve' => ['stdClass' => ['v' => [0], 'w' => [$marker]]], + 'refs' => [1 => 'r'], + ], null, true); + $messages[] = 'returned'; + } catch (ValueError $e) { + $messages[] = $e->getMessage(); + } + } + echo $messages[0] === $messages[1] ? $messages[0] : implode(' <> ', $messages), "\n"; +} + +check('Node', 5, true); +check('Node', 'x', true); +check('Node', -3, true); +check('Node', PHP_INT_MIN, true); +check('Node', -3, false); +check('Node', 2, false); +check('Node', 'x', false); +check('Node', 1, 'e'); +check('Node', 'Foo', 'e'); +check('Node', 'x', [true]); +check('Node', [7], [true]); +check('Node', [], [false]); +check('Node', [0 => 1], [1 => true]); +check('Node', [7, 'strlen'], 0); +check('Node', [1.5, 'strlen'], 0); +check('Node', ['Node', '', 0, 0], 1); +check('Node', ['Node', '', 0, -1, 1], 1); + +// What depends on what the payload references fails on first access +$node = deepclone_from_array([ + 'classes' => 'Node', + 'objectMeta' => 1, + 'prepared' => 0, + 'properties' => ['stdClass' => ['v' => [[null, 'strlen']], 'w' => [[-1, 'NoSuchEnum::Case']]]], + 'resolve' => ['stdClass' => ['v' => [0], 'w' => [[true, 'e']]]], + 'refs' => [1 => 'r'], +], null, true); +var_dump((new ReflectionClass(Node::class))->isUninitializedLazyObject($node)); +try { + $node->w; +} catch (ValueError $e) { + echo $e->getMessage(), "\n"; +} + +// The state of an __unserialize() ghost is checked the same way +try { + deepclone_from_array([ + 'classes' => 'Unser', + 'objectMeta' => [[0, -1]], + 'prepared' => 0, + 'states' => [[0, ['c' => [null, 'strlen'], 'o' => 5], ['c' => 0, 'o' => true]]], + ], null, true); + echo "returned\n"; +} catch (ValueError $e) { + echo $e->getMessage(), "\n"; +} + +?> +--EXPECT-- +deepclone_from_array(): malformed payload, unknown object id 5 +deepclone_from_array(): malformed payload, object reference value must be of type int, string given +deepclone_from_array(): malformed payload, unknown ref id 3 +deepclone_from_array(): malformed payload, ref id out of range +deepclone_from_array(): malformed payload, unknown ref id 3 +deepclone_from_array(): malformed payload, ref id out of range +deepclone_from_array(): malformed payload, hard-ref value must be of type int, string given +deepclone_from_array(): malformed payload, enum value must be of type string, int given +deepclone_from_array(): malformed payload, enum value must match "Class::Case" +deepclone_from_array(): malformed payload, array-mask value must be of type array, string given +deepclone_from_array(): malformed payload, unknown object id 7 +deepclone_from_array(): malformed payload, hard-ref slot must be of type int, null given +deepclone_from_array(): malformed payload, object reference value must be of type int, null given +deepclone_from_array(): malformed payload, named-closure references unknown id 7 +deepclone_from_array(): malformed payload, named-closure callable must be [obj_or_class_or_null, string] +deepclone_from_array(): malformed payload, const-expr-closure value must have 5 elements +deepclone_from_array(): malformed payload, const-expr-closure references unknown closure index -1 +bool(true) +deepclone_from_array(): malformed payload, enum class "NoSuchEnum::Case" not found +deepclone_from_array(): malformed payload, unknown object id 5 diff --git a/tests/deepclone_from_array_wakeup_flags.phpt b/tests/deepclone_from_array_wakeup_flags.phpt new file mode 100644 index 0000000..93b63c2 --- /dev/null +++ b/tests/deepclone_from_array_wakeup_flags.phpt @@ -0,0 +1,44 @@ +--TEST-- +deepclone_from_array() reads the wakeup flags of "objectMeta" by their sign, whatever their size +--EXTENSIONS-- +deepclone +--SKIPIF-- + +--FILE-- + 'Foo', 'objectMeta' => [[0, $flag]], 'prepared' => 0, 'states' => [[0, []]]])), "\n"; + } catch (ValueError $e) { + echo $e->getMessage(), "\n"; + } +} + +foreach ([4294967296, PHP_INT_MAX, -4294967295] as $flag) { + try { + echo get_debug_type(deepclone_from_array(['classes' => 'Bar', 'objectMeta' => [[0, $flag]], 'prepared' => 0, 'states' => [0]])), "\n"; + } catch (ValueError $e) { + echo $e->getMessage(), "\n"; + } +} + +?> +--EXPECT-- +Foo +Foo +Foo +deepclone_from_array(): Argument #1 ($data) object 0 of class Foo has an __unserialize() method but "objectMeta" does not flag it for an __unserialize state +deepclone_from_array(): Argument #1 ($data) object 0 of class Foo has an __unserialize() method but "objectMeta" does not flag it for an __unserialize state +wakeup +Bar +wakeup +Bar +deepclone_from_array(): Argument #1 ($data) "states" has a __wakeup entry for object id 0 but "objectMeta" does not flag it for __wakeup