2154 lines
98 KiB
PHP
2154 lines
98 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 silently lose state through property restoration. */
|
|
private const NOT_ROUND_TRIPPABLE = [
|
|
'ZipArchive' => true,
|
|
'XMLWriter' => true,
|
|
'XMLReader' => true,
|
|
'SNMP' => true,
|
|
'tidy' => true,
|
|
'tidyNode' => true,
|
|
];
|
|
|
|
private static array $reflectors = [];
|
|
private static array $prototypes = [];
|
|
private static array $cloneable = [];
|
|
private static array $instantiableWithoutConstructor = [];
|
|
private static array $needsFullUnserialize = [];
|
|
private static array $hydrators = [];
|
|
private static array $simpleHydrators = [];
|
|
private static array $scopeMaps = [];
|
|
private static array $propertyScopes = []; // [class][key] = [declaring class, real name]; key is bare name, "\0*\0name", or "\0class\0name"
|
|
private static array $protos = [];
|
|
private static array $classInfo = [];
|
|
private static array $constExprIndex = [];
|
|
private static array $closureLiteralLines = [];
|
|
private static \stdClass $sentinel;
|
|
|
|
/**
|
|
* @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_resource($value)) {
|
|
throw new \DeepClone\NotInstantiableException('Type "'.get_resource_type($value).' resource" is not instantiable.');
|
|
}
|
|
|
|
if (!\is_object($value) && !(\is_array($value) && $value) || $value instanceof \UnitEnum) {
|
|
return ['value' => $value];
|
|
}
|
|
|
|
$allowedSet = null;
|
|
if (null !== $allowed_classes) {
|
|
$allowedSet = [];
|
|
foreach ($allowed_classes as $cls) {
|
|
if (!\is_string($cls)) {
|
|
throw new \ValueError('deepclone_to_array(): Argument $allowed_classes must be an array of class names, '.self::valueName($cls).' given');
|
|
}
|
|
$allowedSet[strtolower($cls)] = true;
|
|
}
|
|
}
|
|
|
|
$objectsPool = [];
|
|
$refsPool = [];
|
|
$objectsCount = 0;
|
|
$isStatic = true;
|
|
$refs = [];
|
|
$refMasks = [];
|
|
$topMask = null;
|
|
|
|
try {
|
|
$prepared = self::prepare([$value], $objectsPool, $refsPool, $objectsCount, $isStatic, $topMask, $allowedSet, $allow_named_closures)[0];
|
|
} finally {
|
|
// Snapshot ref state while references still break cycles.
|
|
foreach ($refsPool as $i => $v) {
|
|
if ($v[0]->count) {
|
|
if ($v[1] instanceof \UnitEnum) {
|
|
$refs[1 + $i] = $v[1]::class.'::'.$v[1]->name;
|
|
$refMasks[1 + $i] = 'e';
|
|
} elseif (\is_object($v[1])) {
|
|
$oid = spl_object_id($v[1]);
|
|
$refs[1 + $i] = isset($objectsPool[$oid]) ? $objectsPool[$oid][0] : $v[2];
|
|
$refMasks[1 + $i] = true;
|
|
} elseif (\is_array($v[2])) {
|
|
$m = null;
|
|
$refs[1 + $i] = self::replaceRefs($v[2], $m);
|
|
if (null !== $m) {
|
|
$refMasks[1 + $i] = $m;
|
|
}
|
|
} else {
|
|
$refs[1 + $i] = $v[2];
|
|
}
|
|
}
|
|
$v[0] = $v[1];
|
|
}
|
|
}
|
|
|
|
if ($isStatic) {
|
|
return ['value' => $value];
|
|
}
|
|
|
|
$objectMeta = [];
|
|
$properties = [];
|
|
$resolve = [];
|
|
$states = [];
|
|
|
|
foreach ($objectsPool as [$id, $class, $props, $wakeup, $v, $propMask]) {
|
|
$objectMeta[$id] = [$class, $wakeup];
|
|
|
|
if (0 < $wakeup) {
|
|
$states[$wakeup] = $id;
|
|
} elseif (0 > $wakeup) {
|
|
$states[-$wakeup] = null !== $propMask ? [$id, $props, $propMask] : [$id, $props];
|
|
$props = [];
|
|
}
|
|
|
|
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 (!\is_int($prepared)) {
|
|
$m = null;
|
|
$prepared = self::replaceRefs($prepared, $m);
|
|
if (\is_array($preparedMask)) {
|
|
if (null !== $m) {
|
|
$preparedMask = $m + array_filter($preparedMask, static fn ($v) => false !== $v);
|
|
} else {
|
|
$preparedMask = array_filter($preparedMask, static fn ($v) => false !== $v) ?: null;
|
|
}
|
|
} elseif (false === $preparedMask) {
|
|
$preparedMask = $m;
|
|
}
|
|
}
|
|
|
|
foreach ($resolve as $scope => $names) {
|
|
foreach ($names as $name => $ids) {
|
|
foreach ($ids as $id => $marker) {
|
|
if (false !== $marker) {
|
|
continue;
|
|
}
|
|
$v = $properties[$scope][$name][$id];
|
|
if (!$v instanceof Reference) {
|
|
$m = null;
|
|
$properties[$scope][$name][$id] = self::replaceRefs($v, $m);
|
|
if (null !== $m) {
|
|
$ids[$id] = $m;
|
|
} else {
|
|
unset($ids[$id]);
|
|
}
|
|
} elseif ($v->count) {
|
|
$properties[$scope][$name][$id] = $v->id;
|
|
$ids[$id] = true;
|
|
} else {
|
|
$m = null;
|
|
$properties[$scope][$name][$id] = self::replaceRefs($v->value, $m);
|
|
if (null !== $m) {
|
|
$ids[$id] = $m;
|
|
} else {
|
|
unset($ids[$id]);
|
|
}
|
|
}
|
|
}
|
|
$resolve[$scope][$name] = $ids;
|
|
}
|
|
}
|
|
|
|
foreach ($states as $k => $v) {
|
|
if (\is_array($v)) {
|
|
$m = null;
|
|
$states[$k][1] = self::replaceRefs($v[1], $m);
|
|
if ($m) {
|
|
$states[$k][2] = isset($v[2]) ? $v[2] + $m : $m;
|
|
}
|
|
}
|
|
}
|
|
|
|
// After unwrapping unshared hard refs, the value may have become static.
|
|
if (!$objectMeta && !$refs && null === $preparedMask) {
|
|
return ['value' => $prepared];
|
|
}
|
|
|
|
// Deduplicate class names in objectMeta.
|
|
$classes = [];
|
|
$classMap = [];
|
|
$metaOut = [];
|
|
foreach ($objectMeta as $id => [$class, $wakeup]) {
|
|
if (!isset($classMap[$class])) {
|
|
$classMap[$class] = \count($classes);
|
|
$classes[] = $class;
|
|
}
|
|
$metaOut[$id] = 0 !== $wakeup ? [$classMap[$class], $wakeup] : $classMap[$class];
|
|
}
|
|
|
|
// When all entries share class index 0 with wakeup 0, store just the count.
|
|
$n = \count($metaOut);
|
|
foreach ($metaOut as $v) {
|
|
if (0 !== $v) {
|
|
$n = $metaOut;
|
|
break;
|
|
}
|
|
}
|
|
|
|
$data = [
|
|
'classes' => 1 === \count($classes) ? $classes[0] : ($classes ?: ''),
|
|
'objectMeta' => $n,
|
|
'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');
|
|
}
|
|
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 = array_change_key_case(array_flip($allowed_classes));
|
|
foreach ($classes as $cls) {
|
|
if (!isset($allowed[strtolower($cls)])) {
|
|
throw new \ValueError('deepclone_from_array(): class "'.$cls.'" 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');
|
|
}
|
|
$objectMeta = $meta ? array_fill(0, $meta, [$classes[0], 0]) : [];
|
|
$numObjects = $meta;
|
|
} else {
|
|
$numObjects = \count($meta);
|
|
$objectMeta = [];
|
|
foreach ($meta as $id => $v) {
|
|
if (!\is_int($id) || $id < 0 || $id >= $numObjects) {
|
|
throw new \ValueError('deepclone_from_array(): Argument #1 ($data) "objectMeta" entry index '.$id.' out of range');
|
|
}
|
|
if (\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]');
|
|
}
|
|
} elseif (\is_int($v)) {
|
|
$cidx = $v;
|
|
$wakeup = 0;
|
|
} 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);
|
|
}
|
|
$objectMeta[$id] = [$classes[$cidx], $wakeup];
|
|
if (0 !== $wakeup) {
|
|
$expectedStates[$id] = $wakeup;
|
|
}
|
|
}
|
|
}
|
|
|
|
return self::reconstruct(
|
|
$data['prepared'],
|
|
$objectMeta,
|
|
$numObjects,
|
|
$data['properties'] ?? [],
|
|
$data['resolve'] ?? [],
|
|
$data['states'] ?? [],
|
|
$data['refs'] ?? [],
|
|
$data['mask'] ?? null,
|
|
$data['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('Class "'.$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 && 'stdClass' !== $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));
|
|
}
|
|
// Valid scope, but 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', '*' === $scopeName ? $class : $scopeName, $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;
|
|
}
|
|
|
|
/**
|
|
* 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 (true === $value) {
|
|
return 'true';
|
|
}
|
|
if (false === $value) {
|
|
return '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)
|
|
{
|
|
$sentinel = self::$sentinel ??= new \stdClass();
|
|
$refs = $values;
|
|
foreach ($values as $k => $value) {
|
|
if (\is_resource($value)) {
|
|
throw new \DeepClone\NotInstantiableException('Type "'.get_resource_type($value).' resource" is not instantiable.');
|
|
}
|
|
$refs[$k] = $sentinel;
|
|
|
|
if ($isRef = !$valueIsStatic = $values[$k] !== $sentinel) {
|
|
$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;
|
|
}
|
|
$refsPool[] = [&$refs[$k], $value, &$value];
|
|
$refs[$k] = $values[$k] = new Reference(-\count($refsPool), $value);
|
|
}
|
|
|
|
if (\is_array($value)) {
|
|
if ($value) {
|
|
$m = null;
|
|
$value = self::prepare($value, $objectsPool, $refsPool, $objectsCount, $valueIsStatic, $m, $allowedSet, $allowNamedClosures);
|
|
if (null !== $m) {
|
|
$mask[$k] = $m;
|
|
}
|
|
}
|
|
goto handle_value;
|
|
} elseif (!\is_object($value) || $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);
|
|
|
|
if (!(\PHP_VERSION_ID >= 80200 ? $r->isAnonymous() : str_contains($r->name, "@anonymous\0"))) {
|
|
// 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];
|
|
$rm = $callable[0] ? 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 "'.$class.'" 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);
|
|
$properties = [];
|
|
$sleep = null;
|
|
$proto = self::$prototypes[$class];
|
|
|
|
if (self::$classInfo[$class][2] ??= $reflector->hasMethod('__serialize') ? ($reflector->getMethod('__serialize')->isPublic() ?: $reflector->getMethod('__serialize')) : false) {
|
|
if (self::$classInfo[$class][2] instanceof \ReflectionMethod) {
|
|
throw new \Error('Call to '.(self::$classInfo[$class][2]->isProtected() ? 'protected' : 'private').' method "'.$class.'::__serialize()".');
|
|
}
|
|
|
|
if (!\is_array($arrayValue = $value->__serialize())) {
|
|
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. Let the serializer
|
|
// materialize real values while the object is still alive.
|
|
if (\PHP_VERSION_ID < 80300 && $value instanceof \Random\Randomizer) {
|
|
$arrayValue = unserialize(serialize($arrayValue));
|
|
}
|
|
|
|
if ($hasUnserialize = self::$classInfo[$class][0] ??= $reflector->hasMethod('__unserialize')) {
|
|
$properties = $arrayValue;
|
|
goto prepare_value;
|
|
}
|
|
} elseif ($value instanceof \Serializable || $value instanceof \__PHP_Incomplete_Class) {
|
|
++$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')) {
|
|
if (!\is_array($sleep = $value->__sleep())) {
|
|
trigger_error('serialize(): __sleep should return an array only containing the names of instance-variables to serialize', \E_USER_NOTICE);
|
|
$value = null;
|
|
goto handle_value;
|
|
}
|
|
$sleep = array_flip($sleep);
|
|
}
|
|
|
|
$arrayValue = (array) $value;
|
|
}
|
|
|
|
$proto = self::$protos[$class] ??= (array) $proto;
|
|
|
|
if (null === $scopeMap = self::$scopeMaps[$class] ?? null) {
|
|
$scopeMap = [];
|
|
$parent = $reflector;
|
|
do {
|
|
foreach ($parent->getProperties() as $p) {
|
|
if (!$p->isStatic() && !isset($scopeMap[$p->name])) {
|
|
$scopeMap[$p->name] = !$p->isPublic() || (\PHP_VERSION_ID >= 80400 ? $p->isProtectedSet() || $p->isPrivateSet() : $p->isReadOnly()) ? $p->class : 'stdClass';
|
|
}
|
|
}
|
|
} while ($parent = $parent->getParentClass());
|
|
self::$scopeMaps[$class] = $scopeMap;
|
|
}
|
|
|
|
foreach ($arrayValue as $name => $v) {
|
|
$i = 0;
|
|
$n = (string) $name;
|
|
if ('' === $n || "\0" !== $n[0]) {
|
|
$c = $scopeMap[$n] ?? 'stdClass';
|
|
} elseif ('*' === $n[1]) {
|
|
$n = substr($n, 3);
|
|
$c = $scopeMap[$n] ?? $reflector->getProperty($n)->class;
|
|
} else {
|
|
$i = strpos($n, "\0", 2);
|
|
$c = substr($n, 1, $i - 1);
|
|
$n = substr($n, 1 + $i);
|
|
}
|
|
if (null !== $sleep) {
|
|
if (!isset($sleep[$name]) && (!isset($sleep[$n]) || ($i && $c !== $class))) {
|
|
unset($arrayValue[$name]);
|
|
continue;
|
|
}
|
|
unset($sleep[$name], $sleep[$n]);
|
|
}
|
|
if ("\x00Error\x00trace" === $name || "\x00Exception\x00trace" === $name || "\x00*\x00file" === $name || "\x00*\x00line" === $name || !\array_key_exists($name, $proto) || $proto[$name] !== $v) {
|
|
$properties[$c][$n] = $v;
|
|
}
|
|
}
|
|
if ($sleep) {
|
|
foreach ($sleep as $n => $v) {
|
|
if (\is_string($n) && $reflector->hasProperty($n)) {
|
|
continue;
|
|
}
|
|
trigger_error(\sprintf('serialize(): "%s" returned as member variable from __sleep() but does not exist', $n), \E_USER_NOTICE);
|
|
}
|
|
}
|
|
if ($hasUnserialize = self::$classInfo[$class][0] ??= $reflector->hasMethod('__unserialize')) {
|
|
$properties = $arrayValue;
|
|
}
|
|
|
|
prepare_value:
|
|
$objectsPool[$oid] = [$id = \count($objectsPool)];
|
|
$m = null;
|
|
$properties = self::prepare($properties, $objectsPool, $refsPool, $objectsCount, $valueIsStatic, $m, $allowedSet, $allowNamedClosures);
|
|
++$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) {
|
|
$mask[$k] = false;
|
|
unset($value); // Break the hard reference created above
|
|
} elseif (!$valueIsStatic) {
|
|
$values[$k] = $value;
|
|
}
|
|
$valuesAreStatic = $valueIsStatic && $valuesAreStatic;
|
|
}
|
|
|
|
return $values;
|
|
}
|
|
|
|
private static function reconstruct($prepared, $objectMeta, $numObjects, $properties, $resolve, $states, $refs, $preparedMask = null, $refMasks = [], ?array $allowedClasses = null, array $expectedStates = [])
|
|
{
|
|
$objects = [];
|
|
|
|
foreach ($objectMeta as $id => [$class, $wakeup]) {
|
|
if (':' === ($class[1] ?? null)) {
|
|
// The result is used as an object below; a malformed payload can
|
|
// carry any serialize form (i:…, s:…, a:…), so reject anything
|
|
// that did not decode to an object rather than mistreating it.
|
|
if (!\is_object($objects[$id] = unserialize($class, null !== $allowedClasses ? ['allowed_classes' => $allowedClasses] : []))) {
|
|
throw new \ValueError('deepclone_from_array(): Argument #1 ($data) object '.$id.' did not unserialize to an object, '.get_debug_type($objects[$id]).' given');
|
|
}
|
|
continue;
|
|
}
|
|
try {
|
|
self::$reflectors[$class] ??= self::getClassReflector($class);
|
|
} catch (\DeepClone\ClassNotFoundException) {
|
|
throw new \DeepClone\ClassNotFoundException('Class "'.$class.'" 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 '.$class.' 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
|
|
} elseif (self::$cloneable[$class]) {
|
|
$objects[$id] = clone 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:{}');
|
|
}
|
|
}
|
|
|
|
// Eagerly finalize deferred objects whose state has no object-ref masks,
|
|
// so they are real instances when the properties loop resolves references to them.
|
|
foreach ($states as $state) {
|
|
if (!\is_array($state) || null !== $objects[$state[0]] || isset($state[2])) {
|
|
continue;
|
|
}
|
|
$class = $objectMeta[$state[0]][0];
|
|
$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[$state[0]] = $obj;
|
|
}
|
|
|
|
foreach ($refMasks as $k => $m) {
|
|
$refs[$k] = self::resolveWithMask($refs[$k], $m, $objects, $refs, $allowedClasses);
|
|
}
|
|
|
|
// Finalize the remaining deferred objects: needsFullUnserialize objects
|
|
// whose __serialize() state nests another object (e.g. Random\Randomizer
|
|
// wrapping a Random\Engine\*), so their state carries an object-ref mask.
|
|
// The loop above skipped them because resolving the mask needs the
|
|
// referenced objects to exist first. Iterate to a fixpoint: each pass
|
|
// finalizes those whose referenced objects are now available. A leftover
|
|
// null (a cycle of such classes, which pure PHP cannot reconstruct) is
|
|
// reported by the properties/states loop below.
|
|
if ($states) {
|
|
do {
|
|
$progress = false;
|
|
foreach ($states as $state) {
|
|
if (!\is_array($state) || !isset($state[2])) {
|
|
continue;
|
|
}
|
|
$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 = $objectMeta[$zid][0];
|
|
$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 ($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 "'.$scope.'" must be of type array, '.self::valueName($scopeProps).' given');
|
|
}
|
|
if ('stdClass' !== $scope && !class_exists($scope, false)) {
|
|
throw new \ValueError('deepclone_from_array(): Argument #1 ($data) "properties" scope "'.$scope.'" is not a loaded class name');
|
|
}
|
|
$resolveScope = null;
|
|
if (isset($resolve[$scope])) {
|
|
if (!\is_array($resolve[$scope])) {
|
|
throw new \ValueError('deepclone_from_array(): Argument #1 ($data) "resolve" entry for scope "'.$scope.'" must be of type array, '.self::valueName($resolve[$scope]).' given');
|
|
}
|
|
$resolveScope = $resolve[$scope];
|
|
}
|
|
foreach ($scopeProps as $name => $idValues) {
|
|
// 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($idValues)) {
|
|
throw new \ValueError('deepclone_from_array(): Argument #1 ($data) "properties" value for "'.$scope.'::'.$name.'" must be of type array, '.self::valueName($idValues).' given');
|
|
}
|
|
$resolveIds = null;
|
|
if ($resolveScope && isset($resolveScope[$name])) {
|
|
if (!\is_array($resolveScope[$name])) {
|
|
throw new \ValueError('deepclone_from_array(): Argument #1 ($data) "resolve" value for "'.$scope.'::'.$name.'" must be of type array, '.self::valueName($resolveScope[$name]).' given');
|
|
}
|
|
$resolveIds = $resolveScope[$name];
|
|
}
|
|
if (null === $resolveIds) {
|
|
continue;
|
|
}
|
|
foreach ($resolveIds as $id => $marker) {
|
|
if (true === $marker) {
|
|
if (null === ($v = $scopeProps[$name][$id] ?? null) || !\is_int($v)) {
|
|
throw new \ValueError('deepclone_from_array(): Argument #1 ($data) malformed payload, object reference value must be of type int, '.self::valueName($v).' given');
|
|
}
|
|
if ($v >= 0) {
|
|
if (!isset($objects[$v])) {
|
|
throw new \ValueError('deepclone_from_array(): Argument #1 ($data) malformed payload, unknown object id '.$v);
|
|
}
|
|
$scopeProps[$name][$id] = $objects[$v];
|
|
} else {
|
|
if (\PHP_INT_MIN === $v) {
|
|
throw new \ValueError('deepclone_from_array(): Argument #1 ($data) malformed payload, ref id out of range');
|
|
}
|
|
if (!isset($refs[-$v])) {
|
|
throw new \ValueError('deepclone_from_array(): Argument #1 ($data) malformed payload, unknown ref id '.(-$v));
|
|
}
|
|
$scopeProps[$name][$id] = $refs[-$v];
|
|
}
|
|
} elseif (0 === $marker) {
|
|
$scopeProps[$name][$id] = self::resolveNamedClosureScalar($scopeProps[$name][$id] ?? null, $objects, $refs);
|
|
} else {
|
|
$scopeProps[$name][$id] = self::resolveWithMask($scopeProps[$name][$id] ?? null, $marker, $objects, $refs, $allowedClasses);
|
|
}
|
|
}
|
|
}
|
|
(self::$hydrators[$scope] ??= self::getHydrator($scope))($scopeProps, $objects);
|
|
}
|
|
|
|
foreach ($states as $state) {
|
|
if (\is_array($state)) {
|
|
$zid = $state[0] ?? null;
|
|
$sprops = $state[1] ?? 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]);
|
|
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 = $objectMeta[$zid][0];
|
|
$resolvedProps = isset($state[2]) ? self::resolveWithMask($sprops, $state[2], $objects, $refs, $allowedClasses) : $sprops;
|
|
$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 (self::$needsFullUnserialize[$objectMeta[$zid][0]] ?? false) {
|
|
// 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;
|
|
}
|
|
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');
|
|
}
|
|
$resolvedProps = isset($state[2]) ? self::resolveWithMask($sprops, $state[2], $objects, $refs, $allowedClasses) : $sprops;
|
|
$obj->__unserialize($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')) {
|
|
$objects[$state]->__wakeup();
|
|
}
|
|
} 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])) {
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* 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(): Argument #1 ($data) 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(): Argument #1 ($data) malformed payload, unknown object id '.$value);
|
|
}
|
|
|
|
return $objects[$value];
|
|
}
|
|
if (\PHP_INT_MIN === $value) {
|
|
throw new \ValueError('deepclone_from_array(): Argument #1 ($data) malformed payload, ref id out of range');
|
|
}
|
|
if (!isset($refs[-$value])) {
|
|
throw new \ValueError('deepclone_from_array(): Argument #1 ($data) malformed payload, unknown ref id '.(-$value));
|
|
}
|
|
|
|
return $refs[-$value];
|
|
}
|
|
|
|
if (false === $mask) {
|
|
if (!\is_int($value)) {
|
|
throw new \ValueError('deepclone_from_array(): Argument #1 ($data) 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(): Argument #1 ($data) malformed payload, ref id out of range');
|
|
}
|
|
$rid = -$value;
|
|
if (!isset($refs[$rid])) {
|
|
throw new \ValueError('deepclone_from_array(): Argument #1 ($data) 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(): Argument #1 ($data) malformed payload, enum value must be of type string, '.self::valueName($value).' given');
|
|
}
|
|
if (false === $sepPos = strpos($value, '::')) {
|
|
throw new \ValueError('deepclone_from_array(): Argument #1 ($data) malformed payload, enum value must match "Class::Case"');
|
|
}
|
|
$className = substr($value, 0, $sepPos);
|
|
if (!enum_exists($className)) {
|
|
throw new \ValueError('deepclone_from_array(): Argument #1 ($data) malformed payload, enum class "'.$value.'" not found');
|
|
}
|
|
$caseName = substr($value, $sepPos + 2);
|
|
try {
|
|
return \constant($value);
|
|
} catch (\Error) {
|
|
throw new \ValueError('deepclone_from_array(): Argument #1 ($data) malformed payload, enum case "'.$value.'" not found');
|
|
}
|
|
}
|
|
|
|
if (!\is_array($mask)) {
|
|
return $value;
|
|
}
|
|
|
|
if (!\is_array($value)) {
|
|
throw new \ValueError('deepclone_from_array(): Argument #1 ($data) malformed payload, array-mask value must be of type array, '.self::valueName($value).' given');
|
|
}
|
|
|
|
foreach ($mask as $k => $m) {
|
|
if (false === $m) {
|
|
$slot = $value[$k] ?? null;
|
|
if (!\is_int($slot)) {
|
|
throw new \ValueError('deepclone_from_array(): Argument #1 ($data) 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(): Argument #1 ($data) malformed payload, ref id out of range');
|
|
}
|
|
$rid = -$slot;
|
|
if (!isset($refs[$rid])) {
|
|
throw new \ValueError('deepclone_from_array(): Argument #1 ($data) malformed payload, unknown ref id '.$rid);
|
|
}
|
|
$value[$k] = &$refs[$rid];
|
|
} else {
|
|
$value[$k] = self::resolveWithMask($value[$k] ?? null, $m, $objects, $refs, $allowedClasses);
|
|
}
|
|
}
|
|
|
|
return $value;
|
|
}
|
|
|
|
private static function resolveNamedClosureScalar($value, $objects, $refs)
|
|
{
|
|
if (!\is_array($value)) {
|
|
throw new \ValueError('deepclone_from_array(): Argument #1 ($data) 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(): Argument #1 ($data) malformed payload, named-closure value must have at least 2 elements');
|
|
}
|
|
|
|
$method = null;
|
|
|
|
if (\is_array($value[0])) {
|
|
$callable = $value[0];
|
|
if (!\is_string($value[1])) {
|
|
throw new \ValueError('deepclone_from_array(): Argument #1 ($data) 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(): Argument #1 ($data) malformed payload, named-closure private method name must be of type string');
|
|
}
|
|
$method = new \ReflectionMethod($value[1], $value[2]);
|
|
} else {
|
|
$callable = $value;
|
|
}
|
|
|
|
if (!\array_key_exists(0, $callable) || !\array_key_exists(1, $callable) || !\is_string($callable[1])) {
|
|
throw new \ValueError('deepclone_from_array(): Argument #1 ($data) 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(): Argument #1 ($data) malformed payload, named-closure references unknown id '.$obj);
|
|
}
|
|
$obj = $objects[$obj];
|
|
} else {
|
|
if (\PHP_INT_MIN === $obj || !isset($refs[-$obj])) {
|
|
throw new \ValueError('deepclone_from_array(): Argument #1 ($data) malformed payload, named-closure references unknown id '.$obj);
|
|
}
|
|
$obj = $refs[-$obj];
|
|
}
|
|
}
|
|
$name = $callable[1];
|
|
|
|
if (!($method?->isPublic() ?? true)) {
|
|
return $method->getClosure(\is_object($obj) ? $obj : null);
|
|
}
|
|
|
|
if (!$obj) {
|
|
return $name(...);
|
|
}
|
|
|
|
return \is_object($obj) ? $obj->$name(...) : $obj::$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;
|
|
}
|
|
|
|
// 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 (!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(): Argument #1 ($data) 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(): Argument #1 ($data) 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(): Argument #1 ($data) 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(): Argument #1 ($data) 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(): Argument #1 ($data) 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(): Argument #1 ($data) 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(): Argument #1 ($data) 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 "'.$class.'" is not allowed');
|
|
}
|
|
|
|
try {
|
|
$rc = new \ReflectionClass($class);
|
|
} catch (\ReflectionException) {
|
|
throw new \ValueError('deepclone_from_array(): Argument #1 ($data) malformed payload, const-expr-closure references unknown class "'.$class.'"');
|
|
}
|
|
|
|
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(): Argument #1 ($data) malformed payload, const-expr-closure references unknown hook "'.$site.'"');
|
|
}
|
|
} else {
|
|
try {
|
|
$target = $rc->getProperty(substr($site, 1));
|
|
} catch (\ReflectionException) {
|
|
throw new \ValueError('deepclone_from_array(): Argument #1 ($data) malformed payload, const-expr-closure references unknown property "'.$site.'"');
|
|
}
|
|
}
|
|
} 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(): Argument #1 ($data) malformed payload, const-expr-closure references unknown parameter "'.$site.'"');
|
|
}
|
|
} elseif (str_ends_with($site, '()')) {
|
|
try {
|
|
$target = $rc->getMethod(substr($site, 0, -2));
|
|
} catch (\ReflectionException) {
|
|
throw new \ValueError('deepclone_from_array(): Argument #1 ($data) malformed payload, const-expr-closure references unknown method "'.$site.'"');
|
|
}
|
|
} elseif (!$target = $rc->getReflectionConstant($site)) {
|
|
throw new \ValueError('deepclone_from_array(): Argument #1 ($data) malformed payload, const-expr-closure references unknown constant "'.$site.'"');
|
|
}
|
|
|
|
if (null !== $attrIndex) {
|
|
$attrs = $target->getAttributes();
|
|
if (!isset($attrs[$attrIndex])) {
|
|
throw new \ValueError('deepclone_from_array(): Argument #1 ($data) malformed payload, const-expr-closure references unknown attribute index '.$attrIndex);
|
|
}
|
|
try {
|
|
$values = $attrs[$attrIndex]->getArguments();
|
|
} catch (\Throwable $e) {
|
|
throw new \ValueError('deepclone_from_array(): Argument #1 ($data) malformed payload, const-expr-closure evaluation failed for site "'.$site.'"', 0, $e);
|
|
}
|
|
} elseif ($target instanceof \ReflectionClass || $target instanceof \ReflectionMethod) {
|
|
throw new \ValueError('deepclone_from_array(): Argument #1 ($data) malformed payload, const-expr-closure attribute index is required for site "'.$site.'"');
|
|
} 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(): Argument #1 ($data) malformed payload, const-expr-closure evaluation failed for site "'.$site.'"', 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(): Argument #1 ($data) 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(): Argument #1 ($data) 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
|
|
return self::replaceRefs($value->value, $mask);
|
|
}
|
|
|
|
private static function getClassReflector($class, $instantiableWithoutConstructor = false, $cloneable = null)
|
|
{
|
|
if (!($isClass = class_exists($class)) && !interface_exists($class, false) && !trait_exists($class, false)) {
|
|
throw new \DeepClone\ClassNotFoundException('Class "'.$class.'" not found.');
|
|
}
|
|
if (isset(self::NOT_ROUND_TRIPPABLE[$class])) {
|
|
throw new \DeepClone\NotInstantiableException('Type "'.$class.'" is not instantiable.');
|
|
}
|
|
$reflector = new \ReflectionClass($class);
|
|
|
|
if ($instantiableWithoutConstructor) {
|
|
$proto = $reflector->newInstanceWithoutConstructor();
|
|
} elseif (!$isClass || $reflector->isAbstract() || $reflector->isEnum()) {
|
|
throw new \DeepClone\NotInstantiableException('Type "'.$class.'" is not instantiable.');
|
|
} 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.', 0, $e);
|
|
}
|
|
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;
|
|
}
|
|
}
|
|
}
|
|
if (null !== $proto && !$proto instanceof \Throwable && !$proto instanceof \Serializable && !method_exists($class, '__sleep') && !method_exists($class, '__serialize')) {
|
|
try {
|
|
serialize($proto);
|
|
} catch (\Exception $e) {
|
|
throw new \DeepClone\NotInstantiableException('Type "'.$class.'" is not instantiable.', 0, $e);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (null === $cloneable) {
|
|
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');
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
private static function getHydrator($class)
|
|
{
|
|
$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';
|
|
} elseif ('ErrorException' === $class) {
|
|
$class = 'Exception';
|
|
}
|
|
|
|
// 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) {
|
|
$setValue($objects[$i], $v);
|
|
}
|
|
continue;
|
|
}
|
|
foreach ($values as $i => $v) {
|
|
$objects[$i]->$name = $v;
|
|
}
|
|
}
|
|
};
|
|
}
|
|
|
|
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 ('ErrorException' === $class) {
|
|
$class = 'Exception';
|
|
} 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 function __construct(
|
|
public readonly int $id,
|
|
public readonly mixed $value = null,
|
|
) {
|
|
}
|
|
}
|