Files
code-bang/backend/vendor/symfony/polyfill-deepclone/DeepClone.php
T

2967 lines
142 KiB
PHP

<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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 <p@tchwork.com>
*
* @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<string>|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<string>|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<string, array<string, \ReflectionProperty>>
*/
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,
) {
}
}