2967 lines
142 KiB
PHP
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,
|
|
) {
|
|
}
|
|
}
|