Releases: symfony/php-ext-deepclone
v0.8.1
v0.8.0
Added
deepclone_to_array()anddeepclone_from_array()gained a third
parameter,bool $allow_named_closures = false, gating the by-name
encoding of closures over named callables (first-class callables such as
strlen(...),$obj->method(...),Cls::method(...), and
Closure::fromCallable()). Both ends must enable it: with it off (the
default)deepclone_to_array()refuses to encode such a closure and
deepclone_from_array()rejects any payload carrying a by-name closure
marker, before instantiating anything. A by-name payload can mint a
Closureover any function or method of that name, including internal
functions likesystem(), so it is restricted to ends that trust each
other. See SECURITY.md.- On PHP 8.5+, first-class callables over a method of their own declaring
class declared in a constant expression (e.g.
#[When(self::isStrict(...))]) now serialize as a reference to their
declaration site (the same code-free,allowed_classes-gated payload as
anonymous const-expr closures), instead of by name. They round-trip
without$allow_named_closures, restoring the attribute-cache use case for
first-class-callable arguments. References whose declaring class cannot be
derived from the closure (cross-class or global-function callables,
inherited methods, runtime-created callables) still take the by-name path
and require the opt-in.
Changed
-
BC break. Closures over named callables no longer serialize or resolve
by default; they now require$allow_named_closureson both
deepclone_to_array()anddeepclone_from_array()(see Added). Code that
relied on the previous unconditional by-name behavior must pass the flag.
Closures declared in constant expressions are unaffected. -
On PHP 8.4+,
deepclone_from_array()now creates object nodes whose
payload slots or replayed__unserializestate carry a named-closure or
const-expr-closure marker as
native lazy ghosts: all object identities (back-references, shared&
references,===) exist when the call returns, but those nodes' property
hydration, closure resolution included, is deferred until the engine
first touches each of them. Resolving closures (fake-closure creation,
attribute-args re-evaluation) is the measurably expensive part of
hydration, so deferral is restricted to the nodes that carry them; plain
value slots hydrate eagerly as before (copy-on-write makes them cheaper
to hydrate than to ghost), as do internal classes,stdClassand
zero-declared-property classes, all mixing freely with lazy ones.
Closure-bearing__wakeup/__unserializenodes defer too: their hook
runs at the end of their own initialization instead of in the global
children-first replay sequence, while per-entry validation stays inside
the call. On PHP 8.2/8.3 everything keeps hydrating eagerly. Structural
validation and$allowed_classesenforcement (including the
const-expr-closure gate) remain eager; only value-level resolution errors
(e.g. a stale const-expr closure line, a named-closure target that no
longer exists) surface at first access instead of inside
deepclone_from_array(), where the engine reverts the ghost and keeps it
retryable. The shared hydration state lives in the new internal-only
DeepClone\HydrationContextclass;
ReflectionClass::getLazyInitializer()returns a Closure bound to it.
Abandoned half-hydrated graphs are reclaimed by the cycle collector. One
documented deferral residue: type sources for shared&references bound
to typed properties are registered per node as it hydrates, so a write
through such a reference is only checked against the already-hydrated
holders (see README).
Fixed
- Binding a shared
&reference to a typed declared property aborted
debug builds (engine deref assertion) and skipped type-source registration
on release builds, so later writes through the reference bypassed the
property type.deepclone_from_array()and
deepclone_hydrate(..., DEEPCLONE_HYDRATE_PRESERVE_REFS)now mirror
unserialize(): the referenced value is verified against the property
type and the property is registered as a type source of the reference. - Resolving an object-ref marker (
true) against a ref id returned either
an alias or a by-value snapshot of the shared slot depending on which
consumer resolved first. It is now always a by-value snapshot (deref
before copy), making the result independent of hydration order, a
prerequisite for lazy mode, where that order is the user's touch order.
Such payloads are only ever hand-crafted:deepclone_to_array()never
emits object-ref markers with negative ids.
v0.7.2
Fixed
deepclone_to_array()now resolves theIS_INDIRECTslots that internal
classes may leave in the state returned by__serialize()(e.g.
Random\Randomizerbefore PHP 8.3, whose raw property table points into
the object). The payload previously retained those pointers, and using it
after the source object was released crasheddeepclone_from_array()on
PHP 8.2.
v0.7.1
Added
deepclone_to_array()/deepclone_from_array()now round-trip closures
declared in constant expressions (PHP 8.5+: attribute arguments, class
constants, enum cases, property and parameter defaults, and property hooks).
Such closures are compile-time-checked to be static and capture-free, so
they carry no state and are encoded by their declaration site (a new mask
marker) rather than by code.deepclone_from_array()re-evaluates the
addressed constant expression, selects the closure by a depth-first index,
and verifies the declaration line still matches, so a payload that outlived a
code change fails loudly with a "stale payload" error instead of resolving to
a moved closure. The payload carries names and indices only, never code;
allowed_classesgates both directions (Closureto encode, the declaring
class to decode). Closures created at runtime, and any the scanner cannot
match, keep throwing\DeepClone\NotInstantiableExceptionas before.
Fixed
deepclone_from_array()now rejects, with a\ValueError, a payload that
creates an object of a class with__unserialize()without flagging it for
its negative-wakeup state replay. Such a crafted payload previously built a
bareobject_init_ex()shell that__unserialize()never initialized; for
BcMath\Numberthebc_numstaysNULLand any operation on it crashed.
deepclone_to_array()only ever emits such a class with the replay flag, so
well-formed payloads are unaffected (php/php-src#22259 proposed an engine-side
guard but was declined, as the state is unreachable from userland).
v0.6.1
Fixed
deepclone_hydrate()now refuses an internalfinalclass with a
serialization API whose empty serialization payload cannot reconstruct an
instance (e.g.BcMath\Number, whosebc_numstaysNULLuntil
__construct()/__unserialize()runs), throwing the documented
\DeepClone\NotInstantiableExceptioninstead of injecting properties into a
half-builtcreate_objectshell, which produced an uninitialized object
that crashed on first use. The engine already refuses
ReflectionClass::newInstanceWithoutConstructor()for such classes; this
matches that behaviour and the polyfill. The instantiability probe runs under
serialize_lockso it stays isolated whendeepclone_hydrate()is called
from inside anotherunserialize()(e.g.Serializable::unserialize()).
Round-trip viadeepclone_to_array()/deepclone_from_array()is
unaffected; it replays the real state through__unserialize()
(symfony/symfony#64323).deepclone_from_array()now rejects a malformed payload whose serialized
class-name blob (a string whose second byte is:) decodes to a non-object
viaunserialize(), instead of storing the scalar/array result and later
dereferencing it as azend_object*. Such a payload now throws the documented
\ValueError.deepclone_from_array()no longer negates aPHP_INT_MINreference id while
resolving the object-reference, named-closure, and top-levelprepared
paths. NegatingZEND_LONG_MINis signed-overflow undefined behaviour; the
guard already present on the hard-ref path is now applied to the three sibling
sites, which reject the malformed id with a\ValueError.- Numeric property names (e.g.
$o->{'999'}) now round-trip through
deepclone_to_array()/deepclone_from_array(), matching
serialize()/unserialize()(symfony/symfony#64548).deepclone_to_array()
emitted such names as a non-canonical string array key, which both differed
from the polyfill's(array)-cast output and broke as soon as the payload
passed throughvar_export()/require(the OPcache cache-file use case) or
JSON, where"999"re-normalizes to the integer999— a key
deepclone_from_array()then rejected. Numeric names are now stored as the
canonical integer key on output and accepted as integer keys on input.
Non-canonical names such as"007"correctly remain strings.
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.