Skip to content

Releases: symfony/php-ext-deepclone

v0.8.1

12 Jun 07:43
Immutable release. Only release title and notes can be modified.

Choose a tag to compare

Fixed

  • An unescaped double quote in the allow_named_closures refusal message
    terminated the C string literal early and broke compilation on every
    platform.

v0.8.0

12 Jun 07:15
Immutable release. Only release title and notes can be modified.

Choose a tag to compare

Added

  • deepclone_to_array() and deepclone_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
    Closure over any function or method of that name, including internal
    functions like system(), 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_closures on both
    deepclone_to_array() and deepclone_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 __unserialize state 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, stdClass and
    zero-declared-property classes, all mixing freely with lazy ones.
    Closure-bearing __wakeup/__unserialize nodes 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_classes enforcement (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\HydrationContext class;
    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

10 Jun 19:25
Immutable release. Only release title and notes can be modified.

Choose a tag to compare

Fixed

  • deepclone_to_array() now resolves the IS_INDIRECT slots that internal
    classes may leave in the state returned by __serialize() (e.g.
    Random\Randomizer before 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 crashed deepclone_from_array() on
    PHP 8.2.

v0.7.1

10 Jun 18:17
Immutable release. Only release title and notes can be modified.

Choose a tag to compare

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_classes gates both directions (Closure to encode, the declaring
    class to decode). Closures created at runtime, and any the scanner cannot
    match, keep throwing \DeepClone\NotInstantiableException as 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
    bare object_init_ex() shell that __unserialize() never initialized; for
    BcMath\Number the bc_num stays NULL and 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

09 Jun 06:23
Immutable release. Only release title and notes can be modified.

Choose a tag to compare

Fixed

  • deepclone_hydrate() now refuses an internal final class with a
    serialization API whose empty serialization payload cannot reconstruct an
    instance (e.g. BcMath\Number, whose bc_num stays NULL until
    __construct()/__unserialize() runs), throwing the documented
    \DeepClone\NotInstantiableException instead of injecting properties into a
    half-built create_object shell, 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_lock so it stays isolated when deepclone_hydrate() is called
    from inside another unserialize() (e.g. Serializable::unserialize()).
    Round-trip via deepclone_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
    via unserialize(), instead of storing the scalar/array result and later
    dereferencing it as a zend_object*. Such a payload now throws the documented
    \ValueError.
  • deepclone_from_array() no longer negates a PHP_INT_MIN reference id while
    resolving the object-reference, named-closure, and top-level prepared
    paths. Negating ZEND_LONG_MIN is 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 through var_export()/require (the OPcache cache-file use case) or
    JSON, where "999" re-normalizes to the integer 999 — 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

26 Apr 12:55

Choose a tag to compare

Removed

  • deepclone_hydrate() no longer treats the special "\0" key as SPL
    internal state. ArrayObject, ArrayIterator, and SplObjectStorage
    all ship __serialize / __unserialize since PHP 7.4 — callers can
    populate them by instantiating with deepclone_hydrate() and calling
    __unserialize() with the documented array shape, or by round-tripping
    via deepclone_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 — offsetSet loops,
    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

17 Apr 08:01

Choose a tag to compare

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_array stashed pointers into the dst hash in
    ref_entry->tree_pos for later dtor; the first insert with a string
    key triggered zend_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 when Z_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_name leak on private-property skip:
    the goto next_prop paths (for __sleep-filtered or proto-identical
    values) bypassed the release of scope_name allocated in the
    private-key branch. Fix: track scope_name_owned and release at
    next_prop.
  • deepclone_from_array() DoS via unbounded IS_LONG objectMeta
    count: a 59-byte payload with objectMeta as 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

16 Apr 12:28

Choose a tag to compare

BC Break

  • deepclone_hydrate() now interprets $vars exclusively as a flat
    mangled-key array (the shape (array) $obj produces). 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_VARS constant 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_REFS flag value changed from 1 << 3 to
    1 << 2 (filling the slot vacated by DEEPCLONE_HYDRATE_MANGLED_VARS).
    Symbolic references via the constant name are unaffected; anyone using
    the raw integer value 4 now gets PRESERVE_REFS instead 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 than SplObjectStorage,
    ArrayObject, ArrayIterator) with a ValueError. Previously the
    value silently landed in obj->properties as a NUL-named dynamic
    property.
  • deepclone_hydrate() rejects malformed SPL "\0" payloads: a
    non-even-count pair stream for SplObjectStorage and a payload with
    more than 3 ctor args for ArrayObject / ArrayIterator. Both were
    previously tolerated silently (odd tail dropped; excess args truncated).
  • deepclone_hydrate() no longer direct-writes IS_PROP_UNINIT to a
    lazy object's slot via the null → uninitialized shortcut. The
    shortcut is now gated on zend_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-validates objectMeta wakeup flags
    against states entries: each state entry must match the sign
    advertised in objectMeta[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 through zend_update_property_ex() instead
    of zend_std_write_property(), respecting overridden write_property
    handlers on internal classes and extensions. Matches the
    deepclone_hydrate() path.
  • deepclone_from_array() throws ValueError on out-of-range object
    ids in "properties" entries (previously silently skipped).

Changed

  • deepclone_from_array() object-creation loop drops the pointer-scan
    over class_names[] that recovered the class id per object. A
    per-object uint32_t class_id is stored directly from the
    objectMeta parse, turning an O(N × K) step into O(N) on payloads
    with many objects across many classes.
  • deepclone_hydrate() caches the offsetSet method lookup across
    iterations on SplObjectStorage "\0" payloads (was re-resolved
    by name on every entry).

v0.4.0

15 Apr 17:00

Choose a tag to compare

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 new DEEPCLONE_HYDRATE_PRESERVE_REFS flag
    in $flags to 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_REFS constant — see BC break above. Composes
    with DEEPCLONE_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 as unserialize() on an O:… payload with a NUL-containing key);
    NUL-prefix names surface the engine's native Error: Cannot access property starting with "\0". The pre-v0.4.0 ValueError was 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

15 Apr 13:22

Choose a tag to compare

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 at DEEPCLONE_HYDRATE_MANGLED_VARS and
    the new $flags argument.