diff --git a/src/DeepClone/DeepClone.php b/src/DeepClone/DeepClone.php index da1ecb93..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 = []; @@ -275,7 +297,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'])) { @@ -396,7 +418,7 @@ public static function deepclone_hydrate(object|string $object_or_class, array $ // 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]) { @@ -590,11 +612,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 +915,26 @@ 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.'); } // A class with __unserialize() is only ever emitted (by @@ -1987,17 +2015,20 @@ 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.'); + throw new \DeepClone\ClassNotFoundException('Class "'.explode("\0", $class, 2)[0].'" not found.'); } - if (isset(self::NOT_ROUND_TRIPPABLE[$class])) { - throw new \DeepClone\NotInstantiableException('Type "'.$class.'" is not instantiable.'); + 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) { $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 +2052,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,14 +2068,15 @@ 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) { + // 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.'); } @@ -2074,6 +2106,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..c64d9496 100644 --- a/tests/DeepClone/DeepCloneTest.php +++ b/tests/DeepClone/DeepCloneTest.php @@ -1690,6 +1690,201 @@ 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 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:{}')); + + $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' => DeepCloneIteratorIteratorWithWakeup::class, + 'objectMeta' => 1, + 'prepared' => 0, + 'properties' => ['stdClass' => ['a' => [1]]], + ]); + $this->assertSame(1, $object->a); + $this->assertSame(1, deepclone_hydrate(DeepCloneIteratorIteratorWithWakeup::class, ['a' => 1])->a); + } + + 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.'); + } + + // 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()); + } + } + } + } + + 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 +3046,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..4a332b19 100644 --- a/tests/DeepClone/fixtures.php +++ b/tests/DeepClone/fixtures.php @@ -608,6 +608,38 @@ public function __unserialize(array $data): void } } +interface DeepCloneInterface +{ +} + +class DeepCloneIteratorIterator extends \IteratorIterator +{ + public $a; + private $b; + + public function getB() + { + return $this->b; + } +} + +class DeepCloneIteratorIteratorWithWakeup extends \IteratorIterator +{ + public $a; + + public function __wakeup(): void + { + } +} + +class DeepCloneWakeupThrows +{ + public function __wakeup(): void + { + throw new \LogicException('Cannot wake up.'); + } +} + if (\PHP_VERSION_ID >= 80400) { eval(<<<'PHP' namespace Symfony\Polyfill\Tests\DeepClone;