* * For the full copyright and license information, please view the LICENSE * file that was distributed with this source code. */ namespace Symfony\Polyfill\DeepClone; /** * @author Nicolas Grekas * * @internal */ final class DeepClone { /** * 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, ]; /** The properties of DatePeriod that its __wakeup() restores it from before PHP 8.2. */ private const DATE_PERIOD_STATE = ['start' => true, 'current' => true, 'end' => true, 'interval' => true, 'recurrences' => true, 'include_start_date' => true]; private static array $serializedDatePeriods = []; // [object id] = serialized state, before PHP 8.2 private static array $closureFactories = []; private static array $declaredProperties = []; private static array $reflectors = []; private static array $prototypes = []; private static array $clonePrototypes = []; // [class] = prototype that deepclone_from_array() clones, for objects not flagged for __unserialize() private static array $unserializeClonePrototypes = []; // [class] = prototype that deepclone_from_array() clones, for objects flagged for __unserialize() private static array $cloneable = []; private static array $instantiableWithoutConstructor = []; private static array $needsFullUnserialize = []; private static array $hydrators = []; private static array $simpleHydrators = []; private static array $shapes = []; private static array $typedPropertyKeys = []; private static array $propertyScopes = []; // [class][key] = [declaring class, real name]; key is bare name, "\0*\0name", or "\0class\0name" private static array $classInfo = []; private static array $constExprIndex = []; private static array $closureLiteralLines = []; private static array $refHydrators = []; private static array $hookedProperties = []; private static array $refBindings = []; private static array $refMarkers = []; // [ReflectionReference id] = Reference, for references that don't hold their marker /** * @param list|null $allowed_classes Classes that may be serialized * (null = all, [] = none) * @param bool $allow_named_closures Allow encoding closures over named * callables by name (both ends must opt in) */ public static function deepclone_to_array(mixed $value, ?array $allowed_classes = null, bool $allow_named_closures = false): array { if (!\is_object($value) && !(\is_array($value) && $value) || $value instanceof \UnitEnum) { if (null === $value || \is_scalar($value) || \is_array($value) || \is_object($value)) { return ['value' => $value]; } // A resource, closed ones included throw new \DeepClone\NotInstantiableException('Type "'.get_resource_type($value).' resource" is not instantiable.'); } $allowedSet = null === $allowed_classes ? null : self::getAllowedSet($allowed_classes, 'deepclone_to_array'); $objectsPool = []; $refsPool = []; $objectsCount = 0; $isStatic = true; $refs = []; $refMasks = []; $topMask = null; $refMarkers = self::$refMarkers; self::$refMarkers = []; if (\PHP_VERSION_ID < 80200) { $serializedDatePeriods = self::$serializedDatePeriods; self::$serializedDatePeriods = []; } try { $prepared = self::prepare([$value], $objectsPool, $refsPool, $objectsCount, $isStatic, $topMask, $allowedSet, $allow_named_closures)[0]; } finally { self::$refMarkers = $refMarkers; // Snapshot ref state while references still break cycles. foreach ($refsPool as $i => $v) { if (($marker = $v[0])->count) { if ($v[1] instanceof \UnitEnum) { $refs[1 + $i] = $v[1]::class.'::'.$v[1]->name; $refMasks[1 + $i] = 'e'; } else { $m = null; $refs[1 + $i] = self::replaceRefs($marker->value, $m); if (null !== $m = self::mergeRefMasks($marker->mask, $m)) { $refMasks[1 + $i] = $m; } } } $v[0] = $v[1]; } if (\PHP_VERSION_ID < 80200) { foreach (self::$serializedDatePeriods as $oid => $class) { // unserialize() calls its __wakeup() $objectsPool[$oid][1] = $class; $objectsPool[$oid][3] = 0; } self::$serializedDatePeriods = $serializedDatePeriods; } } if ($isStatic) { return ['value' => $value]; } $classes = []; $classMap = []; $objectMeta = []; $metaIsCount = true; $properties = []; $resolve = []; $states = []; foreach ($objectsPool as [$id, $class, $props, $wakeup, , $propMask]) { // Deduplicate class names in objectMeta. if (null === $classId = $classMap[$class] ?? null) { $classMap[$class] = $classId = \count($classes); $classes[] = $class; } if (0 === $wakeup) { $objectMeta[$id] = $classId; $metaIsCount = $metaIsCount && 0 === $classId; } else { $objectMeta[$id] = [$classId, $wakeup]; $metaIsCount = false; if (0 < $wakeup) { $states[$wakeup] = $id; } else { $states[-$wakeup] = null !== $propMask ? [$id, $props, $propMask] : [$id, $props]; continue; } } foreach ($props as $scope => $scopeProps) { foreach ($scopeProps as $name => $propValue) { $properties[$scope][$name][$id] = $propValue; if (isset($propMask[$scope][$name])) { $resolve[$scope][$name][$id] = $propMask[$scope][$name]; } } } } ksort($states); // Unwrap remaining Reference objects into plain integers/arrays. $preparedMask = \is_int($prepared) ? null : ($topMask[0] ?? null); if ($refsPool) { if (!\is_int($prepared)) { $m = null; $prepared = self::replaceRefs($prepared, $m); $preparedMask = self::mergeRefMasks($preparedMask, $m); } foreach ($resolve as $scope => $names) { foreach ($names as $name => $ids) { foreach ($ids as $id => $marker) { if (false !== $marker && !\is_array($marker)) { continue; } $m = null; $properties[$scope][$name][$id] = self::replaceRefs($properties[$scope][$name][$id], $m); if (null !== $m = self::mergeRefMasks($marker, $m)) { $ids[$id] = $m; } else { unset($ids[$id]); } } if ($ids) { $resolve[$scope][$name] = $ids; } else { unset($resolve[$scope][$name]); } } if (!$resolve[$scope]) { unset($resolve[$scope]); } } foreach ($states as $k => $v) { if (\is_array($v)) { $m = null; $states[$k][1] = self::replaceRefs($v[1], $m); if (null !== $m = self::mergeRefMasks($v[2] ?? null, $m)) { $states[$k][2] = $m; } else { unset($states[$k][2]); } } } } // After unwrapping unshared hard refs, the value may have become static. if (!$objectMeta && !$refs && null === $preparedMask) { return ['value' => $prepared]; } $data = [ 'classes' => 1 === \count($classes) ? $classes[0] : ($classes ?: ''), // When all entries share class index 0 with wakeup 0, store just the count. 'objectMeta' => $metaIsCount ? \count($objectMeta) : $objectMeta, 'prepared' => $prepared, ]; if (null !== $preparedMask) { $data['mask'] = $preparedMask; } if ($properties) { $data['properties'] = $properties; } if ($resolve) { $data['resolve'] = $resolve; } if ($states) { $data['states'] = $states; } if ($refs) { $data['refs'] = $refs; } if ($refMasks) { $data['refMasks'] = $refMasks; } return $data; } /** * @param list|null $allowed_classes Classes that may be instantiated * (null = all, [] = none) */ public static function deepclone_from_array(array $data, ?array $allowed_classes = null, bool $allow_named_closures = false): mixed { if (\array_key_exists('value', $data)) { return $data['value']; } if (!\array_key_exists('classes', $data)) { throw new \ValueError('deepclone_from_array(): Argument #1 ($data) is missing required "classes" key'); } if (!\array_key_exists('objectMeta', $data)) { throw new \ValueError('deepclone_from_array(): Argument #1 ($data) is missing required "objectMeta" key'); } if (!\array_key_exists('prepared', $data)) { throw new \ValueError('deepclone_from_array(): Argument #1 ($data) is missing required "prepared" key'); } $classes = $data['classes']; if (!\is_string($classes) && !\is_array($classes)) { throw new \ValueError('deepclone_from_array(): Argument #1 ($data) "classes" must be of type string|array, '.self::valueName($classes).' given'); } $meta = $data['objectMeta']; if (!\is_int($meta) && !\is_array($meta)) { throw new \ValueError('deepclone_from_array(): Argument #1 ($data) "objectMeta" must be of type int|array, '.self::valueName($meta).' given'); } $properties = $data['properties'] ?? []; $resolve = $data['resolve'] ?? []; $states = $data['states'] ?? []; $refs = $data['refs'] ?? []; $refMasks = $data['refMasks'] ?? []; // Null values, which the coalescing operator hides, are reported by the loop if (!\is_array($properties) || !\is_array($resolve) || !\is_array($states) || !\is_array($refs) || !\is_array($refMasks) || \in_array(null, $data, true)) { foreach (['properties', 'resolve', 'states', 'refs', 'refMasks'] as $key) { if (\array_key_exists($key, $data) && !\is_array($data[$key])) { throw new \ValueError('deepclone_from_array(): Argument #1 ($data) "'.$key.'" must be of type array, '.self::valueName($data[$key]).' given'); } } } if (\is_string($classes)) { $classes = '' !== $classes ? [$classes] : []; } else { foreach ($classes as $cls) { if (!\is_string($cls)) { throw new \ValueError('deepclone_from_array(): Argument #1 ($data) "classes" entries must be of type string, '.self::valueName($cls).' given'); } } $classes = array_values($classes); } $numClasses = \count($classes); if (null !== $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 "'.explode("\0", $cls, 2)[0].'" is not allowed'); } } if (!isset($allowed['closure'])) { foreach (['mask', 'refMasks'] as $key) { if (isset($data[$key]) && self::maskHasClosure($data[$key])) { throw new \ValueError('deepclone_from_array(): class "Closure" is not allowed'); } } if (isset($data['resolve'])) { foreach ($data['resolve'] as $scope) { if (\is_array($scope)) { foreach ($scope as $name) { if (self::maskHasClosure($name)) { throw new \ValueError('deepclone_from_array(): class "Closure" is not allowed'); } } } } } if (isset($data['states'])) { foreach ($data['states'] as $state) { if (\is_array($state) && isset($state[2]) && self::maskHasClosure($state[2])) { throw new \ValueError('deepclone_from_array(): class "Closure" is not allowed'); } } } } } // Named closures (the by-name marker, 0) let a payload mint a Closure // over any function or method by name; they resolve only when the // caller opts in, which the producer must also have done. Const-expr // closure references (marker 1) are unaffected. The scan runs before // anything is instantiated so such a payload is rejected wholesale. if (!$allow_named_closures && self::payloadHasNamedClosure($data)) { throw new \ValueError('deepclone_from_array(): resolving a closure over a named callable requires enabling the "allow_named_closures" option; do it only if you trust the input; alternatively, install the "deepclone" extension, which can reference callables declared in constant expressions'); } // $expectedStates maps ids that flag a state replay to their wakeup // sign (+1 / -1 is enough; the raw value is fine). Stays empty in // the common no-wakeup case so the later validation pass is O(1). $expectedStates = []; if (\is_int($meta)) { if ($meta < 0) { throw new \ValueError('deepclone_from_array(): Argument #1 ($data) "objectMeta" count must be non-negative, '.$meta.' given'); } // Sanity cap: the IS_LONG form specifies a count without the per- // object payload, so a tiny input can demand huge allocations. // Legitimate use never needs more than ~1M objects in a single // payload — beyond that, use the array form which is naturally // bounded by the hash-table size. if ($meta > 0x100000) { throw new \ValueError('deepclone_from_array(): Argument #1 ($data) "objectMeta" count out of range: '.$meta); } if ($meta > 0 && $numClasses < 1) { throw new \ValueError('deepclone_from_array(): Argument #1 ($data) "objectMeta" references class index 0 but "classes" is empty'); } $numObjects = $meta; $meta = $meta ? array_fill(0, $meta, 0) : []; } else { $numObjects = \count($meta); // The keys of a list are valid ids $isList = array_is_list($meta); foreach ($meta as $id => $v) { if (!$isList && (!\is_int($id) || $id < 0 || $id >= $numObjects)) { throw new \ValueError('deepclone_from_array(): Argument #1 ($data) "objectMeta" entry index '.explode("\0", $id, 2)[0].' out of range'); } if (\is_int($v)) { $cidx = $v; } elseif (\is_array($v)) { $cidx = $v[0] ?? null; $wakeup = $v[1] ?? null; if (!\is_int($cidx) || !\is_int($wakeup)) { throw new \ValueError('deepclone_from_array(): Argument #1 ($data) "objectMeta" entry '.$id.' must be [int, int]'); } if (0 !== $wakeup) { $expectedStates[$id] = $wakeup; } } else { throw new \ValueError('deepclone_from_array(): Argument #1 ($data) "objectMeta" entry '.$id.' must be of type int|array, '.self::valueName($v).' given'); } if ($cidx < 0 || $cidx >= $numClasses) { throw new \ValueError('deepclone_from_array(): Argument #1 ($data) "objectMeta" entry '.$id.' has out-of-range class index '.$cidx); } } } return self::reconstruct( $data['prepared'], $meta, $classes, $numObjects, $properties, $resolve, $states, $refs, $data['mask'] ?? null, $refMasks, $allowed_classes, $expectedStates, ); } public static function deepclone_hydrate(object|string $object_or_class, array $vars = [], int $flags = 0): object { if ($flags & ~(\DEEPCLONE_HYDRATE_CALL_HOOKS | \DEEPCLONE_HYDRATE_NO_LAZY_INIT | \DEEPCLONE_HYDRATE_PRESERVE_REFS)) { throw new \ValueError('deepclone_hydrate(): Argument #3 ($flags) contains unknown bits'); } if (($flags & \DEEPCLONE_HYDRATE_CALL_HOOKS) && ($flags & \DEEPCLONE_HYDRATE_NO_LAZY_INIT)) { throw new \ValueError('deepclone_hydrate(): Argument #3 ($flags) DEEPCLONE_HYDRATE_CALL_HOOKS and DEEPCLONE_HYDRATE_NO_LAZY_INIT are mutually exclusive'); } if (\is_string($class = $object_or_class)) { $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 // BcMath\Number). Such a class can only be reconstructed via a // full serialization round-trip, never by property injection. throw new \DeepClone\NotInstantiableException('Type "'.$class.'" is not instantiable.'); } elseif (self::$cloneable[$class]) { $object = clone self::$prototypes[$class]; } elseif (self::$instantiableWithoutConstructor[$class]) { $object = $r->newInstanceWithoutConstructor(); } elseif ($r->implementsInterface('Serializable') && !method_exists($class, '__unserialize')) { $object = unserialize('C:'.\strlen($class).':"'.$class.'":0:{}'); } else { $object = unserialize('O:'.\strlen($class).':"'.$class.'":0:{}'); } } else { $r = null; $object = $object_or_class; $class = $object::class; } if (!$vars) { return $object; } // Look up each key in the pre-built (propertyScopes) index, which // handles all three mangled-key shapes uniformly — bare "foo", // "\0*\0foo", "\0Class\0foo" — with a single hash lookup. Bare // "foo" resolves to the most-derived declaring class; shadowed // parent-privates are reachable via "\0Parent\0foo". Unknown // keys fall through to the object's own class scope. // // Scan first: if every key is a bare name that maps to $class, hand // $vars straight to the $class hydrator and skip the intermediate // grouping. Mangled keys always take the grouped path, which re-keys // them to their real name — the simple hydrators write keys verbatim, // so a mangled name would fail even when its scope resolves to $class. $r ??= self::$reflectors[$class] ?? new \ReflectionClass($class); $propertyScopes = self::$propertyScopes[$class] ??= self::getPropertyScopes($r); $needsGroup = false; foreach ($vars as $name => $_) { if ("\0" === ($name[0] ?? '') || $class !== ($propertyScopes[$name][0] ?? $class)) { $needsGroup = true; break; } } $effectiveFlags = $flags & \DEEPCLONE_HYDRATE_CALL_HOOKS; if (\PHP_VERSION_ID >= 80400 && ($flags & \DEEPCLONE_HYDRATE_NO_LAZY_INIT)) { $r ??= self::$reflectors[$class] ?? new \ReflectionClass($class); if ($r->isUninitializedLazyObject($object)) { $effectiveFlags |= \DEEPCLONE_HYDRATE_NO_LAZY_INIT; } } $hasRefs = false; if ($flags & \DEEPCLONE_HYDRATE_PRESERVE_REFS) { foreach ($vars as $k => $_) { if (\ReflectionReference::fromArrayElement($vars, $k)) { $hasRefs = true; break; } } } if (!$needsGroup) { // Fast path: every key resolves to $class. Hand $vars straight // to the $class hydrator — no grouping, no re-keying. $cacheKey = $effectiveFlags ? $effectiveFlags.$class : $class; (self::$simpleHydrators[$cacheKey] ??= self::getSimpleHydrator($class, $effectiveFlags))($vars, $object, $hasRefs); return $object; } // Full parse: group values by their write scope and real name. A // NUL-prefixed key not in $propertyScopes cannot resolve to a // declared property, so we reject it here — which in turn means // every scope we hand to the hydrator is $class or a real parent. $scoped = []; foreach ($vars as $name => &$value) { if ([$scopeName, $realName] = $propertyScopes[$name] ?? null) { $scoped[$scopeName][$realName] = &$value; continue; } if (!\is_string($name) || "\0" !== ($name[0] ?? '')) { $scoped[$class][$name] = &$value; continue; } // NUL-prefixed key that isn't in $propertyScopes: either malformed // syntax, an unknown scope class, or a valid scope + unknown prop. // Differentiate to match the ext's error messages. $sep = strpos($name, "\0", 1); if (false === $sep || 1 === $sep || str_contains(substr($name, $sep + 1), "\0")) { throw new \ValueError('deepclone_hydrate(): Argument #2 ($vars) contains an invalid mangled key'); } $scopeName = substr($name, 1, $sep - 1); $realName = substr($name, $sep + 1); if ('*' !== $scopeName && $scopeName !== $class && (!is_a($class, $scopeName, true) || interface_exists($scopeName, false)) ) { throw new \ValueError(\sprintf('deepclone_hydrate(): Argument #2 ($vars) key scope "%s" is not a parent of "%s"', $scopeName, $class)); } // Like the extension, and unserialize() for the "*" scope and the one of the object's class, the key names the // property that its scope declares or inherits, whatever their visibility $scope = '*' === $scopeName ? $class : $scopeName; for ($r = self::$reflectors[$scope] ?? new \ReflectionClass($scope); $r; $r = $r->getParentClass()) { if ($r->hasProperty($realName) && ($p = $r->getProperty($realName))->class === $r->name) { if ($p->isStatic() || \PHP_VERSION_ID >= 80400 && $p->isVirtual()) { break; } $scoped[$r->name][$realName] = &$value; continue 2; } } // The targeted slot isn't declared: reject instead of silently creating a dynamic property, // since the mangled form specifically targets a declared protected/private slot throw new \ValueError(\sprintf('deepclone_hydrate(): Argument #2 ($vars) key scope "%s" does not declare a "%s" property', $scope, $realName)); } unset($value); foreach ($scoped as $scope => $properties) { $cacheKey = $effectiveFlags ? $effectiveFlags.$scope : $scope; (self::$simpleHydrators[$cacheKey] ??= self::getSimpleHydrator($scope, $effectiveFlags))($properties, $object, $hasRefs); } return $object; } /** * Builds a per-class map keyed by every shape a caller might use to * target a declared property: * - bare "name" (public/protected inherited or own; for private * declared on $class; also set to the most-derived * private when a parent-private shares the name) * - "\0*\0name" (for protected props — points to the declaring * class entry) * - "\0ClassName\0name" (for private props declared on ClassName, where * ClassName is $class or a parent). * * Each entry is [$declaringClass, $propertyName] — the scope and real * name to use for grouping + dispatch. Adapted from VarExporter's * LazyObjectRegistry::getPropertyScopes(). */ private static function getPropertyScopes(\ReflectionClass $class): array { $propertyScopes = []; foreach ($class->getProperties() as $property) { if ($property->isStatic()) { continue; } $name = $property->name; $entry = [$property->class, $name]; if ($property->isPrivate()) { $propertyScopes["\0".$property->class."\0".$name] = $propertyScopes[$name] = $entry; continue; } $propertyScopes[$name] = $entry; if ($property->isProtected()) { $propertyScopes["\0*\0".$name] = $entry; } } while ($class = $class->getParentClass()) { foreach ($class->getProperties(\ReflectionProperty::IS_PRIVATE) as $property) { if ($property->isStatic()) { continue; } $entry = [$property->class, $property->name]; $propertyScopes["\0".$property->class."\0".$property->name] = $entry; $propertyScopes[$property->name] ??= $entry; } } return $propertyScopes; } /** * Lists the non-static properties that a class declares or inherits, but for the private ones of its parents. */ private static function getDeclaredProperties(string $class): array { $declared = []; foreach ((self::$reflectors[$class] ?? new \ReflectionClass($class))->getProperties() as $p) { if (!$p->isStatic()) { $declared[$p->name] = true; } } return $declared; } /** * Validates class names like unserialize() does, and maps them lowercased. */ private static function getAllowedSet(array $allowedClasses, string $function): array { $allowedSet = []; foreach ($allowedClasses as $class) { if (!\is_string($class)) { throw new \ValueError($function.'(): Argument $allowed_classes must be an array of class names, '.self::valueName($class).' given'); } if (preg_match('/[^a-zA-Z0-9_\x80-\xff\\\\]/', $class)) { throw new \ValueError($function.'(): Argument $allowed_classes must be an array of class names, "'.explode("\0", $class, 2)[0].'" given'); } $allowedSet[strtolower($class)] = true; } return $allowedSet; } /** * Returns a type name matching zend_zval_value_name() output for * error messages compatible with the C extension. */ private static function valueName(mixed $value): string { if (null === $value) { return 'null'; } 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; } return match (true) { \is_int($value) => 'int', \is_float($value) => 'float', \is_string($value) => 'string', \is_array($value) => 'array', \is_resource($value) => 'resource', default => get_debug_type($value), }; } private static function prepare($values, &$objectsPool, &$refsPool, &$objectsCount, &$valuesAreStatic, &$mask = null, ?array $allowedSet = null, bool $allowNamedClosures = false, int $refFreeDepth = 0) { $refs = $values; foreach ($values as $k => $value) { if ($refFreeDepth) { // Known to hold no hard reference $isRef = false; } else { $isRef = null !== \ReflectionReference::fromArrayElement($values, $k); } $valueIsStatic = !$isRef; if ($isRef) { $values[$k] = &$value; // Break hard reference unset($value); $refs[$k] = $value = $values[$k]; if ($value instanceof Reference && 0 > $value->id) { $valuesAreStatic = false; ++$value->count; $mask[$k] = false; continue; } if (self::$refMarkers && $marker = self::$refMarkers[\ReflectionReference::fromArrayElement($refs, $k)->getId()] ?? null) { $values[$k] = $marker; $valuesAreStatic = false; ++$marker->count; $mask[$k] = false; continue; } $values[$k] = $marker = new Reference(-1 - \count($refsPool), $value); try { // Mark the reference as seen by storing the marker in it $refs[$k] = $marker; $refsPool[] = [&$refs[$k], $value]; } catch (\TypeError) { // Unless a typed property rejects it: keep the marker aside, as are the ones of the references met before self::hideMarkers($refsPool); self::$refMarkers[\ReflectionReference::fromArrayElement($refs, $k)->getId()] = $marker; $refsPool[] = [$marker, $value, &$refs[$k]]; } } if (\is_array($value)) { if ($value) { $m = null; $value = self::prepare($value, $objectsPool, $refsPool, $objectsCount, $valueIsStatic, $m, $allowedSet, $allowNamedClosures, $refFreeDepth ? $refFreeDepth - 1 : 0); if (null !== $m) { $mask[$k] = $m; } } goto handle_value; } elseif (!\is_object($value)) { if (!\is_scalar($value) && null !== $value) { // A resource, closed ones included throw new \DeepClone\NotInstantiableException('Type "'.get_resource_type($value).' resource" is not instantiable.'); } goto handle_value; } elseif ($value instanceof \UnitEnum) { goto handle_value; } $valueIsStatic = false; $oid = spl_object_id($value); if (isset($objectsPool[$oid])) { ++$objectsCount; $value = $objectsPool[$oid][0]; $mask[$k] = true; goto handle_value; } if ($value instanceof \Closure) { $r = new \ReflectionFunction($value); // Before PHP 8.2, only anonymous closures are named "{closure}", prefixed by their namespace if (!(\PHP_VERSION_ID >= 80200 ? $r->isAnonymous() : str_ends_with($r->name, '{closure}'))) { // First-class callable. When it references a method of its // own declaring class declared in a constant expression // (e.g. #[When(self::isStrict(...))]), encode it as a // declaration-site reference like the extension does, so it // round-trips without the allow_named_closures opt-in. // Closure is allow-list-gated first, mirroring the // extension (reported before any const-expr is evaluated). if (\PHP_VERSION_ID >= 80500 && !$r->getClosureThis() && $r->getClosureScopeClass()) { if (null !== $allowedSet && !isset($allowedSet['closure'])) { throw new \ValueError('deepclone_to_array(): class "Closure" is not allowed'); } if (null !== $ref = self::locateConstExprClosure($r)) { $value = $ref; $mask[$k] = 1; goto handle_value; } } // Not addressable as a declaration-site reference (runtime // first-class callable, cross-class or global-function // target, internal function): encode it by name. That lets // deepclone_from_array() mint a Closure over any function or // method of that name, so it is gated behind // allow_named_closures, which both ends must enable. if (!$allowNamedClosures) { throw new \ValueError('deepclone_to_array(): serializing a closure over the named callable "'.$r->name.'" requires enabling the "allow_named_closures" option; do it only if you trust the input; alternatively, install the "deepclone" extension, which can reference callables declared in constant expressions'); } if (null !== $allowedSet && !isset($allowedSet['closure'])) { throw new \ValueError('deepclone_to_array(): class "Closure" is not allowed'); } $callable = [$r->getClosureThis() ?? (\PHP_VERSION_ID >= 80111 ? $r->getClosureCalledClass() : $r->getClosureScopeClass())?->name, $r->name]; // The method can be one that __call() or __callStatic() handles $rm = $callable[0] && method_exists(...$callable) ? new \ReflectionMethod(...$callable) : null; $unused = null; $callable = self::prepare($callable, $objectsPool, $refsPool, $objectsCount, $valueIsStatic, $unused, $allowedSet, $allowNamedClosures); $value = !($rm?->isPublic() ?? true) ? [$callable, $rm->class, $rm->name] : $callable; $mask[$k] = 0; goto handle_value; } if (null !== $allowedSet && !isset($allowedSet['closure'])) { throw new \ValueError('deepclone_to_array(): class "Closure" is not allowed'); } if (\PHP_VERSION_ID >= 80500 && null !== $ref = self::locateConstExprClosure($r)) { $value = $ref; $mask[$k] = 1; goto handle_value; } } $class = $value::class; if (null !== $allowedSet && !isset($allowedSet[strtolower($class)])) { throw new \ValueError('deepclone_to_array(): class "'.explode("\0", $class, 2)[0].'" is not allowed'); } if ('stdClass' === $class) { self::$reflectors[$class] ??= self::getClassReflector($class); $arrayValue = (array) $value; $objectsPool[$oid] = [$id = \count($objectsPool)]; $m = null; $properties = $arrayValue ? self::prepare(['stdClass' => $arrayValue], $objectsPool, $refsPool, $objectsCount, $valueIsStatic, $m, $allowedSet, $allowNamedClosures) : []; ++$objectsCount; $objectsPool[$oid] = [$id, 'stdClass', $properties, 0, $value, $m]; $value = $id; $mask[$k] = true; goto handle_value; } $reflector = self::$reflectors[$class] ??= self::getClassReflector($class); // Code run for the object, eg __serialize() or a lazy initializer, must read the values of the references met so far, not their markers if (\PHP_VERSION_ID >= 80400 && $refsPool && $reflector->isUninitializedLazyObject($value)) { self::hideMarkers($refsPool); } // A payload cannot carry a lazy-object initializer: initialize lazy // objects first, like clone does. On a lazy proxy, this returns its // real instance, which may itself have been reset as lazy since. if (\PHP_VERSION_ID >= 80400 && $value !== $instance = $reflector->initializeLazyObject($value)) { if ($refsPool) { self::hideMarkers($refsPool); } while ($instance !== $next = (self::$reflectors[$instance::class] ??= self::getClassReflector($instance::class))->initializeLazyObject($instance)) { $instance = $next; } } $properties = []; $sleep = null; $refFree = false; if (self::$classInfo[$class][2] ??= $reflector->hasMethod('__serialize') ? ($reflector->getMethod('__serialize')->isPublic() ?: $reflector->getMethod('__serialize')) : false) { if ($refsPool) { self::hideMarkers($refsPool); } // Like serialize(), call it whatever its visibility if (!\is_array($arrayValue = true === self::$classInfo[$class][2] ? $value->__serialize() : self::$classInfo[$class][2]->invoke($value))) { throw new \TypeError($class.'::__serialize() must return an array'); } // Before PHP 8.3, Random\Randomizer::__serialize() returns its raw // property table, whose IS_INDIRECT "engine" slot dangles once the // object is released; it is the only affected class, as the others // sharing the pattern declare no properties. Read the properties // from the object instead, which keeps the engine instance. if (\PHP_VERSION_ID < 80300 && $value instanceof \Random\Randomizer) { $arrayValue = [(array) $value]; } // ArrayObject and ArrayIterator return their storage without separating it, so that writing to the object later would change the payload if (($value instanceof \ArrayObject || $value instanceof \ArrayIterator) && \is_array($storage = $arrayValue[1] ?? null)) { if (null === $firstKey = array_key_first($storage)) { $storage = []; } else { $storage[$firstKey] = $storage[$firstKey]; } $arrayValue[1] = $storage; unset($storage); } if ($hasUnserialize = self::$classInfo[$class][0] ??= $reflector->hasMethod('__unserialize')) { $properties = $arrayValue; goto prepare_value; } // unserialize() writes the keys in turn, so that the last one naming a property wins, even with its default // value: keep them all $keys = (self::$shapes[$class] ??= self::getShape($reflector, self::$prototypes[$class]))[0]; $defaults = []; } elseif ($value instanceof \Serializable || $value instanceof \__PHP_Incomplete_Class) { if ($refsPool) { self::hideMarkers($refsPool); } ++$objectsCount; $objectsPool[$oid] = [$id = \count($objectsPool), serialize($value), [], 0, $value, null]; $value = $id; $mask[$k] = true; goto handle_value; } else { if (self::$classInfo[$class][3] ??= $reflector->hasMethod('__sleep') || \PHP_VERSION_ID < 80200 && $value instanceof \DatePeriod) { if (\PHP_VERSION_ID >= 80200 || !$value instanceof \DatePeriod || $reflector->hasMethod('__sleep')) { if ($refsPool) { self::hideMarkers($refsPool); } if (!\is_array($sleep = true === (self::$classInfo[$class][4] ??= $reflector->getMethod('__sleep')->isPublic() ?: $reflector->getMethod('__sleep')) ? $value->__sleep() : self::$classInfo[$class][4]->invoke($value))) { trigger_error('serialize(): '.$class.'::__sleep() should return an array only containing the names of instance-variables to serialize', \E_USER_WARNING); $value = null; goto handle_value; } $sleep = self::getSleepProperties($value, $reflector, $sleep); } if (\PHP_VERSION_ID < 80200 && $value instanceof \DatePeriod) { $sleep ??= (array) $value; self::serializeDatePeriod($oid, $class, $sleep, $allowedSet); } } $arrayValue = $sleep ?? (array) $value; [$keys, $defaults] = self::$shapes[$class] ??= self::getShape($reflector, self::$prototypes[$class]); } $refFree = true; $propertiesAreStatic = true; foreach ($arrayValue as $name => $v) { if ($key = $keys[$name] ?? null) { [$c, $n] = $key; } else { $n = (string) $name; if ('' === $n || "\0" !== $n[0]) { $c = 'stdClass'; } elseif ('*' === $n[1]) { // An undeclared protected name, eg returned by __serialize(), can only be restored as a dynamic property $n = substr($n, 3); $c = 'stdClass'; } else { $i = strpos($n, "\0", 2); $c = substr($n, 1, $i - 1); $n = substr($n, 1 + $i); } } // Carry the hard references between properties over if (\ReflectionReference::fromArrayElement($arrayValue, $name)) { $properties[$c][$n] = &$arrayValue[$name]; $refFree = false; } elseif (!\array_key_exists($name, $defaults) || $defaults[$name] !== $v) { $properties[$c][$n] = $v; $propertiesAreStatic = $propertiesAreStatic && (null === $v || \is_scalar($v) || $v instanceof \UnitEnum); } } if ($hasUnserialize = self::$classInfo[$class][0] ??= $reflector->hasMethod('__unserialize')) { $properties = $arrayValue; $refFree = false; } prepare_value: $objectsPool[$oid] = [$id = \count($objectsPool)]; $m = null; if (!$refFree) { $properties = self::prepare($properties, $objectsPool, $refsPool, $objectsCount, $valueIsStatic, $m, $allowedSet, $allowNamedClosures); } elseif (!$propertiesAreStatic) { // Grouped by value, the scopes and properties of an object that had // no hard reference can't hold any foreach ($properties as $c => $scopeProperties) { $scopeMask = null; $properties[$c] = self::prepare($scopeProperties, $objectsPool, $refsPool, $objectsCount, $valueIsStatic, $scopeMask, $allowedSet, $allowNamedClosures, 1); if (null !== $scopeMask) { $m[$c] = $scopeMask; } } } ++$objectsCount; $objectsPool[$oid] = [$id, $class, $properties, $hasUnserialize ? -$objectsCount : ((self::$classInfo[$class][1] ??= $reflector->hasMethod('__wakeup')) ? $objectsCount : 0), $value, $m]; $value = $id; $mask[$k] = true; handle_value: if ($isRef) { // The marker carries the prepared value, with its mask $values[$k]->value = $value; $values[$k]->mask = $mask[$k] ?? null; $mask[$k] = false; unset($value); // Break the hard reference created above } elseif (!$valueIsStatic) { $values[$k] = $value; } $valuesAreStatic = $valueIsStatic && $valuesAreStatic; } return $values; } /** * Moves the markers stored in the references met so far aside, restoring their values. */ private static function hideMarkers(array &$refsPool): void { // The references that hold their marker are the last ones met for ($i = \count($refsPool) - 1; 0 <= $i && 2 === \count($refsPool[$i]); --$i) { $ref = &$refsPool[$i][0]; $marker = $ref; $ref = $refsPool[$i][1]; self::$refMarkers[\ReflectionReference::fromArrayElement($refsPool[$i], 0)->getId()] = $marker; // Keep the reference alive so that its id isn't reused $refsPool[$i] = [$marker, $ref, &$ref]; unset($ref); } } private static function reconstruct($prepared, array $objectMeta, array $classes, int $numObjects, $properties, $resolve, $states, $refs, $preparedMask, $refMasks, ?array $allowedClasses, array $expectedStates) { $objects = []; $placeholders = []; // [id] = class, for the objects that only __unserialize() can create foreach ($objectMeta as $id => $m) { if (\is_int($m)) { $class = $classes[$m]; $wakeup = 0; $prototype = self::$clonePrototypes[$class] ?? null; } else { $class = $classes[$m[0]]; $wakeup = $m[1]; $prototype = $wakeup < 0 ? self::$unserializeClonePrototypes[$class] ?? null : self::$clonePrototypes[$class] ?? null; } // Objects of a class that passed the checks below are clones of its prototype if ($prototype) { $objects[$id] = clone $prototype; continue; } 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])) { 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 "'.explode("\0", $class, 2)[0].'" not found.'); } // A class with __unserialize() is only ever emitted (by // deepclone_to_array()) as a negative-wakeup state replay; a payload // that creates one without that flag could not initialize it (e.g. // BcMath\Number's bc_num would stay NULL). Reject rather than build // an unusable object, matching the extension. if ($wakeup >= 0 && (self::$classInfo[$class][0] ??= self::$reflectors[$class]->hasMethod('__unserialize'))) { throw new \ValueError('deepclone_from_array(): Argument #1 ($data) object '.$id.' of class '.self::$reflectors[$class]->name.' has an __unserialize() method but "objectMeta" does not flag it for an __unserialize state'); } if (self::$needsFullUnserialize[$class] ?? false) { $objects[$id] = null; // placeholder — finalized below or in states loop $placeholders[$id] = $class; } elseif (self::$cloneable[$class]) { $objects[$id] = clone self::$prototypes[$class]; if ($wakeup < 0) { self::$unserializeClonePrototypes[$class] = self::$prototypes[$class]; } else { self::$clonePrototypes[$class] = self::$prototypes[$class]; } } elseif (self::$instantiableWithoutConstructor[$class]) { $objects[$id] = self::$reflectors[$class]->newInstanceWithoutConstructor(); } elseif (null === self::$prototypes[$class]) { throw new \DeepClone\NotInstantiableException('Type "'.$class.'" is not instantiable.'); } elseif (self::$reflectors[$class]->implementsInterface('Serializable') && !method_exists($class, '__unserialize')) { $objects[$id] = unserialize('C:'.\strlen($class).':"'.$class.'":0:{}'); } else { $objects[$id] = unserialize('O:'.\strlen($class).':"'.$class.'":0:{}'); } } // Backed hooked properties of the payload's classes, by payload scope $hooked = []; if (\PHP_VERSION_ID >= 80400) { foreach ($classes as $class) { // A class whose hooked properties are known has a reflector if ($classHooked = self::$hookedProperties[$class] ?? (isset(self::$reflectors[$class]) ? self::$hookedProperties[$class] = self::getHookedProperties(self::$reflectors[$class]) : null)) { foreach ($classHooked as $scope => $props) { foreach ($props as $name => $propertyReflector) { $hooked[$scope][$name][self::$reflectors[$class]->name] = $propertyReflector; } } } } } // Eagerly finalize deferred objects whose state has no object-ref masks, // so they are real instances when the properties loop resolves references to them. $deferred = []; if ($placeholders) { try { foreach ($states as $state) { // Unknown ids and malformed states are reported below if (!\is_array($state) || !\is_int($zid = $state[0] ?? null) || !\array_key_exists($zid, $objects) || null !== $objects[$zid] || !\is_array($state[1] ?? null)) { continue; } if (isset($state[2])) { $deferred[$zid] ??= $state; continue; } $class = $placeholders[$zid]; $ser = serialize($state[1]); 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; } } catch (\TypeError|\ValueError $e) { self::checkStates($states, $objects, $placeholders, $numObjects, $expectedStates); throw $e; } } if ($deferred) { self::finalizeDeferred($deferred, $objects, $placeholders, $refs, $refMasks, $allowedClasses); } else { try { foreach ($refMasks as $k => $m) { if (isset($refs[$k]) || \array_key_exists($k, $refs)) { if (false === $m && -$k === $refs[$k]) { throw new \ValueError('deepclone_from_array(): malformed payload, ref id '.$k.' cannot reference itself'); } $refs[$k] = self::resolveWithMask($refs[$k], $m, $objects, $refs, $allowedClasses); } else { self::resolveWithMask(null, $m, $objects, $refs, $allowedClasses); } } } catch (\ValueError $e) { throw self::checkPendingStates($e, $states, $objects, $placeholders, $numObjects, $expectedStates); } } 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'); } if (!\is_array($scopeProps)) { throw new \ValueError('deepclone_from_array(): Argument #1 ($data) "properties" entry for scope "'.explode("\0", $scope, 2)[0].'" must be of type array, '.self::valueName($scopeProps).' given'); } if ('stdClass' === $scope) { $declared = null; } elseif (null === $declared = self::$declaredProperties[$scope] ?? null) { if (!class_exists($scope, false)) { throw new \ValueError('deepclone_from_array(): Argument #1 ($data) "properties" scope "'.explode("\0", $scope, 2)[0].'" is not a loaded class name'); } $declared = self::$declaredProperties[$scope] = self::getDeclaredProperties($scope); } if (null === $resolveScope = $resolve[$scope] ?? null) { if (\array_key_exists($scope, $resolve)) { throw new \ValueError('deepclone_from_array(): Argument #1 ($data) "resolve" entry for scope "'.explode("\0", $scope, 2)[0].'" must be of type array, null given'); } $resolveScope = []; } elseif (!\is_array($resolveScope)) { throw new \ValueError('deepclone_from_array(): Argument #1 ($data) "resolve" entry for scope "'.explode("\0", $scope, 2)[0].'" must be of type array, '.self::valueName($resolveScope).' given'); } // A name that maps to null is looked up the slow way, to be reported below $resolvesNull = $resolveScope && \in_array(null, $resolveScope, true); $refSlots = []; foreach ($scopeProps as $name => $values) { // Numeric property names (e.g. $o->{'999'}) surface as integer // array keys because PHP normalizes numeric string keys; accept // them as-is, the same way unserialize() round-trips them. if (!\is_array($values)) { throw new \ValueError('deepclone_from_array(): Argument #1 ($data) "properties" value for "'.explode("\0", $scope, 2)[0].'::'.explode("\0", $name, 2)[0].'" must be of type array, '.self::valueName($values).' given'); } if (str_starts_with($name, "\0")) { // Not a legal name, which the engine refuses to access, even if an array cast can give it to a stdClass throw new \ValueError('deepclone_from_array(): Argument #1 ($data) "properties" names of scope "'.explode("\0", $scope, 2)[0].'" cannot start with "\0"'); } // Checked for the first object of each property only, like the extension does for all if (null !== $declared && \is_int($id = array_key_first($values)) && \is_object($o = $objects[$id] ?? null)) { if (!$o instanceof $scope) { throw new \ValueError('deepclone_from_array(): Argument #1 ($data) "properties" scope "'.explode("\0", $scope, 2)[0].'" is not a parent of object id '.$id.' ('.explode("\0", $o::class, 2)[0].')'); } if (!isset($declared[$name])) { throw new \ValueError('deepclone_from_array(): Argument #1 ($data) "properties" value for "'.explode("\0", $scope, 2)[0].'::'.explode("\0", $name, 2)[0].'" does not match a declared property on object id '.$id); } } if (!isset($resolveScope[$name]) && !($resolvesNull && \array_key_exists($name, $resolveScope))) { continue; } if (!\is_array($resolveIds = $resolveScope[$name])) { throw new \ValueError('deepclone_from_array(): Argument #1 ($data) "resolve" value for "'.explode("\0", $scope, 2)[0].'::'.explode("\0", $name, 2)[0].'" must be of type array, '.self::valueName($resolveIds).' given'); } foreach ($resolveIds as $id => $marker) { if (true === $marker && \is_int($v = $values[$id] ?? null) && isset($objects[$v])) { $values[$id] = $objects[$v]; } elseif (0 === $marker) { $values[$id] = self::resolveNamedClosureScalar($values[$id] ?? null, $objects, $refs); } elseif (false === $marker) { // Validated and resolved by value, bound below where PHP allows it $v = $values[$id] ?? null; $values[$id] = self::resolveWithMask($v, false, $objects, $refs, $allowedClasses); $refSlots[$name][$id] = -$v; } elseif (isset($values[$id]) || \array_key_exists($id, $values)) { try { $values[$id] = self::resolveWithMask($values[$id], $marker, $objects, $refs, $allowedClasses); } catch (\ValueError $e) { throw self::checkPendingStates($e, $states, $objects, $placeholders, $numObjects, $expectedStates); } } else { // Like the extension, check a marker that matches no value against null, which markers reject, and add nothing self::resolveWithMask(null, $marker, $objects, $refs, $allowedClasses); } } $scopeProps[$name] = $values; } if ($refSlots) { $silenced = false; // Hooked, readonly and internal properties can't take a reference: they get the value $internalScope = 'stdClass' !== $scope && (self::$reflectors[$scope] ?? new \ReflectionClass($scope))->isInternal(); foreach ($refSlots as $name => $ids) { foreach ($ids as $id => $rid) { $binding = $internalScope || !\is_object($objects[$id] ?? null) ? 0 : self::getRefBinding('stdClass' === $scope ? $objects[$id]::class : $scope, (string) $name); if (0 > $binding) { throw new \ValueError('deepclone_from_array(): hard references cannot target virtual properties or dynamic properties behind custom handlers'); } if (!$binding) { unset($refSlots[$name][$id]); } elseif (2 === $binding) { // Bound without raising the deprecation of dynamic properties $refSlots[$name][$id] = -$rid; $silenced = true; } } } $refSlots = array_filter($refSlots); } if (isset($hooked[$scope])) { // Backed hooked properties are written raw, bypassing their set // hook, like unserialize() and the extension do foreach ($hooked[$scope] as $name => $propertyReflectors) { foreach ($scopeProps[$name] ?? [] as $id => $v) { if (\is_object($object = $objects[$id] ?? null) && $propertyReflector = $propertyReflectors[$object::class] ?? null) { $propertyReflector->setRawValue($object, $v); unset($scopeProps[$name][$id]); } } } } try { if ($refSlots) { (self::$refHydrators[$scope][$silenced] ??= self::getRefHydrator($scope, $silenced))($scopeProps, $objects, $refSlots, $refs); } else { (self::$hydrators[$scope] ??= self::getHydrator($scope))($scopeProps, $objects); } } catch (\Error $e) { // Writing to what isn't an object or to a property it can't have, which the extension checks before writing if (!$e instanceof \ValueError && (!$e instanceof \TypeError || !str_starts_with($e->getMessage(), 'Cannot assign '))) { self::checkProperties($properties, $scope, $objects, $numObjects); } throw $e; } } foreach ($states as $state) { if (\is_array($state)) { $zid = $state[0] ?? null; $sprops = $state[1] ?? null; if (!\is_int($zid) || null === $sprops && !\array_key_exists(1, $state)) { throw new \ValueError('deepclone_from_array(): Argument #1 ($data) malformed "states" entry: expected [int, mixed, mixed?]'); } // Flagged ids are in range if (($expectedStates[$zid] ?? 0) >= 0) { if ($zid < 0 || $zid >= $numObjects) { throw new \ValueError('deepclone_from_array(): Argument #1 ($data) "states" entry references unknown object id '.$zid); } throw new \ValueError('deepclone_from_array(): Argument #1 ($data) "states" has an __unserialize entry for object id '.$zid.' but "objectMeta" does not flag it for __unserialize'); } unset($expectedStates[$zid]); if (null === $obj = $objects[$zid]) { // Internal final class with __unserialize that rejects empty unserialize // reconstruct via the full O: serialization form (same as PHP's unserialize). $class = $placeholders[$zid]; $resolvedProps = isset($state[2]) ? self::resolveWithMask($sprops, $state[2], $objects, $refs, $allowedClasses) : $sprops; if (!\is_array($resolvedProps)) { throw self::unserializeTypeError($class, $resolvedProps); } $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; continue; } $objClass = $obj::class; if (isset($placeholders[$zid])) { // Already fully reconstructed via the full O: serialization // form (eager-finalize loop above), which invokes // __unserialize() internally. Calling it again would re-init // an already-initialized (often readonly) object, e.g. // BcMath\Number throws "Cannot modify readonly property". continue; } // Like unserialize(), call it whatever its visibility if (null === $m = self::$classInfo[$objClass][5] ?? null) { if (!method_exists($obj, '__unserialize')) { throw new \ValueError('deepclone_from_array(): Argument #1 ($data) "states" entry references object id '.$zid.' whose class '.$objClass.' has no __unserialize() method'); } $m = self::$classInfo[$objClass][5] = (new \ReflectionMethod($objClass, '__unserialize'))->isPublic() ?: new \ReflectionMethod($objClass, '__unserialize'); } $resolvedProps = isset($state[2]) ? self::resolveWithMask($sprops, $state[2], $objects, $refs, $allowedClasses) : $sprops; if (true === $m && \is_array($resolvedProps)) { $obj->__unserialize($resolvedProps); } else { // Called from an internal function, so that the TypeError of a non-array state doesn't mention this file ($m instanceof \ReflectionMethod ? $m : new \ReflectionMethod($obj, '__unserialize'))->invoke($obj, $resolvedProps); } } elseif (\is_int($state)) { if ($state < 0 || $state >= $numObjects) { throw new \ValueError('deepclone_from_array(): Argument #1 ($data) "states" entry references unknown object id '.$state); } if (!isset($expectedStates[$state]) || $expectedStates[$state] <= 0) { throw new \ValueError('deepclone_from_array(): Argument #1 ($data) "states" has a __wakeup entry for object id '.$state.' but "objectMeta" does not flag it for __wakeup'); } unset($expectedStates[$state]); if (method_exists($objects[$state], '__wakeup')) { // Like unserialize(), call it whatever its visibility if (true === $m = self::$classInfo[$objects[$state]::class][6] ??= (new \ReflectionMethod($objects[$state], '__wakeup'))->isPublic() ?: new \ReflectionMethod($objects[$state], '__wakeup')) { $objects[$state]->__wakeup(); } else { $m->invoke($objects[$state]); } } } else { throw new \ValueError('deepclone_from_array(): Argument #1 ($data) "states" entry must be of type int|array, '.self::valueName($state).' given'); } } if ($expectedStates) { $id = array_key_first($expectedStates); throw new \ValueError('deepclone_from_array(): Argument #1 ($data) "objectMeta" entry '.$id.' flags object for state replay but no matching "states" entry was found'); } if (\is_int($prepared)) { if ($prepared >= 0) { if ($prepared >= $numObjects) { throw new \ValueError('deepclone_from_array(): Argument #1 ($data) "prepared" references unknown object id '.$prepared); } return $objects[$prepared]; } if (\PHP_INT_MIN === $prepared) { throw new \ValueError('deepclone_from_array(): Argument #1 ($data) "prepared" references unknown ref id out of range'); } if (!(isset($refs[-$prepared]) || \array_key_exists(-$prepared, $refs))) { throw new \ValueError('deepclone_from_array(): Argument #1 ($data) "prepared" references unknown ref id '.(-$prepared)); } return $refs[-$prepared]; } if (null !== $preparedMask) { return self::resolveWithMask($prepared, $preparedMask, $objects, $refs, $allowedClasses); } return $prepared; } /** * Finalizes the objects that only __unserialize() can create and whose state * nests other objects, resolving the references of the payload on the way. */ private static function finalizeDeferred(array $deferred, array &$objects, array $placeholders, array &$refs, array $refMasks, ?array $allowedClasses): void { // Creating them with unserialize() would copy the objects their state // nests, before these are hydrated and without the references they hold // back: build them around the objects themselves instead $members = []; foreach ($deferred as $k => $state) { $zid = $state[0]; $sprops = $state[1] ?? null; $class = $placeholders[$zid]; if ('Random\Randomizer' === $class) { // Like __unserialize(), the constructor keeps the engine and draws from it $eid = $sprops[0]['engine'] ?? null; if ([['engine' => true]] === $state[2] && [['engine' => $eid]] === $sprops && \is_int($eid) && ($engine = $objects[$eid] ?? null) instanceof \Random\Engine) { $objects[$zid] = new \Random\Randomizer($engine); unset($deferred[$k]); } } elseif ('HashContext' === $class && \is_array($sprops) && \is_array($sprops[4] ?? null) && \is_array($state[2]) && [4] === array_keys($state[2]) && \is_array($state[2][4])) { // Its dynamic properties are set once the objects they reference exist $members[$zid] = [$sprops[4], $state[2][4]]; $sprops[4] = []; $ser = serialize($sprops); if (false === $objects[$zid] = unserialize('O:11:"HashContext"'.substr($ser, strpos($ser, ':', 1)))) { throw new \ValueError('deepclone_from_array(): could not reconstruct "HashContext" via __unserialize()'); } unset($deferred[$k]); } } foreach ($refMasks as $k => $m) { if (isset($refs[$k]) || \array_key_exists($k, $refs)) { if (false === $m && -$k === $refs[$k]) { throw new \ValueError('deepclone_from_array(): malformed payload, ref id '.$k.' cannot reference itself'); } $refs[$k] = self::resolveWithMask($refs[$k], $m, $objects, $refs, $allowedClasses); } else { // Like the extension, check a mask that matches no reference against null, which markers reject, and add nothing self::resolveWithMask(null, $m, $objects, $refs, $allowedClasses); } } // The remaining ones come from payloads that deepclone_to_array() doesn't // produce. Iterate to a fixpoint: each pass finalizes those whose referenced // objects are now available. A leftover null is reported by the caller. do { $progress = false; foreach ($deferred as $state) { $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 = $placeholders[$zid]; $resolvedProps = self::resolveWithMask($state[1] ?? null, $state[2], $objects, $refs, $allowedClasses); $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 ($members as $zid => [$values, $mask]) { $object = $objects[$zid]; foreach (self::resolveWithMask($values, $mask, $objects, $refs, $allowedClasses) as $name => &$v) { // Raises the deprecation of __unserialize() since PHP 8.2 if (false === ($mask[$name] ?? null)) { $object->$name = &$v; } else { $object->$name = $v; } } unset($v); } } /** * Before PHP 8.2, DatePeriod rejects writes to the properties that its __wakeup() restores it from, which only * unserialize() sets: they're exported serialized as its class, which it's created from, and its other properties * as usual. */ private static function serializeDatePeriod(int $oid, string $class, array &$arrayValue, ?array $allowedSet): void { $state = array_intersect_key($arrayValue, self::DATE_PERIOD_STATE); $arrayValue = array_diff_key($arrayValue, self::DATE_PERIOD_STATE); foreach ($state as $v) { if (null !== $allowedSet && \is_object($v) && !isset($allowedSet[strtolower(\get_class($v))])) { throw new \ValueError('deepclone_to_array(): class "'.\get_class($v).'" is not allowed'); } } $state = serialize($state); self::$serializedDatePeriods[$oid] = 'O:'.\strlen($class).':"'.$class.'"'.substr($state, strpos($state, ':', 1)); } /** * 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, $allowedClasses = null) { if (true === $mask) { if (!\is_int($value)) { throw new \ValueError('deepclone_from_array(): malformed payload, object reference value must be of type int, '.self::valueName($value).' given'); } if ($value >= 0) { if (!isset($objects[$value])) { throw new \ValueError('deepclone_from_array(): malformed payload, unknown object id '.$value); } return $objects[$value]; } if (\PHP_INT_MIN === $value) { throw new \ValueError('deepclone_from_array(): malformed payload, ref id out of range'); } if (!(isset($refs[-$value]) || \array_key_exists(-$value, $refs))) { throw new \ValueError('deepclone_from_array(): malformed payload, unknown ref id '.(-$value)); } return $refs[-$value]; } if (false === $mask) { if (!\is_int($value)) { throw new \ValueError('deepclone_from_array(): malformed payload, hard-ref value must be of type int, '.self::valueName($value).' given'); } if ($value >= 0 || \PHP_INT_MIN === $value) { throw new \ValueError('deepclone_from_array(): malformed payload, ref id out of range'); } $rid = -$value; if (!(isset($refs[$rid]) || \array_key_exists($rid, $refs))) { throw new \ValueError('deepclone_from_array(): malformed payload, unknown ref id '.$rid); } return $refs[$rid]; } if (0 === $mask) { return self::resolveNamedClosureScalar($value, $objects, $refs); } if (1 === $mask) { return self::resolveConstExprClosureScalar($value, $allowedClasses); } if ('e' === $mask) { if (!\is_string($value)) { throw new \ValueError('deepclone_from_array(): malformed payload, enum value must be of type string, '.self::valueName($value).' given'); } if (false === $sepPos = strpos($value, '::')) { throw new \ValueError('deepclone_from_array(): malformed payload, enum value must match "Class::Case"'); } $className = substr($value, 0, $sepPos); if (!enum_exists($className)) { throw new \ValueError('deepclone_from_array(): malformed payload, enum class "'.explode("\0", $value, 2)[0].'" not found'); } $caseName = substr($value, $sepPos + 2); try { return \constant($value); } catch (\Error) { throw new \ValueError('deepclone_from_array(): malformed payload, enum case "'.explode("\0", $value, 2)[0].'" not found'); } } if (!\is_array($mask)) { return $value; } if (!\is_array($value)) { throw new \ValueError('deepclone_from_array(): malformed payload, array-mask value must be of type array, '.self::valueName($value).' given'); } foreach ($mask as $k => $m) { if (true === $m && \is_int($v = $value[$k] ?? null) && isset($objects[$v])) { // Inlines the most common case of the recursion below $value[$k] = $objects[$v]; } elseif (false === $m) { $slot = $value[$k] ?? null; if (!\is_int($slot)) { throw new \ValueError('deepclone_from_array(): malformed payload, hard-ref slot must be of type int, '.self::valueName($slot).' given'); } if ($slot >= 0 || \PHP_INT_MIN === $slot) { throw new \ValueError('deepclone_from_array(): malformed payload, ref id out of range'); } $rid = -$slot; if (!(isset($refs[$rid]) || \array_key_exists($rid, $refs))) { throw new \ValueError('deepclone_from_array(): malformed payload, unknown ref id '.$rid); } $value[$k] = &$refs[$rid]; } elseif (isset($value[$k]) || \array_key_exists($k, $value)) { $value[$k] = self::resolveWithMask($value[$k], $m, $objects, $refs, $allowedClasses); } else { // Like the extension, check a mask that matches no value against null, which markers reject, and add nothing self::resolveWithMask(null, $m, $objects, $refs, $allowedClasses); } } return $value; } private static function resolveNamedClosureScalar($value, $objects, $refs) { if (!\is_array($value)) { throw new \ValueError('deepclone_from_array(): malformed payload, named-closure value must be of type array, '.self::valueName($value).' given'); } if (!\array_key_exists(0, $value) || !\array_key_exists(1, $value)) { throw new \ValueError('deepclone_from_array(): malformed payload, named-closure value must have at least 2 elements'); } $declaringClass = null; if (\is_array($value[0])) { $callable = $value[0]; if (!\is_string($value[1])) { throw new \ValueError('deepclone_from_array(): malformed payload, named-closure private class name must be of type string, '.self::valueName($value[1]).' given'); } if (!\array_key_exists(2, $value) || !\is_string($value[2])) { throw new \ValueError('deepclone_from_array(): malformed payload, named-closure private method name must be of type string'); } $declaringClass = $value[1]; } else { $callable = $value; } if (!\array_key_exists(0, $callable) || !\array_key_exists(1, $callable) || !\is_string($callable[1])) { throw new \ValueError('deepclone_from_array(): malformed payload, named-closure callable must be [obj_or_class_or_null, string]'); } $obj = $callable[0]; if (\is_int($obj)) { if ($obj >= 0) { if (!isset($objects[$obj])) { throw new \ValueError('deepclone_from_array(): malformed payload, named-closure references unknown id '.$obj); } $obj = $objects[$obj]; } else { if (\PHP_INT_MIN === $obj || !(isset($refs[-$obj]) || \array_key_exists(-$obj, $refs))) { throw new \ValueError('deepclone_from_array(): malformed payload, named-closure references unknown id '.$obj); } $obj = $refs[-$obj]; } } $name = null !== $declaringClass ? $value[2] : $callable[1]; // Like Closure::fromCallable(), the method is looked up on the class of the object, or on the named class, or // on the class that declares it for the non-public ones; the closure gets the scope of that declaring class if (\is_object($obj)) { $calledClass = $obj::class; } elseif (\is_string($obj)) { $calledClass = class_exists($obj) || interface_exists($obj) ? (new \ReflectionClass($obj))->name : null; } elseif (null !== $obj) { throw new \ValueError('deepclone_from_array(): malformed payload, named-closure callable must be [obj_or_class_or_null, string]'); } else { $calledClass = null; } if (null === $declaringClass && null === $obj) { if (!\function_exists($name)) { throw new \ValueError('deepclone_from_array(): malformed payload, named-closure function or method not found'); } return $name(...); } if (!($lookup = $declaringClass ?? $calledClass) || !(class_exists($lookup) || interface_exists($lookup) || trait_exists($lookup)) || !method_exists($lookup, $name)) { if (null === $declaringClass && null !== $calledClass && method_exists($calledClass, \is_object($obj) ? '__call' : '__callStatic')) { // A method that __call() or __callStatic() handles return \is_object($obj) ? $obj->$name(...) : $calledClass::$name(...); } throw new \ValueError('deepclone_from_array(): malformed payload, named-closure function or method not found'); } $method = new \ReflectionMethod($lookup, $name); $calledClass ??= $method->class; if (!is_a($calledClass, $method->class, true)) { throw new \ValueError('deepclone_from_array(): malformed payload, named-closure method '.$method->class.'::'.$method->name.'() cannot be called on '.$calledClass); } if (!$method->isStatic() && !\is_object($obj)) { throw new \ValueError('deepclone_from_array(): malformed payload, named-closure method '.$method->class.'::'.$method->name.'() is not static'); } return (self::$closureFactories[$method->class] ??= \Closure::bind(static function ($target, $name) { return \is_object($target) ? $target->$name(...) : $target::$name(...); }, null, $method->class))(\is_object($obj) ? $obj : $calledClass, $method->name); } /** * Resolves an anonymous closure declared in a constant expression (attribute * argument, class constant, property or parameter default) to its declaration * site: [class, site, attribute index or null, closure index, start line]. */ private static function locateConstExprClosure(\ReflectionFunction $r): ?array { if (!$r->isStatic() || $r->getClosureThis() || $r->getClosureUsedVariables() || !($scope = $r->getClosureScopeClass())) { return null; } [$index, $lines] = self::$constExprIndex[$scope->name] ??= self::indexConstExprClosures($scope); $file = $r->getFileName(); $line = $r->getStartLine(); if (!$candidates = $index[$r->name.':'.$file.':'.$line.':'.$r->getEndLine().':'.self::closureSignature($r)] ?? null) { return null; } // First-class callables are keyed by their target method's identity, so // several declaration sites can map to the same key; they are // equivalent and the first one wins, exactly like the extension. The // anonymous-only checks below (the same-site ambiguity guard and the // runtime-aliasing refusal) only concern anonymous closure literals, // which are told apart by source position. if (!$r->isAnonymous()) { return [$scope->name, $candidates[0][0], $candidates[0][1], $candidates[0][2], $line]; } $sites = []; foreach ($candidates as $candidate) { if (isset($sites[$siteKey = $candidate[0].'#'.$candidate[1]])) { throw new \ValueError('deepclone_to_array(): cannot reference anonymous closure declared at '.$file.':'.$line.', multiple closures share this declaration site'); } $sites[$siteKey] = true; } // Candidates in distinct sites are the same literal exposed twice, eg an attribute argument referencing a // class constant, unless the source line declares several closures, eg an attribute and a default value if (1 < \count($sites) && 1 < self::countClosureLiterals($file, $line)) { throw new \ValueError('deepclone_to_array(): cannot reference anonymous closure declared at '.$file.':'.$line.', multiple closures share this declaration site'); } // Closures declared in method or hook attributes are named after that // function, exactly like closures created at runtime in its body. When // the source line holds more closure literals than the index knows // about, a runtime literal may alias an indexed one; refuse rather // than risk resolving to the wrong body. if (preg_match('/\(\):\d+\}$/', $r->name) && ($lines[$file.':'.$line] ?? 0) < self::countClosureLiterals($file, $line)) { throw new \ValueError('deepclone_to_array(): cannot reference anonymous closure declared at '.$file.':'.$line.', multiple closures share this declaration site'); } return [$scope->name, $candidates[0][0], $candidates[0][1], $candidates[0][2], $line]; } private static function countClosureLiterals(string $file, int $line): int { if (null === $counts = self::$closureLiteralLines[$file] ?? null) { if (!\function_exists('token_get_all')) { throw new \ValueError('deepclone_to_array(): cannot reference anonymous closure declared at '.$file.':'.$line.', the "tokenizer" extension is needed to tell same-line closures apart; install it, or the "deepclone" extension, which resolves closures without reading source files'); } if (!is_file($file) || false === $code = @file_get_contents($file)) { $counts = false; } else { $counts = []; $tokens = token_get_all($code); foreach ($tokens as $i => $t) { if (!\is_array($t) || (\T_FUNCTION !== $t[0] && \T_FN !== $t[0])) { continue; } while (isset($tokens[++$i])) { $t2 = $tokens[$i]; if (!\is_array($t2) || (\T_WHITESPACE !== $t2[0] && \T_COMMENT !== $t2[0] && \T_DOC_COMMENT !== $t2[0])) { if ('(' === $t2 || '&' === $t2) { $counts[$t[2]] = ($counts[$t[2]] ?? 0) + 1; } break; } } } } self::$closureLiteralLines[$file] = $counts; } if (false === $counts) { throw new \ValueError('deepclone_to_array(): cannot reference anonymous closure declared at '.$file.':'.$line.', its source file cannot be read to tell same-line closures apart; install the "deepclone" extension, which resolves closures without reading source files'); } return $counts[$line] ?? 0; } /** * Maps every anonymous closure found in the constant expressions of a class to * its declaration site. Sites are keyed by name:file:start:end:signature; the * name encodes the lexical nesting chain, which keeps closures created at * runtime inside a const-expr closure's body from matching the outer literal. * The same literal can legitimately surface through several sites (an attribute * argument referencing a class constant, a trait alias, a promoted default * applied by a constructor evaluated in another site); candidates in distinct * sites are interchangeable and the first one wins. Two candidates in the SAME * site are distinct literals sharing a declaration line: those are ambiguous. */ private static function indexConstExprClosures(\ReflectionClass $rc): array { $class = $rc->name; $index = []; $lines = []; $retained = []; $seen = []; $add = static function ($values, string $site, ?int $attrIndex) use ($class, &$index, &$lines, &$retained, &$seen) { $i = 0; $objects = []; $walk = static function ($value) use ($class, &$walk, &$i, &$index, &$lines, &$retained, &$seen, &$objects, $site, $attrIndex) { if ($value instanceof \Closure) { $n = $i++; $retained[] = $value; if (isset($seen[$id = spl_object_id($value)])) { return; } $seen[$id] = true; $r = new \ReflectionFunction($value); // Index anonymous closures declared in this class, and // first-class callables over a method of this class (their // scope is the target method's class): both are closures // the class declares about itself. Cross-class and // global-function callables have a different (or null) // scope and are addressed by name instead. if ($r->getClosureScopeClass()?->name !== $class) { return; } $index[$r->name.':'.$r->getFileName().':'.$r->getStartLine().':'.$r->getEndLine().':'.self::closureSignature($r)][] = [$site, $attrIndex, $n]; if ($r->isAnonymous()) { // The runtime-aliasing refusal keys off source lines and // only concerns anonymous closure literals. $lines[$k = $r->getFileName().':'.$r->getStartLine()] = ($lines[$k] ?? 0) + 1; } } elseif (\is_array($value)) { foreach ($value as $v) { $walk($v); } } elseif (\is_object($value) && !isset($objects[$id = spl_object_id($value)])) { $objects[$id] = true; foreach ((array) $value as $v) { $walk($v); } } }; $walk($values); // Break the closure <-> &$walk reference cycle $walk = null; }; foreach ($rc->getAttributes() as $i => $a) { try { $add($a->getArguments(), '', $i); } catch (\Throwable) { } } foreach ($rc->getReflectionConstants() as $rcc) { if ($rcc->getDeclaringClass()->name !== $class) { continue; } foreach ($rcc->getAttributes() as $i => $a) { try { $add($a->getArguments(), $rcc->name, $i); } catch (\Throwable) { } } try { $add([$rcc->getValue()], $rcc->name, null); } catch (\Throwable) { } } foreach ($rc->getProperties() as $rp) { if ($rp->class !== $class || $rp->isPromoted()) { continue; } foreach ($rp->getAttributes() as $i => $a) { try { $add($a->getArguments(), '$'.$rp->name, $i); } catch (\Throwable) { } } try { if ($rp->hasDefaultValue()) { $add([$rp->getDefaultValue()], '$'.$rp->name, null); } } catch (\Throwable) { } foreach (['get', 'set'] as $hookName) { if (!$hook = $rp->getHook(\PropertyHookType::from($hookName))) { continue; } $hSite = '$'.$rp->name.'::'.$hookName.'()'; foreach ($hook->getAttributes() as $i => $a) { try { $add($a->getArguments(), $hSite, $i); } catch (\Throwable) { } } foreach ($hook->getParameters() as $pi => $hp) { foreach ($hp->getAttributes() as $i => $a) { try { $add($a->getArguments(), $hSite.'#'.$pi, $i); } catch (\Throwable) { } } } } } foreach ($rc->getMethods() as $rm) { if ($rm->class !== $class) { continue; } $mSite = $rm->name.'()'; foreach ($rm->getAttributes() as $i => $a) { try { $add($a->getArguments(), $mSite, $i); } catch (\Throwable) { } } foreach ($rm->getParameters() as $pi => $rp) { foreach ($rp->getAttributes() as $i => $a) { try { $add($a->getArguments(), $mSite.'#'.$pi, $i); } catch (\Throwable) { } } try { if ($rp->isDefaultValueAvailable()) { $add([$rp->getDefaultValue()], $mSite.'#'.$pi, null); } } catch (\Throwable) { } } } return [$index, $lines]; } private static function closureSignature(\ReflectionFunction $r): string { $sig = ''; foreach ($r->getParameters() as $p) { $sig .= ($p->isPassedByReference() ? '&' : '').($p->isVariadic() ? '...' : '').'$'.$p->name.':'.$p->getType().';'; } return $sig.':'.$r->getReturnType(); } private static function resolveConstExprClosureScalar($value, ?array $allowedClasses = null): \Closure { if (!\is_array($value)) { throw new \ValueError('deepclone_from_array(): malformed payload, const-expr-closure value must be of type array, '.self::valueName($value).' given'); } if (!\array_key_exists(0, $value) || !\array_key_exists(1, $value) || !\array_key_exists(2, $value) || !\array_key_exists(3, $value) || !\array_key_exists(4, $value) || 5 !== \count($value)) { throw new \ValueError('deepclone_from_array(): malformed payload, const-expr-closure value must have 5 elements'); } [$class, $site, $attrIndex, $closureIndex, $line] = $value; if (!\is_string($class)) { throw new \ValueError('deepclone_from_array(): malformed payload, const-expr-closure class name must be of type string, '.self::valueName($class).' given'); } if (!\is_string($site)) { throw new \ValueError('deepclone_from_array(): malformed payload, const-expr-closure site must be of type string, '.self::valueName($site).' given'); } if (null !== $attrIndex && !\is_int($attrIndex)) { throw new \ValueError('deepclone_from_array(): malformed payload, const-expr-closure attribute index must be of type int or null, '.self::valueName($attrIndex).' given'); } if (!\is_int($closureIndex)) { throw new \ValueError('deepclone_from_array(): malformed payload, const-expr-closure closure index must be of type int, '.self::valueName($closureIndex).' given'); } if (!\is_int($line)) { throw new \ValueError('deepclone_from_array(): malformed payload, const-expr-closure line must be of type int, '.self::valueName($line).' given'); } if (null !== $allowedClasses && !isset(array_change_key_case(array_flip($allowedClasses))[strtolower($class)])) { throw new \ValueError('deepclone_from_array(): class "'.explode("\0", $class, 2)[0].'" is not allowed'); } if ($closureIndex < 0 || $closureIndex > 0xFFFFFFFF) { throw new \ValueError('deepclone_from_array(): malformed payload, const-expr-closure references unknown closure index '.$closureIndex); } try { $rc = new \ReflectionClass($class); } catch (\ReflectionException) { throw new \ValueError('deepclone_from_array(): malformed payload, const-expr-closure references unknown class "'.explode("\0", $class, 2)[0].'"'); } if ('' === $site) { $target = $rc; } elseif ('$' === $site[0]) { if (false !== $pos = strpos($site, '::')) { $target = null; $hookSpec = substr($site, $pos + 2); $paramIndex = null; if (false !== $hashPos = strpos($hookSpec, ')#')) { $paramIndex = substr($hookSpec, $hashPos + 2); $hookSpec = substr($hookSpec, 0, $hashPos + 1); } if (('get()' === $hookSpec || 'set()' === $hookSpec) && (null === $paramIndex || $paramIndex === (string) (int) $paramIndex)) { try { $target = $rc->getProperty(substr($site, 1, $pos - 1))->getHook(\PropertyHookType::from(substr($hookSpec, 0, 3))); if ($target && null !== $paramIndex) { $target = $target->getParameters()[(int) $paramIndex] ?? null; } } catch (\ReflectionException) { } } if (!$target) { throw new \ValueError('deepclone_from_array(): malformed payload, const-expr-closure references unknown hook "'.explode("\0", $site, 2)[0].'"'); } } else { try { $target = $rc->getProperty(substr($site, 1)); } catch (\ReflectionException) { throw new \ValueError('deepclone_from_array(): malformed payload, const-expr-closure references unknown property "'.explode("\0", $site, 2)[0].'"'); } } } elseif (false !== $pos = strpos($site, ')#')) { $num = substr($site, $pos + 2); $target = null; if (2 <= $pos && '(' === $site[$pos - 1] && $num === (string) (int) $num) { try { $target = $rc->getMethod(substr($site, 0, $pos - 1))->getParameters()[(int) $num] ?? null; } catch (\ReflectionException) { } } if (null === $target) { throw new \ValueError('deepclone_from_array(): malformed payload, const-expr-closure references unknown parameter "'.explode("\0", $site, 2)[0].'"'); } } elseif (str_ends_with($site, '()')) { try { $target = $rc->getMethod(substr($site, 0, -2)); } catch (\ReflectionException) { throw new \ValueError('deepclone_from_array(): malformed payload, const-expr-closure references unknown method "'.explode("\0", $site, 2)[0].'"'); } } elseif (!$target = $rc->getReflectionConstant($site)) { throw new \ValueError('deepclone_from_array(): malformed payload, const-expr-closure references unknown constant "'.explode("\0", $site, 2)[0].'"'); } if (null !== $attrIndex) { $attrs = $target->getAttributes(); if (!isset($attrs[$attrIndex])) { throw new \ValueError('deepclone_from_array(): malformed payload, const-expr-closure references unknown attribute index '.$attrIndex); } try { $values = $attrs[$attrIndex]->getArguments(); } catch (\Throwable $e) { throw new \ValueError('deepclone_from_array(): malformed payload, const-expr-closure evaluation failed for site "'.explode("\0", $site, 2)[0].'"', 0, $e); } } elseif ($target instanceof \ReflectionClass || $target instanceof \ReflectionMethod) { throw new \ValueError('deepclone_from_array(): malformed payload, const-expr-closure attribute index is required for site "'.explode("\0", $site, 2)[0].'"'); } else { try { if ($target instanceof \ReflectionClassConstant) { $values = [$target->getValue()]; } elseif ($target instanceof \ReflectionParameter) { $values = $target->isDefaultValueAvailable() ? [$target->getDefaultValue()] : []; } else { $values = $target->hasDefaultValue() ? [$target->getDefaultValue()] : []; } } catch (\Throwable $e) { throw new \ValueError('deepclone_from_array(): malformed payload, const-expr-closure evaluation failed for site "'.explode("\0", $site, 2)[0].'"', 0, $e); } } $found = null; $i = 0; $objects = []; $walk = static function ($value) use (&$walk, &$found, &$i, $closureIndex, &$objects) { if ($value instanceof \Closure) { if ($i++ === $closureIndex) { $found = $value; } } elseif (\is_array($value)) { foreach ($value as $v) { $walk($v); if (null !== $found) { return; } } } elseif (\is_object($value) && !isset($objects[$id = spl_object_id($value)])) { $objects[$id] = true; foreach ((array) $value as $v) { $walk($v); if (null !== $found) { return; } } } }; $walk($values); // Break the closure <-> &$walk reference cycle $walk = null; if (null === $found) { throw new \ValueError('deepclone_from_array(): malformed payload, const-expr-closure references unknown closure index '.$closureIndex); } // Internal functions (e.g. a global strlen(...) reference) have no // start line; getStartLine() returns false, which the extension encodes // as 0. Normalize so such a reference does not look stale. $foundLine = (new \ReflectionFunction($found))->getStartLine() ?: 0; if ($line !== $foundLine) { throw new \ValueError('deepclone_from_array(): stale payload, const-expr-closure moved from line '.$line.' to line '.$foundLine); } return $found; } private static function maskHasClosure($mask): bool { if (0 === $mask || 1 === $mask) { return true; } if (\is_array($mask)) { foreach ($mask as $v) { if (self::maskHasClosure($v)) { return true; } } } return false; } /** * Like maskHasClosure() but matches only the named-closure marker (0), * ignoring const-expr-closure references (1). */ private static function maskHasNamedClosure($mask): bool { if (0 === $mask) { return true; } if (\is_array($mask)) { foreach ($mask as $v) { if (self::maskHasNamedClosure($v)) { return true; } } } return false; } /** * Scans the payload regions that can carry closure markers (the top mask, * the reference masks, the resolve table and the replayed state masks) for * a named-closure marker. Mirrors the region set used by the * allowed_classes "Closure" gate. */ private static function payloadHasNamedClosure(array $data): bool { foreach (['mask', 'refMasks'] as $key) { if (isset($data[$key]) && self::maskHasNamedClosure($data[$key])) { return true; } } if (isset($data['resolve'])) { foreach ($data['resolve'] as $scope) { if (\is_array($scope)) { foreach ($scope as $name) { if (self::maskHasNamedClosure($name)) { return true; } } } } } if (isset($data['states'])) { foreach ($data['states'] as $state) { if (\is_array($state) && isset($state[2]) && self::maskHasNamedClosure($state[2])) { return true; } } } return false; } private static function replaceRefs($value, &$mask) { if (\is_array($value)) { foreach ($value as $k => $v) { if ($v instanceof Reference || \is_array($v)) { $m = null; $value[$k] = self::replaceRefs($v, $m); if (null !== $m) { $mask[$k] = $m; } } } } if (!$value instanceof Reference) { return $value; } if ($value->id >= 0) { $mask = true; return $value->id; } if ($value->count) { $mask = false; return $value->id; } // Unshared hard ref (count=0): unwrap the inner value $m = null; $inner = self::replaceRefs($value->value, $m); $mask = self::mergeRefMasks($value->mask, $m); return $inner; } /** * Merges the markers replaceRefs() derived into a mask built by prepare(), * where hard refs are all marked false: shared ones keep that marker, and * unwrapped ones get the mask of their value. */ private static function mergeRefMasks($mask, $refMask) { if (false === $mask) { return $refMask; } if (!\is_array($mask)) { return $mask; } foreach ($mask as $k => $m) { if (false !== $m && !\is_array($m)) { continue; } if (null === $m = self::mergeRefMasks($m, $refMask[$k] ?? null)) { unset($mask[$k]); } else { $mask[$k] = $m; } } return $mask ?: null; } /** * Selects the properties named by __sleep() like serialize() does, in the same order and with the same warnings. * * A name selects the property whose (array) cast key it is, else the private property of the object's class or the * protected property by that name. */ private static function getSleepProperties(object $object, \ReflectionClass $reflector, array $names): array { $class = $object::class; $properties = (array) $object; $selected = []; foreach ($names as $name) { if (!\is_string($name)) { trigger_error('serialize(): '.$class.'::__sleep() should return an array only containing the names of instance-variables to serialize', \E_USER_WARNING); $name = (string) $name; } // Numeric names are integer keys, which ReflectionReference::fromArrayElement() needs foreach ([$name === (string) (int) $name ? (int) $name : $name, "\0".$class."\0".$name, "\0*\0".$name] as $key) { if (\array_key_exists($key, $properties)) { if (\array_key_exists($key, $selected)) { // Names are cut at their first NUL byte, as by serialize() trigger_error(\sprintf('serialize(): "%s" is returned from __sleep() multiple times', explode("\0", $name, 2)[0]), \PHP_VERSION_ID >= 80300 ? \E_USER_WARNING : \E_USER_NOTICE); } elseif (\ReflectionReference::fromArrayElement($properties, $key)) { $selected[$key] = &$properties[$key]; } else { $selected[$key] = $properties[$key]; } continue 2; } } // Uninitialized typed properties are skipped silently, unset untyped ones don't exist $typed = self::$typedPropertyKeys[$class] ??= self::getTypedPropertyKeys($reflector); if (!isset($typed[$name]) && !isset($typed["\0".$class."\0".$name]) && !isset($typed["\0*\0".$name])) { trigger_error(\sprintf('serialize(): "%s" returned as member variable from __sleep() but does not exist', explode("\0", $name, 2)[0]), \E_USER_WARNING); } } return $selected; } /** * Lists the (array) cast keys of the typed properties of a class. */ private static function getTypedPropertyKeys(\ReflectionClass $reflector): array { $keys = []; do { foreach ($reflector->getProperties() as $p) { if (!$p->isStatic() && $p->hasType()) { $keys[$p->isPrivate() ? "\0".$p->class."\0".$p->name : ($p->isProtected() ? "\0*\0".$p->name : $p->name)] = true; } } } while ($reflector = $reflector->getParentClass()); return $keys; } /** * Describes how deepclone_to_array() exports the properties of a class. * * Maps the keys that (array) casts give to declared properties to their scope and name, * and lists the default values that payloads leave out. */ private static function getShape(\ReflectionClass $reflector, $proto): array { $keys = []; $parent = $reflector; do { foreach ($parent->getProperties() as $p) { if ($p->isStatic()) { continue; } $name = $p->name; if ($p->isPrivate()) { $keys["\0".$p->class."\0".$name] = [$p->class, $name]; } if (!isset($keys[$name])) { $keys[$name] = $keys["\0*\0".$name] = [!$p->isPublic() || (\PHP_VERSION_ID >= 80400 ? $p->isProtectedSet() || $p->isPrivateSet() : $p->isReadOnly()) ? $p->class : 'stdClass', $name]; } } } while ($parent = $parent->getParentClass()); if (\PHP_VERSION_ID < 80200 && $proto instanceof \Transliterator) { // Before PHP 8.2, which refuses serializing it, Transliterator::$id is read-only but to reflection $keys['id'][0] = $keys["\0*\0id"][0] = 'Transliterator'; } // Traces, files and lines of throwables are always exported $defaults = (array) $proto; unset($defaults["\0Error\0trace"], $defaults["\0Exception\0trace"], $defaults["\0*\0file"], $defaults["\0*\0line"]); return [$keys, $defaults]; } 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 "'.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) { $proto = $reflector->newInstanceWithoutConstructor(); } elseif (!$isClass || $reflector->isAbstract() || $reflector->isEnum()) { 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]; self::$instantiableWithoutConstructor[$class] = self::$instantiableWithoutConstructor[$name]; self::$prototypes[$class] = self::$prototypes[$name]; return $reflector; } else { try { $proto = $reflector->newInstanceWithoutConstructor(); $instantiableWithoutConstructor = true; } catch (\ReflectionException) { $proto = $reflector->implementsInterface('Serializable') && !method_exists($class, '__unserialize') ? 'C:' : 'O:'; if ('C:' === $proto && !$reflector->getMethod('unserialize')->isInternal()) { $proto = null; } else { try { $proto = @unserialize($proto.\strlen($class).':"'.$class.'":0:{}'); } catch (\Exception $e) { if (__FILE__ !== $e->getFile()) { throw $e; } if (!method_exists($class, '__unserialize')) { throw new \DeepClone\NotInstantiableException('Type "'.$class.'" is not instantiable.'); } self::$needsFullUnserialize[$class] = true; $proto = null; } if (false === $proto) { if (!method_exists($class, '__unserialize')) { throw new \DeepClone\NotInstantiableException('Type "'.$class.'" is not instantiable.'); } self::$needsFullUnserialize[$class] = true; $proto = null; } } } // 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->isInternal() && ($r = $r->getParentClass()) && !$r->isAnonymous();) { } 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.'); } $cloneable = $reflector->isCloneable() && !$reflector->hasMethod('__clone'); // Cloning a bare ImagickPixel aborts, a bare DOMNameSpaceNode crashes before PHP 8.3, and most bare intl objects fail before PHP 8.2, where they're serializable if ($cloneable && ($proto instanceof \ImagickPixel || \PHP_VERSION_ID < 80300 && ($proto instanceof \DOMNameSpaceNode || \PHP_VERSION_ID < 80200 && ($proto instanceof \MessageFormatter || $proto instanceof \IntlDateFormatter || $proto instanceof \IntlDatePatternGenerator || $proto instanceof \Transliterator || $proto instanceof \IntlTimeZone || $proto instanceof \IntlCalendar || $proto instanceof \Spoofchecker || $proto instanceof \IntlBreakIterator || $proto instanceof \UConverter)))) { $cloneable = false; } } self::$cloneable[$class] = $cloneable; self::$instantiableWithoutConstructor[$class] = $instantiableWithoutConstructor; self::$prototypes[$class] = $proto; if ($proto instanceof \Throwable) { static $setTrace; if (null === $setTrace) { $setTrace = [ new \ReflectionProperty(\Error::class, 'trace'), new \ReflectionProperty(\Exception::class, 'trace'), ]; $setTrace[0] = $setTrace[0]->setValue(...); $setTrace[1] = $setTrace[1]->setValue(...); } $setTrace[$proto instanceof \Exception]($proto, []); } 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 set_error_handler(static fn () => true); try { unserialize('O:'.\strlen($class).':"'.$class.'":'); } catch (\Exception) { return true; } finally { restore_error_handler(); } return false; } private static function getHydrator($class) { // $objects by reference, which spares copying it to fetch its objects for writing $baseHydrator = self::$hydrators['stdClass'] ??= static function ($properties, &$objects) { foreach ($properties as $name => $values) { foreach ($values as $i => $v) { $objects[$i]->$name = $v; } } }; if ('stdClass' === $class) { return $baseHydrator; } if ('TypeError' === $class) { $class = 'Error'; } // self::$reflectors must be populated via getClassReflector() (companion caches), so read with ??. $classReflector = self::$reflectors[$class] ?? new \ReflectionClass($class); if (!$classReflector->isInternal()) { return $baseHydrator->bindTo(null, $class); } if ($classReflector->name !== $class) { return self::$hydrators[$classReflector->name] ??= self::getHydrator($classReflector->name); } $propertySetters = []; foreach ($classReflector->getProperties() as $propertyReflector) { if (!$propertyReflector->isStatic()) { $propertySetters[$propertyReflector->name] = $propertyReflector->setValue(...); } } if (!$propertySetters) { return $baseHydrator; } return static function ($properties, $objects) use ($propertySetters) { foreach ($properties as $name => $values) { if ($setValue = $propertySetters[$name] ?? null) { foreach ($values as $i => $v) { // Unknown ids are reported by checkProperties() $setValue($objects[$i] ?? null, $v); } continue; } foreach ($values as $i => $v) { $objects[$i]->$name = $v; } } }; } /** * Lists the backed hooked properties of a class by the scope that the * payload uses for them. * * @return array> */ private static function getHookedProperties(\ReflectionClass $class): array { $hooked = []; for ($r = $class; $r; $r = $r->getParentClass()) { foreach ($r->getProperties() as $p) { if ($p->class !== $r->name || $p->isStatic() || $p->isAbstract() || $p->isVirtual() || !$p->getHooks()) { continue; } $scope = $p->isPublic() && !$p->isProtectedSet() && !$p->isPrivateSet() ? 'stdClass' : $p->class; $hooked[$scope][$p->name] ??= $p; } } return $hooked; } /** * Like getHydrator(), binding the slots listed in $refSlots to the hard * references they target. With $silenced, negative ids create a dynamic * property without raising its deprecation. */ private static function getRefHydrator(string $class, bool $silenced): \Closure { if ($silenced) { $silence = static fn () => true; $hydrator = static function ($properties, $objects, $refSlots, &$refs) use ($silence) { foreach ($properties as $name => $values) { $slots = $refSlots[$name] ?? []; foreach ($values as $i => $v) { if (!isset($slots[$i])) { $objects[$i]->$name = $v; } elseif (0 < $rid = $slots[$i]) { $objects[$i]->$name = &$refs[$rid]; } else { // The origin raised the deprecation of dynamic properties already. Like the extension, don't // raise it again: @ would still report it to error handlers set_error_handler($silence, \E_DEPRECATED); try { $objects[$i]->$name = &$refs[-$rid]; } finally { restore_error_handler(); } } } } }; } else { $hydrator = static function ($properties, $objects, $refSlots, &$refs) { foreach ($properties as $name => $values) { $slots = $refSlots[$name] ?? []; foreach ($values as $i => $v) { if (isset($slots[$i])) { $objects[$i]->$name = &$refs[$slots[$i]]; } else { $objects[$i]->$name = $v; } } } }; } return 'stdClass' === $class ? $hydrator : \Closure::bind($hydrator, null, $class); } /** * Throws the ValueError of the extension for the properties of a payload that failed to hydrate, * when it finds why in the scopes processed so far: the extension checks them before writing. */ private static function checkProperties(array $properties, string $lastScope, array $objects, int $numObjects): void { foreach ($properties as $scope => $scopeProps) { foreach ($scopeProps as $name => $idValues) { foreach ($idValues as $id => $_) { if (!\is_int($id) || $id < 0 || $id >= $numObjects) { throw new \ValueError('deepclone_from_array(): Argument #1 ($data) "properties" entry for "'.explode("\0", $scope, 2)[0].'::'.explode("\0", $name, 2)[0].'" references unknown object id '.explode("\0", $id, 2)[0]); } if (!\is_object($object = $objects[$id])) { continue; } if ('stdClass' === $scope) { if ('stdClass' !== $object::class && ($p = self::findDeclaredProperty($object::class, $name)) && ($p->isStatic() || !$p->isPublic() || \PHP_VERSION_ID >= 80400 && ($p->isProtectedSet() || $p->isPrivateSet()))) { throw new \ValueError('deepclone_from_array(): Argument #1 ($data) "properties" value for "'.explode("\0", $scope, 2)[0].'::'.explode("\0", $name, 2)[0].'" targets a non-public declared property on object id '.$id); } } elseif (!$object instanceof $scope) { throw new \ValueError('deepclone_from_array(): Argument #1 ($data) "properties" scope "'.explode("\0", $scope, 2)[0].'" is not a parent of object id '.$id.' ('.explode("\0", $object::class, 2)[0].')'); } elseif (!($p = self::findDeclaredProperty($scope, $name)) || $p->isStatic()) { throw new \ValueError('deepclone_from_array(): Argument #1 ($data) "properties" value for "'.explode("\0", $scope, 2)[0].'::'.explode("\0", $name, 2)[0].'" does not match a declared property on object id '.$id); } } } if ($scope === $lastScope) { break; } } } /** * Finds the property that the extension resolves a name to, private properties of parent classes included. */ private static function findDeclaredProperty(string $class, int|string $name): ?\ReflectionProperty { for ($r = new \ReflectionClass($class); $r; $r = $r->getParentClass()) { if ($r->hasProperty($name)) { return $r->getProperty($name); } } return null; } /** * Throws the ValueError of the extension for the states of a payload that failed to reconstruct, when it finds why. * With $replayed, missing states and non-array states of the objects that only __unserialize() can create are * reported too. */ private static function checkStates(array $states, array $objects, array $placeholders, int $numObjects, array $expectedStates, bool $replayed = false): void { foreach ($states as $state) { if (\is_array($state)) { $zid = $state[0] ?? null; if (!\is_int($zid) || !\array_key_exists(1, $state)) { throw new \ValueError('deepclone_from_array(): Argument #1 ($data) malformed "states" entry: expected [int, mixed, mixed?]'); } if ($zid < 0 || $zid >= $numObjects) { throw new \ValueError('deepclone_from_array(): Argument #1 ($data) "states" entry references unknown object id '.$zid); } if (!isset($expectedStates[$zid]) || $expectedStates[$zid] >= 0) { throw new \ValueError('deepclone_from_array(): Argument #1 ($data) "states" has an __unserialize entry for object id '.$zid.' but "objectMeta" does not flag it for __unserialize'); } unset($expectedStates[$zid]); $class = \is_object($objects[$zid] ?? null) ? $objects[$zid]::class : (new \ReflectionClass($placeholders[$zid]))->name; if (!method_exists($class, '__unserialize')) { throw new \ValueError('deepclone_from_array(): Argument #1 ($data) "states" entry references object id '.$zid.' whose class '.$class.' has no __unserialize() method'); } if ($replayed && isset($placeholders[$zid]) && !\is_array($state[1]) && !isset($state[2])) { throw self::unserializeTypeError($class, $state[1]); } } elseif (\is_int($state)) { if ($state < 0 || $state >= $numObjects) { throw new \ValueError('deepclone_from_array(): Argument #1 ($data) "states" entry references unknown object id '.$state); } if (!isset($expectedStates[$state]) || $expectedStates[$state] <= 0) { throw new \ValueError('deepclone_from_array(): Argument #1 ($data) "states" has a __wakeup entry for object id '.$state.' but "objectMeta" does not flag it for __wakeup'); } unset($expectedStates[$state]); } else { throw new \ValueError('deepclone_from_array(): Argument #1 ($data) "states" entry must be of type int|array, '.self::valueName($state).' given'); } } if ($replayed && $expectedStates) { throw new \ValueError('deepclone_from_array(): Argument #1 ($data) "objectMeta" entry '.array_key_first($expectedStates).' flags object for state replay but no matching "states" entry was found'); } } /** * Checks the states of a payload when $e reports a reference to an object that only __unserialize() can create and * that has no state yet. The extension creates these objects before replaying the states, so it reports the * errors of the states first. */ private static function checkPendingStates(\ValueError $e, array $states, array $objects, array $placeholders, int $numObjects, array $expectedStates): \ValueError { if ($placeholders && preg_match('/^deepclone_from_array\(\): malformed payload, unknown object id (\d+)$/', $e->getMessage(), $m) && isset($placeholders[$m[1]])) { self::checkStates($states, $objects, $placeholders, $numObjects, $expectedStates, true); } return $e; } /** * Returns the TypeError of the __unserialize() method of an internal class called with a non-array value. */ private static function unserializeTypeError(string $class, mixed $value): \TypeError { $method = new \ReflectionMethod($class, '__unserialize'); $parameter = $method->getParameters()[0]; return new \TypeError($method->class.'::__unserialize(): Argument #1 ($'.$parameter->name.') must be of type '.$parameter->getType().', '.self::valueName($value).' given'); } /** * How a hard reference targeting property $name of a $class instance is * restored: 1 by binding it, 2 by binding it without the deprecation of * dynamic properties, 0 by writing its value where PHP can't bind one * (hooked, readonly or internal properties), -1 not at all on virtual * properties, like the extension. */ private static function getRefBinding(string $class, string $name): int { if (null !== $binding = self::$refBindings[$class][$name] ?? null) { return $binding; } $r = self::$reflectors[$class] ?? new \ReflectionClass($class); if (!$r->hasProperty($name)) { // Dynamic property, unless behind the custom handlers of an internal class. Creating a dynamic property on a // user class can raise a deprecation $binding = 'stdClass' === $class ? 1 : ($r->isInternal() ? 0 : ($r->isSubclassOf(\stdClass::class) ? 1 : 2)); } elseif (\PHP_VERSION_ID >= 80400 && ($p = $r->getProperty($name))->isVirtual()) { $binding = -1; } else { $p ??= $r->getProperty($name); $binding = $p->getDeclaringClass()->isInternal() || $p->isReadOnly() || (\PHP_VERSION_ID >= 80400 && $p->getHooks()) ? 0 : 1; } return self::$refBindings[$class][$name] = $binding; } private static function getSimpleHydrator(string $class, int $flags = 0): \Closure { $callHooks = (bool) ($flags & \DEEPCLONE_HYDRATE_CALL_HOOKS); $noLazyInit = \PHP_VERSION_ID >= 80400 && ($flags & \DEEPCLONE_HYDRATE_NO_LAZY_INIT); $baseHydrator = self::$simpleHydrators['stdClass'] ??= static function ($properties, $object, $hasRefs) { if ($hasRefs) { foreach ($properties as $name => &$value) { $object->$name = $value; $object->$name = &$value; } } else { foreach ($properties as $name => $value) { $object->$name = $value; } } }; if ('stdClass' === $class) { return $baseHydrator; } if ('TypeError' === $class) { $class = 'Error'; } elseif (!class_exists($class, false)) { throw new \DeepClone\ClassNotFoundException('Class "'.$class.'" not found.'); } $classReflector = self::$reflectors[$class] ?? new \ReflectionClass($class); if ($classReflector->isInternal()) { if ($classReflector->name !== $class) { return self::$simpleHydrators[$classReflector->name] ??= self::getSimpleHydrator($classReflector->name); } $propertySetters = []; foreach ($classReflector->getProperties() as $propertyReflector) { if (!$propertyReflector->isStatic()) { $propertySetters[$propertyReflector->name] = $propertyReflector->setValue(...); } } if (!$propertySetters) { return $baseHydrator; } return static function ($properties, $object) use ($propertySetters) { foreach ($properties as $name => $value) { if ($setValue = $propertySetters[$name] ?? null) { $setValue($object, $value); continue; } $object->$name = $value; } }; } $notByRef = []; $unsetOnNull = []; $backedEnum = []; foreach ($classReflector->getProperties() as $propertyReflector) { if ($propertyReflector->isStatic()) { continue; } if ($noLazyInit && !$propertyReflector->isVirtual()) { $notByRef[$propertyReflector->name] = $propertyReflector->setRawValueWithoutLazyInitialization(...); continue; } $t = null; if (\PHP_VERSION_ID >= 80400 && !$propertyReflector->isAbstract() && $propertyReflector->getHooks()) { if ($propertyReflector->isVirtual()) { $notByRef[$propertyReflector->name] = true; } else { $notByRef[$propertyReflector->name] = $callHooks ? $propertyReflector->setValue(...) : $propertyReflector->setRawValue(...); } } elseif ($propertyReflector->isReadOnly()) { $notByRef[$propertyReflector->name] = static function ($object, $value) use ($propertyReflector) { if (!$propertyReflector->isInitialized($object) || $propertyReflector->getValue($object) !== $value) { $propertyReflector->setValue($object, $value); } }; } elseif (($t = $propertyReflector->getType()) && !$t->allowsNull()) { $unsetOnNull[$propertyReflector->name] = true; } // Property-type-only decision: hook presence and CALL_HOOKS don't influence it. if (($t ??= $propertyReflector->getType()) instanceof \ReflectionNamedType && !$t->isBuiltin() && enum_exists($enumName = $t->getName()) && (new \ReflectionEnum($enumName))->getBackingType() ) { $backedEnum[$propertyReflector->name] = $enumName; } } // Four variants. When the class has no hooked/readonly/unsetOnNull/backedEnum // props, the hottest path skips the $notByRef table lookup entirely. Each // hasRefs branch is hoisted out of the loop so it's branch-predicted once. if (!$unsetOnNull && !$backedEnum) { if (!$notByRef) { return \Closure::bind(static function ($properties, $object, $hasRefs): void { if ($hasRefs) { foreach ($properties as $name => &$value) { $object->$name = $value; $object->$name = &$value; } } else { foreach ($properties as $name => $value) { $object->$name = $value; } } }, null, $class); } return \Closure::bind(static function ($properties, $object, $hasRefs) use ($notByRef): void { if ($hasRefs) { foreach ($properties as $name => &$value) { if (!$noRef = $notByRef[$name] ?? false) { $object->$name = $value; $object->$name = &$value; } elseif (true !== $noRef) { $noRef($object, $value); } else { $object->$name = $value; } } } else { foreach ($properties as $name => $value) { if (!$noRef = $notByRef[$name] ?? false) { $object->$name = $value; } elseif (true !== $noRef) { $noRef($object, $value); } else { $object->$name = $value; } } } }, null, $class); } if (!$backedEnum) { return \Closure::bind(static function ($properties, $object, $hasRefs) use ($notByRef, $unsetOnNull): void { if ($hasRefs) { foreach ($properties as $name => &$value) { if (null === $value && isset($unsetOnNull[$name])) { unset($object->$name); continue; } if (!$noRef = $notByRef[$name] ?? false) { $object->$name = $value; $object->$name = &$value; } elseif (true !== $noRef) { $noRef($object, $value); } else { $object->$name = $value; } } } else { foreach ($properties as $name => $value) { if (null === $value && isset($unsetOnNull[$name])) { unset($object->$name); continue; } if (!$noRef = $notByRef[$name] ?? false) { $object->$name = $value; } elseif (true !== $noRef) { $noRef($object, $value); } else { $object->$name = $value; } } } }, null, $class); } return \Closure::bind(static function ($properties, $object, $hasRefs) use ($notByRef, $unsetOnNull, $backedEnum): void { if ($hasRefs) { foreach ($properties as $name => &$value) { if (null === $value && isset($unsetOnNull[$name])) { unset($object->$name); continue; } if (isset($backedEnum[$name]) && (\is_int($value) || \is_string($value))) { $value = $backedEnum[$name]::from($value); } if (!$noRef = $notByRef[$name] ?? false) { $object->$name = $value; $object->$name = &$value; } elseif (true !== $noRef) { $noRef($object, $value); } else { $object->$name = $value; } } } else { foreach ($properties as $name => $value) { if (null === $value && isset($unsetOnNull[$name])) { unset($object->$name); continue; } if (isset($backedEnum[$name]) && (\is_int($value) || \is_string($value))) { $value = $backedEnum[$name]::from($value); } if (!$noRef = $notByRef[$name] ?? false) { $object->$name = $value; } elseif (true !== $noRef) { $noRef($object, $value); } else { $object->$name = $value; } } } }, null, $class); } } /** * Internal marker for hard references discovered during the walk. * * @internal */ final class Reference { public int $count = 0; public mixed $mask = null; public function __construct( public readonly int $id, public mixed $value = null, ) { } }