Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 68 additions & 18 deletions src/DeepClone/DeepClone.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,14 +18,36 @@
*/
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,
'XMLReader' => true,
'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 = [];
Expand Down Expand Up @@ -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'])) {
Expand Down Expand Up @@ -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]) {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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];
Expand All @@ -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;
Expand All @@ -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.');
}
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading