diff --git a/CHANGELOG.md b/CHANGELOG.md index 72a2e61..91b7379 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,74 @@ 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). +## [0.5.0] - 2026-04-15 + +### BC Break + +- `deepclone_hydrate()` now interprets `$vars` exclusively as a flat + mangled-key array (the shape `(array) $obj` produces). The per-class + scoped shape (`[$class => ['prop' => $val]]`) is no longer supported — + callers passing the old shape will hit the `"invalid mangled key"` / + `"not a parent"` errors on NUL-prefixed keys, or silently create a + dynamic property named after the class on non-NUL keys. Migrate by + flattening: for each scope entry, use bare names for public / protected + / most-derived-private, and `"\0ScopeClass\0prop"` for parent-private + props. Motivation: the two shapes were functionally equivalent (same + resolution path, same slot writes), and keeping both required an + intermediate scoped_props HashTable + a double-pass write. Dropping + scoped mode simplifies the dispatcher into a single key-parse + write + loop, and removes ~200 lines of C. +- `DEEPCLONE_HYDRATE_MANGLED_VARS` constant removed — flat mangled is + now the only mode, so the flag is redundant. Callers who were passing + the flag can simply drop it. +- `DEEPCLONE_HYDRATE_PRESERVE_REFS` flag value changed from `1 << 3` to + `1 << 2` (filling the slot vacated by `DEEPCLONE_HYDRATE_MANGLED_VARS`). + Symbolic references via the constant name are unaffected; anyone using + the raw integer value `4` now gets `PRESERVE_REFS` instead of the old + `MANGLED_VARS` — in practice both are the flags real callers pass, so + the arithmetic happens to line up. + +### Fixed + +- `deepclone_hydrate()` rejects the SPL-internal-state `"\0"` key on + objects that don't support it (anything other than `SplObjectStorage`, + `ArrayObject`, `ArrayIterator`) with a `ValueError`. Previously the + value silently landed in `obj->properties` as a NUL-named dynamic + property. +- `deepclone_hydrate()` rejects malformed SPL `"\0"` payloads: a + non-even-count pair stream for `SplObjectStorage` and a payload with + more than 3 ctor args for `ArrayObject` / `ArrayIterator`. Both were + previously tolerated silently (odd tail dropped; excess args truncated). +- `deepclone_hydrate()` no longer direct-writes `IS_PROP_UNINIT` to a + lazy object's slot via the `null` → uninitialized shortcut. The + shortcut is now gated on `zend_lazy_object_initialized(obj)`, so + `DEEPCLONE_HYDRATE_NO_LAZY_INIT` + lazy objects fall through to the + Reflection-based path instead of bypassing the lazy-props bookkeeping. +- `deepclone_from_array()` cross-validates `objectMeta` wakeup flags + against `states` entries: each state entry must match the sign + advertised in `objectMeta[id][1]` (positive → `__wakeup`, negative → + `__unserialize`), and any id flagged for state replay without a + matching entry is rejected. Closes a validation hole where payloads + with impossible meta like `[0, 999]` or `[0, -123]` were accepted. +- `deepclone_from_array()` routes writes to undeclared property names + on non-stdClass objects through `zend_update_property_ex()` instead + of `zend_std_write_property()`, respecting overridden `write_property` + handlers on internal classes and extensions. Matches the + `deepclone_hydrate()` path. +- `deepclone_from_array()` throws `ValueError` on out-of-range object + ids in `"properties"` entries (previously silently skipped). + +### Changed + +- `deepclone_from_array()` object-creation loop drops the pointer-scan + over `class_names[]` that recovered the class id per object. A + per-object `uint32_t class_id` is stored directly from the + `objectMeta` parse, turning an O(N × K) step into O(N) on payloads + with many objects across many classes. +- `deepclone_hydrate()` caches the `offsetSet` method lookup across + iterations on `SplObjectStorage` `"\0"` payloads (was re-resolved + by name on every entry). + ## [0.4.0] - 2026-04-15 ### BC Break diff --git a/README.md b/README.md index 7fb84a5..200c8b4 100644 --- a/README.md +++ b/README.md @@ -56,27 +56,23 @@ properties — including private, protected, and readonly ones — without calli their constructor, faster than Reflection: ```php -// Scoped array — keyed by declaring class +// Flat bare-name array — ideal for hydrating from a flat row +// (e.g. a PDO result). $user = deepclone_hydrate(User::class, [ - User::class => ['id' => 42, 'name' => 'Alice'], - AbstractEntity::class => ['createdAt' => new \DateTimeImmutable()], + 'id' => 42, + 'name' => 'Alice', + 'email' => 'alice@example.com', ]); -// Flat bare-name array — ideal for hydrating from a flat row -// (e.g. a PDO result), no scope grouping needed -$user = deepclone_hydrate(User::class, - ['id' => 42, 'name' => 'Alice', 'email' => 'alice@example.com'], - DEEPCLONE_HYDRATE_MANGLED_VARS, -); - -// Mangled keys (same format as (array) $obj cast) -$user = deepclone_hydrate(User::class, - ['name' => 'Alice', "\0User\0email" => 'alice@example.com'], - DEEPCLONE_HYDRATE_MANGLED_VARS, -); +// Mangled keys for parent-declared private properties — same format as +// (array) $obj cast produces. +$user = deepclone_hydrate(User::class, [ + 'name' => 'Alice', + "\0AbstractEntity\0createdAt" => new \DateTimeImmutable(), +]); // Hydrate an existing object -deepclone_hydrate($existingUser, ['User' => ['name' => 'Bob']]); +deepclone_hydrate($existingUser, ['name' => 'Bob']); ``` ## API @@ -97,40 +93,32 @@ name to instantiate without calling its constructor. By default, PHP `&` references in `$vars` are dropped on write; pass `DEEPCLONE_HYDRATE_PRESERVE_REFS` to keep them. -By default, `$vars` is keyed by declaring class name; each value is an array -of property names to values: +`$vars` is a flat array keyed by property name — the exact shape +`(array) $obj` produces: -```php -$user = deepclone_hydrate(User::class, [ - User::class => ['id' => 42, 'name' => 'Alice'], - AbstractEntity::class => ['createdAt' => new \DateTimeImmutable()], -]); -``` +| key shape | target | +|-----------------------------|-----------------------------------------------------------| +| `"propName"` | public, protected (any declaring class), or private declared on the object's own class | +| `"\0*\0propName"` | protected (the declaring class is resolved via the object) | +| `"\0ClassName\0propName"` | private declared on `ClassName` — must be the object's own class or a parent | +| `"\0"` | SPL internal state (SplObjectStorage / ArrayObject / ArrayIterator) | -Pass `DEEPCLONE_HYDRATE_MANGLED_VARS` in `$flags` to interpret `$vars` as a -flat key array. Keys can be bare property names (auto-resolved to the -declaring class, the ideal shape for hydrating from a PDO row), or -mangled (`"\0ClassName\0prop"` for private, `"\0*\0prop"` for protected — the -same shape `(array) $object` produces). The two forms can be mixed: +Each key triggers one `properties_info` hash lookup followed by a direct +slot write. ```php -// Bare names — each is resolved to its declaring class automatically -$user = deepclone_hydrate(User::class, - ['id' => 42, 'name' => 'Alice', 'email' => 'alice@example.com'], - DEEPCLONE_HYDRATE_MANGLED_VARS, -); - -// Mangled keys, typical (array) cast shape -$user = deepclone_hydrate(User::class, - ['name' => 'Alice', "\0User\0email" => 'alice@example.com'], - DEEPCLONE_HYDRATE_MANGLED_VARS, -); +$user = deepclone_hydrate(User::class, [ + 'id' => 42, // bare — public or own-private + 'name' => 'Alice', + "\0*\0createdAt" => new \DateTimeImmutable(), // protected + "\0AbstractEntity\0metadata" => [...], // parent-private +]); ``` -Both the scoped shape and the flat-bare-name shape take a direct -`properties_info` lookup per key followed by a direct slot write — there's -no meaningful performance difference between the two, pick whichever -representation your caller already has on hand. +Bare names are enough for every public, protected, or most-derived-private +property. Parent-declared private properties need the explicit +`"\0ClassName\0prop"` mangled form (the engine keys them that way in the +child's `properties_info`). `$flags` selects the write semantics for declared-property assignments: @@ -139,11 +127,10 @@ representation your caller already has on hand. | `0` (default) | `ReflectionProperty::setRawValue` — bypass set hooks, type-check, respect readonly | | `DEEPCLONE_HYDRATE_CALL_HOOKS` | `ReflectionProperty::setValue` — invoke set hooks | | `DEEPCLONE_HYDRATE_NO_LAZY_INIT` | `ReflectionProperty::setRawValueWithoutLazyInitialization` — skip the lazy initializer; realize the object when the last lazy property is set | -| `DEEPCLONE_HYDRATE_MANGLED_VARS` | interpret `$vars` as a flat mangled-key array (above) | | `DEEPCLONE_HYDRATE_PRESERVE_REFS` | preserve PHP `&` references from `$vars` onto the target property slots; by default, references are dropped (dereferenced) on write | `DEEPCLONE_HYDRATE_CALL_HOOKS` and `DEEPCLONE_HYDRATE_NO_LAZY_INIT` are -mutually exclusive; `MANGLED_VARS` and `PRESERVE_REFS` compose with either. +mutually exclusive; `PRESERVE_REFS` composes with either. `deepclone_from_array()` always uses the default setRawValue semantics, mirroring `unserialize()`. @@ -185,24 +172,18 @@ strict-type errors. They run under every mode unless noted: enum-typed properties accordingly receive the enum case, not the raw scalar. -The special `"\0"` key sets the internal state of SPL classes. In scoped -mode it goes inside a scope entry; in `MANGLED_VARS` mode it is a flat key: +The special `"\0"` key sets the internal state of SPL classes: ```php -// Scoped mode: -$ao = deepclone_hydrate('ArrayObject', ['ArrayObject' => ["\0" => [['x' => 1]]]]); - -// MANGLED_VARS mode: -$ao = deepclone_hydrate('ArrayObject', - ["\0" => [['x' => 1], ArrayObject::ARRAY_AS_PROPS]], - DEEPCLONE_HYDRATE_MANGLED_VARS, -); - -// SplObjectStorage: "\0" => [$obj1, $info1, $obj2, $info2, ...] -$s = deepclone_hydrate('SplObjectStorage', - ["\0" => [$obj, 'metadata']], - DEEPCLONE_HYDRATE_MANGLED_VARS, -); +// ArrayObject / ArrayIterator — ["\0" => [$array, $flags?, $iteratorClass?]] +$ao = deepclone_hydrate('ArrayObject', [ + "\0" => [['x' => 1, 'y' => 2], ArrayObject::ARRAY_AS_PROPS], +]); + +// SplObjectStorage — ["\0" => [$obj1, $info1, $obj2, $info2, ...]] +$s = deepclone_hydrate('SplObjectStorage', [ + "\0" => [$obj, 'metadata'], +]); ``` ## What it preserves diff --git a/deepclone.c b/deepclone.c index a06f872..0220f07 100644 --- a/deepclone.c +++ b/deepclone.c @@ -152,10 +152,9 @@ static zend_always_inline zend_class_entry *dc_register_internal_class_with_flag * PHP-level constants via deepclone.stub.php; values must match. */ #define DEEPCLONE_HYDRATE_CALL_HOOKS (1 << 0) #define DEEPCLONE_HYDRATE_NO_LAZY_INIT (1 << 1) -#define DEEPCLONE_HYDRATE_MANGLED_VARS (1 << 2) -#define DEEPCLONE_HYDRATE_PRESERVE_REFS (1 << 3) +#define DEEPCLONE_HYDRATE_PRESERVE_REFS (1 << 2) #define DEEPCLONE_HYDRATE_FLAGS_MASK \ - (DEEPCLONE_HYDRATE_CALL_HOOKS | DEEPCLONE_HYDRATE_NO_LAZY_INIT | DEEPCLONE_HYDRATE_MANGLED_VARS | DEEPCLONE_HYDRATE_PRESERVE_REFS) + (DEEPCLONE_HYDRATE_CALL_HOOKS | DEEPCLONE_HYDRATE_NO_LAZY_INIT | DEEPCLONE_HYDRATE_PRESERVE_REFS) /* The stub-generated header relies on the compat shims above (specifically * zend_register_internal_class_with_flags on PHP < 8.4), so it has to be @@ -754,12 +753,18 @@ static bool dc_write_backed_property(zend_object *obj, zend_property_info *pi, } /* null → uninitialized for non-nullable typed slots; hooked props excluded - * (no backing slot to "unset", and the set hook may handle null itself). */ + * (no backing slot to "unset", and the set hook may handle null itself). + * Skip the shortcut on lazy objects — a direct slot write would bypass + * the lazy-props bookkeeping. On NO_LAZY_INIT + lazy we fall through to + * the Reflection-based path below, which enforces type semantics. */ if (Z_TYPE_P(value) == IS_NULL && ZEND_TYPE_IS_SET(pi->type) && !ZEND_TYPE_ALLOW_NULL(pi->type) - && !DC_PROP_HAS_HOOKS(pi)) - { + && !DC_PROP_HAS_HOOKS(pi) +#if PHP_VERSION_ID >= 80400 + && zend_lazy_object_initialized(obj) +#endif + ) { if (Z_TYPE_P(slot) != IS_UNDEF) { zval old; ZVAL_COPY_VALUE(&old, slot); @@ -2382,7 +2387,7 @@ PHP_FUNCTION(deepclone_from_array) zend_class_entry **class_ces = NULL; zval *objects = NULL; uint32_t num_objects = 0; - zend_string **obj_classes = NULL; + uint32_t *obj_class_ids = NULL; int *obj_wakeups = NULL; bool refs_inited = false; @@ -2524,17 +2529,14 @@ PHP_FUNCTION(deepclone_from_array) if (num_classes < 1) { DC_INVALID("deepclone_from_array(): Argument #1 ($data) \"objectMeta\" references class index 0 but \"classes\" is empty"); } - obj_classes = emalloc(num_objects * sizeof(zend_string *)); - obj_wakeups = ecalloc(num_objects, sizeof(int)); - for (uint32_t i = 0; i < num_objects; i++) { - obj_classes[i] = class_names[0]; - } + /* IS_LONG form: every object uses class index 0. ecalloc zero-fills. */ + obj_class_ids = ecalloc(num_objects, sizeof(uint32_t)); + /* IS_LONG form implies no state replays — obj_wakeups stays NULL. */ } } else { num_objects = zend_hash_num_elements(Z_ARRVAL_P(zobject_meta)); if (num_objects > 0) { - obj_classes = emalloc(num_objects * sizeof(zend_string *)); - obj_wakeups = ecalloc(num_objects, sizeof(int)); + obj_class_ids = emalloc(num_objects * sizeof(uint32_t)); } zend_ulong id; zval *meta; @@ -2550,7 +2552,16 @@ PHP_FUNCTION(deepclone_from_array) DC_INVALID("deepclone_from_array(): Argument #1 ($data) \"objectMeta\" entry " ZEND_ULONG_FMT " must be [int, int]", id); } cidx_val = Z_LVAL_P(cidx); - obj_wakeups[id] = (int) Z_LVAL_P(wk); + /* Lazy-alloc: only pay for the array if at least one entry + * actually flags a state replay. Typical payloads (no __wakeup + * / __unserialize) keep obj_wakeups NULL and skip the final + * validation scan entirely. */ + if (Z_LVAL_P(wk) != 0) { + if (!obj_wakeups) { + obj_wakeups = ecalloc(num_objects, sizeof(int)); + } + obj_wakeups[id] = (int) Z_LVAL_P(wk); + } } else if (Z_TYPE_P(meta) == IS_LONG) { cidx_val = Z_LVAL_P(meta); } else { @@ -2559,7 +2570,7 @@ PHP_FUNCTION(deepclone_from_array) if (cidx_val < 0 || (zend_ulong) cidx_val >= num_classes) { DC_INVALID("deepclone_from_array(): Argument #1 ($data) \"objectMeta\" entry " ZEND_ULONG_FMT " has out-of-range class index " ZEND_LONG_FMT, id, cidx_val); } - obj_classes[id] = class_names[cidx_val]; + obj_class_ids[id] = (uint32_t) cidx_val; } ZEND_HASH_FOREACH_END(); } @@ -2581,7 +2592,8 @@ PHP_FUNCTION(deepclone_from_array) } for (uint32_t id = 0; id < num_objects; id++) { - zend_string *class_name = obj_classes[id]; + uint32_t cid = obj_class_ids[id]; + zend_string *class_name = class_names[cid]; zval obj_zval; if (ZSTR_LEN(class_name) > 1 && ZSTR_VAL(class_name)[1] == ':') { @@ -2598,22 +2610,16 @@ PHP_FUNCTION(deepclone_from_array) } PHP_VAR_UNSERIALIZE_DESTROY(var_hash); } else { - /* Look up CE, caching by class_id (parallel to class_names). */ - zend_class_entry *ce = NULL; - for (uint32_t ci = 0; ci < num_classes; ci++) { - if (class_names[ci] == class_name) { - ce = class_ces[ci]; - if (!ce) { - ce = zend_lookup_class(class_name); - if (!ce) { - zend_throw_exception_ex(dc_ce_class_not_found_exception, 0, - "Class \"%s\" not found.", ZSTR_VAL(class_name)); - goto cleanup; - } - class_ces[ci] = ce; - } - break; + /* class_ces is lazily populated — fill on miss. */ + zend_class_entry *ce = class_ces[cid]; + if (!ce) { + ce = zend_lookup_class(class_name); + if (!ce) { + zend_throw_exception_ex(dc_ce_class_not_found_exception, 0, + "Class \"%s\" not found.", ZSTR_VAL(class_name)); + goto cleanup; } + class_ces[cid] = ce; } if (UNEXPECTED(object_init_ex(&obj_zval, ce) != SUCCESS)) { goto cleanup; @@ -2752,7 +2758,11 @@ PHP_FUNCTION(deepclone_from_array) zend_ulong obj_id; zval *prop_val; ZEND_HASH_FOREACH_NUM_KEY_VAL(Z_ARRVAL_P(id_values), obj_id, prop_val) { - if (obj_id >= num_objects) continue; + if (UNEXPECTED(obj_id >= num_objects)) { + EG(fake_scope) = old_scope; + DC_INVALID("deepclone_from_array(): Argument #1 ($data) \"properties\" entry for \"%s::%s\" references unknown object id " ZEND_ULONG_FMT, + ZSTR_VAL(scope_name), ZSTR_VAL(prop_name), obj_id); + } zval *obj_zval = &objects[obj_id]; zend_object *obj = Z_OBJ_P(obj_zval); @@ -2814,7 +2824,11 @@ PHP_FUNCTION(deepclone_from_array) } zend_hash_update(obj->properties, prop_name, &final_val); } else { - zend_std_write_property(obj, prop_name, &final_val, NULL); + /* Dynamic property on a non-stdClass object. Routed + * through zend_update_property_ex() so any overridden + * write_property handler (internal classes, extensions) + * is respected. Matches the deepclone_hydrate() path. */ + zend_update_property_ex(scope_ce, obj, prop_name, &final_val); zval_ptr_dtor(&final_val); if (EG(exception)) { EG(fake_scope) = old_scope; @@ -2828,7 +2842,14 @@ PHP_FUNCTION(deepclone_from_array) } ZEND_HASH_FOREACH_END(); } - /* ── States: __unserialize / __wakeup ──────── */ + /* ── States: __unserialize / __wakeup ────────── + * + * Each entry must match its objectMeta wakeup sign (positive → __wakeup, + * negative → __unserialize). As entries are consumed, the matching slot + * in obj_wakeups is zeroed; a second reference to the same id will see + * a zero slot and be rejected. After the loop, any non-zero wakeup left + * in obj_wakeups means the payload advertised a state replay that never + * came — also a hard error. */ if (zstates) { zval *state; ZEND_HASH_FOREACH_VAL(Z_ARRVAL_P(zstates), state) { @@ -2843,6 +2864,10 @@ PHP_FUNCTION(deepclone_from_array) if (Z_LVAL_P(zid) < 0 || (zend_ulong) Z_LVAL_P(zid) >= num_objects) { DC_INVALID("deepclone_from_array(): Argument #1 ($data) \"states\" entry references unknown object id " ZEND_LONG_FMT, Z_LVAL_P(zid)); } + if (!obj_wakeups || obj_wakeups[Z_LVAL_P(zid)] >= 0) { + DC_INVALID("deepclone_from_array(): Argument #1 ($data) \"states\" has an __unserialize entry for object id " ZEND_LONG_FMT " but \"objectMeta\" does not flag it for __unserialize", Z_LVAL_P(zid)); + } + obj_wakeups[Z_LVAL_P(zid)] = 0; zval *obj_zval = &objects[Z_LVAL_P(zid)]; zend_class_entry *unser_ce = Z_OBJCE_P(obj_zval); if (!unser_ce->__unserialize) { @@ -2865,6 +2890,10 @@ PHP_FUNCTION(deepclone_from_array) if (Z_LVAL_P(state) < 0 || (zend_ulong) Z_LVAL_P(state) >= num_objects) { DC_INVALID("deepclone_from_array(): Argument #1 ($data) \"states\" entry references unknown object id " ZEND_LONG_FMT, Z_LVAL_P(state)); } + if (!obj_wakeups || obj_wakeups[Z_LVAL_P(state)] <= 0) { + DC_INVALID("deepclone_from_array(): Argument #1 ($data) \"states\" has a __wakeup entry for object id " ZEND_LONG_FMT " but \"objectMeta\" does not flag it for __wakeup", Z_LVAL_P(state)); + } + obj_wakeups[Z_LVAL_P(state)] = 0; zval *obj_zval = &objects[Z_LVAL_P(state)]; zend_class_entry *wakeup_ce = Z_OBJCE_P(obj_zval); zend_function *wakeup_fn = zend_hash_find_ptr(&wakeup_ce->function_table, ZSTR_KNOWN(ZEND_STR_WAKEUP)); @@ -2879,6 +2908,16 @@ PHP_FUNCTION(deepclone_from_array) } ZEND_HASH_FOREACH_END(); } + /* Any wakeup slot still non-zero means objectMeta advertised a state + * replay that never appeared in "states". */ + if (obj_wakeups) { + for (uint32_t i = 0; i < num_objects; i++) { + if (obj_wakeups[i] != 0) { + DC_INVALID("deepclone_from_array(): Argument #1 ($data) \"objectMeta\" entry %u flags object for state replay but no matching \"states\" entry was found", i); + } + } + } + /* ── Resolve prepared tree ─────────────────── */ if (Z_TYPE_P(zprepared) == IS_LONG) { zend_long id = Z_LVAL_P(zprepared); @@ -2911,14 +2950,14 @@ PHP_FUNCTION(deepclone_from_array) } efree(objects); } - if (obj_classes) efree(obj_classes); + if (obj_class_ids) efree(obj_class_ids); if (obj_wakeups) efree(obj_wakeups); if (class_ces) efree(class_ces); if (class_names) efree(class_names); } #undef DC_INVALID -/* ── deepclone_hydrate() — instantiate/hydrate with scoped property writes ── */ +/* ── deepclone_hydrate() — instantiate/hydrate from a flat mangled-key array ── */ PHP_FUNCTION(deepclone_hydrate) { @@ -2943,12 +2982,6 @@ PHP_FUNCTION(deepclone_hydrate) RETURN_THROWS(); } - /* In MANGLED_VARS mode $vars is a flat mangled-key array (the shape - * (array) $obj produces). Otherwise it's the scoped per-class shape. */ - bool mangled_vars_mode = (flags & DEEPCLONE_HYDRATE_MANGLED_VARS) != 0; - HashTable *scoped_props = mangled_vars_mode ? NULL : vars; - HashTable *mangled_vars = mangled_vars_mode ? vars : NULL; - zval obj_zval; if (EXPECTED(obj_arg)) { @@ -3028,347 +3061,245 @@ PHP_FUNCTION(deepclone_hydrate) } - /* Resolve $mangled_vars (flat mangled-key array) into scoped_props. - * "\0ClassName\0propName" → scope = ClassName - * "\0*\0propName" → scope = declaring class (or object class) - * "propName" → scope = declaring class (or object class) + if (!vars || !zend_hash_num_elements(vars)) { + ZVAL_COPY_VALUE(return_value, &obj_zval); + return; + } + + zend_object *obj = Z_OBJ(obj_zval); + zend_class_entry *obj_ce = obj->ce; + + /* One-time rebuild for stdClass — avoids the check inside the write loop. */ + if (obj_ce == zend_standard_class_def && UNEXPECTED(!obj->properties)) { + rebuild_object_properties_internal(obj); + } + + /* $vars keys are interpreted in the same shape `(array) $obj` produces: + * "propName" → public, or private declared on obj_ce + * "\0*\0propName" → protected (declaring class resolved via obj_ce) + * "\0ClassName\0propName" → private declared on ClassName (must be obj_ce + * or a parent; interfaces are not valid scopes) + * "\0" → SPL internal state (SplObjectStorage/ + * ArrayObject/ArrayIterator) */ - zval local_scoped; - bool scoped_owned = false; - if (mangled_vars && zend_hash_num_elements(mangled_vars)) { - zend_class_entry *obj_ce = Z_OBJCE(obj_zval); - - if (!scoped_props || !zend_hash_num_elements(scoped_props)) { - array_init(&local_scoped); - scoped_props = Z_ARRVAL(local_scoped); - scoped_owned = true; - } else { - /* Copy scoped_props so we can add to it without modifying the caller's array */ - array_init(&local_scoped); - zend_hash_copy(Z_ARRVAL(local_scoped), scoped_props, zval_add_ref); - scoped_props = Z_ARRVAL(local_scoped); - scoped_owned = true; - } - - zend_string *prop_key; - zval *prop_val; - ZEND_HASH_FOREACH_STR_KEY_VAL(mangled_vars, prop_key, prop_val) { - if (UNEXPECTED(!prop_key)) { - zend_value_error("deepclone_hydrate(): Argument #2 ($vars) in MANGLED_VARS mode must have only string keys"); - if (scoped_owned) zval_ptr_dtor(&local_scoped); + zend_ulong prop_idx; + zend_string *prop_key; + zval *prop_val; + ZEND_HASH_FOREACH_KEY_VAL(vars, prop_idx, prop_key, prop_val) { + /* Integer keys: coerce to string on dynamic property access, matching + * unserialize()'s permissiveness. Allocated name is released below. */ + bool prop_key_owned = false; + if (!prop_key) { + prop_key = zend_long_to_str((zend_long) prop_idx); + prop_key_owned = true; + } + + const char *key = ZSTR_VAL(prop_key); + size_t key_len = ZSTR_LEN(prop_key); + + /* "\0" = internal state for SplObjectStorage/ArrayObject/ArrayIterator */ + if (key_len == 1 && key[0] == '\0') { + if (UNEXPECTED(Z_TYPE_P(prop_val) != IS_ARRAY)) { + zend_value_error("deepclone_hydrate(): Argument #2 ($vars) special \"\\0\" value must be of type array, %s given", + zend_zval_value_name(prop_val)); + if (prop_key_owned) zend_string_release(prop_key); zval_ptr_dtor(&obj_zval); RETURN_THROWS(); } - - const char *key = ZSTR_VAL(prop_key); - size_t key_len = ZSTR_LEN(prop_key); - zend_string *scope_str; - zend_string *real_name; - - if (key_len > 0 && key[0] == '\0') { - if (key_len == 1) { - /* Special "\0" key (SplObjectStorage/ArrayObject/ArrayIterator) — - * route to object's own class scope with key "\0" */ - scope_str = obj_ce->name; - real_name = prop_key; /* keep as-is */ - goto add_to_scope; - } - - /* Find second \0 separator */ - const char *sep = memchr(key + 1, '\0', key_len - 1); - if (!sep || sep == key + 1) { - zend_value_error("deepclone_hydrate(): Argument #2 ($vars) in MANGLED_VARS mode contains an invalid mangled key"); - if (scoped_owned) zval_ptr_dtor(&local_scoped); + if (instanceof_function(obj_ce, spl_ce_SplObjectStorage)) { + /* [$obj1, $info1, $obj2, $info2, ...] → offsetSet(obj, info). + * Count must be even (pair-aligned); packed/contiguous indexing + * is required since the format is a flat pair stream. */ + uint32_t count = zend_hash_num_elements(Z_ARRVAL_P(prop_val)); + if (UNEXPECTED(count & 1)) { + zend_value_error("deepclone_hydrate(): Argument #2 ($vars) special \"\\0\" value for %s must have an even number of entries, %u given", + ZSTR_VAL(obj_ce->name), count); + if (prop_key_owned) zend_string_release(prop_key); zval_ptr_dtor(&obj_zval); RETURN_THROWS(); } - size_t class_len = sep - (key + 1); - size_t name_len = key_len - class_len - 2; - - /* Reject embedded NUL in the property name portion */ - if (UNEXPECTED(memchr(sep + 1, '\0', name_len) != NULL)) { - zend_value_error("deepclone_hydrate(): Argument #2 ($vars) in MANGLED_VARS mode contains an invalid mangled key"); - if (scoped_owned) zval_ptr_dtor(&local_scoped); + zend_function *offset_set_fn = NULL; + for (uint32_t i = 0; i < count; i += 2) { + zval *zkey = zend_hash_index_find(Z_ARRVAL_P(prop_val), i); + zval *zinfo = zend_hash_index_find(Z_ARRVAL_P(prop_val), i + 1); + if (UNEXPECTED(!zkey || !zinfo)) { + zend_value_error("deepclone_hydrate(): Argument #2 ($vars) special \"\\0\" value for %s must be a packed array of [obj, info] pairs", + ZSTR_VAL(obj_ce->name)); + if (prop_key_owned) zend_string_release(prop_key); + zval_ptr_dtor(&obj_zval); + RETURN_THROWS(); + } + zend_call_method_with_2_params(obj, obj_ce, &offset_set_fn, "offsetset", NULL, zkey, zinfo); + if (UNEXPECTED(EG(exception))) { + if (prop_key_owned) zend_string_release(prop_key); + zval_ptr_dtor(&obj_zval); + RETURN_THROWS(); + } + } + } else if (instanceof_function(obj_ce, spl_ce_ArrayObject) + || instanceof_function(obj_ce, spl_ce_ArrayIterator)) { + /* [$array, $flags?, $iteratorClass?] — call constructor */ + uint32_t argc = zend_hash_num_elements(Z_ARRVAL_P(prop_val)); + if (UNEXPECTED(argc > 3)) { + zend_value_error("deepclone_hydrate(): Argument #2 ($vars) special \"\\0\" value for %s accepts at most 3 entries, %u given", + ZSTR_VAL(obj_ce->name), argc); + if (prop_key_owned) zend_string_release(prop_key); zval_ptr_dtor(&obj_zval); RETURN_THROWS(); } - real_name = zend_string_init(sep + 1, name_len, 0); - - if (class_len == 1 && key[1] == '*') { - /* Protected: "\0*\0propName" — find declaring class */ - zend_property_info *pi = zend_hash_find_ptr(&obj_ce->properties_info, real_name); - if (pi && !(pi->flags & ZEND_ACC_STATIC)) { - scope_str = pi->ce->name; - } else { - scope_str = obj_ce->name; - } - } else { - /* Private: "\0ClassName\0propName" */ - scope_str = zend_string_init(key + 1, class_len, 0); - } - } else { - /* Bare name — find declaring class */ - real_name = prop_key; - zend_property_info *pi = zend_hash_find_ptr(&obj_ce->properties_info, real_name); - - /* Fast path: the resolved property has a real backing slot (typed or - * non-hooked). Write directly via dc_write_backed_property and skip the - * scoped_props accumulation + second-pass write. This is the hot case - * for Doctrine-style `fetchAll()` flows where the row is a flat dict - * of declared field names. */ - if (pi && !(pi->flags & ZEND_ACC_STATIC) && dc_is_backed_declared_property(pi)) { - zval *v = prop_val; - if (!(flags & DEEPCLONE_HYDRATE_PRESERVE_REFS)) { - ZVAL_DEREF(v); + zend_function *ctor = obj_ce->constructor; + if (ctor) { + zval args[3]; + for (uint32_t a = 0; a < argc; a++) { + zval *arg = zend_hash_index_find(Z_ARRVAL_P(prop_val), a); + if (UNEXPECTED(!arg)) { + zend_value_error("deepclone_hydrate(): Argument #2 ($vars) special \"\\0\" value for %s must be a packed array", + ZSTR_VAL(obj_ce->name)); + if (prop_key_owned) zend_string_release(prop_key); + zval_ptr_dtor(&obj_zval); + RETURN_THROWS(); + } + ZVAL_COPY_VALUE(&args[a], arg); } - if (UNEXPECTED(!dc_write_backed_property(Z_OBJ(obj_zval), pi, real_name, v, flags))) { - if (scoped_owned) zval_ptr_dtor(&local_scoped); + zval retval; + ZVAL_UNDEF(&retval); + zend_call_known_function(ctor, obj, obj_ce, &retval, argc, args, NULL); + zval_ptr_dtor(&retval); + if (UNEXPECTED(EG(exception))) { + if (prop_key_owned) zend_string_release(prop_key); zval_ptr_dtor(&obj_zval); RETURN_THROWS(); } - continue; - } - - if (pi && !(pi->flags & ZEND_ACC_STATIC)) { - scope_str = pi->ce->name; - /* For private-set properties, use the declaring class as write scope */ - if (pi->flags & ZEND_ACC_PRIVATE_SET) { - scope_str = pi->ce->name; - } - } else { - scope_str = obj_ce->name; - } - real_name = NULL; /* don't free — it's prop_key */ - } - -add_to_scope: - /* Add to scoped_props[scope_str][real_name] = &prop_val */ - zval *scope_bucket = zend_hash_find(scoped_props, scope_str); - if (!scope_bucket) { - zval new_arr; - array_init(&new_arr); - scope_bucket = zend_hash_update(scoped_props, scope_str, &new_arr); - } - if (UNEXPECTED(Z_TYPE_P(scope_bucket) != IS_ARRAY)) { - zend_value_error("deepclone_hydrate(): Argument #2 ($vars) value for scope \"%s\" must be of type array, %s given", - ZSTR_VAL(scope_str), zend_zval_value_name(scope_bucket)); - if (real_name && real_name != prop_key) zend_string_release(real_name); - if (key_len > 2 && key[0] == '\0' && key[1] != '*') { - zend_string_release(scope_str); } - if (scoped_owned) zval_ptr_dtor(&local_scoped); + } else { + zend_value_error("deepclone_hydrate(): Argument #2 ($vars) uses the special \"\\0\" key, which is only supported for SplObjectStorage, ArrayObject, and ArrayIterator; got \"%s\"", + ZSTR_VAL(obj_ce->name)); + if (prop_key_owned) zend_string_release(prop_key); zval_ptr_dtor(&obj_zval); RETURN_THROWS(); } - SEPARATE_ARRAY(scope_bucket); - zend_string *name_to_use = real_name ? real_name : prop_key; - Z_TRY_ADDREF_P(prop_val); - zend_hash_update(Z_ARRVAL_P(scope_bucket), name_to_use, prop_val); - - if (real_name && real_name != prop_key) zend_string_release(real_name); - /* Free scope_str only if we allocated it (private "\0ClassName\0" case) */ - if (key_len > 2 && key[0] == '\0' && key[1] != '*') { - zend_string_release(scope_str); - } - } ZEND_HASH_FOREACH_END(); - } - - if (!scoped_props || !zend_hash_num_elements(scoped_props)) { - if (scoped_owned) zval_ptr_dtor(&local_scoped); - ZVAL_COPY_VALUE(return_value, &obj_zval); - return; - } - - zend_object *obj = Z_OBJ(obj_zval); - zend_class_entry *obj_ce = obj->ce; - - /* One-time rebuild for stdClass — avoids the check inside the scope loop. */ - if (obj_ce == zend_standard_class_def && UNEXPECTED(!obj->properties)) { - rebuild_object_properties_internal(obj); - } - - zend_string *scope_name; - zval *scope_props; - ZEND_HASH_FOREACH_STR_KEY_VAL(scoped_props, scope_name, scope_props) { - if (UNEXPECTED(!scope_name)) { - zend_value_error("deepclone_hydrate(): Argument #2 ($vars) must have only string keys"); - if (scoped_owned) zval_ptr_dtor(&local_scoped); - zval_ptr_dtor(&obj_zval); - RETURN_THROWS(); - } - if (UNEXPECTED(Z_TYPE_P(scope_props) != IS_ARRAY)) { - zend_value_error("deepclone_hydrate(): Argument #2 ($vars) must have only array values, %s given for key \"%s\"", - zend_zval_value_name(scope_props), ZSTR_VAL(scope_name)); - if (scoped_owned) zval_ptr_dtor(&local_scoped); - zval_ptr_dtor(&obj_zval); - RETURN_THROWS(); - } - /* Footgun: NUL-prefixed scope key almost certainly means a missing DEEPCLONE_HYDRATE_MANGLED_VARS flag. */ - if (UNEXPECTED(ZSTR_VAL(scope_name)[0] == '\0')) { - zend_value_error("deepclone_hydrate(): Argument #2 ($vars) contains a NUL-prefixed key — " - "pass DEEPCLONE_HYDRATE_MANGLED_VARS in the $flags argument to interpret $vars as a flat mangled-key array"); - if (scoped_owned) zval_ptr_dtor(&local_scoped); - zval_ptr_dtor(&obj_zval); - RETURN_THROWS(); + if (prop_key_owned) zend_string_release(prop_key); + continue; } - /* Resolve scope class entry — must be obj_ce, a parent class, or stdClass. - * No autoloader is triggered; interfaces are not valid scopes. */ - zend_class_entry *scope_ce = NULL; - if (EXPECTED(zend_string_equals(scope_name, obj_ce->name))) { - scope_ce = obj_ce; - } else if (zend_string_equals(scope_name, ZEND_STANDARD_CLASS_DEF_PTR->name)) { - /* stdClass scope = dynamic/public properties, fake_scope stays NULL */ - } else { - zend_class_entry *p = obj_ce->parent; - while (p) { - if (zend_string_equals(scope_name, p->name)) { - scope_ce = p; - break; - } - p = p->parent; - } - if (UNEXPECTED(!scope_ce)) { - zend_value_error("deepclone_hydrate(): Argument #2 ($vars) scope \"%s\" is not a parent of \"%s\"", - ZSTR_VAL(scope_name), ZSTR_VAL(obj_ce->name)); - if (scoped_owned) zval_ptr_dtor(&local_scoped); + /* Resolve (scope_ce, real_name) from the mangled key shape. + * is_mangled is set when the key started with NUL — if the resolved + * property_info lookup then misses, we reject the key rather than + * silently creating a dynamic property, since a mangled-form key + * specifically targets a declared protected / private slot. */ + zend_class_entry *scope_ce = obj_ce; + zend_string *real_name = prop_key; + bool real_name_owned = false; + bool is_mangled = false; + + if (key_len > 0 && key[0] == '\0') { + is_mangled = true; + /* Find second NUL separator */ + const char *sep = memchr(key + 1, '\0', key_len - 1); + if (UNEXPECTED(!sep || sep == key + 1)) { + zend_value_error("deepclone_hydrate(): Argument #2 ($vars) contains an invalid mangled key"); + if (prop_key_owned) zend_string_release(prop_key); zval_ptr_dtor(&obj_zval); RETURN_THROWS(); } - } + size_t class_len = sep - (key + 1); + size_t name_len = key_len - class_len - 2; -#if PHP_VERSION_ID >= 80500 - const zend_class_entry *old_scope = EG(fake_scope); -#else - zend_class_entry *old_scope = EG(fake_scope); -#endif - if (scope_ce) { - EG(fake_scope) = scope_ce; - } - - zend_ulong prop_idx; - zend_string *prop_name; - zval *prop_val; - ZEND_HASH_FOREACH_KEY_VAL(Z_ARRVAL_P(scope_props), prop_idx, prop_name, prop_val) { - /* Integer keys: coerce to string on dynamic property access, matching - * unserialize()'s permissiveness. Allocated name is released below. */ - bool prop_name_owned = false; - if (!prop_name) { - prop_name = zend_long_to_str((zend_long) prop_idx); - prop_name_owned = true; + /* Reject embedded NUL in the property name portion */ + if (UNEXPECTED(memchr(sep + 1, '\0', name_len) != NULL)) { + zend_value_error("deepclone_hydrate(): Argument #2 ($vars) contains an invalid mangled key"); + if (prop_key_owned) zend_string_release(prop_key); + zval_ptr_dtor(&obj_zval); + RETURN_THROWS(); } + real_name = zend_string_init(sep + 1, name_len, 0); + real_name_owned = true; - /* "\0" key = internal state for ArrayObject/ArrayIterator/SplObjectStorage */ - if (ZSTR_LEN(prop_name) == 1 && ZSTR_VAL(prop_name)[0] == '\0') { - if (UNEXPECTED(Z_TYPE_P(prop_val) != IS_ARRAY)) { - zend_value_error("deepclone_hydrate(): Argument #2 ($vars) scope \"%s\" special \"\\0\" value must be of type array, %s given", - ZSTR_VAL(scope_name), zend_zval_value_name(prop_val)); - EG(fake_scope) = old_scope; - if (scoped_owned) zval_ptr_dtor(&local_scoped); - zval_ptr_dtor(&obj_zval); - RETURN_THROWS(); - } - - if (instanceof_function(obj_ce, spl_ce_SplObjectStorage)) { - /* [$obj1, $info1, $obj2, $info2, ...] → offsetSet(obj, info) */ - uint32_t count = zend_hash_num_elements(Z_ARRVAL_P(prop_val)); - for (uint32_t i = 0; i + 1 < count; i += 2) { - zval *zkey = zend_hash_index_find(Z_ARRVAL_P(prop_val), i); - zval *zinfo = zend_hash_index_find(Z_ARRVAL_P(prop_val), i + 1); - if (!zkey || !zinfo) continue; - zend_call_method_with_2_params(obj, obj_ce, NULL, "offsetset", NULL, zkey, zinfo); - if (UNEXPECTED(EG(exception))) { - EG(fake_scope) = old_scope; - if (scoped_owned) zval_ptr_dtor(&local_scoped); - zval_ptr_dtor(&obj_zval); - RETURN_THROWS(); + if (class_len != 1 || key[1] != '*') { + /* "\0ClassName\0propName" — resolve and validate the scope. + * Must be obj_ce or a parent; no autoloader is triggered. */ + if (class_len == ZSTR_LEN(obj_ce->name) + && !memcmp(key + 1, ZSTR_VAL(obj_ce->name), class_len)) { + scope_ce = obj_ce; + } else { + scope_ce = NULL; + for (zend_class_entry *p = obj_ce->parent; p; p = p->parent) { + if (class_len == ZSTR_LEN(p->name) + && !memcmp(key + 1, ZSTR_VAL(p->name), class_len)) { + scope_ce = p; + break; } } - } else if (instanceof_function(obj_ce, spl_ce_ArrayObject) - || instanceof_function(obj_ce, spl_ce_ArrayIterator)) { - /* [$array, $flags?, $iteratorClass?] — call constructor */ - zend_function *ctor = obj_ce->constructor; - if (ctor) { - uint32_t argc = zend_hash_num_elements(Z_ARRVAL_P(prop_val)); - if (argc > 3) argc = 3; - zval args[3]; - for (uint32_t a = 0; a < argc; a++) { - zval *arg = zend_hash_index_find(Z_ARRVAL_P(prop_val), a); - ZVAL_COPY_VALUE(&args[a], arg ? arg : &EG(uninitialized_zval)); - } - zval retval; - ZVAL_UNDEF(&retval); - zend_call_known_function(ctor, obj, obj_ce, &retval, argc, args, NULL); - zval_ptr_dtor(&retval); - if (UNEXPECTED(EG(exception))) { - EG(fake_scope) = old_scope; - if (scoped_owned) zval_ptr_dtor(&local_scoped); - zval_ptr_dtor(&obj_zval); - RETURN_THROWS(); - } + if (UNEXPECTED(!scope_ce)) { + zend_value_error("deepclone_hydrate(): Argument #2 ($vars) key scope \"%.*s\" is not a parent of \"%s\"", + (int) class_len, key + 1, ZSTR_VAL(obj_ce->name)); + zend_string_release(real_name); + if (prop_key_owned) zend_string_release(prop_key); + zval_ptr_dtor(&obj_zval); + RETURN_THROWS(); } } - continue; } + /* "\0*\0propName" keeps scope_ce = obj_ce and lets properties_info + * lookup find the entry (protected props are inherited under bare + * name, so pi->ce will point at the declaring class). */ + } - /* Default: the input's PHP & references are dropped (dereferenced) on - * write. Pass DEEPCLONE_HYDRATE_PRESERVE_REFS to keep the ref link, - * which matters for call sites that intentionally share a value slot - * between two properties or between a property and a caller-side var. */ - if (!(flags & DEEPCLONE_HYDRATE_PRESERVE_REFS)) { - ZVAL_DEREF(prop_val); - } + /* Default: the input's PHP & references are dropped (dereferenced) on + * write. Pass DEEPCLONE_HYDRATE_PRESERVE_REFS to keep the ref link. */ + zval *v = prop_val; + if (!(flags & DEEPCLONE_HYDRATE_PRESERVE_REFS)) { + ZVAL_DEREF(v); + } - if (obj_ce == zend_standard_class_def) { - /* Matches unserialize(): NUL-in-middle names are stored as-is, - * NUL-prefix names are rejected by the engine on read. No - * pre-validation — the polyfill follows the same rule. */ - Z_TRY_ADDREF_P(prop_val); - zend_hash_update(obj->properties, prop_name, prop_val); - } else { - /* Try direct property slot write first — declared property names - * are engine-interned and never contain NUL, so a successful - * lookup is a fast-path that skips all validation. - * Use _known_hash: prop_name comes from a HashTable iteration, - * so its hash is already populated. */ - zend_property_info *pi = NULL; - if (scope_ce) { - zval *zv = zend_hash_find_known_hash(&scope_ce->properties_info, prop_name); - if (zv) pi = Z_PTR_P(zv); + if (obj_ce == zend_standard_class_def) { + /* Matches unserialize(): NUL-in-middle names are stored as-is on the + * stdClass dynamic properties table; NUL-prefix names are rejected + * by the engine on read. */ + Z_TRY_ADDREF_P(v); + zend_hash_update(obj->properties, real_name, v); + } else { + zend_property_info *pi = NULL; + zval *zv = zend_hash_find(&scope_ce->properties_info, real_name); + if (zv) pi = Z_PTR_P(zv); + + if (dc_is_backed_declared_property(pi)) { + if (UNEXPECTED(!dc_write_backed_property(obj, pi, real_name, v, flags))) { + if (real_name_owned) zend_string_release(real_name); + if (prop_key_owned) zend_string_release(prop_key); + zval_ptr_dtor(&obj_zval); + RETURN_THROWS(); } - if (dc_is_backed_declared_property(pi)) { - bool ok = dc_write_backed_property(obj, pi, prop_name, prop_val, flags); - if (UNEXPECTED(!ok)) { - if (prop_name_owned) zend_string_release(prop_name); - EG(fake_scope) = old_scope; - if (scoped_owned) zval_ptr_dtor(&local_scoped); - zval_ptr_dtor(&obj_zval); - RETURN_THROWS(); - } - } else { - /* Fallback: dynamic property or unknown name. Goes through - * zend_update_property_ex() so any overridden write_property - * handler (internal classes, extensions overriding default - * handlers) is respected. Matches unserialize(): the engine - * rejects NUL-prefix names with \Error and accepts NUL-in-middle - * as raw dynamic properties. */ - zend_update_property_ex(scope_ce ?: obj_ce, obj, prop_name, prop_val); - if (UNEXPECTED(EG(exception))) { - if (prop_name_owned) zend_string_release(prop_name); - EG(fake_scope) = old_scope; - if (scoped_owned) zval_ptr_dtor(&local_scoped); - zval_ptr_dtor(&obj_zval); - RETURN_THROWS(); - } + } else if (UNEXPECTED(is_mangled)) { + /* Mangled-form key but the targeted slot isn't declared. + * Reject instead of silently creating a dynamic property — + * the caller explicitly asked for a declared protected / + * private slot via the "\0*\0name" or "\0Class\0name" form. */ + zend_value_error("deepclone_hydrate(): Argument #2 ($vars) key scope \"%s\" does not declare a \"%s\" property", + ZSTR_VAL(scope_ce->name), ZSTR_VAL(real_name)); + if (real_name_owned) zend_string_release(real_name); + if (prop_key_owned) zend_string_release(prop_key); + zval_ptr_dtor(&obj_zval); + RETURN_THROWS(); + } else { + /* Dynamic property or unknown name. Goes through + * zend_update_property_ex() so any overridden write_property + * handler (internal classes, extensions overriding default + * handlers) is respected. */ + zend_update_property_ex(scope_ce, obj, real_name, v); + if (UNEXPECTED(EG(exception))) { + if (real_name_owned) zend_string_release(real_name); + if (prop_key_owned) zend_string_release(prop_key); + zval_ptr_dtor(&obj_zval); + RETURN_THROWS(); } } - if (prop_name_owned) zend_string_release(prop_name); - } ZEND_HASH_FOREACH_END(); + } - EG(fake_scope) = old_scope; + if (real_name_owned) zend_string_release(real_name); + if (prop_key_owned) zend_string_release(prop_key); } ZEND_HASH_FOREACH_END(); - if (scoped_owned) zval_ptr_dtor(&local_scoped); ZVAL_COPY_VALUE(return_value, &obj_zval); } diff --git a/deepclone.stub.php b/deepclone.stub.php index 5ed57e8..cc38d31 100644 --- a/deepclone.stub.php +++ b/deepclone.stub.php @@ -23,12 +23,6 @@ class ClassNotFoundException extends \InvalidArgumentException {} */ const DEEPCLONE_HYDRATE_NO_LAZY_INIT = UNKNOWN; - /** - * @var int - * @cvalue DEEPCLONE_HYDRATE_MANGLED_VARS - */ - const DEEPCLONE_HYDRATE_MANGLED_VARS = UNKNOWN; - /** * @var int * @cvalue DEEPCLONE_HYDRATE_PRESERVE_REFS diff --git a/deepclone_arginfo.h b/deepclone_arginfo.h index 62a9bf9..1989b9c 100644 --- a/deepclone_arginfo.h +++ b/deepclone_arginfo.h @@ -1,5 +1,5 @@ /* This is a generated file, edit the .stub.php file instead. - * Stub hash: 9880c7855ef1f15a09a60285c303666d90ce82f2 */ + * Stub hash: 0a228308779b19a274903e520b96a0f8e842d20e */ ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_deepclone_to_array, 0, 1, IS_ARRAY, 0) ZEND_ARG_TYPE_INFO(0, value, IS_MIXED, 0) @@ -32,7 +32,6 @@ static void register_deepclone_symbols(int module_number) { REGISTER_LONG_CONSTANT("DEEPCLONE_HYDRATE_CALL_HOOKS", DEEPCLONE_HYDRATE_CALL_HOOKS, CONST_PERSISTENT); REGISTER_LONG_CONSTANT("DEEPCLONE_HYDRATE_NO_LAZY_INIT", DEEPCLONE_HYDRATE_NO_LAZY_INIT, CONST_PERSISTENT); - REGISTER_LONG_CONSTANT("DEEPCLONE_HYDRATE_MANGLED_VARS", DEEPCLONE_HYDRATE_MANGLED_VARS, CONST_PERSISTENT); REGISTER_LONG_CONSTANT("DEEPCLONE_HYDRATE_PRESERVE_REFS", DEEPCLONE_HYDRATE_PRESERVE_REFS, CONST_PERSISTENT); } diff --git a/php_deepclone.h b/php_deepclone.h index 2d21350..1cf5c4d 100644 --- a/php_deepclone.h +++ b/php_deepclone.h @@ -4,7 +4,7 @@ extern zend_module_entry deepclone_module_entry; #define phpext_deepclone_ptr &deepclone_module_entry -#define PHP_DEEPCLONE_VERSION "0.4.0" +#define PHP_DEEPCLONE_VERSION "0.5.0" ZEND_BEGIN_MODULE_GLOBALS(deepclone) HashTable hydrate_cache; diff --git a/tests/deepclone_allowed_classes.phpt b/tests/deepclone_allowed_classes.phpt index 0b8465e..caea6e6 100644 --- a/tests/deepclone_allowed_classes.phpt +++ b/tests/deepclone_allowed_classes.phpt @@ -94,6 +94,23 @@ var_dump($c->x === 1); $c = deepclone_from_array(['value' => 42], []); var_dump($c === 42); +// ── from_array: allowed child carries inherited parent-declared private +// state even when the parent isn't listed in $allowedClasses. The scope +// check on "properties" entries (is-a-parent-of obj->ce) is the security +// boundary; allowed_classes is for instantiation. ── +class AllowedParent { private string $secret = ''; public function getSecret(): string { return $this->secret; } } +class AllowedChild extends AllowedParent { public string $pub = ''; } + +$c = new AllowedChild(); +(function () { $this->secret = 'inherited'; })->bindTo($c, AllowedParent::class)(); +$c->pub = 'visible'; + +$d = deepclone_to_array($c, ['AllowedChild']); +$r = deepclone_from_array($d, ['AllowedChild']); +var_dump($r instanceof AllowedChild); +var_dump($r->getSecret() === 'inherited'); +var_dump($r->pub === 'visible'); + echo "Done\n"; ?> --EXPECT-- @@ -113,4 +130,7 @@ bool(true) bool(true) bool(true) bool(true) +bool(true) +bool(true) +bool(true) Done diff --git a/tests/deepclone_from_array_validation.phpt b/tests/deepclone_from_array_validation.phpt index 0e32895..d36d7a8 100644 --- a/tests/deepclone_from_array_validation.phpt +++ b/tests/deepclone_from_array_validation.phpt @@ -31,9 +31,28 @@ check('properties wrong type', ['classes' => 'stdClass', 'objectMeta' => 0, 'pre check('states wrong type', ['classes' => 'stdClass', 'objectMeta' => 0, 'prepared' => 0, 'states' => 'foo']); check('states entry wrong type', ['classes' => 'stdClass', 'objectMeta' => 1, 'prepared' => 0, 'states' => [1 => 'foo']]); check('states refs unknown id', ['classes' => 'stdClass', 'objectMeta' => 1, 'prepared' => 0, 'states' => [1 => 99]]); +check('properties obj_id out of range', + ['classes' => 'stdClass', 'objectMeta' => 1, 'prepared' => 0, + 'properties' => ['stdClass' => ['x' => [99 => 'val']]]]); check('prepared object id out of range', ['classes' => 'stdClass', 'objectMeta' => 1, 'prepared' => 99]); check('prepared ref id unknown', ['classes' => '', 'objectMeta' => 0, 'prepared' => -99]); +// objectMeta flags wakeup but states is missing the matching entry. +check('wakeup advertised but no states', + ['classes' => 'stdClass', 'objectMeta' => [[0, 1]], 'prepared' => 0]); +check('unserialize advertised but no states', + ['classes' => 'stdClass', 'objectMeta' => [[0, -1]], 'prepared' => 0]); + +// states direction contradicts objectMeta sign. +check('states wakeup entry without positive meta', + ['classes' => 'stdClass', 'objectMeta' => [[0, -1]], 'prepared' => 0, 'states' => [1 => 0]]); +check('states unserialize entry without negative meta', + ['classes' => 'stdClass', 'objectMeta' => [[0, 1]], 'prepared' => 0, 'states' => [1 => [0, []]]]); + +// states entry references an id whose meta flag is 0. +check('states wakeup entry but meta=0', + ['classes' => 'stdClass', 'objectMeta' => 1, 'prepared' => 0, 'states' => [1 => 0]]); + echo "Done\n"; ?> --EXPECTF-- @@ -52,6 +71,12 @@ properties wrong type: ValueError: deepclone_from_array(): Argument #1 ($data) " states wrong type: ValueError: deepclone_from_array(): Argument #1 ($data) "states" must be of type array, %s given states entry wrong type: ValueError: deepclone_from_array(): Argument #1 ($data) "states" entry must be of type int|array, %s given states refs unknown id: ValueError: deepclone_from_array(): Argument #1 ($data) "states" entry references unknown object id 99 +properties obj_id out of range: ValueError: deepclone_from_array(): Argument #1 ($data) "properties" entry for "stdClass::x" references unknown object id 99 prepared object id out of range: ValueError: deepclone_from_array(): Argument #1 ($data) "prepared" references unknown object id 99 prepared ref id unknown: ValueError: deepclone_from_array(): Argument #1 ($data) "prepared" references unknown ref id 99 +wakeup advertised but no states: ValueError: deepclone_from_array(): Argument #1 ($data) "objectMeta" entry 0 flags object for state replay but no matching "states" entry was found +unserialize advertised but no states: ValueError: deepclone_from_array(): Argument #1 ($data) "objectMeta" entry 0 flags object for state replay but no matching "states" entry was found +states wakeup entry without positive meta: ValueError: deepclone_from_array(): Argument #1 ($data) "states" has a __wakeup entry for object id 0 but "objectMeta" does not flag it for __wakeup +states unserialize entry without negative meta: ValueError: deepclone_from_array(): Argument #1 ($data) "states" has an __unserialize entry for object id 0 but "objectMeta" does not flag it for __unserialize +states wakeup entry but meta=0: ValueError: deepclone_from_array(): Argument #1 ($data) "states" has a __wakeup entry for object id 0 but "objectMeta" does not flag it for __wakeup Done diff --git a/tests/deepclone_hydrate.phpt b/tests/deepclone_hydrate.phpt index d1b96f7..2753c76 100644 --- a/tests/deepclone_hydrate.phpt +++ b/tests/deepclone_hydrate.phpt @@ -1,5 +1,5 @@ --TEST-- -deepclone_hydrate() instantiates and hydrates objects with scoped properties +deepclone_hydrate() instantiates and hydrates objects from a flat mangled-key array --EXTENSIONS-- deepclone --FILE-- @@ -27,88 +27,69 @@ class Bar extends Foo { private $priv; } -// === Scoped vars (2nd parameter) === +// === Basic hydration — $vars is the flat (array) $obj shape === -// Instantiate from class name +// Instantiate from class name — mix of private (mangled), protected (bare or +// "\0*\0name"), and public (bare) keys. $obj = deepclone_hydrate('Child', [ - 'Base' => ['secret' => 'hidden'], - 'Child' => ['num' => 42], - 'stdClass' => ['pub' => 'visible'], + "\0Base\0secret" => 'hidden', + 'num' => 42, + 'pub' => 'visible', ]); var_dump($obj instanceof Child); var_dump($obj->getSecret() === 'hidden'); var_dump($obj->getNum() === 42); var_dump($obj->pub === 'visible'); -// Hydrate existing object +// Hydrate existing object — partial update keeps untouched props $existing = new Child(); $result = deepclone_hydrate($existing, [ - 'Base' => ['secret' => 'updated'], - 'stdClass' => ['pub' => 'changed'], + "\0Base\0secret" => 'updated', + 'pub' => 'changed', ]); var_dump($result === $existing); var_dump($result->getSecret() === 'updated'); var_dump($result->pub === 'changed'); var_dump($result->getNum() === 0); // untouched -// stdClass -$o = deepclone_hydrate('stdClass', ['stdClass' => ['x' => 1, 'y' => 'hi']]); +// stdClass with dynamic properties +$o = deepclone_hydrate('stdClass', ['x' => 1, 'y' => 'hi']); var_dump($o->x === 1); var_dump($o->y === 'hi'); -// SplObjectStorage — "\0" key convention +// Case-insensitive class name +$o = deepclone_hydrate('STDcLASS', ['p' => 123]); +var_dump($o->p === 123); + +// === "\0" key = SPL internal state === + +// SplObjectStorage via "\0" key $s = new SplObjectStorage(); $o1 = new stdClass(); $o1->name = 'a'; $o2 = new stdClass(); $o2->name = 'b'; -$result = deepclone_hydrate($s, ['stdClass' => ["\0" => [$o1, 'info1', $o2, 'info2']]]); +$result = deepclone_hydrate($s, ["\0" => [$o1, 'info1', $o2, 'info2']]); var_dump($result === $s); var_dump($result->count() === 2); $result->rewind(); var_dump($result->current() === $o1); var_dump($result->getInfo() === 'info1'); -// ArrayObject — "\0" key convention -$ao = deepclone_hydrate('ArrayObject', [ - 'stdClass' => ["\0" => [['x' => 1, 'y' => 2], ArrayObject::ARRAY_AS_PROPS]], -]); +$o3 = new stdClass(); +$s = deepclone_hydrate('SplObjectStorage', ["\0" => [$o3, 'data']]); +var_dump($s->count() === 1); + +// ArrayObject via "\0" key +$ao = deepclone_hydrate('ArrayObject', ["\0" => [['x' => 1, 'y' => 2], ArrayObject::ARRAY_AS_PROPS]]); var_dump($ao instanceof ArrayObject); var_dump($ao['x'] === 1); var_dump($ao->getFlags() === ArrayObject::ARRAY_AS_PROPS); -// ArrayIterator — "\0" key convention -$ai = deepclone_hydrate('ArrayIterator', [ - 'stdClass' => ["\0" => [['a', 'b', 'c']]], -]); +// ArrayIterator via "\0" key +$ai = deepclone_hydrate('ArrayIterator', ["\0" => [['a', 'b', 'c']]]); var_dump($ai instanceof ArrayIterator); var_dump(count($ai) === 3); -// === Mangled vars (3rd parameter) === - -// stdClass with flat properties -$o = deepclone_hydrate('stdClass', ['p' => 123], DEEPCLONE_HYDRATE_MANGLED_VARS); -var_dump($o->p === 123); - -// Case-insensitive class name -$o = deepclone_hydrate('STDcLASS', ['p' => 123], DEEPCLONE_HYDRATE_MANGLED_VARS); -var_dump($o->p === 123); - -// ArrayObject via "\0" key in mangled-vars mode -$ao = deepclone_hydrate('ArrayObject', ["\0" => [[123]]], DEEPCLONE_HYDRATE_MANGLED_VARS); -var_dump($ao[0] === 123); - -// ArrayObject via "\0" key in scoped mode (own class as scope) -$ao = deepclone_hydrate('ArrayObject', ['ArrayObject' => ["\0" => [[456]]]]); -var_dump($ao[0] === 456); - -// SplObjectStorage via "\0" key in mangled-vars mode -$o1 = new stdClass(); -$s = deepclone_hydrate('SplObjectStorage', ["\0" => [$o1, 'data']], DEEPCLONE_HYDRATE_MANGLED_VARS); -var_dump($s->count() === 1); - -// SplObjectStorage via "\0" key in $scoped_vars -$o1 = new stdClass(); -$s = deepclone_hydrate('SplObjectStorage', ['SplObjectStorage' => ["\0" => [$o1, 'info']]]); -var_dump($s->count() === 1); +// === (array) $obj round-trip — all four mangled-key shapes === $expected = [ "\0*\0prot" => 345, @@ -118,24 +99,24 @@ $expected = [ 'ro' => 567, ]; -// Mangled key format $actual = (array) deepclone_hydrate('Bar', [ "\0*\0prot" => 345, "\0Bar\0priv" => 123, "\0Foo\0priv" => 234, 'dyn' => 456, 'ro' => 567, -], DEEPCLONE_HYDRATE_MANGLED_VARS); +]); ksort($actual); var_dump($actual === $expected); // Exception trace (internal class hydration) -$e = deepclone_hydrate('Exception', ['trace' => [234]], DEEPCLONE_HYDRATE_MANGLED_VARS); +$e = deepclone_hydrate('Exception', ['trace' => [234]]); var_dump($e->getTrace() === [234]); -// Readonly: hydrating an already-initialized readonly property throws, -// matching ReflectionProperty::setRawValue semantics (engine checks -// ZEND_ACC_READONLY during the write). +// === Readonly === + +// Rehydrating an already-initialized readonly property with a DIFFERENT value +// throws — engine enforces ZEND_ACC_READONLY. class ReadonlyClass { public string $status = 'new'; private readonly int $value; @@ -144,73 +125,87 @@ class ReadonlyClass { } $obj = new ReadonlyClass(123); try { - deepclone_hydrate($obj, ['value' => 456], DEEPCLONE_HYDRATE_MANGLED_VARS); + deepclone_hydrate($obj, ['value' => 456]); var_dump(false); } catch (\Error $e) { var_dump(str_contains($e->getMessage(), 'readonly')); } var_dump($obj->getValue() === 123); -// Readonly: uninitialized → sets value -$obj = deepclone_hydrate('ReadonlyClass', ['ReadonlyClass' => ['value' => 456]]); +// Uninitialized readonly → sets value (first write wins) +$obj = deepclone_hydrate('ReadonlyClass', ['value' => 456]); var_dump($obj->getValue() === 456); -// PHP references are dropped by default (deref on write) +// === PHP & references === + +// Default: references are dropped on write $a = 1; $props = ['p1' => &$a, 'p2' => &$a]; -$obj = deepclone_hydrate('stdClass', $props, DEEPCLONE_HYDRATE_MANGLED_VARS); +$obj = deepclone_hydrate('stdClass', $props); var_dump($obj->p1 === 1 && $obj->p2 === 1); $a = 2; -var_dump($obj->p1 === 1 && $obj->p2 === 1); // stayed at 1 — no ref preserved +var_dump($obj->p1 === 1 && $obj->p2 === 1); -// PRESERVE_REFS keeps the ref link across the hydrate +// PRESERVE_REFS: the ref link is kept across the hydrate $a = 1; $props = ['p1' => &$a, 'p2' => &$a]; -$obj = deepclone_hydrate('stdClass', $props, DEEPCLONE_HYDRATE_MANGLED_VARS | DEEPCLONE_HYDRATE_PRESERVE_REFS); +$obj = deepclone_hydrate('stdClass', $props, DEEPCLONE_HYDRATE_PRESERVE_REFS); var_dump($obj->p1 === 1 && $obj->p2 === 1); $a = 2; var_dump($obj->p1 === 2 && $obj->p2 === 2); -// Refs in scoped properties also need PRESERVE_REFS -$v = 'hello'; -$obj = deepclone_hydrate('stdClass', ['stdClass' => ['x' => &$v, 'y' => &$v]], DEEPCLONE_HYDRATE_PRESERVE_REFS); -$v = 'world'; -var_dump($obj->x === 'world' && $obj->y === 'world'); - // === Grandparent private (3-level inheritance) === + class GP { private string $secret = ''; public function getSecret(): string { return $this->secret; } } class P extends GP { private int $mid = 0; public function getMid(): int { return $this->mid; } } class C extends P { public string $pub = ''; } -$o = deepclone_hydrate('C', ["\0GP\0secret" => 'gp_val', "\0P\0mid" => 42, 'pub' => 'hi'], DEEPCLONE_HYDRATE_MANGLED_VARS); +$o = deepclone_hydrate('C', [ + "\0GP\0secret" => 'gp_val', + "\0P\0mid" => 42, + 'pub' => 'hi', +]); var_dump($o->getSecret() === 'gp_val'); var_dump($o->getMid() === 42); var_dump($o->pub === 'hi'); +// Bare name reaches a parent-private slot when it's the only one of that name +// in the inheritance chain — no mangled key required. +$o = deepclone_hydrate('C', [ + 'secret' => 'bare_gp', + 'mid' => 77, + 'pub' => 'bare', +]); +var_dump($o->getSecret() === 'bare_gp'); +var_dump($o->getMid() === 77); +var_dump($o->pub === 'bare'); + +$props = (array) $o; +var_dump(!isset($props['secret'])); // no stray dynamic prop +var_dump(!isset($props['mid'])); // ,, +var_dump(isset($props["\0GP\0secret"])); // went to GP's slot +var_dump(isset($props["\0P\0mid"])); // went to P's slot + // === Error cases === -// Unknown class try { deepclone_hydrate('NoSuchClass99', []); } catch (\DeepClone\ClassNotFoundException $e) { var_dump(str_contains($e->getMessage(), 'NoSuchClass99')); } -// Abstract class try { deepclone_hydrate('SplHeap', []); } catch (\DeepClone\NotInstantiableException $e) { var_dump(str_contains($e->getMessage(), 'SplHeap')); } -// Interface try { deepclone_hydrate('Throwable', []); } catch (\DeepClone\NotInstantiableException $e) { var_dump(str_contains($e->getMessage(), 'Throwable')); } -// Enum enum HydrateColor { case Red; } try { deepclone_hydrate('HydrateColor', []); @@ -218,7 +213,6 @@ try { var_dump(str_contains($e->getMessage(), 'HydrateColor')); } -// Trait trait HydrateTrait {} try { deepclone_hydrate('HydrateTrait', []); @@ -226,98 +220,125 @@ try { var_dump(str_contains($e->getMessage(), 'HydrateTrait')); } -// Reflector subclass (internal, cannot function without constructor) try { deepclone_hydrate('ReflectionClass', []); } catch (\DeepClone\NotInstantiableException $e) { var_dump(str_contains($e->getMessage(), 'ReflectionClass')); } -// Closure (internal final with create_object) try { deepclone_hydrate('Closure', []); } catch (\DeepClone\NotInstantiableException $e) { var_dump(str_contains($e->getMessage(), 'Closure')); } -// SplFileInfo (internal, serialize fails) try { deepclone_hydrate('SplFileInfo', []); } catch (\DeepClone\NotInstantiableException $e) { var_dump(str_contains($e->getMessage(), 'SplFileInfo')); } -// Bad type try { deepclone_hydrate([], []); } catch (\TypeError $e) { var_dump(str_contains($e->getMessage(), 'must be of type object|string')); } -// Integer key in $mangled_vars → ValueError +// Malformed mangled key — missing second NUL try { - deepclone_hydrate('stdClass', [0 => 'val'], DEEPCLONE_HYDRATE_MANGLED_VARS); + deepclone_hydrate('stdClass', ["\0broken" => 'val']); } catch (\ValueError $e) { - var_dump(str_contains($e->getMessage(), 'string keys')); + var_dump(str_contains($e->getMessage(), 'invalid mangled key')); } -// Non-array in $scoped_vars → ValueError +// Malformed mangled key — empty class name try { - deepclone_hydrate('stdClass', ['stdClass' => 'not-array']); + deepclone_hydrate('stdClass', ["\0\0prop" => 'val']); } catch (\ValueError $e) { - var_dump(str_contains($e->getMessage(), 'array values')); + var_dump(str_contains($e->getMessage(), 'invalid mangled key')); } -// NUL-in-middle of a property name: matches unserialize() — accepted -// as a dynamic property, the engine stores the raw name. -$o = deepclone_hydrate('stdClass', ['stdClass' => ["foo\0bar" => 'val']]); -var_dump(((array) $o)["foo\0bar"] === 'val'); - -// NUL byte in mangled key property portion → ValueError (MANGLED_VARS mode parses keys) +// Embedded NUL in the property name portion of a mangled key try { - deepclone_hydrate('stdClass', ["\0*\0foo\0bar" => 'val'], DEEPCLONE_HYDRATE_MANGLED_VARS); + deepclone_hydrate('stdClass', ["\0*\0foo\0bar" => 'val']); } catch (\ValueError $e) { var_dump(str_contains($e->getMessage(), 'invalid mangled key')); } -// Integer key inside a scope: matches unserialize() — coerced to string -// on dynamic property access. -$o = deepclone_hydrate('stdClass', ['stdClass' => [0 => 'val']]); -var_dump($o->{'0'} === 'val'); +// "\0" special key with a non-array value → ValueError +try { + deepclone_hydrate('SplObjectStorage', ["\0" => 42]); +} catch (\ValueError $e) { + var_dump(str_contains($e->getMessage(), 'must be of type array')); +} -// Mangled-shape key inside a scope: engine rejects the NUL-prefix -// property name with \Error. Callers wanting mangled keys pass -// DEEPCLONE_HYDRATE_MANGLED_VARS. +// "\0" special key on a class that doesn't support it → ValueError +class NoSpl { public int $x = 0; } try { - deepclone_hydrate('stdClass', ['stdClass' => ["\0stdClass\0x" => 'val']]); -} catch (\Error $e) { - var_dump(str_contains($e->getMessage(), 'starting with')); + deepclone_hydrate('NoSpl', ["\0" => [1, 2]]); +} catch (\ValueError $e) { + var_dump(str_contains($e->getMessage(), 'SplObjectStorage')); } -// Interface as scope → ValueError -interface HydrateIface {} -class HydrateImpl implements HydrateIface { public string $x = ''; } +// SplObjectStorage "\0" payload with odd count → ValueError try { - deepclone_hydrate('HydrateImpl', ['HydrateIface' => ['x' => 'hello']]); + deepclone_hydrate('SplObjectStorage', ["\0" => [new stdClass()]]); } catch (\ValueError $e) { - var_dump(str_contains($e->getMessage(), 'not a parent')); + var_dump(str_contains($e->getMessage(), 'even number of entries')); } -// Unrelated scope → ValueError +// ArrayObject "\0" payload with more than 3 args → ValueError try { - deepclone_hydrate('stdClass', ['SplHeap' => ['x' => 1]]); + deepclone_hydrate('ArrayObject', ["\0" => [[], 0, 'ArrayIterator', 'extra']]); +} catch (\ValueError $e) { + var_dump(str_contains($e->getMessage(), 'at most 3 entries')); +} + +// Mangled-private key referencing a non-parent class → ValueError +class HydrateUnrelated { public int $x = 0; } +try { + deepclone_hydrate('stdClass', ["\0HydrateUnrelated\0x" => 1]); } catch (\ValueError $e) { var_dump(str_contains($e->getMessage(), 'not a parent')); } -// Non-existing scope → ValueError (no autoloader triggered) +// Mangled-protected key referencing an undeclared property → ValueError +// (a "\0*\0name" form specifically targets a protected slot; if the slot +// doesn't exist, reject instead of silently creating a dynamic property). +class HydrateProt { protected int $real = 0; } +try { + deepclone_hydrate('HydrateProt', ["\0*\0undeclared" => 1]); +} catch (\ValueError $e) { + var_dump(str_contains($e->getMessage(), 'does not declare')); +} + +// Mangled-private key with a valid parent class but undeclared property → ValueError +class HydrateChildProt extends HydrateProt {} try { - deepclone_hydrate('stdClass', ['NonExistent' => ['x' => 1]]); + deepclone_hydrate('HydrateChildProt', ["\0HydrateProt\0undeclared" => 1]); +} catch (\ValueError $e) { + var_dump(str_contains($e->getMessage(), 'does not declare')); +} + +// Mangled-private key referencing an interface (which isn't a parent class) +interface HydrateIface {} +class HydrateImpl implements HydrateIface { public string $x = ''; } +try { + deepclone_hydrate('HydrateImpl', ["\0HydrateIface\0x" => 'hello']); } catch (\ValueError $e) { var_dump(str_contains($e->getMessage(), 'not a parent')); } -// No arguments (just instantiate) +// Integer keys coerce to string (matches unserialize()) +$o = deepclone_hydrate('stdClass', [0 => 'val']); +var_dump($o->{'0'} === 'val'); + +// NUL-in-middle of a bare property name: stored as a raw dynamic property +// on stdClass (matches unserialize()). +$o = deepclone_hydrate('stdClass', ["foo\0bar" => 'val']); +var_dump(((array) $o)["foo\0bar"] === 'val'); + +// No $vars (just instantiate) $o = deepclone_hydrate('stdClass'); var_dump($o instanceof stdClass); @@ -380,4 +401,11 @@ bool(true) bool(true) bool(true) bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) Done diff --git a/tests/deepclone_hydrate_dtor_reentrance.phpt b/tests/deepclone_hydrate_dtor_reentrance.phpt index 12fb56f..8e3afdf 100644 --- a/tests/deepclone_hydrate_dtor_reentrance.phpt +++ b/tests/deepclone_hydrate_dtor_reentrance.phpt @@ -42,7 +42,7 @@ $new->back = $host; // Hydrate $host with slot = $new. The assignment frees the old object, // whose __destruct reads $host->slot. That read must observe $new, not // a freed zval. -deepclone_hydrate($host, [Host::class => ['slot' => $new]]); +deepclone_hydrate($host, ['slot' => $new]); var_dump($host->slot->tag === 'new'); var_dump(ReadsSlotOnDtor::$observed === 'new'); diff --git a/tests/deepclone_hydrate_enum_cast.phpt b/tests/deepclone_hydrate_enum_cast.phpt index 81555bb..9e59e4b 100644 --- a/tests/deepclone_hydrate_enum_cast.phpt +++ b/tests/deepclone_hydrate_enum_cast.phpt @@ -23,22 +23,22 @@ class WithEnum // String → string-backed enum $o = new WithEnum(); -$o = deepclone_hydrate($o, [WithEnum::class => ['s' => 'S']]); +$o = deepclone_hydrate($o, ['s' => 'S']); var_dump($o->s === Suit::Spades); // Int → int-backed enum $o = new WithEnum(); -$o = deepclone_hydrate($o, [WithEnum::class => ['n' => 2]]); +$o = deepclone_hydrate($o, ['n' => 2]); var_dump($o->n === Size::Large); // Nullable enum + null → stored as null (no cast, unset rule doesn't fire either) $o = new WithEnum(); -$o = deepclone_hydrate($o, [WithEnum::class => ['ns' => null]]); +$o = deepclone_hydrate($o, ['ns' => null]); var_dump($o->ns === null); // Nullable enum + matching scalar → cast $o = new WithEnum(); -$o = deepclone_hydrate($o, [WithEnum::class => ['ns' => 'S']]); +$o = deepclone_hydrate($o, ['ns' => 'S']); var_dump($o->ns === Suit::Spades); // Cross-type scalar (int → string-backed enum): Enum::from() coerces the @@ -46,7 +46,7 @@ var_dump($o->ns === Suit::Spades); // ValueError. Same path the polyfill takes via Enum::from($value). $o = new WithEnum(); try { - deepclone_hydrate($o, [WithEnum::class => ['s' => 5]]); + deepclone_hydrate($o, ['s' => 5]); var_dump(false); } catch (\ValueError $e) { var_dump(str_contains($e->getMessage(), 'not a valid backing value for enum')); @@ -56,7 +56,7 @@ try { // valid backing value for enum Suit"). $o = new WithEnum(); try { - deepclone_hydrate($o, [WithEnum::class => ['s' => 'X']]); + deepclone_hydrate($o, ['s' => 'X']); var_dump(false); } catch (\ValueError $e) { var_dump(str_contains($e->getMessage(), 'not a valid backing value for enum')); @@ -65,7 +65,7 @@ try { // Non-backed enum: no cast applied, engine TypeError on scalar $o = new WithEnum(); try { - deepclone_hydrate($o, [WithEnum::class => ['p' => 'Alpha']]); + deepclone_hydrate($o, ['p' => 'Alpha']); var_dump(false); } catch (\TypeError $e) { var_dump(str_contains($e->getMessage(), 'Plain')); @@ -73,12 +73,12 @@ try { // Passing the enum case directly still works (no cast needed) $o = new WithEnum(); -$o = deepclone_hydrate($o, [WithEnum::class => ['s' => Suit::Spades]]); +$o = deepclone_hydrate($o, ['s' => Suit::Spades]); var_dump($o->s === Suit::Spades); // CALL_HOOKS gate is per-prop: non-hooked enum-typed props still get cast. $o = new WithEnum(); -$o = deepclone_hydrate($o, [WithEnum::class => ['s' => 'S']], DEEPCLONE_HYDRATE_CALL_HOOKS); +$o = deepclone_hydrate($o, ['s' => 'S'], DEEPCLONE_HYDRATE_CALL_HOOKS); var_dump($o->s === Suit::Spades); // Hooked enum prop under CALL_HOOKS: the cast decision is property-type @@ -99,7 +99,7 @@ if (PHP_VERSION_ID >= 80400) { } }'); $o = new WithHookedEnumWider(); - $o = deepclone_hydrate($o, [WithHookedEnumWider::class => ['s' => 'S']], DEEPCLONE_HYDRATE_CALL_HOOKS); + $o = deepclone_hydrate($o, ['s' => 'S'], DEEPCLONE_HYDRATE_CALL_HOOKS); var_dump($o->s === Suit::Spades); var_dump(WithHookedEnumWider::$lastRaw === null); @@ -109,7 +109,7 @@ if (PHP_VERSION_ID >= 80400) { } }'); $o = new WithHookedEnumStrict(); - $o = deepclone_hydrate($o, [WithHookedEnumStrict::class => ['s' => 'S']], DEEPCLONE_HYDRATE_CALL_HOOKS); + $o = deepclone_hydrate($o, ['s' => 'S'], DEEPCLONE_HYDRATE_CALL_HOOKS); var_dump($o->s === Suit::Spades); } else { var_dump(true); diff --git a/tests/deepclone_hydrate_flags.phpt b/tests/deepclone_hydrate_flags.phpt index fa0701c..feb1de4 100644 --- a/tests/deepclone_hydrate_flags.phpt +++ b/tests/deepclone_hydrate_flags.phpt @@ -18,11 +18,11 @@ class HookedBacking { } // Default (flags=0) → setRawValue: bypass hook -$o = deepclone_hydrate(HookedBacking::class, [HookedBacking::class => ['x' => 7]]); +$o = deepclone_hydrate(HookedBacking::class, ['x' => 7]); var_dump($o->x === 7); // DEEPCLONE_HYDRATE_CALL_HOOKS → setValue: invoke hook -$o = deepclone_hydrate(HookedBacking::class, [HookedBacking::class => ['x' => 7]], DEEPCLONE_HYDRATE_CALL_HOOKS); +$o = deepclone_hydrate(HookedBacking::class, ['x' => 7], DEEPCLONE_HYDRATE_CALL_HOOKS); var_dump($o->x === 70); // Unknown bit → ValueError @@ -53,7 +53,7 @@ $ghost = $rc->newLazyGhost(function (Lazy $o) use (&$initRan) { $o->a = 1; $o->b = 'init'; }); -deepclone_hydrate($ghost, [Lazy::class => ['a' => 99]]); +deepclone_hydrate($ghost, ['a' => 99]); var_dump($initRan === 1); // initializer ran once var_dump($ghost->a === 99); // hydrate value wins var_dump($ghost->b === 'init'); // initializer value preserved @@ -66,7 +66,7 @@ $ghost = $rc->newLazyGhost(function (Lazy $o) use (&$initRan) { $o->a = 1; $o->b = 'init'; }); -deepclone_hydrate($ghost, [Lazy::class => ['a' => 99, 'b' => 'hyd']], DEEPCLONE_HYDRATE_NO_LAZY_INIT); +deepclone_hydrate($ghost, ['a' => 99, 'b' => 'hyd'], DEEPCLONE_HYDRATE_NO_LAZY_INIT); var_dump($initRan === 0); // initializer did NOT run var_dump($ghost->a === 99); var_dump($ghost->b === 'hyd'); diff --git a/tests/deepclone_hydrate_mangled_validation.phpt b/tests/deepclone_hydrate_mangled_validation.phpt index 96ab225..2316301 100644 --- a/tests/deepclone_hydrate_mangled_validation.phpt +++ b/tests/deepclone_hydrate_mangled_validation.phpt @@ -10,7 +10,7 @@ class BadKeys {} function check_bad_key(string $key): bool { try { - deepclone_hydrate('BadKeys', [$key => 123], DEEPCLONE_HYDRATE_MANGLED_VARS); + deepclone_hydrate('BadKeys', [$key => 123]); return false; } catch (ValueError $e) { return str_contains($e->getMessage(), 'invalid mangled key'); diff --git a/tests/deepclone_hydrate_null_unset.phpt b/tests/deepclone_hydrate_null_unset.phpt index d5bb21c..beba9e6 100644 --- a/tests/deepclone_hydrate_null_unset.phpt +++ b/tests/deepclone_hydrate_null_unset.phpt @@ -14,7 +14,7 @@ class Typed // null into non-nullable typed → slot becomes uninitialized $o = new Typed(); -$o = deepclone_hydrate($o, [Typed::class => ['x' => null]]); +$o = deepclone_hydrate($o, ['x' => null]); var_dump((new ReflectionProperty(Typed::class, 'x'))->isInitialized($o)); // Reading an uninitialized typed prop throws the standard engine error @@ -27,18 +27,18 @@ try { // null into nullable → stored as null $o = new Typed(); -$o = deepclone_hydrate($o, [Typed::class => ['y' => null]]); +$o = deepclone_hydrate($o, ['y' => null]); var_dump($o->y === null); // null into mixed → stored as null $o = new Typed(); -$o = deepclone_hydrate($o, [Typed::class => ['m' => null]]); +$o = deepclone_hydrate($o, ['m' => null]); var_dump($o->m === null); // CALL_HOOKS is per-prop: a non-hooked typed prop still gets unset, even // when the flag is set (no set hook on this property to defer to). $o = new Typed(); -$o = deepclone_hydrate($o, [Typed::class => ['x' => null]], DEEPCLONE_HYDRATE_CALL_HOOKS); +$o = deepclone_hydrate($o, ['x' => null], DEEPCLONE_HYDRATE_CALL_HOOKS); var_dump((new ReflectionProperty(Typed::class, 'x'))->isInitialized($o)); echo "Done\n"; diff --git a/tests/deepclone_hydrate_readonly_idempotent.phpt b/tests/deepclone_hydrate_readonly_idempotent.phpt index f0cab47..ae178ea 100644 --- a/tests/deepclone_hydrate_readonly_idempotent.phpt +++ b/tests/deepclone_hydrate_readonly_idempotent.phpt @@ -19,13 +19,13 @@ class ReadonlyObj // Same value (===) → silently skipped $o = new ReadonlyInt(42); -$o = deepclone_hydrate($o, [ReadonlyInt::class => ['v' => 42]]); +$o = deepclone_hydrate($o, ['v' => 42]); var_dump($o->v === 42); // Different value → engine throws $o = new ReadonlyInt(42); try { - deepclone_hydrate($o, [ReadonlyInt::class => ['v' => 43]]); + deepclone_hydrate($o, ['v' => 43]); var_dump(false); } catch (\Error $e) { var_dump(str_contains($e->getMessage(), 'readonly')); @@ -35,20 +35,20 @@ var_dump($o->v === 42); // Object identity (same zval) → skipped $inner = new \stdClass(); $o = new ReadonlyObj($inner); -$o = deepclone_hydrate($o, [ReadonlyObj::class => ['o' => $inner]]); +$o = deepclone_hydrate($o, ['o' => $inner]); var_dump($o->o === $inner); // Different object (even if equal) → engine throws $o = new ReadonlyObj(new \stdClass()); try { - deepclone_hydrate($o, [ReadonlyObj::class => ['o' => new \stdClass()]]); + deepclone_hydrate($o, ['o' => new \stdClass()]); var_dump(false); } catch (\Error $e) { var_dump(str_contains($e->getMessage(), 'readonly')); } // Uninitialized readonly → writes normally -$o = deepclone_hydrate(ReadonlyInt::class, [ReadonlyInt::class => ['v' => 99]]); +$o = deepclone_hydrate(ReadonlyInt::class, ['v' => 99]); var_dump($o->v === 99); echo "Done\n"; diff --git a/tests/deepclone_hydrate_typed_props.phpt b/tests/deepclone_hydrate_typed_props.phpt index e84fab2..554c95a 100644 --- a/tests/deepclone_hydrate_typed_props.phpt +++ b/tests/deepclone_hydrate_typed_props.phpt @@ -17,19 +17,19 @@ class TypedReadonly // A. Strict type mismatch → TypeError try { - deepclone_hydrate(TypedInt::class, [TypedInt::class => ['x' => 'hello']]); + deepclone_hydrate(TypedInt::class, ['x' => 'hello']); echo "A: NO THROW\n"; } catch (\TypeError $e) { var_dump(str_contains($e->getMessage(), 'type int')); } // B. Coercible scalar → coerced to int (non-strict default) -$o = deepclone_hydrate(TypedInt::class, [TypedInt::class => ['x' => '42']]); +$o = deepclone_hydrate(TypedInt::class, ['x' => '42']); var_dump($o->x === 42); // C. Readonly + wrong type → TypeError try { - deepclone_hydrate(TypedReadonly::class, [TypedReadonly::class => ['v' => 'nope']]); + deepclone_hydrate(TypedReadonly::class, ['v' => 'nope']); echo "C: NO THROW\n"; } catch (\TypeError $e) { var_dump(str_contains($e->getMessage(), 'type int')); diff --git a/tests/deepclone_private_shadowing.phpt b/tests/deepclone_private_shadowing.phpt index 2abc104..dd54a4c 100644 --- a/tests/deepclone_private_shadowing.phpt +++ b/tests/deepclone_private_shadowing.phpt @@ -17,11 +17,11 @@ class PrivShadowB extends PrivShadowA public function getChild(): string { return $this->x; } } -// 1) Per-scope hydrate writes to distinct slots. +// 1) Both slots targeted in one call via mangled keys. $b = new PrivShadowB(); deepclone_hydrate($b, [ - PrivShadowA::class => ['x' => 'A_written'], - PrivShadowB::class => ['x' => 'B_written'], + "\0PrivShadowA\0x" => 'A_written', + "\0PrivShadowB\0x" => 'B_written', ]); var_dump($b->get() === 'A_written'); var_dump($b->getChild() === 'B_written'); @@ -37,15 +37,15 @@ $clone = deepclone_from_array(deepclone_to_array($orig)); var_dump($clone->get() === 'parent_val'); var_dump($clone->getChild() === 'child_val'); -// 3) Bare name in $mangled_vars targets the most-derived private slot. +// 3) Bare name targets the most-derived private slot. $b2 = new PrivShadowB(); -deepclone_hydrate($b2, ['x' => 'bare_val'], DEEPCLONE_HYDRATE_MANGLED_VARS); +deepclone_hydrate($b2, ['x' => 'bare_val']); var_dump($b2->get() === 'a_init'); // parent untouched var_dump($b2->getChild() === 'bare_val'); // child written // 4) Explicit mangled key targets the parent's private slot. $b3 = new PrivShadowB(); -deepclone_hydrate($b3, ["\0PrivShadowA\0x" => 'parent_targeted'], DEEPCLONE_HYDRATE_MANGLED_VARS); +deepclone_hydrate($b3, ["\0PrivShadowA\0x" => 'parent_targeted']); var_dump($b3->get() === 'parent_targeted'); var_dump($b3->getChild() === 'b_init'); diff --git a/tests/deepclone_property_hooks.phpt b/tests/deepclone_property_hooks.phpt index 58c77bd..a98e52f 100644 --- a/tests/deepclone_property_hooks.phpt +++ b/tests/deepclone_property_hooks.phpt @@ -21,7 +21,7 @@ class HookedProps } } -$hydrated = deepclone_hydrate('HookedProps', ['HookedProps' => ['x' => 5]]); +$hydrated = deepclone_hydrate('HookedProps', ['x' => 5]); var_dump($hydrated->x === 6); $fromArray = deepclone_from_array([ @@ -46,7 +46,7 @@ class HookedBackingProps // Non-virtual hooked property: hydrate writes the backing slot directly, // bypassing the set hook (matches ReflectionProperty::setRawValue). -$bypass = deepclone_hydrate('HookedBackingProps', ['HookedBackingProps' => ['x' => 7]]); +$bypass = deepclone_hydrate('HookedBackingProps', ['x' => 7]); var_dump($bypass->x === 7); $bypassFromArray = deepclone_from_array([