|
| 1 | +--TEST-- |
| 2 | +deepclone references const-expr closures through engine ids (PHP 8.6) |
| 3 | +--EXTENSIONS-- |
| 4 | +deepclone |
| 5 | +--SKIPIF-- |
| 6 | +<?php if (PHP_VERSION_ID < 80600) die('skip requires PHP 8.6'); ?> |
| 7 | +--FILE-- |
| 8 | +<?php |
| 9 | + |
| 10 | +#[Attribute(Attribute::TARGET_ALL | Attribute::IS_REPEATABLE)] |
| 11 | +class CA { public array $args; public function __construct(mixed ...$args) { $this->args = $args; } } |
| 12 | + |
| 13 | +#[CA(static function (): string { return self::SECRET; })] |
| 14 | +class Fix { |
| 15 | + private const SECRET = 'class-secret'; |
| 16 | + public const CALLBACKS = ['first' => static function (): string { return 'const-value'; }]; |
| 17 | + #[CA(cb: [1, ['x' => static function (int $i): int { return $i * 2; }]])] |
| 18 | + public string $tagged = 'v'; |
| 19 | + public ?Closure $factory = static function (): string { return 'prop-default'; }; |
| 20 | + #[CA('not-a-closure')] |
| 21 | + #[CA(static function (): string { return 'repeated'; })] |
| 22 | + public function tagged( |
| 23 | + #[CA(static function (): string { return 'param-attr'; })] |
| 24 | + ?Closure $cb = static function (): string { return 'param-default'; }, |
| 25 | + ): void {} |
| 26 | +} |
| 27 | + |
| 28 | +$rc = new ReflectionClass(Fix::class); |
| 29 | + |
| 30 | +// ── Wire format: engine-id reference [class, id, line] ── |
| 31 | +$c = $rc->getAttributes()[0]->getArguments()[0]; |
| 32 | +$line = (new ReflectionFunction($c))->getStartLine(); |
| 33 | +$d = deepclone_to_array($c); |
| 34 | +var_dump($d['prepared'] === [Fix::class, 0, $line]); |
| 35 | +var_dump($d['mask'] === 1); |
| 36 | +$r = deepclone_from_array($d); |
| 37 | +var_dump($r instanceof Closure, $r !== $c, $r() === 'class-secret'); |
| 38 | + |
| 39 | +// ── The emitted reference matches the engine's ── |
| 40 | +$rf = new ReflectionFunction($c); |
| 41 | +var_dump($d['prepared'] === [$rf->getConstExprClass(), $rf->getConstExprId(), $line]); |
| 42 | + |
| 43 | +// ── Attribute sites: nested argument, repeated attribute, parameter attribute, parameter default ── |
| 44 | +foreach ([ |
| 45 | + [$rc->getProperty('tagged')->getAttributes()[0]->getArguments()['cb'][1]['x'], [3], 6], |
| 46 | + [$rc->getMethod('tagged')->getAttributes()[1]->getArguments()[0], [], 'repeated'], |
| 47 | + [$rc->getMethod('tagged')->getParameters()[0]->getAttributes()[0]->getArguments()[0], [], 'param-attr'], |
| 48 | + [$rc->getMethod('tagged')->getParameters()[0]->getDefaultValue(), [], 'param-default'], |
| 49 | +] as [$c, $args, $expected]) { |
| 50 | + $d = deepclone_to_array($c); |
| 51 | + $rf = new ReflectionFunction($c); |
| 52 | + var_dump($d['prepared'] === [Fix::class, $rf->getConstExprId(), $rf->getStartLine()], deepclone_from_array($d)(...$args) === $expected); |
| 53 | +} |
| 54 | + |
| 55 | +// ── Constant values and property defaults have no engine id: site-based form ── |
| 56 | +$d = deepclone_to_array(Fix::CALLBACKS['first']); |
| 57 | +var_dump(count($d['prepared']) === 5, $d['prepared'][1] === 'CALLBACKS', deepclone_from_array($d)() === 'const-value'); |
| 58 | +$d = deepclone_to_array($rc->getProperty('factory')->getDefaultValue()); |
| 59 | +var_dump($d['prepared'][1] === '$factory', deepclone_from_array($d)() === 'prop-default'); |
| 60 | + |
| 61 | +// ── Site-based references written on PHP 8.5 still resolve ── |
| 62 | +var_dump(deepclone_from_array(['classes' => '', 'objectMeta' => 0, 'prepared' => [Fix::class, '', 0, 0, $line], 'mask' => 1])() === 'class-secret'); |
| 63 | + |
| 64 | +// ── Same-line closures get distinct ids ── |
| 65 | +#[CA(static function (): string { return 'first'; }, static function (): string { return 'second'; })] |
| 66 | +class FixAmbiguous {} |
| 67 | +$args = (new ReflectionClass(FixAmbiguous::class))->getAttributes()[0]->getArguments(); |
| 68 | +$d0 = deepclone_to_array($args[0]); |
| 69 | +$d1 = deepclone_to_array($args[1]); |
| 70 | +var_dump([$d0['prepared'][1], $d1['prepared'][1]] === [0, 1]); |
| 71 | +var_dump(deepclone_from_array($d0)() === 'first', deepclone_from_array($d1)() === 'second'); |
| 72 | + |
| 73 | +// ── Enum case attribute gets an id, enum constant value stays site-based ── |
| 74 | +enum FixEnum: string { |
| 75 | + #[CA(static function (): string { return 'enum-case-attr'; })] |
| 76 | + case Active = 'A'; |
| 77 | + public const FILTER = static function (): string { return 'enum-const'; }; |
| 78 | +} |
| 79 | +$d = deepclone_to_array((new ReflectionClassConstant(FixEnum::class, 'Active'))->getAttributes()[0]->getArguments()[0]); |
| 80 | +var_dump(is_int($d['prepared'][1]), deepclone_from_array($d)() === 'enum-case-attr'); |
| 81 | +$d = deepclone_to_array(FixEnum::FILTER); |
| 82 | +var_dump($d['prepared'][1] === 'FILTER', deepclone_from_array($d)() === 'enum-const'); |
| 83 | + |
| 84 | +// ── Property hooks ── |
| 85 | +class FixHooked { |
| 86 | + public string $virtual { |
| 87 | + #[CA(static function (): string { return 'get-hook-attr'; })] |
| 88 | + get => 'vx'; |
| 89 | + } |
| 90 | +} |
| 91 | +$c = (new ReflectionProperty(FixHooked::class, 'virtual'))->getHook(PropertyHookType::Get)->getAttributes()[0]->getArguments()[0]; |
| 92 | +$d = deepclone_to_array($c); |
| 93 | +var_dump(is_int($d['prepared'][1]), deepclone_from_array($d)() === 'get-hook-attr'); |
| 94 | + |
| 95 | +// ── Trait method attribute: the using class declares the closure ── |
| 96 | +trait FixTrait { |
| 97 | + #[CA(static function (): string { return 'trait-attr'; })] |
| 98 | + public function traitTagged(): void {} |
| 99 | +} |
| 100 | +class FixTraitUser { use FixTrait; } |
| 101 | +$d = deepclone_to_array((new ReflectionClass(FixTraitUser::class))->getMethod('traitTagged')->getAttributes()[0]->getArguments()[0]); |
| 102 | +var_dump(is_int($d['prepared'][1]), deepclone_from_array($d)() === 'trait-attr'); |
| 103 | + |
| 104 | +// ── Inherited declaration keeps the declaring class ── |
| 105 | +class FixParent { |
| 106 | + #[CA(static function (): string { return 'parent-attr'; })] |
| 107 | + public function pm(): void {} |
| 108 | +} |
| 109 | +class FixChild extends FixParent {} |
| 110 | +$c = (new ReflectionMethod(FixChild::class, 'pm'))->getAttributes()[0]->getArguments()[0]; |
| 111 | +$d = deepclone_to_array($c); |
| 112 | +var_dump($d['prepared'][0] === FixParent::class, deepclone_from_array($d)() === 'parent-attr'); |
| 113 | + |
| 114 | +// ── First-class callables use the site-based reference, not an engine id: |
| 115 | +// the engine id of an fcc resolves to a site the decode path cannot recreate, |
| 116 | +// so they keep the declaration-site (5-element) form ── |
| 117 | +class FixFcc { |
| 118 | + #[CA(self::helper(...))] |
| 119 | + public static function helper(): bool { return true; } |
| 120 | +} |
| 121 | +$d = deepclone_to_array((new ReflectionMethod(FixFcc::class, 'helper'))->getAttributes()[0]->getArguments()[0]); |
| 122 | +var_dump($d['mask'] === 1, deepclone_from_array($d)() === true); |
| 123 | + |
| 124 | +// ── ... but a crafted payload addressing the FCC site is rejected ── |
| 125 | +try { |
| 126 | + deepclone_from_array(['classes' => '', 'objectMeta' => 0, 'prepared' => [FixFcc::class, 0, 1], 'mask' => 1]); |
| 127 | +} catch (\ValueError $e) { |
| 128 | + var_dump($e->getMessage()); |
| 129 | +} |
| 130 | + |
| 131 | +// ── Runtime closures still refuse, through the engine's own __serialize() ── |
| 132 | +try { |
| 133 | + deepclone_to_array(static function () { return 'runtime'; }); |
| 134 | +} catch (\Exception $e) { |
| 135 | + var_dump($e->getMessage()); |
| 136 | +} |
| 137 | + |
| 138 | +// ── Object graph survives a JSON round trip ── |
| 139 | +$graph = (object) ['cb' => $rc->getAttributes()[0]->getArguments()[0]]; |
| 140 | +$d = json_decode(json_encode(deepclone_to_array($graph)), true); |
| 141 | +var_dump((deepclone_from_array($d)->cb)() === 'class-secret'); |
| 142 | + |
| 143 | +// ── allowed_classes gating, both directions ── |
| 144 | +try { |
| 145 | + deepclone_to_array($rc->getAttributes()[0]->getArguments()[0], []); |
| 146 | +} catch (\ValueError $e) { |
| 147 | + var_dump($e->getMessage()); |
| 148 | +} |
| 149 | +$d = deepclone_to_array($rc->getAttributes()[0]->getArguments()[0], ['Closure']); |
| 150 | +try { |
| 151 | + deepclone_from_array($d, []); |
| 152 | +} catch (\ValueError $e) { |
| 153 | + var_dump($e->getMessage()); |
| 154 | +} |
| 155 | +try { |
| 156 | + deepclone_from_array($d, ['Closure']); |
| 157 | +} catch (\ValueError $e) { |
| 158 | + var_dump($e->getMessage()); |
| 159 | +} |
| 160 | +var_dump(deepclone_from_array($d, ['Closure', 'Fix'])() === 'class-secret'); |
| 161 | + |
| 162 | +// ── Stale payload ── |
| 163 | +$d = deepclone_to_array($rc->getAttributes()[0]->getArguments()[0]); |
| 164 | +$d['prepared'][2]++; |
| 165 | +try { |
| 166 | + deepclone_from_array($d); |
| 167 | +} catch (\ValueError $e) { |
| 168 | + var_dump(str_contains($e->getMessage(), 'stale payload, const-expr-closure moved from line')); |
| 169 | +} |
| 170 | + |
| 171 | +// ── Unknown id, unknown class ── |
| 172 | +try { |
| 173 | + deepclone_from_array(['classes' => '', 'objectMeta' => 0, 'prepared' => [Fix::class, 999, $line], 'mask' => 1]); |
| 174 | +} catch (\ValueError $e) { |
| 175 | + var_dump($e->getMessage()); |
| 176 | +} |
| 177 | +try { |
| 178 | + deepclone_from_array(['classes' => '', 'objectMeta' => 0, 'prepared' => ['No\Such\ClassAtAll', 0, 1], 'mask' => 1]); |
| 179 | +} catch (\ValueError $e) { |
| 180 | + var_dump($e->getMessage()); |
| 181 | +} |
| 182 | +?> |
| 183 | +--EXPECT-- |
| 184 | +bool(true) |
| 185 | +bool(true) |
| 186 | +bool(true) |
| 187 | +bool(true) |
| 188 | +bool(true) |
| 189 | +bool(true) |
| 190 | +bool(true) |
| 191 | +bool(true) |
| 192 | +bool(true) |
| 193 | +bool(true) |
| 194 | +bool(true) |
| 195 | +bool(true) |
| 196 | +bool(true) |
| 197 | +bool(true) |
| 198 | +bool(true) |
| 199 | +bool(true) |
| 200 | +bool(true) |
| 201 | +bool(true) |
| 202 | +bool(true) |
| 203 | +bool(true) |
| 204 | +bool(true) |
| 205 | +bool(true) |
| 206 | +bool(true) |
| 207 | +bool(true) |
| 208 | +bool(true) |
| 209 | +bool(true) |
| 210 | +bool(true) |
| 211 | +bool(true) |
| 212 | +bool(true) |
| 213 | +bool(true) |
| 214 | +bool(true) |
| 215 | +bool(true) |
| 216 | +bool(true) |
| 217 | +bool(true) |
| 218 | +bool(true) |
| 219 | +string(100) "deepclone_from_array(): malformed payload, const-expr-closure references a first-class callable site" |
| 220 | +string(41) "Serialization of 'Closure' is not allowed" |
| 221 | +bool(true) |
| 222 | +string(52) "deepclone_to_array(): class "Closure" is not allowed" |
| 223 | +string(54) "deepclone_from_array(): class "Closure" is not allowed" |
| 224 | +string(50) "deepclone_from_array(): class "Fix" is not allowed" |
| 225 | +bool(true) |
| 226 | +bool(true) |
| 227 | +string(110) "deepclone_from_array(): malformed payload, const-expr-closure references unknown closure id 999 in class "Fix"" |
| 228 | +string(107) "deepclone_from_array(): malformed payload, const-expr-closure references unknown class "No\Such\ClassAtAll"" |
0 commit comments