All notable changes to this extension will be documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
-
deepclone_hydrate()no longer treats the special"\0"key as SPL internal state.ArrayObject,ArrayIterator, andSplObjectStorageall ship__serialize/__unserializesince PHP 7.4 — callers can populate them by instantiating withdeepclone_hydrate()and calling__unserialize()with the documented array shape, or by round-tripping viadeepclone_from_array()which routes through__unserializenatively. The mangled-key resolution path ("propName","\0*\0prop","\0Class\0prop") is unchanged.This removes ~80 lines of bespoke SPL handling —
offsetSetloops, constructor invocation, packed-array shape validation, error paths — that duplicated what the classes natively expose. Symfony'sHydrator::hydrate()/Instantiator::instantiate()retain BC by translating the legacy"\0"shape to__unserialize()in user-land.
deepclone_to_array()heap-use-after-free when a referenced value is copied into an array that later transitions from packed to hash storage.dc_copy_arraystashed pointers into the dst hash inref_entry->tree_posfor later dtor; the first insert with a string key triggeredzend_hash_packed_to_hash()which freed the packed storage, leaving earlier tree_pos pointers dangling. Fix: force mixed/hash storage on dst before the loop.deepclone_to_array()unsound refcount-based pool-skip: skipping the object-pool lookup whenZ_REFCOUNT_P(src) == 1(without__serialize) was incorrect when the object is reached via a SHARED parent array — the parent is walked multiple times and the object is visited twice, but the skip bypassed the pool and trippedzend_hash_index_add_new's assertion on the second visit. Fix: always do the pool lookup.deepclone_to_array()scope_nameleak on private-property skip: thegoto next_proppaths (for__sleep-filtered or proto-identical values) bypassed the release ofscope_nameallocated in the private-key branch. Fix: trackscope_name_ownedand release atnext_prop.deepclone_from_array()DoS via unbounded IS_LONGobjectMetacount: a 59-byte payload withobjectMetaas a large integer (e.g.844067442) triggered multi-GB allocations. Fix: cap the IS_LONG form at 1 << 20 (1M); payloads needing more should use the array form which is naturally bounded by hash-table size.
All four were found by libFuzzer harnesses with ASAN/UBSAN — two
targeting deepclone_from_array() and deepclone_hydrate() directly,
and one round-trip harness that builds a graph from a tiny stack
machine and feeds it through deepclone_to_array() /
deepclone_from_array(). Total: 8.47M executions on hydrate and
6.98M on from_array clean after fixes, plus ~million roundtrip execs.
deepclone_hydrate()now interprets$varsexclusively as a flat mangled-key array (the shape(array) $objproduces). 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_VARSconstant 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_REFSflag value changed from1 << 3to1 << 2(filling the slot vacated byDEEPCLONE_HYDRATE_MANGLED_VARS). Symbolic references via the constant name are unaffected; anyone using the raw integer value4now getsPRESERVE_REFSinstead of the oldMANGLED_VARS— in practice both are the flags real callers pass, so the arithmetic happens to line up.
deepclone_hydrate()rejects the SPL-internal-state"\0"key on objects that don't support it (anything other thanSplObjectStorage,ArrayObject,ArrayIterator) with aValueError. Previously the value silently landed inobj->propertiesas a NUL-named dynamic property.deepclone_hydrate()rejects malformed SPL"\0"payloads: a non-even-count pair stream forSplObjectStorageand a payload with more than 3 ctor args forArrayObject/ArrayIterator. Both were previously tolerated silently (odd tail dropped; excess args truncated).deepclone_hydrate()no longer direct-writesIS_PROP_UNINITto a lazy object's slot via thenull→ uninitialized shortcut. The shortcut is now gated onzend_lazy_object_initialized(obj), soDEEPCLONE_HYDRATE_NO_LAZY_INIT+ lazy objects fall through to the Reflection-based path instead of bypassing the lazy-props bookkeeping.deepclone_from_array()cross-validatesobjectMetawakeup flags againststatesentries: each state entry must match the sign advertised inobjectMeta[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 throughzend_update_property_ex()instead ofzend_std_write_property(), respecting overriddenwrite_propertyhandlers on internal classes and extensions. Matches thedeepclone_hydrate()path.deepclone_from_array()throwsValueErroron out-of-range object ids in"properties"entries (previously silently skipped).
deepclone_from_array()object-creation loop drops the pointer-scan overclass_names[]that recovered the class id per object. A per-objectuint32_t class_idis stored directly from theobjectMetaparse, turning an O(N × K) step into O(N) on payloads with many objects across many classes.deepclone_hydrate()caches theoffsetSetmethod lookup across iterations onSplObjectStorage"\0"payloads (was re-resolved by name on every entry).
deepclone_hydrate()no longer preserves PHP&references from$varsonto the target property slots by default. Incoming reference zvals are dereferenced on write (ZVAL_DEREF), so property slots hold plain values instead of ref links. Pass the newDEEPCLONE_HYDRATE_PRESERVE_REFSflag in$flagsto opt back into the old behavior. Motivation: the ref-preserving path requires a per-call probe of the input array, which dominated cost for typical DTO hydration; making it opt-in brings the polyfill in line with Reflection-based hydrators on ref-less input. Callers that intentionally share a value slot between two properties (or between a property and a caller-side variable) need to add the flag.
DEEPCLONE_HYDRATE_PRESERVE_REFSconstant — see BC break above. Composes withDEEPCLONE_HYDRATE_MANGLED_VARS,DEEPCLONE_HYDRATE_CALL_HOOKS, andDEEPCLONE_HYDRATE_NO_LAZY_INIT.
deepclone_hydrate()scoped-mode property-name validation now matchesunserialize()permissiveness: integer keys coerce to strings on dynamic property access; NUL-in-middle names are stored as raw dynamic properties (same asunserialize()on anO:…payload with a NUL-containing key); NUL-prefix names surface the engine's nativeError: Cannot access property starting with "\0". The pre-v0.4.0ValueErrorwas stricter thanunserialize()and cost a per-prop validation in the hot path; dropping it aligns the semantics and saves hot-path work.DEEPCLONE_HYDRATE_MANGLED_VARSmode still parses and validates mangled keys.
deepclone_hydrate()error messages for NUL-containing property names in scoped mode referenced the pre-v0.3.0$scoped_vars/$mangled_varsparameters. Updated to point atDEEPCLONE_HYDRATE_MANGLED_VARSand the new$flagsargument.
deepclone_hydrate()now takes a single$varsarray instead of separate$scoped_varsand$mangled_vars. The default interpretation is the scoped per-class shape; pass the newDEEPCLONE_HYDRATE_MANGLED_VARSflag in$flagsto interpret$varsas a flat mangled-key array (the shape(array) $objectproduces). Old positional callers (deepclone_hydrate($obj, [], $mangled)) need to be updated todeepclone_hydrate($obj, $mangled, DEEPCLONE_HYDRATE_MANGLED_VARS). As a footgun guard, passing a NUL-prefixed key in scoped mode raises aValueErrorpointing at the missing flag.
DEEPCLONE_HYDRATE_MANGLED_VARSconstant — see BC break above.
deepclone_hydrate()silently skips readonly writes when the target slot already holds an identical value (===). Avoids "Cannot modify readonly property" on idempotent rehydration. Writes to uninitialized readonly and to different-valued readonly still obey engine semantics.deepclone_hydrate()writesnullinto a non-nullable typed property asunset()(restoring the uninitialized state) instead of raisingTypeError. Nullable/mixed types keep their existing semantics. Hooked properties are exempt (no backing slot to "unset"; the set hook may handlenullitself).deepclone_hydrate()casts scalar values to the matching backed-enum case when the target is a single-type (possibly nullable) backed-enum property and the value matches the enum's backing type (int↔ int- backed,string↔ string-backed). Unknown backing values raise the standardValueErrorfromEnum::from(). Decision rests on the property type only —DEEPCLONE_HYDRATE_CALL_HOOKSand hook presence don't change it. Set hooks on enum-typed properties accordingly receive the enum case, not the raw scalar.
deepclone_hydrate(..., int $flags = 0)— new optional parameter to choose the write semantics for declared-property assignments:DEEPCLONE_HYDRATE_CALL_HOOKS—ReflectionProperty::setValuesemantics: invoke user-defined set hooks on hooked properties.DEEPCLONE_HYDRATE_NO_LAZY_INIT—ReflectionProperty::setRawValueWithoutLazyInitializationsemantics: skip the lazy initializer for each written property; realize the object when the last lazy property is set. Delegated to the Reflection API because the engine helpers the method relies on (zend_lazy_object_decr_lazy_props,zend_lazy_object_realize) are not exported asZEND_API.- Default (0) —
setRawValuesemantics (bypass set hooks, type-check). - The two flags are mutually exclusive; unknown bits are rejected with
ValueError.
deepclone_from_array()always uses the default setRawValue semantics (same policy asunserialize()— payload-driven).
deepclone_hydrate(object|string $object_or_class, array $scoped_vars = [], array $mangled_vars = []): object— instantiates a class (or takes an existing object) and sets its properties, including private, protected, and readonly ones. Handles mangled key formats ("\0ClassName\0prop","\0*\0prop"), SPL special cases (ArrayObject, ArrayIterator, SplObjectStorage via"\0"key), and preserves PHP&references with correct type source tracking for typed properties.- Instantiability validation for
deepclone_hydrate: rejects the same classes asdeepclone_from_array(abstract, interface, trait, enum, anonymous, Reflector subclasses, internal classes without serialization API). Results are cached per class for zero-cost repeated calls. ValueErroron invalid input: integer keys in$mangled_vars, non-array values in$scoped_vars, mangled keys inside$scoped_vars, property names containing NUL bytes, and scopes that aren't a parent of the object's class.- Strict scope validation in
deepclone_from_array(): rejects unloaded scope-class names, scopes that aren't a parent of the target object's class, stdClass-scoped writes targeting non-public declared properties, and non-stdClass scopes referencing property names not declared on the scope class. Blocks scope-confusion payloads that could otherwise reach private slots on unrelated classes that happen to share a property name. - Mangled-key validation in
deepclone_hydrate(): rejects keys with a missing second NUL separator (e.g."\0broken") or an empty class name (e.g."\0\0prop") with aValueErrorinstead of silently skipping.
- All function parameters now use snake_case to follow PHP conventions:
$allowed_classes,$object_or_class,$scoped_vars,$mangled_vars. deepclone_from_array()now writes declared properties via directOBJ_PROPslot access (same fast path asdeepclone_hydrate), including correctzend_referencetype-source tracking for typed properties. On a 50-node graph this is ~25% faster and also covers a latent assertion on references flowing through typed user-class properties.- Non-virtual hooked properties (PHP 8.4+) are now written via direct slot
access, bypassing the
sethook. MatchesReflectionProperty::setRawValuesemantics: hydration restores stored state rather than re-running transformation logic. Virtual properties still go through the engine write path (they have no backing slot). deepclone_to_array()scalar fast path in the transpose loop — ~10% faster on graphs dominated by scalar leaves.- Scope-class resolution in
deepclone_from_array()useszend_lookup_class_ex(..., ZEND_FETCH_CLASS_NO_AUTOLOAD)— leverages the per-zend_stringCE cache for O(1) repeat lookups and never triggers autoload for scope names (scope classes must already be loaded as parents of validated objects).
deepclone_to_array()no longer warns about__sleep()-listed typed properties that are uninitialized — matching nativeserialize()behavior.deepclone_from_array()rejects ref-id values equal toZEND_LONG_MINor non-negative — prevents signed-integer negation UB on malformed payloads.- ZTS thread-safety: the per-class instantiability cache used by
deepclone_hydrate()is now per-thread via module globals (previously a function-level static, racy under concurrent ZTS init).
- Memory leaks on objects with
__unserialize: spuriousGC_TRY_ADDREFon arrays transferred (not shared) into the states output. - Assertion failure in debug builds:
dc_mask_cleanupcalledzend_hash_applyon a shared (refcount > 1) mask array. Fixed withSEPARATE_ARRAYbefore iterating.
- Replaced
class_list,ce_cache, andobjectsHashTables indeepclone_from_array()with flat C arrays for lower overhead. - Use
zend_hash_find_known_hash()for all interned key lookups. - Use
DC_MASK_IS_NAMED_CLOSURE()consistently indc_mask_has_closure. - Added Serializable code path test (
deepclone_serializable.phpt). - CI: added PHP debug build job for Zend MM leak detection; enabled
ASAN LeakSanitizer (
detect_leaks=1).
deepclone_to_array(mixed $value, ?array $allowedClasses = null): array— walks a PHP value graph and produces a pure-array payload (only scalars and nested arrays). Compatible with the wire format used bySymfony\Component\VarExporter\DeepCloner.deepclone_from_array(array $data, ?array $allowedClasses = null): mixed— reconstructs a value graph from a payload produced bydeepclone_to_array().$allowedClassesparameter on both functions, matchingunserialize()'sallowed_classesoption:null= allow all,[]= allow none, case-insensitive. Closures require"Closure"in the list.- Two typed exceptions under the
DeepClone\namespace, both extending\InvalidArgumentException:DeepClone\NotInstantiableException— thrown bydeepclone_to_array()when the input contains a resource or a non-instantiable class.DeepClone\ClassNotFoundException— thrown bydeepclone_from_array()when the payload references a class that no longer exists.
- Human-friendly exception messages:
'Type "X" is not instantiable.','Class "X" not found.' - Rejects internal classes that hold hidden C-level state (custom
create_objecthandler) and declare no serialization API. Final internal classes are probed viaobject_init_ex()— stateless ones (e.g.MongoDB\BSON\MinKey) pass; others are rejected. - Preserves copy-on-write for strings and scalar arrays across clones.
- Preserves object identity, PHP
&hard references, cycles, private/protected properties,__serialize/__unserialize/__sleep/__wakeupsemantics, named closures (first-class callables), and enum values. - Fuzz tests: 500-iteration seeded round-trip + 200-iteration malformed-input decoder test, both 32-bit safe.
- Compatible with PHP 8.2–8.5, NTS and ZTS, on x86_64 and i386 Linux, macOS, and Windows.