diff --git a/CHANGELOG.md b/CHANGELOG.md index c9afa7e..352d802 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 property table had been built (e.g. by `foreach`, `var_dump()` or `get_object_vars()`), when it had dynamic properties, or when it was a lazy proxy. +- References on dynamic properties are preserved too: + `deepclone_from_array()` binds them like `unserialize()` does instead of + rejecting such payloads, without reporting again the deprecation their + creation raised on the origin; readonly classes still reject them. +- `deepclone_hydrate()` with `DEEPCLONE_HYDRATE_PRESERVE_REFS` aborted + debug builds, and stored the reference unchecked on release ones, when + it targeted a dynamic property, a hooked property, or a property of an + uninitialized lazy object. Dynamic properties and lazy objects (once + initialized) now get the reference, hooked properties the value; so do + references that `deepclone_from_array()` resolves for a property of a + node it creates as a lazy ghost. ## [0.8.3] - 2026-08-24 diff --git a/README.md b/README.md index c376658..013f544 100644 --- a/README.md +++ b/README.md @@ -207,7 +207,7 @@ child's `properties_info`). | `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_PRESERVE_REFS` | preserve PHP `&` references from `$vars` onto the target property slots; by default, references are dropped (dereferenced) on write | +| `DEEPCLONE_HYDRATE_PRESERVE_REFS` | preserve PHP `&` references from `$vars` onto the target property slots, where PHP allows them (not on hooked properties, nor with `DEEPCLONE_HYDRATE_NO_LAZY_INIT` on a lazy object, which get the value); by default, references are dropped (dereferenced) on write | `DEEPCLONE_HYDRATE_CALL_HOOKS` and `DEEPCLONE_HYDRATE_NO_LAZY_INIT` are mutually exclusive; `PRESERVE_REFS` composes with either. @@ -269,7 +269,8 @@ $s->__unserialize([[$obj1, 'info1', $obj2, 'info2'], []]); ## What it preserves - Object identity (shared references stay shared) -- PHP `&` hard references +- PHP `&` hard references, between array elements and properties alike + (declared or dynamic, within an object or across objects) - Cycles in the object graph - Private/protected properties across inheritance - `__serialize` / `__unserialize` / `__sleep` / `__wakeup` semantics diff --git a/deepclone.c b/deepclone.c index 91daa58..26cf796 100644 --- a/deepclone.c +++ b/deepclone.c @@ -858,16 +858,31 @@ static bool dc_write_backed_property(zend_object *obj, zend_property_info *pi, zend_string *name, zval *value, zend_long flags) { bool call_hooks = (flags & DEEPCLONE_HYDRATE_CALL_HOOKS) != 0; + + /* A hooked property cannot hold a PHP &-reference: write its value. */ + if (UNEXPECTED(Z_ISREF_P(value)) && DC_PROP_HAS_HOOKS(pi)) { + value = Z_REFVAL_P(value); + } + #if PHP_VERSION_ID >= 80400 bool no_lazy_init = (flags & DEEPCLONE_HYDRATE_NO_LAZY_INIT) != 0; /* Lazy objects: a direct slot write would bypass the engine's realization * hook and leave the object in a half-initialized state. Route through * zend_update_property_ex() which triggers realization on first write. - * DEEPCLONE_HYDRATE_NO_LAZY_INIT has its own opt-out fast path below. */ + * That writer only takes dereferenced values, so for a reference, + * initialize first and bind it to the slot below, in the real instance + * of a proxy. DEEPCLONE_HYDRATE_NO_LAZY_INIT has its own opt-out fast + * path below. */ if (!no_lazy_init && UNEXPECTED(!zend_lazy_object_initialized(obj))) { - zend_update_property_ex(pi->ce, obj, name, value); - return !EG(exception); + if (EXPECTED(!Z_ISREF_P(value))) { + zend_update_property_ex(pi->ce, obj, name, value); + return !EG(exception); + } + obj = zend_lazy_object_init(obj); + if (UNEXPECTED(!obj)) { + return false; + } } #endif zval *slot = OBJ_PROP(obj, pi->offset); @@ -939,6 +954,8 @@ static bool dc_write_backed_property(zend_object *obj, zend_property_info *pi, #if PHP_VERSION_ID >= 80400 /* Skip the Reflection round-trip when there's no lazy-init to skip. */ if (no_lazy_init && !zend_lazy_object_initialized(obj)) { + /* Like setRawValueWithoutLazyInitialization(), which takes values */ + ZVAL_DEREF(value); # if PHP_VERSION_ID >= 80600 zend_reflection_property_set_raw_value_without_lazy_initialization( pi, name, NULL, pi->ce, obj, value); @@ -1013,6 +1030,57 @@ static bool dc_write_backed_property(zend_object *obj, zend_property_info *pi, return !EG(exception); } +/* Whether a PHP &-reference can be bound to the dynamic property `name` of + * obj: only through the standard property handlers, and never where the class + * declares the name (e.g. a virtual property). */ +static bool dc_can_bind_dynamic_property_ref(zend_object *obj, zend_string *name) +{ + return obj->handlers->write_property == zend_std_write_property + && obj->handlers->get_properties == zend_std_get_properties + && !zend_hash_exists(&obj->ce->properties_info, name); +} + +/* Bind a PHP &-reference to a dynamic property. zend_std_write_property() + * only accepts dereferenced values, so write the properties table directly, + * as unserialize() does. The graph being restored already had the property, + * so its creation deprecation was reported when the origin was built; only + * classes that forbid dynamic properties reject it. Callers check + * dc_can_bind_dynamic_property_ref() first. */ +static bool dc_bind_dynamic_property_ref(zend_object *obj, zend_string *name, zval *ref) +{ +#if PHP_VERSION_ID >= 80400 + /* Like the engine's writes, go through a lazy object to its initialized + * state, i.e. to the real instance of a proxy. */ + if (UNEXPECTED(zend_lazy_object_must_init(obj))) { + obj = zend_lazy_object_init(obj); + if (UNEXPECTED(!obj)) { + return false; + } + } +#endif + + HashTable *properties = zend_std_get_properties(obj); + + if (UNEXPECTED(obj->ce->ce_flags & ZEND_ACC_NO_DYNAMIC_PROPERTIES) + && !zend_hash_exists(properties, name)) { + zend_throw_error(NULL, "Cannot create dynamic property %s::$%s", + ZSTR_VAL(obj->ce->name), ZSTR_VAL(name)); + return false; + } + + if (UNEXPECTED(GC_REFCOUNT(properties) > 1)) { + if (EXPECTED(!(GC_FLAGS(properties) & IS_ARRAY_IMMUTABLE))) { + GC_DELREF(properties); + } + obj->properties = properties = zend_array_dup(properties); + } + + Z_ADDREF_P(ref); + zend_hash_update(properties, name, ref); + + return true; +} + /* ── Core traversal ─────────────────────────────────────────── */ /* Mask markers: TRUE=obj_ref, FALSE=hard_ref, LONG(0)=named_closure, @@ -2604,6 +2672,13 @@ static void dc_process_object(dc_ctx *ctx, zval *src, zval *dst, zval *mask_dst) HashTable *proto = dc_get_proto(ctx, ce); zend_string *arr_key; zval *arr_val; + /* Like the slot fast path above, keep shared references, declared or + * dynamic, where deepclone_from_array() can bind them back: on + * objects with the standard property handlers. */ + const zend_object_handlers *handlers = Z_OBJ_HT_P(src); + bool keep_refs = !handlers->get_properties_for + && handlers->get_properties == zend_std_get_properties + && handlers->write_property == zend_std_write_property; ZEND_HASH_FOREACH_STR_KEY_VAL(array_value, arr_key, arr_val) { const char *key; @@ -2613,16 +2688,12 @@ static void dc_process_object(dc_ctx *ctx, zval *src, zval *dst, zval *mask_dst) bool prop_name_owned = false; bool scope_name_owned = false; - /* Dereference IS_INDIRECT (declared properties) and IS_REFERENCE. - * Like the slot fast path above, keep shared references on - * declared properties of user classes; dynamic properties cannot - * carry them. */ - bool keep_ref = false; + /* Dereference IS_INDIRECT (declared properties), and IS_REFERENCE + * unless kept above */ if (Z_TYPE_P(arr_val) == IS_INDIRECT) { arr_val = Z_INDIRECT_P(arr_val); - keep_ref = ce->type == ZEND_USER_CLASS; } - if (Z_ISREF_P(arr_val) && (!keep_ref || Z_REFCOUNT_P(arr_val) == 1)) { + if (Z_ISREF_P(arr_val) && (!keep_refs || Z_REFCOUNT_P(arr_val) == 1)) { arr_val = Z_REFVAL_P(arr_val); } @@ -4075,7 +4146,15 @@ static void dc_lazy_hydrate(dc_lazy_ctx *ctx, zend_object *obj, uint32_t id) } } else { /* Dynamic property: same engine route as the eager path. */ - zend_update_property_ex(slot->scope_ce, obj, slot->name, &final_val); + if (UNEXPECTED(Z_ISREF(final_val))) { + if (UNEXPECTED(!dc_can_bind_dynamic_property_ref(obj, slot->name))) { + zend_value_error("deepclone_from_array(): hard references cannot target virtual properties or dynamic properties behind custom handlers"); + } else { + dc_bind_dynamic_property_ref(obj, slot->name, &final_val); + } + } else { + zend_update_property_ex(slot->scope_ce, obj, slot->name, &final_val); + } zval_ptr_dtor(&final_val); if (UNEXPECTED(EG(exception))) { goto restore; @@ -5075,13 +5154,19 @@ PHP_FUNCTION(deepclone_from_array) /* 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. */ + * is respected. Matches the deepclone_hydrate() path. + * That writer only takes dereferenced values, so hard + * references are bound to the properties table. */ if (UNEXPECTED(Z_ISREF(final_val))) { - zval_ptr_dtor(&final_val); - EG(fake_scope) = old_scope; - DC_INVALID("deepclone_from_array(): hard references cannot target dynamic or virtual properties"); + if (UNEXPECTED(!dc_can_bind_dynamic_property_ref(obj, prop_name))) { + zval_ptr_dtor(&final_val); + EG(fake_scope) = old_scope; + DC_INVALID("deepclone_from_array(): hard references cannot target virtual properties or dynamic properties behind custom handlers"); + } + dc_bind_dynamic_property_ref(obj, prop_name, &final_val); + } else { + zend_update_property_ex(scope_ce, obj, prop_name, &final_val); } - zend_update_property_ex(scope_ce, obj, prop_name, &final_val); zval_ptr_dtor(&final_val); if (EG(exception)) { EG(fake_scope) = old_scope; @@ -5549,8 +5634,15 @@ PHP_FUNCTION(deepclone_hydrate) /* 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); + * handlers) is respected. That writer only takes dereferenced + * values: with DEEPCLONE_HYDRATE_PRESERVE_REFS, references are + * bound to the properties table where the object allows it, + * and written by value otherwise. */ + if (Z_ISREF_P(v) && dc_can_bind_dynamic_property_ref(obj, real_name)) { + dc_bind_dynamic_property_ref(obj, real_name, v); + } else { + zend_update_property_ex(scope_ce, obj, real_name, Z_ISREF_P(v) ? Z_REFVAL_P(v) : v); + } if (UNEXPECTED(EG(exception))) { if (real_name_owned) zend_string_release(real_name); if (prop_key_owned) zend_string_release(prop_key); diff --git a/tests/deepclone_from_array_dynamic_hard_reference.phpt b/tests/deepclone_from_array_dynamic_hard_reference.phpt index 0c13ac6..f50d014 100644 --- a/tests/deepclone_from_array_dynamic_hard_reference.phpt +++ b/tests/deepclone_from_array_dynamic_hard_reference.phpt @@ -1,5 +1,5 @@ --TEST-- -deepclone_from_array() rejects hard references targeting dynamic properties +deepclone_from_array() binds hard references to dynamic properties like unserialize() --EXTENSIONS-- deepclone --FILE-- @@ -8,18 +8,37 @@ deepclone #[AllowDynamicProperties] class DynamicHardReferenceTarget {} -try { - deepclone_from_array([ - 'classes' => DynamicHardReferenceTarget::class, +class NoDynamicAttributeTarget {} + +readonly class NoDynamicPropertiesTarget {} + +function payload(string $class): array +{ + return [ + 'classes' => $class, 'objectMeta' => 1, 'prepared' => 0, - 'properties' => ['stdClass' => ['dynamic' => [0 => -1]]], - 'resolve' => ['stdClass' => ['dynamic' => [0 => false]]], + 'properties' => ['stdClass' => ['a' => [0 => -1], 'b' => [0 => -1]]], + 'resolve' => ['stdClass' => ['a' => [0 => false], 'b' => [0 => false]]], 'refs' => [1 => 3], - ]); -} catch (ValueError $e) { + ]; +} + +$o = deepclone_from_array(payload(DynamicHardReferenceTarget::class)); +$o->a = 42; +var_dump($o->b); + +$o = deepclone_from_array(payload(NoDynamicAttributeTarget::class)); +$o->a = 42; +var_dump($o->b); + +try { + deepclone_from_array(payload(NoDynamicPropertiesTarget::class)); +} catch (Error $e) { echo $e->getMessage(), "\n"; } ?> --EXPECT-- -deepclone_from_array(): hard references cannot target dynamic or virtual properties +int(42) +int(42) +Cannot create dynamic property NoDynamicPropertiesTarget::$a diff --git a/tests/deepclone_hooked_property_references.phpt b/tests/deepclone_hooked_property_references.phpt new file mode 100644 index 0000000..6c52b9b --- /dev/null +++ b/tests/deepclone_hooked_property_references.phpt @@ -0,0 +1,55 @@ +--TEST-- +References targeting hooked properties are written as values, and rejected on virtual ones by deepclone_from_array() +--EXTENSIONS-- +deepclone +--SKIPIF-- + +--FILE-- + $value * 10; + } + + public int $virtual { + get => 7; + set {} + } +} + +function payload(string $property): array +{ + return [ + 'classes' => HookedReferenceTarget::class, + 'objectMeta' => 1, + 'prepared' => 0, + 'properties' => ['stdClass' => [$property => [0 => -1]]], + 'resolve' => ['stdClass' => [$property => [0 => false]]], + 'refs' => [1 => 3], + ]; +} + +var_dump(deepclone_from_array(payload('backed'))->backed); + +try { + deepclone_from_array(payload('virtual')); +} catch (ValueError $e) { + echo $e->getMessage(), "\n"; +} + +$x = 1; +$o = deepclone_hydrate(HookedReferenceTarget::class, ['backed' => &$x, 'virtual' => &$x], DEEPCLONE_HYDRATE_PRESERVE_REFS); +$x = 2; +var_dump($o->backed, $o->virtual); +?> +--EXPECT-- +int(3) +deepclone_from_array(): hard references cannot target virtual properties or dynamic properties behind custom handlers +int(1) +int(7) diff --git a/tests/deepclone_hydrate_preserve_refs.phpt b/tests/deepclone_hydrate_preserve_refs.phpt new file mode 100644 index 0000000..3657443 --- /dev/null +++ b/tests/deepclone_hydrate_preserve_refs.phpt @@ -0,0 +1,28 @@ +--TEST-- +deepclone_hydrate() with DEEPCLONE_HYDRATE_PRESERVE_REFS binds references to dynamic properties +--EXTENSIONS-- +deepclone +--FILE-- + &$x, 'a' => &$x, 'b' => &$x], DEEPCLONE_HYDRATE_PRESERVE_REFS); +$x = 42; +var_dump($o->declared, $o->a, $o->b); + +$y = 1; +$o = deepclone_hydrate(new stdClass(), ['a' => &$y], DEEPCLONE_HYDRATE_PRESERVE_REFS); +$y = 42; +var_dump($o->a); +?> +--EXPECT-- +int(42) +int(42) +int(42) +int(42) diff --git a/tests/deepclone_hydrate_preserve_refs_lazy.phpt b/tests/deepclone_hydrate_preserve_refs_lazy.phpt new file mode 100644 index 0000000..17a62ff --- /dev/null +++ b/tests/deepclone_hydrate_preserve_refs_lazy.phpt @@ -0,0 +1,43 @@ +--TEST-- +deepclone_hydrate() with DEEPCLONE_HYDRATE_PRESERVE_REFS on lazy objects +--EXTENSIONS-- +deepclone +--SKIPIF-- + +--FILE-- + fn () => $rc->newLazyGhost(function (HydrateLazyRefs $o) { $o->a = 5; }), + 'proxy' => fn () => $rc->newLazyProxy(fn () => new HydrateLazyRefs()), +]; + +// References are bound once the object is initialized; with +// DEEPCLONE_HYDRATE_NO_LAZY_INIT, values are written raw like +// ReflectionProperty::setRawValueWithoutLazyInitialization() does +foreach ($makers as $kind => $make) { + foreach ([0, DEEPCLONE_HYDRATE_NO_LAZY_INIT] as $flag) { + $o = $make(); + $x = 1; + deepclone_hydrate($o, ['a' => &$x, 'b' => &$x], DEEPCLONE_HYDRATE_PRESERVE_REFS | $flag); + $x = 42; + echo $kind, $flag ? ' no-lazy-init: ' : ': ', json_encode([$o->a, $o->b, $rc->isUninitializedLazyObject($o)]), "\n"; + } +} +?> +--EXPECT-- +ghost: [42,42,false] +ghost no-lazy-init: [1,1,false] +proxy: [42,42,false] +proxy no-lazy-init: [1,1,false] diff --git a/tests/deepclone_to_array_dynamic_property_refs.phpt b/tests/deepclone_to_array_dynamic_property_refs.phpt new file mode 100644 index 0000000..a9dd5e4 --- /dev/null +++ b/tests/deepclone_to_array_dynamic_property_refs.phpt @@ -0,0 +1,55 @@ +--TEST-- +deepclone_to_array() keeps references on dynamic properties +--EXTENSIONS-- +deepclone +--FILE-- +$from = 42; + + return 42 === $o->$to; +} + +// Between dynamic properties, and between a declared and a dynamic one +$o = new DynamicRefs(); +$o->a = 1; +$o->b = &$o->a; +$o->declared = 2; +$o->c = &$o->declared; +$c = deepclone_from_array(deepclone_to_array($o)); +var_dump(bound($c, 'a', 'b'), bound($c, 'declared', 'c')); + +// Across objects and array elements +$o1 = new DynamicRefs(); +$o2 = new DynamicRefs(); +$o2->y = 1; +$o1->x = &$o2->y; +$arr = [&$o2->y]; +[$c1, $c2, $carr] = deepclone_from_array(deepclone_to_array([$o1, $o2, $arr])); +$c1->x = 42; +var_dump($c2->y, $carr[0]); + +// On a node that deepclone_from_array() creates as a lazy ghost (PHP 8.4+) +$o = new DynamicRefs(); +$o->cb = strlen(...); +$o->a = 1; +$o->b = &$o->a; +$c = deepclone_from_array(deepclone_to_array($o, allow_named_closures: true), allow_named_closures: true); +var_dump(bound($c, 'a', 'b'), ($c->cb)('abc')); +?> +--EXPECT-- +bool(true) +bool(true) +int(42) +int(42) +bool(true) +int(3)