From fd60080b1861e5dd0d76ded306f7333dbe5fc8eb Mon Sep 17 00:00:00 2001 From: Nicolas Grekas Date: Wed, 23 Sep 2026 09:04:10 +0200 Subject: [PATCH] Preserve references on dynamic properties deepclone_to_array() now keeps shared references on dynamic properties too, on objects with the standard property handlers, and deepclone_from_array() binds them to the properties table like unserialize() does, instead of rejecting such payloads. The deprecation their creation raised on the origin is not reported again. Readonly classes still reject them, as do virtual properties and objects with custom property handlers. zend_std_write_property() only accepts dereferenced values, which the lazy-ghost replay of deepclone_from_array() and deepclone_hydrate() with DEEPCLONE_HYDRATE_PRESERVE_REFS did not ensure: a reference targeting a dynamic property, a hooked property or a property of an uninitialized lazy object aborted debug builds. Dynamic properties and lazy objects, once initialized, now get the reference, and hooked properties the value. --- CHANGELOG.md | 11 ++ README.md | 5 +- deepclone.c | 128 +++++++++++++++--- ...one_from_array_dynamic_hard_reference.phpt | 37 +++-- .../deepclone_hooked_property_references.phpt | 55 ++++++++ tests/deepclone_hydrate_preserve_refs.phpt | 28 ++++ .../deepclone_hydrate_preserve_refs_lazy.phpt | 43 ++++++ ...pclone_to_array_dynamic_property_refs.phpt | 55 ++++++++ 8 files changed, 333 insertions(+), 29 deletions(-) create mode 100644 tests/deepclone_hooked_property_references.phpt create mode 100644 tests/deepclone_hydrate_preserve_refs.phpt create mode 100644 tests/deepclone_hydrate_preserve_refs_lazy.phpt create mode 100644 tests/deepclone_to_array_dynamic_property_refs.phpt 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)