Skip to content

Releases: symfony/php-ext-deepclone

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.

v0.3.0

15 Apr 13:01

Choose a tag to compare

BC Break

  • deepclone_hydrate() now takes a single $vars array instead of
    separate $scoped_vars and $mangled_vars. The default interpretation
    is the scoped per-class shape; pass the new DEEPCLONE_HYDRATE_MANGLED_VARS
    flag in $flags to interpret $vars as a flat mangled-key array (the
    shape (array) $object produces). 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
    a ValueError pointing at the missing flag.

Added

  • DEEPCLONE_HYDRATE_MANGLED_VARS constant — 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() writes null into a non-nullable typed property
    as unset() (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 handle null itself).
  • 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
    standard ValueError from Enum::from(). Decision rests on the
    property type only — DEEPCLONE_HYDRATE_CALL_HOOKS and 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_HOOKSReflectionProperty::setValue
      semantics: invoke user-defined set hooks on hooked properties.
    • DEEPCLONE_HYDRATE_NO_LAZY_INIT
      ReflectionProperty::setRawValueWithoutLazyInitialization semantics:
      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 as ZEND_API.
    • Default (0) — setRawValue semantics (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 as unserialize() — payload-driven).

v0.2.0

14 Apr 18:50

Choose a tag to compare

v0.2.0

v0.1.1

10 Apr 20:39

Choose a tag to compare

Release v0.1.1

v0.1.0

10 Apr 13:01

Choose a tag to compare

Prepare 0.1.0 release: update CHANGELOG, trim stubs