Releases: symfony/php-ext-deepclone
v0.6.0
Removed
-
deepclone_hydrate()no longer treats the special"\0"key as SPL
internal state.ArrayObject,ArrayIterator, andSplObjectStorage
all 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__unserialize
natively. 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's
Hydrator::hydrate()/Instantiator::instantiate()retain BC by
translating the legacy"\0"shape to__unserialize()in user-land.
v0.5.1
Fixed
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 in
ref_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 tripped
zend_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 at
next_prop.deepclone_from_array()DoS via unbounded IS_LONGobjectMeta
count: 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.
v0.5.0
BC Break
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 << 3to
1 << 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 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 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), 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-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_property
handlers on internal classes and extensions. Matches the
deepclone_hydrate()path.deepclone_from_array()throwsValueErroron out-of-range object
ids in"properties"entries (previously silently skipped).
Changed
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 the
objectMetaparse, 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).
v0.4.0
BC Break
deepclone_hydrate()no longer preserves PHP&references from$vars
onto 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.
Added
DEEPCLONE_HYDRATE_PRESERVE_REFSconstant — see BC break above. Composes
withDEEPCLONE_HYDRATE_MANGLED_VARS,DEEPCLONE_HYDRATE_CALL_HOOKS, and
DEEPCLONE_HYDRATE_NO_LAZY_INIT.
Changed
deepclone_hydrate()scoped-mode property-name validation now matches
unserialize()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 than
unserialize()and cost a per-prop validation in the hot path; dropping it
aligns the semantics and saves hot-path work.DEEPCLONE_HYDRATE_MANGLED_VARS
mode still parses and validates mangled keys.
v0.3.1
Fixed
deepclone_hydrate()error messages for NUL-containing property names
in scoped mode referenced the pre-v0.3.0$scoped_vars/$mangled_vars
parameters. Updated to point atDEEPCLONE_HYDRATE_MANGLED_VARSand
the new$flagsargument.
v0.3.0
BC Break
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_VARS
flag in$flagsto interpret$varsas a flat mangled-key array (the
shape(array) $objectproduces). Old positional callers
(deepclone_hydrate($obj, [], $mangled)) need to be updated to
deepclone_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.
Added
DEEPCLONE_HYDRATE_MANGLED_VARSconstant — see BC break above.
Changed
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 raising
TypeError. 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.
Added
deepclone_hydrate(..., int $flags = 0)— new optional parameter to
choose the write semantics for declared-property assignments:DEEPCLONE_HYDRATE_CALL_HOOKS—ReflectionProperty::setValue
semantics: 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).
v0.2.0
v0.2.0
v0.1.1
Release v0.1.1
v0.1.0
Prepare 0.1.0 release: update CHANGELOG, trim stubs