From 0fab20e8f4edbf5a5feb668037b8a0746912066b Mon Sep 17 00:00:00 2001 From: Nicolas Grekas Date: Fri, 25 Sep 2026 22:06:43 +0200 Subject: [PATCH 1/2] [DeepClone] Reject and create classes like the extension in deepclone_from_array() and deepclone_hydrate() --- src/DeepClone/DeepClone.php | 83 ++++++++++++---- tests/DeepClone/DeepCloneTest.php | 153 +++++++++++++++++++++++++++++- tests/DeepClone/fixtures.php | 23 +++++ 3 files changed, 238 insertions(+), 21 deletions(-) diff --git a/src/DeepClone/DeepClone.php b/src/DeepClone/DeepClone.php index da1ecb93..bd8289ab 100644 --- a/src/DeepClone/DeepClone.php +++ b/src/DeepClone/DeepClone.php @@ -33,6 +33,7 @@ final class DeepClone private static array $cloneable = []; private static array $instantiableWithoutConstructor = []; private static array $needsFullUnserialize = []; + private static array $unexportable = []; private static array $hydrators = []; private static array $simpleHydrators = []; private static array $shapes = []; @@ -275,7 +276,7 @@ public static function deepclone_from_array(array $data, ?array $allowed_classes $allowed = self::getAllowedSet($allowed_classes, 'deepclone_from_array'); foreach ($classes as $cls) { if (!isset($allowed[strtolower($cls)])) { - throw new \ValueError('deepclone_from_array(): class "'.$cls.'" is not allowed'); + throw new \ValueError('deepclone_from_array(): class "'.explode("\0", $cls, 2)[0].'" is not allowed'); } } if (!isset($allowed['closure'])) { @@ -390,13 +391,23 @@ public static function deepclone_hydrate(object|string $object_or_class, array $ } if (\is_string($class = $object_or_class)) { - $r = self::$reflectors[$class] ??= self::getClassReflector($class); + try { + $r = self::$reflectors[$class] ??= self::getClassReflector($class); + } catch (\DeepClone\NotInstantiableException $e) { + // Like the extension, which checks internal classes only for the ones whose state serialize() loses, like IteratorIterator + if (!isset(self::$unexportable[($r = new \ReflectionClass($class))->name]) || $r->isInternal()) { + throw $e; + } + $object = $r->newInstanceWithoutConstructor(); + + goto hydrate; + } if (null === self::$prototypes[$class] && !self::$instantiableWithoutConstructor[$class]) { // No empty-shell prototype exists (e.g. an internal final class // whose __unserialize() rejects an empty payload, like // BcMath\Number). Such a class can only be reconstructed via a // full serialization round-trip, never by property injection. - throw new \DeepClone\NotInstantiableException('Class "'.$class.'" is not instantiable.'); + throw new \DeepClone\NotInstantiableException('Type "'.$class.'" is not instantiable.'); } elseif (self::$cloneable[$class]) { $object = clone self::$prototypes[$class]; } elseif (self::$instantiableWithoutConstructor[$class]) { @@ -412,6 +423,7 @@ public static function deepclone_hydrate(object|string $object_or_class, array $ $class = $object::class; } + hydrate: if (!$vars) { return $object; } @@ -590,11 +602,9 @@ private static function valueName(mixed $value): string if (null === $value) { return 'null'; } - if (true === $value) { - return 'true'; - } - if (false === $value) { - return 'false'; + if (\is_bool($value)) { + // Like zend_zval_value_name(), or zend_zval_type_name() before PHP 8.3 + return \PHP_VERSION_ID < 80300 ? 'bool' : ($value ? 'true' : 'false'); } if (\is_object($value)) { return $value::class; @@ -895,18 +905,33 @@ private static function reconstruct($prepared, $objectMeta, $numObjects, $proper foreach ($objectMeta as $id => [$class, $wakeup]) { if (':' === ($class[1] ?? null)) { + try { + $objects[$id] = unserialize($class, null !== $allowedClasses ? ['allowed_classes' => $allowedClasses] : []); + } catch (\Throwable $e) { + throw self::unserializeError($e, $id); + } // The result is used as an object below; a malformed payload can // carry any serialize form (i:…, s:…, a:…), so reject anything // that did not decode to an object rather than mistreating it. - if (!\is_object($objects[$id] = unserialize($class, null !== $allowedClasses ? ['allowed_classes' => $allowedClasses] : []))) { - throw new \ValueError('deepclone_from_array(): Argument #1 ($data) object '.$id.' did not unserialize to an object, '.get_debug_type($objects[$id]).' given'); + if (!\is_object($objects[$id])) { + if (false === $objects[$id] && !str_starts_with($class, 'b:0;')) { + throw new \ValueError('deepclone_from_array(): Argument #1 ($data) failed to unserialize object '.$id); + } + throw new \ValueError('deepclone_from_array(): Argument #1 ($data) object '.$id.' did not unserialize to an object, '.self::valueName($objects[$id]).' given'); } continue; } try { self::$reflectors[$class] ??= self::getClassReflector($class); } catch (\DeepClone\ClassNotFoundException) { - throw new \DeepClone\ClassNotFoundException('Class "'.$class.'" not found.'); + throw new \DeepClone\ClassNotFoundException('Class "'.explode("\0", $class, 2)[0].'" not found.'); + } catch (\DeepClone\NotInstantiableException $e) { + // deepclone_to_array() rejects the classes whose state serialize() loses, like IteratorIterator, but unserialize() creates them + if (!isset(self::$unexportable[(new \ReflectionClass($class))->name])) { + throw $e; + } + $objects[$id] = unserialize('O:'.\strlen($class).':"'.$class.'":0:{}'); + continue; } // A class with __unserialize() is only ever emitted (by @@ -1987,17 +2012,14 @@ private static function getShape(\ReflectionClass $reflector, $proto): array private static function getClassReflector($class, $instantiableWithoutConstructor = false, $cloneable = null) { if (!($isClass = class_exists($class)) && !interface_exists($class, false) && !trait_exists($class, false)) { - throw new \DeepClone\ClassNotFoundException('Class "'.$class.'" not found.'); - } - if (isset(self::NOT_ROUND_TRIPPABLE[$class])) { - throw new \DeepClone\NotInstantiableException('Type "'.$class.'" is not instantiable.'); + throw new \DeepClone\ClassNotFoundException('Class "'.explode("\0", $class, 2)[0].'" not found.'); } $reflector = new \ReflectionClass($class); if ($instantiableWithoutConstructor) { $proto = $reflector->newInstanceWithoutConstructor(); } elseif (!$isClass || $reflector->isAbstract() || $reflector->isEnum()) { - throw new \DeepClone\NotInstantiableException('Type "'.$class.'" is not instantiable.'); + throw new \DeepClone\NotInstantiableException('Type "'.$reflector->name.'" is not instantiable.'); } elseif ($reflector->name !== $class) { $reflector = self::$reflectors[$name = $reflector->name] ??= self::getClassReflector($name, false, $cloneable); self::$cloneable[$class] = self::$cloneable[$name]; @@ -2021,7 +2043,7 @@ private static function getClassReflector($class, $instantiableWithoutConstructo throw $e; } if (!method_exists($class, '__unserialize')) { - throw new \DeepClone\NotInstantiableException('Type "'.$class.'" is not instantiable.', 0, $e); + throw new \DeepClone\NotInstantiableException('Type "'.$class.'" is not instantiable.'); } self::$needsFullUnserialize[$class] = true; $proto = null; @@ -2037,15 +2059,18 @@ private static function getClassReflector($class, $instantiableWithoutConstructo } // Anonymous classes and some internal ones refuse serialization, which subclasses inherit, whatever methods they declare. // Anonymous classes that restore their state with __wakeup() or __unserialize(), like throwables, round-trip in-process all the same. - for ($r = $reflector; $r && !$r->isInternal(); $r = $r->getParentClass()) { + for ($r = $reflector; !$r->isInternal() && ($r = $r->getParentClass()) && !$r->isAnonymous();) { } - if ($r && 'stdClass' !== $r->name && self::refusesSerialization($r->name) || $reflector->isAnonymous() && !method_exists($class, '__wakeup') && !method_exists($class, '__unserialize')) { + if ($r && ($r->isAnonymous() || 'stdClass' !== $r->name && self::refusesSerialization($r->name)) || $reflector->isAnonymous() && !method_exists($class, '__wakeup') && !method_exists($class, '__unserialize')) { throw new \DeepClone\NotInstantiableException('Type "'.(strstr($class, "\0", true) ?: $class).'" is not instantiable.'); } } if (null === $cloneable) { - if (($proto instanceof \Reflector || $proto instanceof \ReflectionGenerator || $proto instanceof \ReflectionType || $proto instanceof \IteratorIterator || $proto instanceof \RecursiveIteratorIterator) && (!$proto instanceof \Serializable && !method_exists($class, '__wakeup') && !method_exists($class, '__unserialize'))) { + // Classes whose state serialize() loses, which deepclone_from_array() creates all the same, like unserialize() does + if (isset(self::NOT_ROUND_TRIPPABLE[$class]) || ($proto instanceof \Reflector || $proto instanceof \ReflectionGenerator || $proto instanceof \ReflectionType || $proto instanceof \IteratorIterator || $proto instanceof \RecursiveIteratorIterator) && (!$proto instanceof \Serializable && !method_exists($class, '__wakeup') && !method_exists($class, '__unserialize'))) { + self::$unexportable[$class] = true; + throw new \DeepClone\NotInstantiableException('Type "'.$class.'" is not instantiable.'); } @@ -2074,6 +2099,24 @@ private static function getClassReflector($class, $instantiableWithoutConstructo return $reflector; } + /** + * Reports what unserialize() throws while decoding an object of the payload as malformed input, + * like the extension does, and lets through what __wakeup() or __unserialize() throw after that. + */ + private static function unserializeError(\Throwable $e, int $id): \Throwable + { + foreach ($trace = $e->getTrace() as $i => $frame) { + if (__FILE__ === ($frame['file'] ?? null) && 'unserialize' === $frame['function'] && !isset($frame['class'])) { + if (\in_array($trace[$i - 1]['function'] ?? null, ['__wakeup', '__unserialize'], true) && isset($trace[$i - 1]['class'])) { + return $e; + } + break; + } + } + + return new \ValueError('deepclone_from_array(): Argument #1 ($data) failed to unserialize object '.$id, 0, $e); + } + private static function refusesSerialization(string $class): bool { // unserialize() checks this before parsing anything past the class name: the truncated payload creates no object diff --git a/tests/DeepClone/DeepCloneTest.php b/tests/DeepClone/DeepCloneTest.php index 401fe3ff..e6a0f877 100644 --- a/tests/DeepClone/DeepCloneTest.php +++ b/tests/DeepClone/DeepCloneTest.php @@ -1690,6 +1690,155 @@ public function __serialize(): array } } + public function testUninstantiableClassesAreRejected() + { + $this->skipIfExtensionRejectsClassesDifferently(); + + foreach ([AbstractScopeBase::class, DeepCloneInterface::class, HydrateTrait::class, DeepCloneColor::class] as $class) { + // Named in lower case: messages use the declared name + $calls = [ + 'deepclone_from_array' => static function () use ($class) { return deepclone_from_array(['classes' => strtolower($class), 'objectMeta' => 1, 'prepared' => 0]); }, + 'deepclone_hydrate' => static function () use ($class) { return deepclone_hydrate(strtolower($class)); }, + ]; + foreach ($calls as $function => $call) { + try { + $call(); + $this->fail(\sprintf('%s() accepted "%s".', $function, $class)); + } catch (\DeepClone\NotInstantiableException $e) { + $this->assertSame('Type "'.$class.'" is not instantiable.', $e->getMessage()); + $this->assertNull($e->getPrevious()); + } + } + } + + try { + deepclone_from_array(['classes' => \Closure::class, 'objectMeta' => 1, 'prepared' => 0]); + $this->fail('deepclone_from_array() accepted "Closure".'); + } catch (\DeepClone\NotInstantiableException $e) { + $this->assertSame('Type "Closure" is not instantiable.', $e->getMessage()); + $this->assertNull($e->getPrevious()); + } + } + + public function testClassesWhoseStateSerializeLosesAreCreatedLikeUnserializeDoes() + { + $this->skipIfExtensionRejectsClassesDifferently(); + + foreach ([\IteratorIterator::class, \LimitIterator::class, \RecursiveIteratorIterator::class, DeepCloneIteratorIterator::class] as $class) { + $this->assertInstanceOf($class, unserialize('O:'.\strlen($class).':"'.$class.'":0:{}')); + $this->assertInstanceOf($class, deepclone_from_array(['classes' => $class, 'objectMeta' => 1, 'prepared' => 0])); + } + + $object = deepclone_from_array([ + 'classes' => DeepCloneIteratorIterator::class, + 'objectMeta' => 1, + 'prepared' => 0, + 'properties' => ['stdClass' => ['a' => [1]], DeepCloneIteratorIterator::class => ['b' => [2]]], + ]); + $this->assertSame(1, $object->a); + $this->assertSame(2, $object->getB()); + + // deepclone_to_array() and deepclone_hydrate() still reject them, the latter for internal classes only + try { + deepclone_to_array(new \IteratorIterator(new \ArrayIterator([1]))); + $this->fail('deepclone_to_array() accepted an IteratorIterator.'); + } catch (\DeepClone\NotInstantiableException $e) { + $this->assertSame('Type "IteratorIterator" is not instantiable.', $e->getMessage()); + } + try { + deepclone_hydrate(\IteratorIterator::class); + $this->fail('deepclone_hydrate() accepted "IteratorIterator".'); + } catch (\DeepClone\NotInstantiableException $e) { + $this->assertSame('Type "IteratorIterator" is not instantiable.', $e->getMessage()); + } + $object = deepclone_hydrate(DeepCloneIteratorIterator::class, ['a' => 1, "\0".DeepCloneIteratorIterator::class."\0b" => 2]); + $this->assertSame(1, $object->a); + $this->assertSame(2, $object->getB()); + } + + public function testSerializedObjectsThatFailToDecodeAreMalformed() + { + $payloads = [ + 'O:11:"SplFileInfo":0:{}' => \Exception::class, + 'O:'.\strlen(DeepCloneInterface::class).':"'.DeepCloneInterface::class.'":0:{}' => \Error::class, + 'O:8:"stdClass":1:{' => null, + ]; + foreach ($payloads as $serialized => $previous) { + try { + @deepclone_from_array(['classes' => $serialized, 'objectMeta' => 1, 'prepared' => 0]); + $this->fail(\sprintf('deepclone_from_array() accepted "%s".', $serialized)); + } catch (\ValueError $e) { + $this->assertSame('deepclone_from_array(): Argument #1 ($data) failed to unserialize object 0', $e->getMessage()); + $this->assertSame($previous, $e->getPrevious() ? \get_class($e->getPrevious()) : null); + } + } + + try { + deepclone_from_array(['classes' => 'b:1;', 'objectMeta' => 1, 'prepared' => 0]); + $this->fail('deepclone_from_array() accepted a boolean.'); + } catch (\ValueError $e) { + $this->assertSame('deepclone_from_array(): Argument #1 ($data) object 0 did not unserialize to an object, '.(\PHP_VERSION_ID >= 80300 ? 'true' : 'bool').' given', $e->getMessage()); + } + + // What __wakeup() throws once the object is decoded goes through + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('Cannot wake up.'); + deepclone_from_array(['classes' => 'O:'.\strlen(DeepCloneWakeupThrows::class).':"'.DeepCloneWakeupThrows::class.'":0:{}', 'objectMeta' => 1, 'prepared' => 0]); + } + + public function testClassesExtendingAnonymousOnesAreRejected() + { + $this->skipIfExtensionRejectsClassesDifferently(); + + if (!class_exists(DeepCloneExtendsAnonymous::class, false)) { + $anonymous = new class { + public function __wakeup(): void + { + } + }; + class_alias(\get_class($anonymous), __NAMESPACE__.'\DeepCloneAnonymousAlias'); + eval('namespace '.__NAMESPACE__.'; class DeepCloneExtendsAnonymous extends DeepCloneAnonymousAlias {}'); + } + + try { + serialize(new DeepCloneExtendsAnonymous()); + $this->fail('serialize() accepted a class extending an anonymous one.'); + } catch (\Exception $e) { + $this->assertSame('Serialization of \''.DeepCloneExtendsAnonymous::class.'\' is not allowed', $e->getMessage()); + } + + $calls = [ + 'deepclone_to_array' => static function () { return deepclone_to_array(new DeepCloneExtendsAnonymous()); }, + 'deepclone_from_array' => static function () { return deepclone_from_array(['classes' => DeepCloneExtendsAnonymous::class, 'objectMeta' => 1, 'prepared' => 0]); }, + 'deepclone_hydrate' => static function () { return deepclone_hydrate(DeepCloneExtendsAnonymous::class); }, + ]; + foreach ($calls as $function => $call) { + try { + $call(); + $this->fail(\sprintf('%s() accepted a class extending an anonymous one.', $function)); + } catch (\DeepClone\NotInstantiableException $e) { + $this->assertSame('Type "'.DeepCloneExtendsAnonymous::class.'" is not instantiable.', $e->getMessage()); + } + } + } + + public function testClassNamesEndAtNulBytesInMessages() + { + try { + deepclone_from_array(['classes' => "Foo\0Bar", 'objectMeta' => 1, 'prepared' => 0], []); + $this->fail('deepclone_from_array() accepted a class missing from $allowed_classes.'); + } catch (\ValueError $e) { + $this->assertSame('deepclone_from_array(): class "Foo" is not allowed', $e->getMessage()); + } + + try { + deepclone_from_array(['classes' => "Foo\0Bar", 'objectMeta' => 1, 'prepared' => 0]); + $this->fail('deepclone_from_array() accepted a class that does not exist.'); + } catch (\DeepClone\ClassNotFoundException $e) { + $this->assertSame('Class "Foo" not found.', $e->getMessage()); + } + } + public function testDocBehaviorsSensitiveParameterValueIsRejected() { if (\PHP_VERSION_ID < 80200) { @@ -2851,12 +3000,14 @@ public function testRoundTripBcMathNumberTopLevel() */ public function testHydrateBcMathNumberThrows() { + $this->skipIfExtensionRejectsClassesDifferently(); + // deepclone_hydrate() injects properties into an empty shell; a class // that only becomes valid through __construct()/__unserialize() cannot // be built that way and must be rejected rather than yielding a broken // (uninitialized) instance. $this->expectException(\DeepClone\NotInstantiableException::class); - $this->expectExceptionMessage(' "BcMath\Number" is not instantiable.'); + $this->expectExceptionMessage('Type "BcMath\Number" is not instantiable.'); deepclone_hydrate(\BcMath\Number::class, ['value' => '7.5']); } diff --git a/tests/DeepClone/fixtures.php b/tests/DeepClone/fixtures.php index 17b8cae3..233437ed 100644 --- a/tests/DeepClone/fixtures.php +++ b/tests/DeepClone/fixtures.php @@ -608,6 +608,29 @@ public function __unserialize(array $data): void } } +interface DeepCloneInterface +{ +} + +class DeepCloneIteratorIterator extends \IteratorIterator +{ + public $a; + private $b; + + public function getB() + { + return $this->b; + } +} + +class DeepCloneWakeupThrows +{ + public function __wakeup(): void + { + throw new \LogicException('Cannot wake up.'); + } +} + if (\PHP_VERSION_ID >= 80400) { eval(<<<'PHP' namespace Symfony\Polyfill\Tests\DeepClone; From 14a31d49fc69d977a2d97da8f967a4ecab0fa58b Mon Sep 17 00:00:00 2001 From: Nicolas Grekas Date: Sun, 27 Sep 2026 07:36:39 +0200 Subject: [PATCH 2/2] [DeepClone] Reject more internal classes that keep their state out of their properties --- src/DeepClone/DeepClone.php | 57 ++++++++++++--------- tests/DeepClone/DeepCloneTest.php | 84 ++++++++++++++++++++++++------- tests/DeepClone/fixtures.php | 9 ++++ 3 files changed, 106 insertions(+), 44 deletions(-) diff --git a/src/DeepClone/DeepClone.php b/src/DeepClone/DeepClone.php index bd8289ab..4e67fc65 100644 --- a/src/DeepClone/DeepClone.php +++ b/src/DeepClone/DeepClone.php @@ -18,7 +18,10 @@ */ final class DeepClone { - /** Internal classes that silently lose state through property restoration. */ + /** + * Internal classes that keep their state out of their properties, which the extension rejects: + * unserialize() creates them without it, and some crash when used, or even destroyed, that way. + */ private const NOT_ROUND_TRIPPABLE = [ 'ZipArchive' => true, 'XMLWriter' => true, @@ -26,6 +29,25 @@ final class DeepClone 'SNMP' => true, 'tidy' => true, 'tidyNode' => true, + 'AMQPChannel' => true, + 'AMQPConnection' => true, + 'APCUIterator' => true, + 'Imagick' => true, + 'ImagickDraw' => true, + 'ImagickKernel' => true, + 'ImagickPixel' => true, + 'ImagickPixelIterator' => true, + 'MessagePack' => true, + 'MessagePackUnpacker' => true, + 'Redis' => true, + 'RedisArray' => true, + 'RedisCluster' => true, + 'RedisSentinel' => true, + 'Relay\AdaptiveCache' => true, + 'Relay\Cluster' => true, + 'Relay\Relay' => true, + 'Relay\Sentinel' => true, + 'Relay\Table' => true, ]; private static array $reflectors = []; @@ -33,7 +55,6 @@ final class DeepClone private static array $cloneable = []; private static array $instantiableWithoutConstructor = []; private static array $needsFullUnserialize = []; - private static array $unexportable = []; private static array $hydrators = []; private static array $simpleHydrators = []; private static array $shapes = []; @@ -391,17 +412,7 @@ public static function deepclone_hydrate(object|string $object_or_class, array $ } if (\is_string($class = $object_or_class)) { - try { - $r = self::$reflectors[$class] ??= self::getClassReflector($class); - } catch (\DeepClone\NotInstantiableException $e) { - // Like the extension, which checks internal classes only for the ones whose state serialize() loses, like IteratorIterator - if (!isset(self::$unexportable[($r = new \ReflectionClass($class))->name]) || $r->isInternal()) { - throw $e; - } - $object = $r->newInstanceWithoutConstructor(); - - goto hydrate; - } + $r = self::$reflectors[$class] ??= self::getClassReflector($class); if (null === self::$prototypes[$class] && !self::$instantiableWithoutConstructor[$class]) { // No empty-shell prototype exists (e.g. an internal final class // whose __unserialize() rejects an empty payload, like @@ -423,7 +434,6 @@ public static function deepclone_hydrate(object|string $object_or_class, array $ $class = $object::class; } - hydrate: if (!$vars) { return $object; } @@ -925,13 +935,6 @@ private static function reconstruct($prepared, $objectMeta, $numObjects, $proper self::$reflectors[$class] ??= self::getClassReflector($class); } catch (\DeepClone\ClassNotFoundException) { throw new \DeepClone\ClassNotFoundException('Class "'.explode("\0", $class, 2)[0].'" not found.'); - } catch (\DeepClone\NotInstantiableException $e) { - // deepclone_to_array() rejects the classes whose state serialize() loses, like IteratorIterator, but unserialize() creates them - if (!isset(self::$unexportable[(new \ReflectionClass($class))->name])) { - throw $e; - } - $objects[$id] = unserialize('O:'.\strlen($class).':"'.$class.'":0:{}'); - continue; } // A class with __unserialize() is only ever emitted (by @@ -2014,6 +2017,12 @@ private static function getClassReflector($class, $instantiableWithoutConstructo if (!($isClass = class_exists($class)) && !interface_exists($class, false) && !trait_exists($class, false)) { throw new \DeepClone\ClassNotFoundException('Class "'.explode("\0", $class, 2)[0].'" not found.'); } + if ((isset(self::NOT_ROUND_TRIPPABLE[$class]) || array_intersect_key(class_parents($class), self::NOT_ROUND_TRIPPABLE)) + && !method_exists($class, '__serialize') && !method_exists($class, '__unserialize') && !method_exists($class, '__sleep') && !method_exists($class, '__wakeup') && !is_subclass_of($class, \Serializable::class) + ) { + // Before creating a prototype: Relay\Table crashes when destroyed without its constructor, and so do its subclasses + throw new \DeepClone\NotInstantiableException('Type "'.(new \ReflectionClass($class))->name.'" is not instantiable.'); + } $reflector = new \ReflectionClass($class); if ($instantiableWithoutConstructor) { @@ -2067,10 +2076,8 @@ private static function getClassReflector($class, $instantiableWithoutConstructo } if (null === $cloneable) { - // Classes whose state serialize() loses, which deepclone_from_array() creates all the same, like unserialize() does - if (isset(self::NOT_ROUND_TRIPPABLE[$class]) || ($proto instanceof \Reflector || $proto instanceof \ReflectionGenerator || $proto instanceof \ReflectionType || $proto instanceof \IteratorIterator || $proto instanceof \RecursiveIteratorIterator) && (!$proto instanceof \Serializable && !method_exists($class, '__wakeup') && !method_exists($class, '__unserialize'))) { - self::$unexportable[$class] = true; - + // Classes whose state serialize() loses + if (($proto instanceof \Reflector || $proto instanceof \ReflectionGenerator || $proto instanceof \ReflectionType || $proto instanceof \IteratorIterator || $proto instanceof \RecursiveIteratorIterator) && (!$proto instanceof \Serializable && !method_exists($class, '__wakeup') && !method_exists($class, '__unserialize'))) { throw new \DeepClone\NotInstantiableException('Type "'.$class.'" is not instantiable.'); } diff --git a/tests/DeepClone/DeepCloneTest.php b/tests/DeepClone/DeepCloneTest.php index e6a0f877..c64d9496 100644 --- a/tests/DeepClone/DeepCloneTest.php +++ b/tests/DeepClone/DeepCloneTest.php @@ -1720,40 +1720,86 @@ public function testUninstantiableClassesAreRejected() } } - public function testClassesWhoseStateSerializeLosesAreCreatedLikeUnserializeDoes() + public function testClassesWhoseStateSerializeLosesAreRejected() { $this->skipIfExtensionRejectsClassesDifferently(); + // unserialize() creates them, but the three functions reject them, and their user subclasses foreach ([\IteratorIterator::class, \LimitIterator::class, \RecursiveIteratorIterator::class, DeepCloneIteratorIterator::class] as $class) { $this->assertInstanceOf($class, unserialize('O:'.\strlen($class).':"'.$class.'":0:{}')); - $this->assertInstanceOf($class, deepclone_from_array(['classes' => $class, 'objectMeta' => 1, 'prepared' => 0])); + + $calls = [ + 'deepclone_from_array' => static function () use ($class) { return deepclone_from_array(['classes' => $class, 'objectMeta' => 1, 'prepared' => 0]); }, + 'deepclone_hydrate' => static function () use ($class) { return deepclone_hydrate($class); }, + ]; + foreach ($calls as $function => $call) { + try { + $call(); + $this->fail(\sprintf('%s() accepted "%s".', $function, $class)); + } catch (\DeepClone\NotInstantiableException $e) { + $this->assertSame('Type "'.$class.'" is not instantiable.', $e->getMessage()); + } + } } + foreach ([new \IteratorIterator(new \ArrayIterator([1])), new DeepCloneIteratorIterator(new \ArrayIterator([1]))] as $object) { + try { + deepclone_to_array($object); + $this->fail(\sprintf('deepclone_to_array() accepted a "%s".', \get_class($object))); + } catch (\DeepClone\NotInstantiableException $e) { + $this->assertSame('Type "'.\get_class($object).'" is not instantiable.', $e->getMessage()); + } + } + + // Unless they declare a serialization API $object = deepclone_from_array([ - 'classes' => DeepCloneIteratorIterator::class, + 'classes' => DeepCloneIteratorIteratorWithWakeup::class, 'objectMeta' => 1, 'prepared' => 0, - 'properties' => ['stdClass' => ['a' => [1]], DeepCloneIteratorIterator::class => ['b' => [2]]], + 'properties' => ['stdClass' => ['a' => [1]]], ]); $this->assertSame(1, $object->a); - $this->assertSame(2, $object->getB()); + $this->assertSame(1, deepclone_hydrate(DeepCloneIteratorIteratorWithWakeup::class, ['a' => 1])->a); + } - // deepclone_to_array() and deepclone_hydrate() still reject them, the latter for internal classes only - try { - deepclone_to_array(new \IteratorIterator(new \ArrayIterator([1]))); - $this->fail('deepclone_to_array() accepted an IteratorIterator.'); - } catch (\DeepClone\NotInstantiableException $e) { - $this->assertSame('Type "IteratorIterator" is not instantiable.', $e->getMessage()); + public function testInternalClassesKeepingTheirStateOutOfTheirPropertiesAreRejected() + { + $this->skipIfExtensionRejectsClassesDifferently(); + + // The ones loaded among the classes the polyfill knows, but Relay\Table, which crashes when destroyed without its constructor, + // and the ones that got a serialization API, like ZipArchive on PHP 8.6 + $classes = array_filter(['XMLWriter', 'XMLReader', 'ZipArchive', 'Redis', 'RedisCluster', 'Imagick', 'ImagickPixel', 'Relay\Relay', 'AMQPConnection', 'APCUIterator', 'MessagePack'], static function ($class) { return class_exists($class) && !method_exists($class, '__unserialize'); }); + if (!$classes) { + $this->markTestSkipped('No extension providing such classes is loaded.'); } - try { - deepclone_hydrate(\IteratorIterator::class); - $this->fail('deepclone_hydrate() accepted "IteratorIterator".'); - } catch (\DeepClone\NotInstantiableException $e) { - $this->assertSame('Type "IteratorIterator" is not instantiable.', $e->getMessage()); + + // And their user subclasses + foreach ($classes as $class) { + if (!(new \ReflectionClass($class))->isFinal()) { + $subclass = 'DeepCloneUser'.strtr($class, '\\', '_'); + if (!class_exists($subclass, false)) { + eval('class '.$subclass.' extends \\'.$class.' {}'); + } + $classes[] = $subclass; + } + } + + foreach ($classes as $class) { + // Named in lower case: messages use the declared name + $calls = [ + 'deepclone_to_array' => static function () use ($class) { return deepclone_to_array((new \ReflectionClass($class))->newInstanceWithoutConstructor()); }, + 'deepclone_from_array' => static function () use ($class) { return deepclone_from_array(['classes' => strtolower($class), 'objectMeta' => 1, 'prepared' => 0]); }, + 'deepclone_hydrate' => static function () use ($class) { return deepclone_hydrate(strtolower($class)); }, + ]; + foreach ($calls as $function => $call) { + try { + $call(); + $this->fail(\sprintf('%s() accepted "%s".', $function, $class)); + } catch (\DeepClone\NotInstantiableException $e) { + $this->assertSame('Type "'.$class.'" is not instantiable.', $e->getMessage()); + } + } } - $object = deepclone_hydrate(DeepCloneIteratorIterator::class, ['a' => 1, "\0".DeepCloneIteratorIterator::class."\0b" => 2]); - $this->assertSame(1, $object->a); - $this->assertSame(2, $object->getB()); } public function testSerializedObjectsThatFailToDecodeAreMalformed() diff --git a/tests/DeepClone/fixtures.php b/tests/DeepClone/fixtures.php index 233437ed..4a332b19 100644 --- a/tests/DeepClone/fixtures.php +++ b/tests/DeepClone/fixtures.php @@ -623,6 +623,15 @@ public function getB() } } +class DeepCloneIteratorIteratorWithWakeup extends \IteratorIterator +{ + public $a; + + public function __wakeup(): void + { + } +} + class DeepCloneWakeupThrows { public function __wakeup(): void