src/Codec.php source

1 <?php
2
3 declare(strict_types=1);
4
5 namespace Serializor;
6
7 use LogicException;
8 use ReflectionReference;
9 use Closure;
10 use ReflectionFunction;
11 use Serializor;
12 use Serializor\Box;
13 use Serializor\SerializerError;
14 use Serializor\Stasis;
15 use Throwable;
16 use WeakMap;
17
18 /**
19 * Serializor provides a powerful way to serialize PHP values including
20 * closures, anonymous classes, and other typically non-serializable types.
21 */
22 class Codec
23 {
24 /**
25 * The secret key used to sign serialized values.
26 */
27 private string $secret;
28
29 /**
30 * Tracks the original value of a reference, for comparison.
31 * @var array<string,mixed>
32 */
33 private array $referenceSources = [];
34
35 /**
36 * Tracks the new value of a reference for reuse.
37 * @var array<string,mixed>
38 */
39 private array $referenceTargets = [];
40
41 /**
42 * Callbacks for when a reference is resolved.
43 * @var array<string,Closure[]>
44 */
45 private array $referenceCallbacks = [];
46
47 /**
48 * Shortcuts to Stasis objects for efficient serialization.
49 */
50 private array $shortcuts = [];
51
52 /**
53 * @var WeakMap<object,object>
54 */
55 private WeakMap $encodedObjects;
56
57 /**
58 * Tracks strongly referenced objects.
59 * @var array<int,true>
60 */
61 private array $stronglyReferenced = [];
62
63 /**
64 * Flag for weak context (WeakReference or WeakMap key).
65 */
66 private bool $inWeakContext = false;
67
68 /**
69 * @param string $secret A string secret for HMAC signing. Empty string disables signing.
70 */
71 public function __construct(string $secret = '')
72 {
73 $this->secret = $secret;
74 $this->encodedObjects = new WeakMap();
75 }
76
77 /**
78 * Perform serialization of a value.
79 */
80 public function serialize(mixed &$value): string
81 {
82 // Fast path: named functions and static methods (no nested objects)
83 if ($value instanceof Closure) {
84 $rf = new ReflectionFunction($value);
85 if (!\str_starts_with($rf->getShortName(), '{closure')) {
86 // Named callable
87 $closureThis = $rf->getClosureThis();
88 if ($closureThis === null) {
89 // Static method or named function - can use fast path
90 $stasis = CallableStasis::fromClosure($value, $rf);
91 $result = \serialize($stasis);
92 if ($this->secret !== '') {
93 return \hash_hmac('sha256', $result, $this->secret, false) . '|' . $result;
94 }
95 return $result;
96 }
97 // Instance method with bound $this - needs full pipeline to handle object
98 }
99 }
100
101 // Force Stasis path for types with broken native serialization in older PHP
102 $forceStasis = ($value instanceof \SplHeap) || ($value instanceof \SplPriorityQueue);
103
104 $this->encodedObjects = new WeakMap();
105 $this->referenceSources = [];
106 $this->referenceTargets = [];
107 $this->referenceCallbacks = [];
108 $this->shortcuts = [];
109 $this->stronglyReferenced = [];
110 $this->inWeakContext = false;
111
112 try {
113 if ($forceStasis) {
114 throw new LogicException('Type requires special handling');
115 }
116 $result = \serialize($value);
117 } catch (Throwable) {
118 $v = [&$value];
119 $transformed = $this->transform($v, [], null);
120 $this->markDeadWeakReferences();
121 // Skip Box wrapper if result is a single simple Stasis
122 $canSkipBox = $transformed[0] instanceof Stasis
123 && \count($this->shortcuts) === 1
124 && $transformed[0]->isSimple();
125 if ($canSkipBox) {
126 $result = \serialize($transformed[0]);
127 } else {
128 $result = \serialize(new Box($transformed, $this->shortcuts));
129 }
130 } finally {
131 $this->referenceSources = [];
132 $this->referenceTargets = [];
133 $this->referenceCallbacks = [];
134 $this->shortcuts = [];
135 $this->stronglyReferenced = [];
136 $this->inWeakContext = false;
137 }
138
139 if ($this->secret !== '') {
140 $signature = \hash_hmac('sha256', $result, $this->secret, false);
141 return $signature . '|' . $result;
142 }
143
144 return $result;
145 }
146
147 /**
148 * Mark WeakReference/WeakMap Stasis objects as dead if not strongly referenced.
149 */
150 private function markDeadWeakReferences(): void
151 {
152 foreach ($this->shortcuts as $stasis) {
153 if ($stasis instanceof WeakReferenceStasis) {
154 $ref = $stasis->getRef();
155 if (\is_object($ref)) {
156 $objId = \spl_object_id($ref);
157 if (!isset($this->stronglyReferenced[$objId])) {
158 $stasis->markDead();
159 }
160 }
161 } elseif ($stasis instanceof WeakMapStasis) {
162 $keys = $stasis->getKeys();
163 $dead = &$stasis->getDead();
164 foreach ($keys as $i => $key) {
165 if (\is_object($key)) {
166 $objId = \spl_object_id($key);
167 if (!isset($this->stronglyReferenced[$objId])) {
168 $dead[$i] = true;
169 }
170 }
171 }
172 }
173 }
174 }
175
176 /**
177 * Transform a value into a serializable structure.
178 */
179 protected function &transform(mixed &$source, array $path, string|int|null $key): mixed
180 {
181 \assert(!($source === null || \is_scalar($source)), 'Trying to encode NULL or scalar');
182 if ($key !== null) {
183 $path[] = $key;
184 }
185 $sourceWrap = [&$source];
186 $referenceId = ReflectionReference::fromArrayElement($sourceWrap, 0)->getId();
187
188 if (isset($this->referenceSources[$referenceId])) {
189 \assert($this->referenceSources[$referenceId][0] === $sourceWrap[0], 'The source value has changed during serialization');
190 return $this->referenceTargets[$referenceId];
191 }
192
193 $this->referenceSources[$referenceId] = &$sourceWrap;
194
195 // Objects can also be found via the WeakMap
196 if (\is_object($source) && isset($this->encodedObjects[$source])) {
197 if (!$this->inWeakContext) {
198 $this->stronglyReferenced[\spl_object_id($source)] = true;
199 }
200 $result = $this->encodedObjects[$source];
201 $this->referenceTargets[$referenceId] = &$result;
202 return $result;
203 }
204
205 // Walk arrays recursively
206 if (\is_array($source)) {
207 $result = [];
208 $this->referenceTargets[$referenceId] = &$result;
209 foreach ($source as $k => &$v) {
210 if (\is_scalar($v) || $v === null) {
211 $result[$k] = &$v;
212 } else {
213 $result[$k] = &$this->transform($source[$k], $path, $k);
214 }
215 }
216 return $this->referenceTargets[$referenceId];
217 }
218
219 // Try native serialization first
220 try {
221 $serialized = serialize($source);
222 // Some types need special handling (broken in older PHP or need weak semantics)
223 if (\str_contains($serialized, 'SplObjectStorage')
224 || \str_contains($serialized, 'WeakReference')
225 || \str_contains($serialized, 'SplMaxHeap')
226 || \str_contains($serialized, 'SplMinHeap')
227 || \str_contains($serialized, 'SplPriorityQueue')
228 ) {
229 throw new LogicException('Type requires special handling');
230 }
231 $target = $source;
232 if (\is_object($source)) {
233 $this->encodedObjects[$source] = $target;
234 if (!$this->inWeakContext) {
235 $this->stronglyReferenced[\spl_object_id($source)] = true;
236 }
237 }
238 $this->referenceTargets[$referenceId] = &$target;
239 return $target;
240 } catch (Throwable) {
241 // Native serialization failed, use Stasis
242 }
243
244 // Create appropriate Stasis subclass
245 $target = Stasis::from($source);
246 $this->shortcuts[] = &$target;
247 if (\is_object($source)) {
248 $this->encodedObjects[$source] = $target;
249 if (!$this->inWeakContext) {
250 $this->stronglyReferenced[\spl_object_id($source)] = true;
251 }
252 }
253 $this->referenceTargets[$referenceId] = &$target;
254
255 // Handle recursive transformation based on Stasis type
256 $this->transformStasisChildren($target, $path);
257
258 return $target;
259 }
260
261 /**
262 * Transform child values within a Stasis object.
263 */
264 private function transformStasisChildren(Stasis $target, array $path): void
265 {
266 $wasInWeakContext = $this->inWeakContext;
267
268 if ($target instanceof WeakReferenceStasis) {
269 // WeakReference: entire content is weak context - nothing to transform
270 // The ref object will be handled by markDeadWeakReferences
271 } elseif ($target instanceof WeakMapStasis) {
272 // WeakMap: keys are weak context, values are strong context
273 $this->inWeakContext = true;
274 $keys = &$target->getKeys();
275 foreach ($keys as $i => &$key) {
276 if (!\is_scalar($key) && $key !== null) {
277 $keys[$i] = &$this->transform($key, $path, 'k' . $i);
278 }
279 }
280 $this->inWeakContext = $wasInWeakContext;
281
282 $values = &$target->getValues();
283 foreach ($values as $i => &$val) {
284 if (!\is_scalar($val) && $val !== null) {
285 $values[$i] = &$this->transform($val, $path, 'v' . $i);
286 }
287 }
288 } elseif ($target instanceof SplObjectStorageStasis) {
289 $objects = &$target->getObjects();
290 foreach ($objects as $i => &$obj) {
291 if (!\is_scalar($obj) && $obj !== null) {
292 $objects[$i] = &$this->transform($obj, $path, 'o' . $i);
293 }
294 }
295 $data = &$target->getData();
296 foreach ($data as $i => &$d) {
297 if (!\is_scalar($d) && $d !== null) {
298 $data[$i] = &$this->transform($d, $path, 'd' . $i);
299 }
300 }
301 } elseif ($target instanceof SplHeapStasis) {
302 $items = &$target->getItems();
303 foreach ($items as $i => &$item) {
304 if (!\is_scalar($item) && $item !== null) {
305 $items[$i] = &$this->transform($item, $path, 'h' . $i);
306 }
307 }
308 } elseif ($target instanceof SplPriorityQueueStasis) {
309 $items = &$target->getItems();
310 foreach ($items as $i => &$item) {
311 // Transform both data and priority (priority could be an object)
312 if (!\is_scalar($item['data']) && $item['data'] !== null) {
313 $items[$i]['data'] = &$this->transform($item['data'], $path, 'pq' . $i . 'd');
314 }
315 if (!\is_scalar($item['priority']) && $item['priority'] !== null) {
316 $items[$i]['priority'] = &$this->transform($item['priority'], $path, 'pq' . $i . 'p');
317 }
318 }
319 } elseif ($target instanceof SplFixedArrayStasis) {
320 $elements = &$target->getElements();
321 foreach ($elements as $i => &$v) {
322 if (!\is_scalar($v) && $v !== null) {
323 $elements[$i] = &$this->transform($v, $path, 'fa' . $i);
324 }
325 }
326 } elseif ($target instanceof AnonymousClassStasis) {
327 $props = &$target->getProps();
328 foreach ($props as $k => &$v) {
329 if (!\is_scalar($v) && $v !== null) {
330 $props[$k] = &$this->transform($v, $path, $k);
331 }
332 }
333 } elseif ($target instanceof ObjectStasis) {
334 foreach ($target->p as $k => &$v) {
335 if (!\is_scalar($v) && $v !== null) {
336 $target->p[$k] = &$this->transform($v, $path, $k);
337 }
338 }
339 } elseif ($target instanceof ClosureStasis) {
340 // Transform use variables
341 $use = &$target->getUse();
342 foreach ($use as $k => &$v) {
343 if (!\is_scalar($v) && $v !== null) {
344 $use[$k] = &$this->transform($v, $path, 'use:' . $k);
345 }
346 }
347 // Transform $this if present
348 $thisObj = $target->getThis();
349 if ($thisObj !== null) {
350 $target->setThis($this->transform($thisObj, $path, 'this'));
351 }
352 } elseif ($target instanceof BoundMethodStasis) {
353 // Transform the bound object
354 $obj = $target->getObject();
355 if ($obj !== null) {
356 $target->setObject($this->transform($obj, $path, 'object'));
357 }
358 }
359 // CallableStasis: no nested objects to transform
360 }
361
362 /**
363 * Perform unserialization of a string.
364 */
365 public function &unserialize(string $value): mixed
366 {
367 try {
368 $this->referenceSources = [];
369 $this->referenceTargets = [];
370 $this->referenceCallbacks = [];
371
372 if ($this->secret !== '') {
373 $signatureEndOffset = \strpos($value, '|') ?: 0;
374 $signature = \substr($value, 0, $signatureEndOffset);
375 $value = \substr($value, $signatureEndOffset + 1);
376 if ($signature !== \hash_hmac('sha256', $value, $this->secret, false)) {
377 throw new SerializerError('Invalid signature in the serialized data');
378 }
379 }
380
381 // Detect and handle foreign serialization formats
382 $result = $this->unserializeWithCompatibility($value);
383
384 // Handle standalone Stasis (e.g., CallableStasis for named functions)
385 if ($result instanceof Stasis) {
386 $result = $result->getInstance();
387 return $result;
388 }
389
390 if ($result instanceof Box) {
391 foreach ($result->shortcuts as &$shortcut) {
392 if ($shortcut instanceof Stasis) {
393 $this->resolve($shortcut);
394 }
395 }
396 return $result->val;
397 }
398
399 return $result;
400 } finally {
401 $this->referenceSources = [];
402 $this->referenceTargets = [];
403 $this->referenceCallbacks = [];
404 }
405 }
406
407 /**
408 * Unserialize with compatibility detection for Laravel and Opis formats.
409 * This provides an upgrade path for users migrating from those libraries.
410 */
411 private function unserializeWithCompatibility(string $value): mixed
412 {
413 // Detect Opis v4 format (uses Opis\Closure\Box)
414 if (\str_contains($value, 'Opis\\Closure\\Box') || \str_contains($value, 'Opis\\Closure\\ClosureInfo')) {
415 if (!\class_exists('Opis\\Closure\\Serializer')) {
416 throw new SerializerError('Data was serialized with opis/closure. Install opis/closure to unserialize it.');
417 }
418 return \Opis\Closure\Serializer::unserialize($value);
419 }
420
421 // Detect Laravel format (uses Laravel\SerializableClosure\SerializableClosure)
422 if (\str_contains($value, 'Laravel\\SerializableClosure\\SerializableClosure')) {
423 if (!\class_exists('Laravel\\SerializableClosure\\SerializableClosure')) {
424 throw new SerializerError('Data was serialized with laravel/serializable-closure. Install it to unserialize.');
425 }
426 $result = \unserialize($value);
427 return $this->unwrapForeignClosures($result);
428 }
429
430 // Detect Opis v3 format (uses Opis\Closure\SerializableClosure directly)
431 if (\str_contains($value, 'Opis\\Closure\\SerializableClosure')) {
432 if (!\class_exists('Opis\\Closure\\SerializableClosure')) {
433 throw new SerializerError('Data was serialized with opis/closure. Install opis/closure to unserialize it.');
434 }
435 $result = \unserialize($value);
436 return $this->unwrapForeignClosures($result);
437 }
438
439 // Standard unserialize for Serializor format
440 return \unserialize($value);
441 }
442
443 /**
444 * Unwrap closures serialized by Laravel or Opis into native Closure objects.
445 * @param array<int,true> $visited Object IDs already visited (cycle detection)
446 */
447 private function unwrapForeignClosures(mixed $value, array &$visited = []): mixed
448 {
449 // Laravel\SerializableClosure\SerializableClosure
450 if (\is_object($value) && $value::class === 'Laravel\\SerializableClosure\\SerializableClosure') {
451 return $value->getClosure();
452 }
453
454 // Opis\Closure\SerializableClosure (v3 compatibility wrapper)
455 if (\is_object($value) && $value::class === 'Opis\\Closure\\SerializableClosure') {
456 return $value->getClosure();
457 }
458
459 // Recursively process arrays
460 if (\is_array($value)) {
461 foreach ($value as $k => $v) {
462 $value[$k] = $this->unwrapForeignClosures($v, $visited);
463 }
464 return $value;
465 }
466
467 // Recursively process object properties (including dynamic properties)
468 if (\is_object($value) && !($value instanceof Closure)) {
469 $objId = \spl_object_id($value);
470 if (isset($visited[$objId])) {
471 return $value; // Already visited, skip to prevent infinite loop
472 }
473 $visited[$objId] = true;
474
475 foreach (\get_object_vars($value) as $propName => $propValue) {
476 $unwrapped = $this->unwrapForeignClosures($propValue, $visited);
477 if ($unwrapped !== $propValue) {
478 $value->$propName = $unwrapped;
479 }
480 }
481 }
482
483 return $value;
484 }
485
486 private function resolve(mixed &$source): void
487 {
488 // If already resolved to a non-Stasis/non-array value, nothing to do
489 if (!\is_array($source) && !($source instanceof Stasis)) {
490 return;
491 }
492
493 $sourceWrap = [&$source];
494 $referenceId = ReflectionReference::fromArrayElement($sourceWrap, 0)->getId();
495 if (isset($this->referenceCallbacks[$referenceId]) || \array_key_exists($referenceId, $this->referenceCallbacks)) {
496 $this->referenceCallbacks[$referenceId][] = static function (mixed &$target) use (&$source) {
497 $source = $target;
498 };
499 return;
500 }
501 $this->referenceCallbacks[$referenceId] = [];
502
503 if (\is_array($source)) {
504 foreach ($source as &$v) {
505 if (\is_array($v) || $v instanceof Stasis) {
506 $this->resolve($v);
507 }
508 }
509 } elseif ($source->hasInstance()) {
510 $source = $source->getInstance();
511 } else {
512 // Resolve children first for Stasis types with nested data
513 $this->resolveStasisChildren($source);
514
515 // Source might have been resolved via reference chain during child resolution
516 if (!($source instanceof Stasis)) {
517 return;
518 }
519
520 // Get the instance
521 $source = $source->getInstance();
522
523 if (!empty($this->referenceCallbacks[$referenceId])) {
524 foreach ($this->referenceCallbacks[$referenceId] as $cb) {
525 $cb($source);
526 }
527 }
528 unset($this->referenceCallbacks[$referenceId]);
529 }
530 }
531
532 /**
533 * Resolve child values within a Stasis object.
534 */
535 private function resolveStasisChildren(Stasis $source): void
536 {
537 if ($source instanceof ObjectStasis) {
538 foreach ($source->p as &$v) {
539 if (\is_array($v) || $v instanceof Stasis) {
540 $this->resolve($v);
541 }
542 }
543 } elseif ($source instanceof WeakMapStasis) {
544 $keys = &$source->getKeys();
545 foreach ($keys as &$key) {
546 if (\is_array($key) || $key instanceof Stasis) {
547 $this->resolve($key);
548 }
549 }
550 $values = &$source->getValues();
551 foreach ($values as &$val) {
552 if (\is_array($val) || $val instanceof Stasis) {
553 $this->resolve($val);
554 }
555 }
556 } elseif ($source instanceof SplObjectStorageStasis) {
557 $objects = &$source->getObjects();
558 foreach ($objects as &$obj) {
559 if (\is_array($obj) || $obj instanceof Stasis) {
560 $this->resolve($obj);
561 }
562 }
563 $data = &$source->getData();
564 foreach ($data as &$d) {
565 if (\is_array($d) || $d instanceof Stasis) {
566 $this->resolve($d);
567 }
568 }
569 } elseif ($source instanceof SplHeapStasis) {
570 $items = &$source->getItems();
571 foreach ($items as &$item) {
572 if (\is_array($item) || $item instanceof Stasis) {
573 $this->resolve($item);
574 }
575 }
576 } elseif ($source instanceof SplPriorityQueueStasis) {
577 $items = &$source->getItems();
578 foreach ($items as &$item) {
579 if (\is_array($item['data']) || $item['data'] instanceof Stasis) {
580 $this->resolve($item['data']);
581 }
582 if (\is_array($item['priority']) || $item['priority'] instanceof Stasis) {
583 $this->resolve($item['priority']);
584 }
585 }
586 } elseif ($source instanceof AnonymousClassStasis) {
587 $props = &$source->getProps();
588 foreach ($props as &$v) {
589 if (\is_array($v) || $v instanceof Stasis) {
590 $this->resolve($v);
591 }
592 }
593 } elseif ($source instanceof ClosureStasis) {
594 $use = &$source->getUse();
595 foreach ($use as &$v) {
596 if (\is_array($v) || $v instanceof Stasis) {
597 $this->resolve($v);
598 }
599 }
600 $thisObj = $source->getThis();
601 if ($thisObj instanceof Stasis) {
602 $this->resolve($thisObj);
603 $source->setThis($thisObj);
604 }
605 } elseif ($source instanceof BoundMethodStasis) {
606 $obj = $source->getObject();
607 if ($obj instanceof Stasis) {
608 $this->resolve($obj);
609 $source->setObject($obj);
610 }
611 }
612 // CallableStasis: no nested Stasis children
613 }
614 }
615