diff --git a/src/DeepClone/DeepClone.php b/src/DeepClone/DeepClone.php index 197ee3b6..0332cc0a 100644 --- a/src/DeepClone/DeepClone.php +++ b/src/DeepClone/DeepClone.php @@ -838,6 +838,40 @@ private static function reconstruct($prepared, $objectMeta, $numObjects, $proper $refs[$k] = self::resolveWithMask($refs[$k], $m, $objects, $refs); } + // Finalize the remaining deferred objects: needsFullUnserialize objects + // whose __serialize() state nests another object (e.g. Random\Randomizer + // wrapping a Random\Engine\*), so their state carries an object-ref mask. + // The loop above skipped them because resolving the mask needs the + // referenced objects to exist first. Iterate to a fixpoint: each pass + // finalizes those whose referenced objects are now available. A leftover + // null (a cycle of such classes, which pure PHP cannot reconstruct) is + // reported by the properties/states loop below. + if ($states) { + do { + $progress = false; + foreach ($states as $state) { + if (!\is_array($state) || !isset($state[2])) { + continue; + } + $zid = $state[0] ?? null; + if (!\is_int($zid) || !\array_key_exists($zid, $objects) || null !== $objects[$zid]) { + continue; + } + if (!self::maskRefsReady($state[1] ?? null, $state[2], $objects)) { + continue; + } + $class = $objectMeta[$zid][0]; + $resolvedProps = self::resolveWithMask($state[1] ?? null, $state[2], $objects, $refs); + $ser = serialize($resolvedProps); + if (false === $obj = unserialize('O:'.\strlen($class).':"'.$class.'"'.substr($ser, strpos($ser, ':', 1)))) { + throw new \ValueError('deepclone_from_array(): could not reconstruct "'.$class.'" via __unserialize()'); + } + $objects[$zid] = $obj; + $progress = true; + } + } while ($progress); + } + foreach ($properties as $scope => $scopeProps) { if (!\is_string($scope)) { throw new \ValueError('deepclone_from_array(): Argument #1 ($data) "properties" keys must be of type string'); @@ -987,6 +1021,29 @@ private static function reconstruct($prepared, $objectMeta, $numObjects, $proper return $prepared; } + /** + * Whether every object referenced by $mask (positive object ids) has already + * been finalized in $objects, so resolveWithMask() can run without hitting an + * unbuilt placeholder. Hard/soft refs (negative ids, resolved against $refs in + * an earlier pass) and scalar/enum/closure masks never gate this. + */ + private static function maskRefsReady($value, $mask, array $objects): bool + { + if (true === $mask) { + return !\is_int($value) || $value < 0 || isset($objects[$value]); + } + if (!\is_array($mask) || !\is_array($value)) { + return true; + } + foreach ($mask as $k => $m) { + if (false !== $m && !self::maskRefsReady($value[$k] ?? null, $m, $objects)) { + return false; + } + } + + return true; + } + private static function resolveWithMask($value, $mask, $objects, &$refs) { if (true === $mask) { diff --git a/tests/DeepClone/DeepCloneTest.php b/tests/DeepClone/DeepCloneTest.php index 6d152894..4e8350c1 100644 --- a/tests/DeepClone/DeepCloneTest.php +++ b/tests/DeepClone/DeepCloneTest.php @@ -1956,4 +1956,39 @@ public function testHydrateBcMathNumberThrows() $this->expectExceptionMessage('Class "BcMath\Number" is not instantiable.'); deepclone_hydrate(\BcMath\Number::class, ['value' => '7.5']); } + + /** + * @requires PHP 8.2 + */ + public function testRoundTripRandomizerAsObjectProperty() + { + // Random\Randomizer is a final internal class whose __serialize() nests + // its engine object, so its deep-clone state carries an object-ref mask + // and it cannot be built as an early empty shell. Used as a property it + // must still round-trip: it is finalized once the engine it references + // exists, before references to it are resolved. + $seed = 1234; + $expected = (new \Random\Randomizer(new \Random\Engine\Mt19937($seed)))->getInt(1, \PHP_INT_MAX); + + $g = (object) ['r' => new \Random\Randomizer(new \Random\Engine\Mt19937($seed))]; + $c = deepclone_from_array(deepclone_to_array($g)); + + $this->assertInstanceOf(\Random\Randomizer::class, $c->r); + $this->assertSame($expected, $c->r->getInt(1, \PHP_INT_MAX)); + } + + /** + * @requires PHP 8.2 + */ + public function testRoundTripRandomizerTopLevelAndNested() + { + $top = deepclone_from_array(deepclone_to_array(new \Random\Randomizer(new \Random\Engine\Mt19937(7)))); + $this->assertInstanceOf(\Random\Randomizer::class, $top); + + $arr = deepclone_from_array(deepclone_to_array([new \Random\Randomizer(new \Random\Engine\Mt19937(8))])); + $this->assertInstanceOf(\Random\Randomizer::class, $arr[0]); + + $deep = deepclone_from_array(deepclone_to_array((object) ['list' => [(object) ['r' => new \Random\Randomizer(new \Random\Engine\Mt19937(9))]]])); + $this->assertInstanceOf(\Random\Randomizer::class, $deep->list[0]->r); + } }