Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# Changelog

## 0.2.0

### Breaking

- `Serializer::unserialize()` refuses closure payloads (data produced by `Serializer::serialize()` for a value containing a closure) and throws `Utopia\Async\Exception\Serialization`. Decoding a closure payload through `opis/closure` rebuilds the objects inside it by reflection, which the `allowed_classes` option does not govern, so it no longer happens by default. Plain payloads keep the `allowed_classes => false` default.

### Added

- `Serializer::unserializeTrusted()` decodes closure payloads for data from a trusted channel (this process or its own workers). The Swoole process pool uses it for its own worker channel.

## 0.1.1

- `Promise::all()` records the error before signalling its channel.
- The parallel pool skips SIGKILL for workers that are already reaped on shutdown.
- PHPStan level-max fixes in the Timer and Promise adapters.

## 0.1.0

- Initial release.
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,23 @@ try {
}
```

## Serialization

`Serializer::serialize()` encodes data containing closures with `opis/closure` and everything else with PHP's `serialize()`. Decoding a closure payload can rebuild objects of any class, so it is an explicit opt-in:

```php
use Utopia\Async\Serializer;

// Plain data only: objects are restored only for the allowed classes (none by default),
// and a closure payload throws Utopia\Async\Exception\Serialization
$data = Serializer::unserialize($payload);
$data = Serializer::unserialize($payload, ['allowed_classes' => [MyValue::class]]);

// Closures and the objects they carry, for payloads from a trusted channel only
// (this process or its own workers, never user input, caches or queues)
$task = Serializer::unserializeTrusted($payload);
```

## Configuration

Both `Parallel` and `Promise` facades expose configurable options via static getter/setter methods.
Expand Down
23 changes: 23 additions & 0 deletions UPGRADE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# Upgrade Guide

## 0.1.x to 0.2.0

### Closure payloads need trusted decoding

`Serializer::unserialize()` no longer decodes closure payloads. It throws `Utopia\Async\Exception\Serialization` for them, because `opis/closure` rebuilds the objects inside such a payload by reflection, outside the control of the `allowed_classes` option.

`Serialization` extends `Utopia\Async\Exception`, not `\RuntimeException`: code that catches only `\RuntimeException` around `Serializer::unserialize()` must also catch `Serialization` (or `\Exception`) to handle a refused payload.

If you pass closures between processes you control, switch those call sites to `Serializer::unserializeTrusted()`:

```php
// 0.1.x
$task = Serializer::unserialize($message);

// 0.2.0
$task = Serializer::unserializeTrusted($message);
```

Keep `Serializer::unserialize()` for everything that does not need closures, and never pass data from users, caches, queues or other shared storage to `Serializer::unserializeTrusted()`.

`Serializer::unserializeTrusted()` accepts the same `$options` and still applies `allowed_classes => false` to plain payloads.
5 changes: 2 additions & 3 deletions src/Parallel/Pool/Swoole/Process.php
Original file line number Diff line number Diff line change
Expand Up @@ -76,9 +76,8 @@ private function initializePool(): void
break;
}

// Deserialize the entire message with Serializer (handles closures automatically)
try {
$taskData = Serializer::unserialize(\is_string($message) ? $message : '');
$taskData = Serializer::unserializeTrusted(\is_string($message) ? $message : '');
} catch (\Throwable $e) {
continue;
}
Expand Down Expand Up @@ -224,7 +223,7 @@ public function execute(array $tasks): array
}

try {
$result = Serializer::unserialize(\is_string($response) ? $response : '');
$result = Serializer::unserializeTrusted(\is_string($response) ? $response : '');
} catch (\Throwable $e) {
continue;
}
Expand Down
63 changes: 49 additions & 14 deletions src/Serializer.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,11 @@

namespace Utopia\Async;

use Utopia\Async\Exception\Serialization;

/**
* High-performance serializer with igbinary support.
*
* Uses igbinary extension if available for 2-3x faster serialization,
* falls back to standard PHP serialize() when not available.
* Serializer for task payloads: opis/closure for data containing closures,
* standard PHP serialize() for everything else.
*
* @package Utopia\Async
*/
Expand Down Expand Up @@ -40,26 +40,61 @@ public static function serialize(mixed $data): string
}

/**
* Unserialize data using opis/closure for Closures and standard unserialization for everything else.
* Unserialize plain data. Objects are restored only for the classes allowed by the
* caller's options (none by default) and closure payloads are refused, because decoding
* them can instantiate any class. Use unserializeTrusted() for data from a trusted channel.
*
* @param string $data
* @param array{allowed_classes?: bool|array<class-string>} $options Options for unserialize
* @return mixed
* @throws Serialization If the data is a closure payload
* @throws \RuntimeException If unserialization fails or data is invalid
*/
public static function unserialize(string $data, array $options = []): mixed
{
if (empty($data)) {
throw new \RuntimeException('Cannot unserialize empty data');
if (\str_starts_with($data, self::OPIS_CLOSURE_PREFIX)) {
throw new Serialization('Refusing to decode a closure payload: use Serializer::unserializeTrusted() for data from a trusted channel');
Comment thread
greptile-apps[bot] marked this conversation as resolved.
}

// Fast prefix check - only check first 3 bytes
if (\str_starts_with($data, self::OPIS_CLOSURE_PREFIX)) {
$opisData = \substr($data, 3);
$result = @\Opis\Closure\unserialize($opisData, $options);
if ($result !== false || $opisData === \Opis\Closure\serialize(false)) {
return $result;
}
return self::unserializePlain($data, $options);
}

/**
* Unserialize data from a trusted channel, restoring closures and the objects they carry.
* Only use this for payloads produced by this process or its own workers: a closure payload
* can rebuild objects that the allowed_classes option does not govern.
*
* @param string $data
* @param array{allowed_classes?: bool|array<class-string>} $options Options for unserialize
* @return mixed
* @throws \RuntimeException If unserialization fails or data is invalid
*/
public static function unserializeTrusted(string $data, array $options = []): mixed
{
if (!\str_starts_with($data, self::OPIS_CLOSURE_PREFIX)) {
return self::unserializePlain($data, $options);
}

$closureData = \substr($data, \strlen(self::OPIS_CLOSURE_PREFIX));
$result = @\Opis\Closure\unserialize($closureData, $options);

if ($result !== false || $closureData === \Opis\Closure\serialize(false)) {
return $result;
}

throw new \RuntimeException('Failed to unserialize data');
}

/**
* @param string $data
* @param array{allowed_classes?: bool|array<class-string>} $options
* @return mixed
* @throws \RuntimeException If unserialization fails or data is invalid
*/
private static function unserializePlain(string $data, array $options): mixed
{
if ($data === '') {
throw new \RuntimeException('Cannot unserialize empty data');
}

/** @var array{allowed_classes?: bool|array<class-string>} $mergedOptions */
Expand Down
27 changes: 27 additions & 0 deletions tests/Unit/SerializerProbe.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<?php

namespace Utopia\Tests\Unit;

final class SerializerProbe
{
public static int $restored = 0;

public string $value = 'probe';

/**
* @return array{value: string}
*/
public function __serialize(): array
{
return ['value' => $this->value];
}

/**
* @param array{value: string} $data
*/
public function __unserialize(array $data): void
{
self::$restored++;
$this->value = $data['value'];
}
}
65 changes: 59 additions & 6 deletions tests/Unit/SerializerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
namespace Utopia\Tests\Unit;

use PHPUnit\Framework\TestCase;
use Utopia\Async\Exception\Serialization;
use Utopia\Async\Serializer;

class SerializerTest extends TestCase
Expand Down Expand Up @@ -48,7 +49,7 @@ public function testSerializeClosure(): void
};

$serialized = Serializer::serialize($closure);
$unserialized = Serializer::unserialize($serialized);
$unserialized = Serializer::unserializeTrusted($serialized);

$this->assertInstanceOf(\Closure::class, $unserialized);
/** @var \Closure(int): int $unserialized */
Expand All @@ -66,7 +67,7 @@ public function testSerializeArrayWithClosure(): void
];

$serialized = Serializer::serialize($data);
$unserialized = Serializer::unserialize($serialized);
$unserialized = Serializer::unserializeTrusted($serialized);

$this->assertIsArray($unserialized);
/** @var array{name: string, value: int, callback: callable(int): int} $unserialized */
Expand All @@ -88,7 +89,7 @@ public function testSerializeNestedClosures(): void
];

$serialized = Serializer::serialize($data);
$unserialized = Serializer::unserialize($serialized);
$unserialized = Serializer::unserializeTrusted($serialized);

$this->assertIsArray($unserialized);
/** @var array{level1: array{level2: array{callback: callable(int): int}}} $unserialized */
Expand Down Expand Up @@ -119,7 +120,7 @@ public function testSerializeObjectWithClosure(): void
};

$serialized = Serializer::serialize($obj);
$unserialized = Serializer::unserialize($serialized, ['allowed_classes' => true]);
$unserialized = Serializer::unserializeTrusted($serialized, ['allowed_classes' => true]);

$this->assertInstanceOf(\stdClass::class, $unserialized);
/** @var \stdClass&object{name: string, callback: callable} $unserialized */
Expand Down Expand Up @@ -184,7 +185,7 @@ function () {
]]]]];

$serialized = Serializer::serialize($data);
$unserialized = Serializer::unserialize($serialized);
$unserialized = Serializer::unserializeTrusted($serialized);

$this->assertIsArray($unserialized);
// Closure should be found and properly serialized
Expand Down Expand Up @@ -318,6 +319,58 @@ public function testFastPathForPrimitives(): void
}
}

public function testUnserializeRefusesClosurePayloadByDefault(): void
{
SerializerProbe::$restored = 0;
$payload = Serializer::serialize([
'task' => fn (): int => 1,
'probe' => new SerializerProbe(),
]);

$refused = false;
try {
Serializer::unserialize($payload);
} catch (Serialization) {
$refused = true;
}

$this->assertTrue($refused, 'A closure payload must not be decoded without opting in to trusted decoding');
$this->assertSame(0, SerializerProbe::$restored, 'No object inside a refused payload may be instantiated');
}

public function testUnserializeRefusesClosurePayloadWithAllowedClasses(): void
{
$payload = Serializer::serialize(fn (): SerializerProbe => new SerializerProbe());

$this->expectException(Serialization::class);

Serializer::unserialize($payload, ['allowed_classes' => true]);
}

public function testUnserializeTrustedRestoresClosurePayload(): void
{
$payload = Serializer::serialize([
'task' => fn (int $x): int => $x * 2,
'probe' => new SerializerProbe(),
]);

$unserialized = Serializer::unserializeTrusted($payload);

$this->assertIsArray($unserialized);
/** @var array{task: \Closure(int): int, probe: SerializerProbe} $unserialized */
$this->assertSame(10, $unserialized['task'](5));
$this->assertInstanceOf(SerializerProbe::class, $unserialized['probe']);
$this->assertSame('probe', $unserialized['probe']->value);
}

public function testUnserializeTrustedDecodesPlainPayloadWithoutClasses(): void
{
$payload = Serializer::serialize(new SerializerProbe());

$this->assertInstanceOf(\__PHP_Incomplete_Class::class, Serializer::unserializeTrusted($payload));
$this->assertSame(['a' => 1], Serializer::unserializeTrusted(Serializer::serialize(['a' => 1])));
}

/**
* Test fast detection of Opis\Closure serialized data.
*/
Expand All @@ -330,7 +383,7 @@ public function testFastOpisClosureDetection(): void
$this->assertStringContainsString('Opis\Closure\\', $serialized);

// Should deserialize correctly using fast detection
$unserialized = Serializer::unserialize($serialized);
$unserialized = Serializer::unserializeTrusted($serialized);
/** @var callable $unserialized */
$this->assertEquals('test', $unserialized());
}
Expand Down
Loading