aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--Cargo.lock1
-rw-r--r--crates/shirabe-php-rpc/Cargo.toml1
-rw-r--r--crates/shirabe-php-rpc/php/stubs/Composer/EventDispatcher/Event.php59
-rw-r--r--crates/shirabe-php-rpc/php/stubs/Composer/Script/Event.php37
-rw-r--r--crates/shirabe-php-rpc/php/worker.php407
-rw-r--r--crates/shirabe-php-rpc/src/frame.rs462
-rw-r--r--crates/shirabe-php-rpc/src/lib.rs547
-rw-r--r--crates/shirabe-php-rpc/src/session.rs110
-rw-r--r--crates/shirabe-php-rpc/src/value.rs723
-rw-r--r--crates/shirabe-php-rpc/tests/oracle.rs199
-rw-r--r--crates/shirabe-php-src/Cargo.toml3
-rw-r--r--crates/shirabe-php-src/src/lib.rs6
-rw-r--r--crates/shirabe-php-src/src/main.rs1
-rw-r--r--crates/shirabe-php-src/src/main/snprintf.rs83
-rw-r--r--crates/shirabe-php-src/src/zend.rs1
-rw-r--r--crates/shirabe-php-src/src/zend/zend_smart_str.rs73
-rw-r--r--docs/dev/php-rpc.md160
17 files changed, 2491 insertions, 382 deletions
diff --git a/Cargo.lock b/Cargo.lock
index 480c4f2d..18ec0a47 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -2148,6 +2148,7 @@ dependencies = [
"indexmap",
"shirabe-external-packages",
"shirabe-php-shim",
+ "shirabe-php-src",
"tempfile",
]
diff --git a/crates/shirabe-php-rpc/Cargo.toml b/crates/shirabe-php-rpc/Cargo.toml
index 0015dba4..cb062e7d 100644
--- a/crates/shirabe-php-rpc/Cargo.toml
+++ b/crates/shirabe-php-rpc/Cargo.toml
@@ -6,6 +6,7 @@ edition.workspace = true
[dependencies]
shirabe-external-packages.workspace = true
shirabe-php-shim.workspace = true
+shirabe-php-src.workspace = true
anyhow.workspace = true
indexmap.workspace = true
tempfile.workspace = true
diff --git a/crates/shirabe-php-rpc/php/stubs/Composer/EventDispatcher/Event.php b/crates/shirabe-php-rpc/php/stubs/Composer/EventDispatcher/Event.php
new file mode 100644
index 00000000..9ce9c12f
--- /dev/null
+++ b/crates/shirabe-php-rpc/php/stubs/Composer/EventDispatcher/Event.php
@@ -0,0 +1,59 @@
+<?php
+
+// Hand-written proxy stub for Composer\EventDispatcher\Event, kept in the shape the future stub
+// generator will output: the real public methods, each forwarding to the Rust-side entity.
+
+namespace Composer\EventDispatcher;
+
+class Event implements \ShirabeRustStub
+{
+ /** @var int */
+ protected $__rhandle;
+ /** @var int */
+ protected $__epoch;
+
+ public function __construct(int $rhandle, int $epoch)
+ {
+ $this->__rhandle = $rhandle;
+ $this->__epoch = $epoch;
+ }
+
+ public function __destruct()
+ {
+ \ShirabeRustObjectRegistry::release($this->__rhandle);
+ }
+
+ public function __shirabeRustHandleDescriptor(): array
+ {
+ return [
+ '__rhandle' => $this->__rhandle,
+ '__class' => static::class,
+ '__epoch' => $this->__epoch,
+ ];
+ }
+
+ public function getName(): string
+ {
+ return \ShirabeRpcRuntime::callRust($this->__rhandle, 'getName', []);
+ }
+
+ public function getArguments(): array
+ {
+ return \ShirabeRpcRuntime::callRust($this->__rhandle, 'getArguments', []);
+ }
+
+ public function getFlags(): array
+ {
+ return \ShirabeRpcRuntime::callRust($this->__rhandle, 'getFlags', []);
+ }
+
+ public function isPropagationStopped(): bool
+ {
+ return \ShirabeRpcRuntime::callRust($this->__rhandle, 'isPropagationStopped', []);
+ }
+
+ public function stopPropagation(): void
+ {
+ \ShirabeRpcRuntime::callRust($this->__rhandle, 'stopPropagation', []);
+ }
+}
diff --git a/crates/shirabe-php-rpc/php/stubs/Composer/Script/Event.php b/crates/shirabe-php-rpc/php/stubs/Composer/Script/Event.php
new file mode 100644
index 00000000..80afe1b7
--- /dev/null
+++ b/crates/shirabe-php-rpc/php/stubs/Composer/Script/Event.php
@@ -0,0 +1,37 @@
+<?php
+
+// Hand-written proxy stub for Composer\Script\Event, kept in the shape the future stub
+// generator will output. See Composer/EventDispatcher/Event.php.
+
+namespace Composer\Script;
+
+use Composer\EventDispatcher\Event as BaseEvent;
+
+class Event extends BaseEvent
+{
+ public function getComposer(): \Composer\Composer
+ {
+ return \ShirabeRpcRuntime::callRust($this->__rhandle, 'getComposer', []);
+ }
+
+ public function getIO(): \Composer\IO\IOInterface
+ {
+ return \ShirabeRpcRuntime::callRust($this->__rhandle, 'getIO', []);
+ }
+
+ public function isDevMode(): bool
+ {
+ return \ShirabeRpcRuntime::callRust($this->__rhandle, 'isDevMode', []);
+ }
+
+ public function getOriginatingEvent(): ?BaseEvent
+ {
+ return \ShirabeRpcRuntime::callRust($this->__rhandle, 'getOriginatingEvent', []);
+ }
+
+ public function setOriginatingEvent(BaseEvent $event): self
+ {
+ \ShirabeRpcRuntime::callRust($this->__rhandle, 'setOriginatingEvent', [$event]);
+ return $this;
+ }
+}
diff --git a/crates/shirabe-php-rpc/php/worker.php b/crates/shirabe-php-rpc/php/worker.php
index 6c3ab443..ce11158e 100644
--- a/crates/shirabe-php-rpc/php/worker.php
+++ b/crates/shirabe-php-rpc/php/worker.php
@@ -1,11 +1,348 @@
<?php
-// PHP glue worker. See docs/dev/php-rpc.md.
+// PHP glue worker. See docs/dev/php-rpc.md for the frame layout and message catalogue.
+
+const SHIRABE_TAG_CALL_FUNCTION = 0x00;
+const SHIRABE_TAG_CALL_STATIC_METHOD = 0x01;
+const SHIRABE_TAG_NEW_OBJECT = 0x02;
+const SHIRABE_TAG_CALL_PHP_METHOD = 0x03;
+const SHIRABE_TAG_CALL_RUST_METHOD = 0x04;
+const SHIRABE_TAG_RETURN = 0x05;
+const SHIRABE_TAG_THROW = 0x06;
+const SHIRABE_TAG_RELEASE_RUST_HANDLE = 0x07;
+const SHIRABE_TAG_RELEASE_PHP_HANDLE = 0x08;
+const SHIRABE_TAG_EPOCH_BUMP = 0x09;
+
+const SHIRABE_MAX_FRAME_LEN = 268435456; // 256 MiB, mirrored on the Rust side.
+
+/** Marker interface every Rust-proxy stub class implements. */
+interface ShirabeRustStub
+{
+ /** @return array{__rhandle: int, __class: string, __epoch: int} */
+ public function __shirabeRustHandleDescriptor(): array;
+}
+
+/** Interns proxy stubs so the same Rust handle always yields the same stub instance. */
+final class ShirabeRustObjectRegistry
+{
+ /** @var array<int, WeakReference> */
+ private static array $internTable = [];
+
+ public static function stubFor(int $rhandle, string $class, int $epoch): object
+ {
+ if (isset(self::$internTable[$rhandle])) {
+ $existing = self::$internTable[$rhandle]->get();
+ if ($existing !== null) {
+ return $existing;
+ }
+ }
+ if (!class_exists($class)) {
+ throw new RuntimeException(
+ "no proxy stub class is available for {$class}"
+ );
+ }
+ $stub = new $class($rhandle, $epoch);
+ self::$internTable[$rhandle] = WeakReference::create($stub);
+ return $stub;
+ }
+
+ /** Invoked when an EpochBump frame arrives. No-op if the stub already died. */
+ public static function bumpEpoch(int $rhandle, int $epoch): void
+ {
+ $ref = self::$internTable[$rhandle] ?? null;
+ $stub = $ref !== null ? $ref->get() : null;
+ if ($stub !== null && method_exists($stub, '__invalidateCache')) {
+ $stub->__invalidateCache($epoch);
+ }
+ }
+
+ /** Invoked from stub destructors. */
+ public static function release(int $rhandle): void
+ {
+ unset(self::$internTable[$rhandle]);
+ ShirabeRpcRuntime::notifyReleaseRustHandle($rhandle);
+ }
+}
+
+final class ShirabeRpcRuntime
+{
+ /** @var resource */
+ public static $socket;
+ public static ?string $stubsDir = null;
+ /** @var array<string, callable(array): mixed> */
+ public static array $dispatch = [];
+ /** Even correlation ids; the Rust side allocates odd ones. */
+ private static int $nextCorrId = 2;
+ private static bool $scriptAutoloaderRegistered = false;
+ private static bool $shuttingDown = false;
+
+ public static function fail(string $message): void
+ {
+ // A malformed frame means the Rust side and this script disagree about the protocol,
+ // which is a bug in Shirabe itself (both halves ship in the same commit). Dying makes
+ // the Rust side observe EOF and report a fatal error.
+ fwrite(STDERR, "shirabe php worker: {$message}\n");
+ exit(1);
+ }
+
+ private static function readExact(int $len): ?string
+ {
+ $buf = '';
+ while (strlen($buf) < $len) {
+ $chunk = fread(self::$socket, $len - strlen($buf));
+ if ($chunk === false || $chunk === '') {
+ return null;
+ }
+ $buf .= $chunk;
+ }
+ return $buf;
+ }
+
+ /** @return ?array{0: int, 1: int, 2: string} [tag, corrId, payload], null on clean EOF */
+ public static function readFrame(): ?array
+ {
+ $header = self::readExact(8);
+ if ($header === null) {
+ return null;
+ }
+ $len = unpack('P', $header)[1];
+ if ($len < 9 || $len > SHIRABE_MAX_FRAME_LEN) {
+ self::fail("invalid frame length {$len}");
+ }
+ $rest = self::readExact($len);
+ if ($rest === null) {
+ self::fail('connection lost mid-frame');
+ }
+ $tag = ord($rest[0]);
+ $corrId = unpack('P', substr($rest, 1, 8))[1];
+ return [$tag, $corrId, substr($rest, 9)];
+ }
+
+ public static function writeFrame(int $tag, int $corrId, string $payload): void
+ {
+ $frame = pack('P', 9 + strlen($payload)) . chr($tag) . pack('P', $corrId) . $payload;
+ if (fwrite(self::$socket, $frame) === false) {
+ // Cannot report an error over a broken channel; die and let Rust observe EOF.
+ exit(1);
+ }
+ }
+
+ public static function notifyReleaseRustHandle(int $rhandle): void
+ {
+ if (self::$shuttingDown) {
+ // Destructors run after the socket may already be closed at shutdown; the whole
+ // process is going away, so there is nothing left to release remotely.
+ return;
+ }
+ self::writeFrame(SHIRABE_TAG_RELEASE_RUST_HANDLE, 0, serialize([$rhandle]));
+ }
+
+ /**
+ * Converts a value about to be serialized onto the wire: proxy stubs become handle
+ * descriptor arrays; plain scalars and arrays pass through.
+ */
+ public static function toWire($value)
+ {
+ if ($value instanceof ShirabeRustStub) {
+ return $value->__shirabeRustHandleDescriptor();
+ }
+ if (is_object($value)) {
+ // TODO(plugin): PHP-owned objects (the P table) are not implemented yet; only Rust
+ // proxy stubs can cross the boundary until the plugin activation milestone.
+ throw new RuntimeException(
+ 'returning a PHP object over RPC is not supported yet: ' . get_class($value)
+ );
+ }
+ if (is_resource($value)) {
+ throw new RuntimeException('a PHP resource cannot cross the RPC boundary');
+ }
+ if (is_array($value)) {
+ return array_map([self::class, 'toWire'], $value);
+ }
+ return $value;
+ }
+
+ /** Converts a decoded wire value: handle descriptor arrays become live objects. */
+ public static function fromWire($value)
+ {
+ if (!is_array($value)) {
+ return $value;
+ }
+ if (isset($value['__rhandle'])) {
+ return ShirabeRustObjectRegistry::stubFor(
+ $value['__rhandle'],
+ $value['__class'],
+ $value['__epoch']
+ );
+ }
+ if (isset($value['__phandle'])) {
+ // TODO(plugin): the P table is not implemented yet.
+ throw new RuntimeException('__phandle descriptors are not supported yet');
+ }
+ if (isset($value['__pclass']) && count($value) === 1) {
+ return $value['__pclass'];
+ }
+ return array_map([self::class, 'fromWire'], $value);
+ }
+
+ /** Sends a CallRustMethod request and drives the cooperative loop until its Return. */
+ public static function callRust(int $rhandle, string $method, array $args)
+ {
+ $corrId = self::$nextCorrId;
+ self::$nextCorrId += 2;
+ self::writeFrame(
+ SHIRABE_TAG_CALL_RUST_METHOD,
+ $corrId,
+ serialize([$rhandle, $method, self::toWire($args), []])
+ );
+ while (true) {
+ $frame = self::readFrame();
+ if ($frame === null) {
+ self::fail('connection lost while waiting for a Return from Rust');
+ }
+ [$tag, $inId, $payload] = $frame;
+ if ($tag === SHIRABE_TAG_RETURN || $tag === SHIRABE_TAG_THROW) {
+ if ($inId !== $corrId) {
+ self::fail("protocol violation: response for unexpected corr_id {$inId}");
+ }
+ $fields = unserialize($payload, ['allowed_classes' => false]);
+ if (!is_array($fields)) {
+ self::fail('protocol violation: unparseable response payload');
+ }
+ if ($tag === SHIRABE_TAG_RETURN) {
+ return self::fromWire($fields[0]);
+ }
+ [$class, $message, $code] = $fields;
+ // TODO(plugin): reconstruct the original exception class instead of collapsing
+ // everything to RuntimeException.
+ throw new RuntimeException($message, (int) $code);
+ }
+ self::dispatchRequest($tag, $inId, $payload);
+ }
+ }
+
+ /** The top-level standing loop: serve incoming requests until the Rust side goes away. */
+ public static function serveForever(): void
+ {
+ while (($frame = self::readFrame()) !== null) {
+ self::dispatchRequest(...$frame);
+ }
+ self::$shuttingDown = true;
+ }
+
+ public static function dispatchRequest(int $tag, int $corrId, string $payload): void
+ {
+ $fields = unserialize($payload, ['allowed_classes' => false]);
+ if (!is_array($fields)) {
+ self::fail('protocol violation: unparseable frame payload');
+ }
+ switch ($tag) {
+ case SHIRABE_TAG_CALL_FUNCTION:
+ [$name, $args] = $fields;
+ self::replyWith($corrId, static function () use ($name, $args) {
+ $args = ShirabeRpcRuntime::fromWire($args);
+ if (isset(ShirabeRpcRuntime::$dispatch[$name])) {
+ return (ShirabeRpcRuntime::$dispatch[$name])($args);
+ }
+ if (function_exists($name)) {
+ return $name(...$args);
+ }
+ throw new RuntimeException("PHP function `{$name}` does not exist");
+ });
+ break;
+ case SHIRABE_TAG_CALL_STATIC_METHOD:
+ [$class, $method, $args] = $fields;
+ self::replyWith($corrId, static function () use ($class, $method, $args) {
+ $args = ShirabeRpcRuntime::fromWire($args);
+ if (!is_callable([$class, $method])) {
+ throw new RuntimeException("{$class}::{$method} is not callable");
+ }
+ return $class::$method(...$args);
+ });
+ break;
+ case SHIRABE_TAG_NEW_OBJECT:
+ self::replyWith($corrId, static function () {
+ // TODO(plugin): requires the P table (plugin activation milestone M2).
+ throw new RuntimeException('NewObject is not supported yet');
+ });
+ break;
+ case SHIRABE_TAG_CALL_PHP_METHOD:
+ self::replyWith($corrId, static function () {
+ // TODO(plugin): requires the P table (plugin activation milestone M2).
+ throw new RuntimeException('CallPhpMethod is not supported yet');
+ });
+ break;
+ case SHIRABE_TAG_RELEASE_PHP_HANDLE:
+ // TODO(plugin): the P table is not implemented yet; nothing to release.
+ break;
+ case SHIRABE_TAG_EPOCH_BUMP:
+ [$rhandle, $epoch] = $fields;
+ ShirabeRustObjectRegistry::bumpEpoch((int) $rhandle, (int) $epoch);
+ break;
+ default:
+ self::fail("protocol violation: unexpected frame tag {$tag}");
+ }
+ }
+
+ /** Runs a handler and sends its result as Return, or the raised Throwable as Throw. */
+ private static function replyWith(int $corrId, callable $handler): void
+ {
+ try {
+ $result = $handler();
+ self::writeFrame(
+ SHIRABE_TAG_RETURN,
+ $corrId,
+ serialize([self::toWire($result), []])
+ );
+ } catch (Throwable $e) {
+ self::writeFrame(
+ SHIRABE_TAG_THROW,
+ $corrId,
+ serialize([get_class($e), $e->getMessage(), (int) $e->getCode()])
+ );
+ }
+ }
+
+ /**
+ * Registers the script-class autoloader: classes referenced by composer.json scripts are
+ * resolved by asking the Rust-side ClassLoader (built by EventDispatcher::makeAutoloader)
+ * where the class file lives. Handle 0 is the runtime service endpoint on the Rust side.
+ */
+ public static function enableScriptAutoloader(): void
+ {
+ if (self::$scriptAutoloaderRegistered) {
+ return;
+ }
+ self::$scriptAutoloaderRegistered = true;
+ spl_autoload_register(static function (string $class): void {
+ $file = ShirabeRpcRuntime::callRust(0, '__shirabe_find_file', [$class]);
+ if (is_string($file) && $file !== '') {
+ require $file;
+ }
+ });
+ }
+}
$client = @stream_socket_client('unix://' . $argv[1], $errno, $errstr);
if ($client === false) {
exit(1);
}
+ShirabeRpcRuntime::$socket = $client;
+ShirabeRpcRuntime::$stubsDir = $argv[2] ?? null;
+
+// Proxy stub classes take priority over any other autoloader (including autoloaders that a
+// script or the composer runtime registers later), so a proxied FQCN can never be shadowed by
+// the real implementation.
+spl_autoload_register(static function (string $class): void {
+ if (ShirabeRpcRuntime::$stubsDir === null) {
+ return;
+ }
+ $file = ShirabeRpcRuntime::$stubsDir . '/' . str_replace('\\', '/', $class) . '.php';
+ if (is_file($file)) {
+ require $file;
+ }
+}, true, true);
+
// Port of Composer\XdebugHandler\XdebugHandler::setXdebugDetails(), which the diagnose payload
// reports as `xdebug_active`.
$xdebug_active = static function (): bool {
@@ -39,14 +376,13 @@ $xdebug_active = static function (): bool {
return $mode !== 'off';
};
-$dispatch = [
- 'defined' => static fn($name) => defined($name),
- 'constant' => static fn($name) => defined($name) ? constant($name) : null,
- 'inet_pton' => static fn($arg) => @inet_pton($arg),
- 'curl_version' => static fn($arg) => function_exists('curl_version') ? (curl_version()['version'] ?? null) : null,
- 'phpversion' => static fn($name) => phpversion($name),
- 'get_loaded_extensions' => static fn($arg) => get_loaded_extensions(),
- 'get_all_ini_files' => static function ($arg) {
+
+ShirabeRpcRuntime::$dispatch = [
+ 'constant' => static fn($args) => defined($args[0]) ? constant($args[0]) : null,
+ 'inet_pton' => static fn($args) => @inet_pton($args[0]),
+ 'curl_version' => static fn($args) => function_exists('curl_version') ? (curl_version()['version'] ?? null) : null,
+ 'get_loaded_extensions' => static fn($args) => get_loaded_extensions(),
+ 'get_all_ini_files' => static function ($args) {
$paths = [(string) php_ini_loaded_file()];
$scanned = php_ini_scanned_files();
if ($scanned !== false) {
@@ -54,16 +390,16 @@ $dispatch = [
}
return $paths;
},
- 'extension_info' => static function ($name) {
- if (!extension_loaded($name)) {
+ 'extension_info' => static function ($args) {
+ if (!extension_loaded($args[0])) {
return '';
}
- $re = new ReflectionExtension($name);
+ $re = new ReflectionExtension($args[0]);
ob_start();
$re->info();
return (string) ob_get_clean();
},
- 'diagnose' => static function ($arg) use ($xdebug_active) {
+ 'diagnose' => static function ($args) use ($xdebug_active) {
$extensions = [];
foreach ([
'apcu',
@@ -139,35 +475,18 @@ $dispatch = [
'ini' => $ini,
];
},
+ // Shirabe-internal helpers, not PHP builtins:
+ '__shirabe_eval' => static fn($args) => eval($args[0]),
+ // Round-trips raw serialize() bytes through the PHP core codec, for the codec oracle tests.
+ '__shirabe_oracle_roundtrip' => static fn($args) => serialize(unserialize($args[0], ['allowed_classes' => false])),
+ '__shirabe_require' => static function ($args) {
+ require_once $args[0];
+ return true;
+ },
+ '__shirabe_enable_script_autoloader' => static function ($args) {
+ ShirabeRpcRuntime::enableScriptAutoloader();
+ return true;
+ },
];
-$read_exact = static function ($conn, int $len): ?string {
- $buf = '';
- while (strlen($buf) < $len) {
- $chunk = fread($conn, $len - strlen($buf));
- if ($chunk === false || $chunk === '') {
- return null;
- }
- $buf .= $chunk;
- }
- return $buf;
-};
-while (true) {
- $header = $read_exact($client, 8);
- if ($header === null) {
- break;
- }
- $len = unpack('P', $header)[1];
- $name = $len === 0 ? '' : $read_exact($client, $len);
- if ($name === null) {
- break;
- }
- $sep = strpos($name, "\0");
- $arg = null;
- if ($sep !== false) {
- $arg = substr($name, $sep + 1);
- $name = substr($name, 0, $sep);
- }
- $result = isset($dispatch[$name]) ? ($dispatch[$name])($arg) : null;
- $payload = serialize($result);
- fwrite($client, pack('P', strlen($payload)) . $payload);
-}
+
+ShirabeRpcRuntime::serveForever();
diff --git a/crates/shirabe-php-rpc/src/frame.rs b/crates/shirabe-php-rpc/src/frame.rs
new file mode 100644
index 00000000..3a355c86
--- /dev/null
+++ b/crates/shirabe-php-rpc/src/frame.rs
@@ -0,0 +1,462 @@
+//! Wire framing: `[u64 length LE][u8 tag][u64 corr_id LE][payload]`, where `length` counts
+//! everything after itself (tag + corr_id + payload) and the payload is the frame's remaining
+//! fields as a PHP-serialized list. See `docs/dev/php-rpc.md`.
+
+use crate::value::{self, PluginValue};
+use indexmap::IndexMap;
+use std::io::{Read as _, Write as _};
+use std::os::unix::net::UnixStream;
+
+/// Upper bound for the declared frame length, so a corrupted length header cannot make the
+/// process allocate absurd amounts of memory.
+pub const MAX_FRAME_LEN: u64 = 256 * 1024 * 1024;
+
+pub const TAG_CALL_FUNCTION: u8 = 0x00;
+pub const TAG_CALL_STATIC_METHOD: u8 = 0x01;
+pub const TAG_NEW_OBJECT: u8 = 0x02;
+pub const TAG_CALL_PHP_METHOD: u8 = 0x03;
+pub const TAG_CALL_RUST_METHOD: u8 = 0x04;
+pub const TAG_RETURN: u8 = 0x05;
+pub const TAG_THROW: u8 = 0x06;
+pub const TAG_RELEASE_RUST_HANDLE: u8 = 0x07;
+pub const TAG_RELEASE_PHP_HANDLE: u8 = 0x08;
+pub const TAG_EPOCH_BUMP: u8 = 0x09;
+
+#[derive(Debug)]
+pub enum Frame {
+ CallFunction {
+ corr_id: u64,
+ function_name: String,
+ args: Vec<PluginValue>,
+ out_param_positions: Vec<u32>,
+ },
+ CallStaticMethod {
+ corr_id: u64,
+ pclass: String,
+ method_name: String,
+ args: Vec<PluginValue>,
+ out_param_positions: Vec<u32>,
+ },
+ NewObject {
+ corr_id: u64,
+ pclass: String,
+ ctor_args: Vec<PluginValue>,
+ },
+ CallPhpMethod {
+ corr_id: u64,
+ phandle: u64,
+ method_name: String,
+ args: Vec<PluginValue>,
+ out_param_positions: Vec<u32>,
+ },
+ CallRustMethod {
+ corr_id: u64,
+ rhandle: u64,
+ method_name: String,
+ args: Vec<PluginValue>,
+ out_param_positions: Vec<u32>,
+ },
+ Return {
+ corr_id: u64,
+ value: PluginValue,
+ out_params: IndexMap<u32, PluginValue>,
+ },
+ Throw {
+ corr_id: u64,
+ exception_class: String,
+ message: String,
+ code: i64,
+ },
+ ReleaseRustHandle {
+ rhandle: u64,
+ },
+ ReleasePhpHandle {
+ phandle: u64,
+ },
+ EpochBump {
+ rhandle: u64,
+ epoch: u64,
+ },
+}
+
+impl Frame {
+ fn tag(&self) -> u8 {
+ match self {
+ Frame::CallFunction { .. } => TAG_CALL_FUNCTION,
+ Frame::CallStaticMethod { .. } => TAG_CALL_STATIC_METHOD,
+ Frame::NewObject { .. } => TAG_NEW_OBJECT,
+ Frame::CallPhpMethod { .. } => TAG_CALL_PHP_METHOD,
+ Frame::CallRustMethod { .. } => TAG_CALL_RUST_METHOD,
+ Frame::Return { .. } => TAG_RETURN,
+ Frame::Throw { .. } => TAG_THROW,
+ Frame::ReleaseRustHandle { .. } => TAG_RELEASE_RUST_HANDLE,
+ Frame::ReleasePhpHandle { .. } => TAG_RELEASE_PHP_HANDLE,
+ Frame::EpochBump { .. } => TAG_EPOCH_BUMP,
+ }
+ }
+
+ /// One-way notifications carry no correlation id; the field is 0 on the wire.
+ fn corr_id(&self) -> u64 {
+ match self {
+ Frame::CallFunction { corr_id, .. }
+ | Frame::CallStaticMethod { corr_id, .. }
+ | Frame::NewObject { corr_id, .. }
+ | Frame::CallPhpMethod { corr_id, .. }
+ | Frame::CallRustMethod { corr_id, .. }
+ | Frame::Return { corr_id, .. }
+ | Frame::Throw { corr_id, .. } => *corr_id,
+ Frame::ReleaseRustHandle { .. }
+ | Frame::ReleasePhpHandle { .. }
+ | Frame::EpochBump { .. } => 0,
+ }
+ }
+
+ fn fields(&self) -> Vec<PluginValue> {
+ match self {
+ Frame::CallFunction {
+ function_name,
+ args,
+ out_param_positions,
+ ..
+ } => vec![
+ PluginValue::string(function_name.clone()),
+ PluginValue::List(args.clone()),
+ positions_value(out_param_positions),
+ ],
+ Frame::CallStaticMethod {
+ pclass,
+ method_name,
+ args,
+ out_param_positions,
+ ..
+ } => vec![
+ PluginValue::string(pclass.clone()),
+ PluginValue::string(method_name.clone()),
+ PluginValue::List(args.clone()),
+ positions_value(out_param_positions),
+ ],
+ Frame::NewObject {
+ pclass, ctor_args, ..
+ } => vec![
+ PluginValue::string(pclass.clone()),
+ PluginValue::List(ctor_args.clone()),
+ ],
+ Frame::CallPhpMethod {
+ phandle,
+ method_name,
+ args,
+ out_param_positions,
+ ..
+ } => vec![
+ int_value(*phandle),
+ PluginValue::string(method_name.clone()),
+ PluginValue::List(args.clone()),
+ positions_value(out_param_positions),
+ ],
+ Frame::CallRustMethod {
+ rhandle,
+ method_name,
+ args,
+ out_param_positions,
+ ..
+ } => vec![
+ int_value(*rhandle),
+ PluginValue::string(method_name.clone()),
+ PluginValue::List(args.clone()),
+ positions_value(out_param_positions),
+ ],
+ Frame::Return {
+ value, out_params, ..
+ } => vec![
+ value.clone(),
+ PluginValue::Array(
+ out_params
+ .iter()
+ .map(|(pos, v)| (pos.to_string().into_bytes(), v.clone()))
+ .collect(),
+ ),
+ ],
+ Frame::Throw {
+ exception_class,
+ message,
+ code,
+ ..
+ } => vec![
+ PluginValue::string(exception_class.clone()),
+ PluginValue::string(message.clone()),
+ PluginValue::Int(*code),
+ ],
+ Frame::ReleaseRustHandle { rhandle } => vec![int_value(*rhandle)],
+ Frame::ReleasePhpHandle { phandle } => vec![int_value(*phandle)],
+ Frame::EpochBump { rhandle, epoch } => vec![int_value(*rhandle), int_value(*epoch)],
+ }
+ }
+}
+
+fn int_value(id: u64) -> PluginValue {
+ PluginValue::Int(i64::try_from(id).expect("handle id exceeds i64"))
+}
+
+fn positions_value(positions: &[u32]) -> PluginValue {
+ PluginValue::List(
+ positions
+ .iter()
+ .map(|p| PluginValue::Int(i64::from(*p)))
+ .collect(),
+ )
+}
+
+pub fn write_frame(stream: &mut UnixStream, frame: &Frame) -> std::io::Result<()> {
+ let payload = value::serialize(&PluginValue::List(frame.fields()));
+ let len = 1 + 8 + payload.len() as u64;
+ stream.write_all(&len.to_le_bytes())?;
+ stream.write_all(&[frame.tag()])?;
+ stream.write_all(&frame.corr_id().to_le_bytes())?;
+ stream.write_all(&payload)?;
+ stream.flush()
+}
+
+pub fn read_frame(stream: &mut UnixStream) -> std::io::Result<Frame> {
+ let mut header = [0u8; 8];
+ stream.read_exact(&mut header)?;
+ let len = u64::from_le_bytes(header);
+ if len > MAX_FRAME_LEN {
+ return Err(std::io::Error::new(
+ std::io::ErrorKind::InvalidData,
+ format!("frame length {len} exceeds MAX_FRAME_LEN"),
+ ));
+ }
+ if len < 9 {
+ return Err(std::io::Error::new(
+ std::io::ErrorKind::InvalidData,
+ format!("frame length {len} is shorter than the tag and corr_id fields"),
+ ));
+ }
+ let mut tag = [0u8; 1];
+ stream.read_exact(&mut tag)?;
+ let mut corr_id = [0u8; 8];
+ stream.read_exact(&mut corr_id)?;
+ let mut payload = vec![0u8; (len - 9) as usize];
+ stream.read_exact(&mut payload)?;
+ Ok(decode_frame(tag[0], u64::from_le_bytes(corr_id), &payload))
+}
+
+/// Both sides of this protocol are written in the same commit of Shirabe, so a frame that does
+/// not decode is a programming error, not a runtime condition; every mismatch panics.
+fn decode_frame(tag: u8, corr_id: u64, payload: &[u8]) -> Frame {
+ let fields = match value::unserialize(payload) {
+ Ok(PluginValue::List(fields)) => fields,
+ other => panic!("PHP RPC: protocol violation — frame payload is not a list: {other:?}"),
+ };
+ let mut fields = fields.into_iter();
+ let mut next = || {
+ fields
+ .next()
+ .unwrap_or_else(|| panic!("PHP RPC: protocol violation — missing frame field"))
+ };
+ match tag {
+ TAG_CALL_FUNCTION => Frame::CallFunction {
+ corr_id,
+ function_name: expect_string(next()),
+ args: expect_list(next()),
+ out_param_positions: expect_positions(next()),
+ },
+ TAG_CALL_STATIC_METHOD => Frame::CallStaticMethod {
+ corr_id,
+ pclass: expect_string(next()),
+ method_name: expect_string(next()),
+ args: expect_list(next()),
+ out_param_positions: expect_positions(next()),
+ },
+ TAG_NEW_OBJECT => Frame::NewObject {
+ corr_id,
+ pclass: expect_string(next()),
+ ctor_args: expect_list(next()),
+ },
+ TAG_CALL_PHP_METHOD => Frame::CallPhpMethod {
+ corr_id,
+ phandle: expect_id(next()),
+ method_name: expect_string(next()),
+ args: expect_list(next()),
+ out_param_positions: expect_positions(next()),
+ },
+ TAG_CALL_RUST_METHOD => Frame::CallRustMethod {
+ corr_id,
+ rhandle: expect_id(next()),
+ method_name: expect_string(next()),
+ args: expect_list(next()),
+ out_param_positions: expect_positions(next()),
+ },
+ TAG_RETURN => Frame::Return {
+ corr_id,
+ value: next(),
+ out_params: expect_out_params(next()),
+ },
+ TAG_THROW => Frame::Throw {
+ corr_id,
+ exception_class: expect_string(next()),
+ message: expect_string(next()),
+ code: match next() {
+ PluginValue::Int(code) => code,
+ other => {
+ panic!("PHP RPC: protocol violation — Throw code is not an int: {other:?}")
+ }
+ },
+ },
+ TAG_RELEASE_RUST_HANDLE => Frame::ReleaseRustHandle {
+ rhandle: expect_id(next()),
+ },
+ TAG_RELEASE_PHP_HANDLE => Frame::ReleasePhpHandle {
+ phandle: expect_id(next()),
+ },
+ TAG_EPOCH_BUMP => Frame::EpochBump {
+ rhandle: expect_id(next()),
+ epoch: expect_id(next()),
+ },
+ _ => panic!("PHP RPC: protocol violation — unknown frame tag {tag:#04x}"),
+ }
+}
+
+fn expect_string(value: PluginValue) -> String {
+ match value {
+ PluginValue::String(bytes) => String::from_utf8(bytes)
+ .unwrap_or_else(|e| panic!("PHP RPC: protocol violation — non-UTF-8 name field: {e}")),
+ other => panic!("PHP RPC: protocol violation — expected a string field: {other:?}"),
+ }
+}
+
+fn expect_list(value: PluginValue) -> Vec<PluginValue> {
+ match value {
+ PluginValue::List(items) => items,
+ other => panic!("PHP RPC: protocol violation — expected a list field: {other:?}"),
+ }
+}
+
+fn expect_id(value: PluginValue) -> u64 {
+ match value {
+ PluginValue::Int(n) => u64::try_from(n)
+ .unwrap_or_else(|_| panic!("PHP RPC: protocol violation — negative handle id {n}")),
+ other => panic!("PHP RPC: protocol violation — expected an int id field: {other:?}"),
+ }
+}
+
+fn expect_positions(value: PluginValue) -> Vec<u32> {
+ expect_list(value)
+ .into_iter()
+ .map(|item| match item {
+ PluginValue::Int(n) => u32::try_from(n).unwrap_or_else(|_| {
+ panic!("PHP RPC: protocol violation — out param position {n} out of range")
+ }),
+ other => {
+ panic!("PHP RPC: protocol violation — out param position is not an int: {other:?}")
+ }
+ })
+ .collect()
+}
+
+fn expect_out_params(value: PluginValue) -> IndexMap<u32, PluginValue> {
+ match value {
+ PluginValue::List(items) if items.is_empty() => IndexMap::new(),
+ PluginValue::List(items) => items
+ .into_iter()
+ .enumerate()
+ .map(|(index, item)| (index as u32, item))
+ .collect(),
+ PluginValue::Array(map) => map
+ .into_iter()
+ .map(|(key, item)| {
+ let position = std::str::from_utf8(&key)
+ .ok()
+ .and_then(|s| s.parse().ok())
+ .unwrap_or_else(|| {
+ panic!(
+ "PHP RPC: protocol violation — out param key is not a position: {:?}",
+ String::from_utf8_lossy(&key)
+ )
+ });
+ (position, item)
+ })
+ .collect(),
+ other => panic!("PHP RPC: protocol violation — out_params is not an array: {other:?}"),
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn roundtrip(frame: Frame) -> Frame {
+ let (mut a, mut b) = UnixStream::pair().unwrap();
+ write_frame(&mut a, &frame).unwrap();
+ read_frame(&mut b).unwrap()
+ }
+
+ #[test]
+ fn frame_roundtrip_call_function() {
+ let frame = roundtrip(Frame::CallFunction {
+ corr_id: 7,
+ function_name: "defined".to_string(),
+ args: vec![PluginValue::string("PHP_VERSION")],
+ out_param_positions: vec![],
+ });
+ match frame {
+ Frame::CallFunction {
+ corr_id,
+ function_name,
+ args,
+ out_param_positions,
+ } => {
+ assert_eq!(corr_id, 7);
+ assert_eq!(function_name, "defined");
+ assert_eq!(args, vec![PluginValue::string("PHP_VERSION")]);
+ assert!(out_param_positions.is_empty());
+ }
+ other => panic!("unexpected frame: {other:?}"),
+ }
+ }
+
+ #[test]
+ fn frame_roundtrip_return_with_out_params() {
+ let frame = roundtrip(Frame::Return {
+ corr_id: 9,
+ value: PluginValue::Bool(true),
+ out_params: [(2u32, PluginValue::string("x"))].into_iter().collect(),
+ });
+ match frame {
+ Frame::Return {
+ corr_id,
+ value,
+ out_params,
+ } => {
+ assert_eq!(corr_id, 9);
+ assert_eq!(value, PluginValue::Bool(true));
+ assert_eq!(out_params.get(&2), Some(&PluginValue::string("x")));
+ }
+ other => panic!("unexpected frame: {other:?}"),
+ }
+ }
+
+ #[test]
+ fn frame_roundtrip_one_way_notification() {
+ let frame = roundtrip(Frame::EpochBump {
+ rhandle: 4,
+ epoch: 2,
+ });
+ match frame {
+ Frame::EpochBump { rhandle, epoch } => {
+ assert_eq!(rhandle, 4);
+ assert_eq!(epoch, 2);
+ }
+ other => panic!("unexpected frame: {other:?}"),
+ }
+ }
+
+ #[test]
+ fn read_frame_rejects_oversized_length() {
+ let (mut a, mut b) = UnixStream::pair().unwrap();
+ a.write_all(&(MAX_FRAME_LEN + 1).to_le_bytes()).unwrap();
+ let err = read_frame(&mut b).unwrap_err();
+ assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
+ assert!(err.to_string().contains("MAX_FRAME_LEN"), "{err}");
+ }
+}
diff --git a/crates/shirabe-php-rpc/src/lib.rs b/crates/shirabe-php-rpc/src/lib.rs
index 9b9c9222..491af072 100644
--- a/crates/shirabe-php-rpc/src/lib.rs
+++ b/crates/shirabe-php-rpc/src/lib.rs
@@ -1,11 +1,17 @@
//! Rust-to-PHP RPC over a Unix domain socket. See `docs/dev/php-rpc.md`.
-use anyhow::Context as _;
+pub mod frame;
+pub mod session;
+pub mod value;
+
+pub use value::{PhpClassHandle, PhpObjHandle, PluginValue, RustObjHandle};
+
+use frame::Frame;
use indexmap::IndexMap;
use shirabe_external_packages::symfony::process::PhpExecutableFinder;
use shirabe_php_shim::PhpMixed;
-use std::io::{Read as _, Write as _};
use std::os::unix::net::{UnixListener, UnixStream};
+use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{LazyLock, Mutex, OnceLock};
use std::time::{Duration, Instant};
@@ -294,8 +300,207 @@ fn string_list(value: PhpMixed, name: &str) -> Vec<String> {
}
}
+/// A PHP exception that crossed the RPC boundary (the recoverable failure lane, as opposed to
+/// the fatal `anyhow::Error` lane used for a dead worker or a broken channel).
+#[derive(Debug, Clone)]
+pub struct PhpThrow {
+ pub exception_class: String,
+ pub message: String,
+ pub code: i64,
+}
+
+impl PhpThrow {
+ fn runtime(message: String) -> PhpThrow {
+ PhpThrow {
+ exception_class: "RuntimeException".to_string(),
+ message,
+ code: 0,
+ }
+ }
+}
+
+impl std::fmt::Display for PhpThrow {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ write!(f, "{}: {}", self.exception_class, self.message)
+ }
+}
+
+impl std::error::Error for PhpThrow {}
+
+/// Handles `CallRustMethod` requests arriving while a Rust-initiated call is waiting for its
+/// `Return` (the cooperative reentrancy loop). A handler may itself issue nested RPC calls.
+pub trait RustMethodDispatcher {
+ fn dispatch(
+ &mut self,
+ rhandle: u64,
+ method_name: &str,
+ args: Vec<PluginValue>,
+ out_param_positions: &[u32],
+ ) -> Result<PluginValue, PhpThrow>;
+}
+
+/// The Rust side allocates odd correlation ids; the PHP side allocates even ones. Calls nest
+/// strictly, so this split is not needed for disambiguation — it just makes any captured frame
+/// attributable to its initiator.
+static NEXT_CORR_ID: AtomicU64 = AtomicU64::new(1);
+
+/// Allocates a Rust-side object handle. Handle 0 is reserved for the runtime service endpoint
+/// (e.g. `__shirabe_find_file` autoload queries), so ids start at 1.
+static NEXT_RHANDLE: AtomicU64 = AtomicU64::new(1);
+
+pub fn alloc_rhandle() -> u64 {
+ NEXT_RHANDLE.fetch_add(1, Ordering::Relaxed)
+}
+
+/// Calls a PHP function in the worker. The outer `Result` is the fatal lane (dead worker, broken
+/// framing); the inner one carries a PHP exception if the call threw.
+pub fn call_function(
+ name: &str,
+ args: Vec<PluginValue>,
+) -> anyhow::Result<Result<PluginValue, PhpThrow>> {
+ call_function_with_dispatcher(name, args, None)
+}
+
+pub fn call_function_with_dispatcher(
+ name: &str,
+ args: Vec<PluginValue>,
+ dispatcher: Option<&mut dyn RustMethodDispatcher>,
+) -> anyhow::Result<Result<PluginValue, PhpThrow>> {
+ rpc_call(
+ |corr_id| Frame::CallFunction {
+ corr_id,
+ function_name: name.to_string(),
+ args,
+ out_param_positions: Vec::new(),
+ },
+ dispatcher,
+ )
+}
+
+/// Calls `$class::$method(...$args)` in the worker (autoloading the class if needed).
+pub fn call_static_method(
+ class: &str,
+ method: &str,
+ args: Vec<PluginValue>,
+ dispatcher: Option<&mut dyn RustMethodDispatcher>,
+) -> anyhow::Result<Result<PluginValue, PhpThrow>> {
+ rpc_call(
+ |corr_id| Frame::CallStaticMethod {
+ corr_id,
+ pclass: class.to_string(),
+ method_name: method.to_string(),
+ args,
+ out_param_positions: Vec::new(),
+ },
+ dispatcher,
+ )
+}
+
+fn rpc_call(
+ request: impl FnOnce(u64) -> Frame,
+ mut dispatcher: Option<&mut dyn RustMethodDispatcher>,
+) -> anyhow::Result<Result<PluginValue, PhpThrow>> {
+ // Held for the whole logical call session; nested calls from the same thread (issued by a
+ // dispatcher handler) re-enter immediately, other threads are serialized.
+ let _session = session::SessionGuard::enter();
+ let my_id = NEXT_CORR_ID.fetch_add(2, Ordering::Relaxed);
+ send_frame(&request(my_id))?;
+ loop {
+ let incoming = recv_frame()?;
+ match incoming {
+ Frame::Return { corr_id, value, .. } if corr_id == my_id => {
+ return Ok(Ok(value));
+ }
+ Frame::Throw {
+ corr_id,
+ exception_class,
+ message,
+ code,
+ } if corr_id == my_id => {
+ return Ok(Err(PhpThrow {
+ exception_class,
+ message,
+ code,
+ }));
+ }
+ Frame::CallRustMethod {
+ corr_id,
+ rhandle,
+ method_name,
+ args,
+ out_param_positions,
+ } => {
+ let outcome = match dispatcher.as_deref_mut() {
+ Some(dispatcher) => {
+ dispatcher.dispatch(rhandle, &method_name, args, &out_param_positions)
+ }
+ // Never fall back to a silent null: an unroutable callback is reported as an
+ // explicit error on the PHP side.
+ None => Err(PhpThrow::runtime(format!(
+ "no Rust method dispatcher is active for this call \
+ (rhandle {rhandle}, method `{method_name}`)"
+ ))),
+ };
+ let reply = match outcome {
+ Ok(value) => Frame::Return {
+ corr_id,
+ value,
+ out_params: IndexMap::new(),
+ },
+ Err(throw) => Frame::Throw {
+ corr_id,
+ exception_class: throw.exception_class,
+ message: throw.message,
+ code: throw.code,
+ },
+ };
+ send_frame(&reply)?;
+ }
+ Frame::ReleaseRustHandle { .. } => {
+ // TODO(plugin): there is no persistent R table yet (script-event handles are
+ // scoped to a single dispatched call), so stub destructor notifications carry no
+ // state to clean up.
+ continue;
+ }
+ Frame::EpochBump { .. } => {
+ // Rust is the sender of epoch bumps; tolerate the symmetric direction.
+ continue;
+ }
+ other => panic!(
+ "PHP RPC: protocol violation — unexpected frame while waiting for corr_id \
+ {my_id}: {other:?}"
+ ),
+ }
+ }
+}
+
+fn call(name: &str, arg: &str) -> PhpMixed {
+ let outcome = call_function(name, vec![PluginValue::string(arg)])
+ .unwrap_or_else(|e| panic!("PHP RPC: request `{name}` failed: {e:#}"));
+ let value = match outcome {
+ Ok(value) => value,
+ Err(throw) => panic!("PHP RPC: request `{name}` threw {throw}"),
+ };
+ value
+ .to_php_mixed()
+ .unwrap_or_else(|e| panic!("PHP RPC: request `{name}` returned an unusable value: {e:#}"))
+}
+
const GLUE_SCRIPT: &str = include_str!("../php/worker.php");
+/// Hand-written proxy stub classes made autoloadable inside the worker, written in the shape the
+/// future stub generator will output.
+const STUB_FILES: &[(&str, &str)] = &[
+ (
+ "Composer/EventDispatcher/Event.php",
+ include_str!("../php/stubs/Composer/EventDispatcher/Event.php"),
+ ),
+ (
+ "Composer/Script/Event.php",
+ include_str!("../php/stubs/Composer/Script/Event.php"),
+ ),
+];
+
struct Worker {
stream: UnixStream,
// Also queried for its exit status when a socket read/write fails, to tell a dead worker
@@ -307,14 +512,6 @@ struct Worker {
}
impl Worker {
- fn request(&mut self, name: &str, arg: &str) -> anyhow::Result<Vec<u8>> {
- let mut payload = name.as_bytes().to_vec();
- payload.push(0);
- payload.extend_from_slice(arg.as_bytes());
- write_frame(&mut self.stream, &payload).with_context(|| self.worker_state())?;
- read_frame(&mut self.stream).with_context(|| self.worker_state())
- }
-
/// Describes the PHP worker's current process state, to be attached as `anyhow::Context` to
/// an I/O error so a dead worker (crash, OOM kill, ...) can be told apart from a live one
/// hitting a framing bug.
@@ -330,7 +527,7 @@ impl Worker {
}
}
-// TODO(phase-c): every failure here panics rather than propagating a `Result`; this is an interim
+// TODO(phase-c): a failed spawn panics rather than propagating a `Result`; this is an interim
// step until PHP RPC gets proper error handling (see docs/dev/php-rpc.md).
static WORKER: LazyLock<Mutex<Worker>> = LazyLock::new(|| {
Mutex::new(
@@ -338,16 +535,23 @@ static WORKER: LazyLock<Mutex<Worker>> = LazyLock::new(|| {
)
});
-fn call(name: &str, arg: &str) -> PhpMixed {
+/// Writes one frame while holding the worker mutex only for the duration of the write, so the
+/// session owner (see `session`) can interleave sends and blocking reads without keeping the
+/// worker locked across a whole call.
+fn send_frame(frame: &Frame) -> anyhow::Result<()> {
let mut guard = WORKER
.lock()
.unwrap_or_else(|e| panic!("PHP RPC: worker mutex poisoned: {e}"));
- let payload = guard
- .request(name, arg)
- .unwrap_or_else(|e| panic!("PHP RPC: request `{name}` failed: {e:#}"));
- parse_serialized_value(&payload).unwrap_or_else(|| {
- panic!("PHP RPC: request `{name}` returned an unparseable payload: {payload:?}")
- })
+ let result = frame::write_frame(&mut guard.stream, frame);
+ result.map_err(|e| anyhow::Error::new(e).context(guard.worker_state()))
+}
+
+fn recv_frame() -> anyhow::Result<Frame> {
+ let mut guard = WORKER
+ .lock()
+ .unwrap_or_else(|e| panic!("PHP RPC: worker mutex poisoned: {e}"));
+ let result = frame::read_frame(&mut guard.stream);
+ result.map_err(|e| anyhow::Error::new(e).context(guard.worker_state()))
}
fn spawn_worker() -> anyhow::Result<Worker> {
@@ -360,13 +564,33 @@ fn spawn_worker() -> anyhow::Result<Worker> {
let script_path = tempdir.path().join("worker.php");
std::fs::write(&script_path, GLUE_SCRIPT)?;
+ let stubs_dir = tempdir.path().join("stubs");
+ for (relative_path, contents) in STUB_FILES {
+ let path = stubs_dir.join(relative_path);
+ std::fs::create_dir_all(path.parent().expect("stub paths have a parent"))?;
+ std::fs::write(&path, contents)?;
+ }
+
// Bind before spawning so the socket exists when the child connects.
let listener = UnixListener::bind(&socket_path)?;
listener.set_nonblocking(true)?;
+ // The socket lives in a 0700 temp dir already; restricting the socket file itself makes the
+ // protection independent of the directory permission.
+ std::fs::set_permissions(
+ &socket_path,
+ std::os::unix::fs::PermissionsExt::from_mode(0o600),
+ )?;
+
let child = std::process::Command::new(&php)
+ // The Rust-side codec produces the byte representation of the default (and only
+ // supported) serialize_precision; pin the child to it in case a distro php.ini overrides
+ // the default.
+ .arg("-d")
+ .arg("serialize_precision=-1")
.arg(&script_path)
.arg(&socket_path)
+ .arg(&stubs_dir)
.spawn()?;
// Poll for the child's connection with a bounded deadline so a child that never connects does
@@ -393,280 +617,35 @@ fn spawn_worker() -> anyhow::Result<Worker> {
})
}
-fn write_frame(stream: &mut UnixStream, payload: &[u8]) -> std::io::Result<()> {
- stream.write_all(&(payload.len() as u64).to_le_bytes())?;
- stream.write_all(payload)?;
- stream.flush()
-}
-
-fn read_frame(stream: &mut UnixStream) -> std::io::Result<Vec<u8>> {
- let mut header = [0u8; 8];
- stream.read_exact(&mut header)?;
- let len = u64::from_le_bytes(header) as usize;
- let mut payload = vec![0u8; len];
- stream.read_exact(&mut payload)?;
- Ok(payload)
-}
-
-/// Parse a whole `serialize()` payload, rejecting trailing garbage.
-fn parse_serialized_value(payload: &[u8]) -> Option<PhpMixed> {
- let mut pos = 0;
- let value = parse_value(payload, &mut pos)?;
- (pos == payload.len()).then_some(value)
-}
-
-/// Parse one `serialize()` value starting at `pos`, advancing it past the value: `N;`, `b:0/1;`,
-/// `i:<n>;`, `d:<f>;`, `s:<len>:"<bytes>";`, `a:<count>:{<key><value>...}`.
-fn parse_value(payload: &[u8], pos: &mut usize) -> Option<PhpMixed> {
- let tag = payload.get(*pos..*pos + 2)?;
- *pos += 2;
- match tag {
- b"N;" => Some(PhpMixed::Null),
- b"b:" => match take_until(payload, pos, b';')? {
- b"0" => Some(PhpMixed::Bool(false)),
- b"1" => Some(PhpMixed::Bool(true)),
- _ => None,
- },
- b"i:" => parse_int(take_until(payload, pos, b';')?).map(PhpMixed::Int),
- b"d:" => std::str::from_utf8(take_until(payload, pos, b';')?)
- .ok()?
- .parse()
- .ok()
- .map(PhpMixed::Float),
- b"s:" => parse_string_body(payload, pos).map(PhpMixed::String),
- b"a:" => parse_array_body(payload, pos),
- _ => None,
- }
-}
-
-/// Parse the `<len>:"<bytes>";` tail of a serialized string.
-fn parse_string_body(payload: &[u8], pos: &mut usize) -> Option<String> {
- let len: usize = std::str::from_utf8(take_until(payload, pos, b':')?)
- .ok()?
- .parse()
- .ok()?;
- if payload.get(*pos) != Some(&b'"') {
- return None;
- }
- *pos += 1;
- let bytes = payload.get(*pos..*pos + len)?;
- *pos += len;
- if payload.get(*pos..*pos + 2) != Some(b"\";") {
- return None;
- }
- *pos += 2;
- Some(String::from_utf8_lossy(bytes).into_owned())
-}
-
-/// Parse the `<count>:{<key><value>...}` tail of a serialized array. An array whose keys are
-/// exactly `0..count` maps to `PhpMixed::List`, matching how PHP renders such an array as a JSON
-/// list; anything else maps to `PhpMixed::Array` with the keys stringified.
-fn parse_array_body(payload: &[u8], pos: &mut usize) -> Option<PhpMixed> {
- let count: usize = std::str::from_utf8(take_until(payload, pos, b':')?)
- .ok()?
- .parse()
- .ok()?;
- if payload.get(*pos) != Some(&b'{') {
- return None;
- }
- *pos += 1;
-
- let mut entries: IndexMap<String, PhpMixed> = IndexMap::new();
- let mut is_list = true;
- for index in 0..count {
- let key = match parse_value(payload, pos)? {
- PhpMixed::Int(n) => {
- is_list &= n == index as i64;
- n.to_string()
- }
- PhpMixed::String(s) => {
- is_list = false;
- s
- }
- _ => return None,
- };
- entries.insert(key, parse_value(payload, pos)?);
- }
-
- if payload.get(*pos) != Some(&b'}') {
- return None;
- }
- *pos += 1;
-
- Some(if is_list {
- PhpMixed::List(entries.into_values().collect())
- } else {
- PhpMixed::Array(entries)
- })
-}
-
-/// Return the bytes from `pos` up to the next `terminator`, advancing `pos` past it.
-fn take_until<'a>(payload: &'a [u8], pos: &mut usize, terminator: u8) -> Option<&'a [u8]> {
- let end = *pos + payload.get(*pos..)?.iter().position(|&b| b == terminator)?;
- let bytes = &payload[*pos..end];
- *pos = end + 1;
- Some(bytes)
-}
-
-fn parse_int(bytes: &[u8]) -> Option<i64> {
- std::str::from_utf8(bytes).ok()?.parse().ok()
-}
-
#[cfg(test)]
mod tests {
use super::*;
#[test]
- fn parses_string_scalar() {
- assert_eq!(
- parse_serialized_value(b"s:5:\"8.5.7\";"),
- Some(PhpMixed::String("8.5.7".to_string())),
- );
- }
-
- #[test]
- fn parses_empty_string() {
- assert_eq!(
- parse_serialized_value(b"s:0:\"\";"),
- Some(PhpMixed::String(String::new())),
- );
- }
-
- #[test]
- fn parses_string_with_embedded_quote() {
- assert_eq!(
- parse_serialized_value(b"s:3:\"a\"b\";"),
- Some(PhpMixed::String("a\"b".to_string())),
- );
- }
-
- #[test]
- fn rejects_truncated_string() {
- assert_eq!(parse_serialized_value(b"s:5:\"ab\";"), None);
- }
-
- #[test]
- fn rejects_trailing_garbage() {
- assert_eq!(parse_serialized_value(b"i:42;i:43;"), None);
- }
-
- #[test]
fn request_error_reports_dead_worker_exit_status() {
let mut worker = spawn_worker().expect("failed to spawn PHP worker");
worker.child.kill().expect("failed to kill PHP worker");
worker.child.wait().expect("failed to reap PHP worker");
- let err = worker
- .request("defined", "PHP_VERSION")
- .expect_err("request against a dead worker should fail");
- let message = format!("{err:#}");
- assert!(
- message.contains("PHP worker process already exited"),
- "unexpected error message: {message}"
+ // Writing may still succeed into the socket buffer; the read is what must fail.
+ let _ = frame::write_frame(
+ &mut worker.stream,
+ &Frame::CallFunction {
+ corr_id: 1,
+ function_name: "defined".to_string(),
+ args: vec![PluginValue::string("PHP_VERSION")],
+ out_param_positions: Vec::new(),
+ },
);
- }
-
- #[test]
- fn rejects_non_numeric_length() {
- assert_eq!(parse_serialized_value(b"s:x:\"ab\";"), None);
- }
-
- #[test]
- fn parses_scalar_null() {
- assert_eq!(parse_serialized_value(b"N;"), Some(PhpMixed::Null));
- }
-
- #[test]
- fn parses_scalar_bool() {
- assert_eq!(parse_serialized_value(b"b:0;"), Some(PhpMixed::Bool(false)));
- assert_eq!(parse_serialized_value(b"b:1;"), Some(PhpMixed::Bool(true)));
- }
-
- #[test]
- fn parses_scalar_int() {
- assert_eq!(parse_serialized_value(b"i:8;"), Some(PhpMixed::Int(8)));
- assert_eq!(parse_serialized_value(b"i:-1;"), Some(PhpMixed::Int(-1)));
- }
-
- #[test]
- fn parses_scalar_float() {
- assert_eq!(
- parse_serialized_value(b"d:1.5;"),
- Some(PhpMixed::Float(1.5))
- );
- }
-
- #[test]
- fn rejects_malformed_scalar() {
- assert_eq!(parse_serialized_value(b"b:2;"), None);
- assert_eq!(parse_serialized_value(b"i:x;"), None);
- assert_eq!(parse_serialized_value(b"d:x;"), None);
- assert_eq!(parse_serialized_value(b"garbage"), None);
- }
-
- #[test]
- fn parses_list_array() {
- assert_eq!(
- parse_serialized_value(b"a:2:{i:0;s:1:\"a\";i:1;i:7;}"),
- Some(PhpMixed::List(vec![
- PhpMixed::String("a".to_string()),
- PhpMixed::Int(7),
- ])),
- );
- }
-
- #[test]
- fn parses_empty_array_as_list() {
- assert_eq!(
- parse_serialized_value(b"a:0:{}"),
- Some(PhpMixed::List(vec![]))
- );
- }
-
- #[test]
- fn parses_keyed_array() {
- let expected: IndexMap<String, PhpMixed> = [
- ("zip".to_string(), PhpMixed::Bool(true)),
- ("apcu".to_string(), PhpMixed::Null),
- ]
- .into_iter()
- .collect();
- assert_eq!(
- parse_serialized_value(b"a:2:{s:3:\"zip\";b:1;s:4:\"apcu\";N;}"),
- Some(PhpMixed::Array(expected)),
- );
- }
-
- #[test]
- fn parses_nested_array() {
- let inner: IndexMap<String, PhpMixed> = [("curl".to_string(), PhpMixed::Bool(false))]
- .into_iter()
- .collect();
- let expected: IndexMap<String, PhpMixed> = [
- ("extensions".to_string(), PhpMixed::Array(inner)),
- ("php_version_id".to_string(), PhpMixed::Int(80500)),
- ]
- .into_iter()
- .collect();
- assert_eq!(
- parse_serialized_value(
- b"a:2:{s:10:\"extensions\";a:1:{s:4:\"curl\";b:0;}s:14:\"php_version_id\";i:80500;}"
- ),
- Some(PhpMixed::Array(expected)),
+ frame::read_frame(&mut worker.stream).expect_err("reading from a dead worker should fail");
+ let state = worker.worker_state();
+ assert!(
+ state.contains("PHP worker process already exited"),
+ "unexpected worker state: {state}"
);
}
#[test]
- fn rejects_malformed_array() {
- // Count larger than the number of entries.
- assert_eq!(parse_serialized_value(b"a:2:{i:0;i:1;}"), None);
- // Missing closing brace.
- assert_eq!(parse_serialized_value(b"a:1:{i:0;i:1;"), None);
- // Non-scalar key.
- assert_eq!(parse_serialized_value(b"a:1:{N;i:1;}"), None);
- }
-
- #[test]
fn queries_string_lists_when_php_available() {
if PhpExecutableFinder::new().find(false).is_none() {
// No PHP in this environment; the worker cannot start.
@@ -706,20 +685,6 @@ mod tests {
}
#[test]
- fn frame_roundtrip() {
- let (mut a, mut b) = UnixStream::pair().unwrap();
- write_frame(&mut a, b"get_php_version").unwrap();
- assert_eq!(read_frame(&mut b).unwrap(), b"get_php_version");
- }
-
- #[test]
- fn frame_roundtrip_empty_payload() {
- let (mut a, mut b) = UnixStream::pair().unwrap();
- write_frame(&mut a, b"").unwrap();
- assert_eq!(read_frame(&mut b).unwrap(), b"");
- }
-
- #[test]
fn queries_real_php_when_available() {
if PhpExecutableFinder::new().find(false).is_none() {
// No PHP in this environment; the worker cannot start.
diff --git a/crates/shirabe-php-rpc/src/session.rs b/crates/shirabe-php-rpc/src/session.rs
new file mode 100644
index 00000000..29bd89f3
--- /dev/null
+++ b/crates/shirabe-php-rpc/src/session.rs
@@ -0,0 +1,110 @@
+//! Thread-ID based reentrant session lock.
+//!
+//! Guarantees at most one logical RPC "call session" is in flight against the shared worker at
+//! any time, while allowing the owning thread to recurse into it freely (a handler may itself
+//! call back into the other side). A different thread attempting to start a session blocks until
+//! the entire outer session (including all of its nested calls) completes — it is never rejected
+//! or panicked on, only serialized. This keeps the "exactly one child process" invariant intact
+//! even when multiple OS threads use this crate concurrently, which happens today only under
+//! `cargo test`'s parallel test harness but is not assumed to be forbidden in the future.
+
+use std::sync::{Condvar, LazyLock, Mutex};
+
+struct SessionLock {
+ owner: Mutex<Option<(std::thread::ThreadId, u32)>>,
+ cvar: Condvar,
+}
+
+impl SessionLock {
+ fn acquire(&self) {
+ let mut owner = self.owner.lock().unwrap();
+ let me = std::thread::current().id();
+ loop {
+ match *owner {
+ None => {
+ *owner = Some((me, 1));
+ return;
+ }
+ Some((tid, depth)) if tid == me => {
+ *owner = Some((me, depth + 1));
+ return;
+ }
+ Some(_) => {
+ owner = self.cvar.wait(owner).unwrap();
+ }
+ }
+ }
+ }
+
+ fn release(&self) {
+ let mut owner = self.owner.lock().unwrap();
+ let me = std::thread::current().id();
+ match *owner {
+ Some((tid, depth)) if tid == me => {
+ if depth == 1 {
+ *owner = None;
+ self.cvar.notify_all();
+ } else {
+ *owner = Some((me, depth - 1));
+ }
+ }
+ _ => unreachable!("SessionLock::release without a matching acquire on this thread"),
+ }
+ }
+}
+
+static SESSION: LazyLock<SessionLock> = LazyLock::new(|| SessionLock {
+ owner: Mutex::new(None),
+ cvar: Condvar::new(),
+});
+
+/// RAII guard; acquired once at the outermost `rpc_call`, re-entered (depth += 1, no blocking)
+/// by nested calls from the same thread.
+pub struct SessionGuard;
+
+impl SessionGuard {
+ pub fn enter() -> Self {
+ SESSION.acquire();
+ SessionGuard
+ }
+}
+
+impl Drop for SessionGuard {
+ fn drop(&mut self) {
+ SESSION.release();
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn same_thread_reenters_without_blocking() {
+ let _outer = SessionGuard::enter();
+ let _inner = SessionGuard::enter();
+ }
+
+ #[test]
+ fn other_threads_are_serialized() {
+ use std::sync::Arc;
+ use std::sync::atomic::{AtomicU32, Ordering};
+
+ let concurrent = Arc::new(AtomicU32::new(0));
+ let mut handles = Vec::new();
+ for _ in 0..4 {
+ let concurrent = Arc::clone(&concurrent);
+ handles.push(std::thread::spawn(move || {
+ for _ in 0..50 {
+ let _guard = SessionGuard::enter();
+ let now = concurrent.fetch_add(1, Ordering::SeqCst);
+ assert_eq!(now, 0, "two sessions were in flight at once");
+ concurrent.fetch_sub(1, Ordering::SeqCst);
+ }
+ }));
+ }
+ for handle in handles {
+ handle.join().unwrap();
+ }
+ }
+}
diff --git a/crates/shirabe-php-rpc/src/value.rs b/crates/shirabe-php-rpc/src/value.rs
new file mode 100644
index 00000000..e05ba6db
--- /dev/null
+++ b/crates/shirabe-php-rpc/src/value.rs
@@ -0,0 +1,723 @@
+//! Plugin-boundary value model and its wire codec. The wire format is documented in
+//! `docs/dev/php-rpc.md`.
+//!
+//! `PluginValue` is the value type crossing the Rust/PHP RPC boundary. It is deliberately
+//! independent from `shirabe_php_shim::PhpMixed`: handles exist only at the plugin boundary,
+//! and the codec below is a separate implementation from `shirabe_php_shim::var::serialize`.
+//!
+//! Strings and array keys are byte strings (`Vec<u8>`), matching PHP's string semantics: the
+//! codec must round-trip non-UTF-8 byte sequences losslessly.
+
+use anyhow::bail;
+use indexmap::IndexMap;
+use shirabe_php_shim::PhpMixed;
+
+/// A handle to an object whose entity lives on the Rust side. PHP holds a thin proxy stub.
+#[derive(Debug, Clone, PartialEq)]
+pub struct RustObjHandle {
+ pub rhandle: u64,
+ pub class: String,
+ pub epoch: u64,
+ /// Present when the descriptor also carries a value snapshot of the entity's fields.
+ pub snapshot: Option<IndexMap<Vec<u8>, PluginValue>>,
+}
+
+/// A handle to an object whose entity lives in the PHP child process.
+#[derive(Debug, Clone, PartialEq)]
+pub struct PhpObjHandle {
+ pub phandle: u64,
+ pub class: String,
+ pub implements: Vec<String>,
+}
+
+/// A PHP class (not an instance), identified by its fully qualified name.
+#[derive(Debug, Clone, PartialEq)]
+pub struct PhpClassHandle {
+ pub class: String,
+}
+
+/// The value model of the plugin RPC boundary: PHP scalars, arrays, and handle descriptors.
+///
+/// `Object` is encode-only: the wire representation of a PHP array does not distinguish arrays
+/// from objects, so the decoder only ever produces `List` (contiguous 0-based int keys) or
+/// `Array`. An encoded `Object` lands on the PHP side as a plain array.
+#[derive(Debug, Clone, PartialEq)]
+pub enum PluginValue {
+ Null,
+ Bool(bool),
+ Int(i64),
+ Float(f64),
+ String(Vec<u8>),
+ List(Vec<PluginValue>),
+ Array(IndexMap<Vec<u8>, PluginValue>),
+ Object(IndexMap<Vec<u8>, PluginValue>),
+ RustHandle(RustObjHandle),
+ PhpHandle(PhpObjHandle),
+ PhpClass(PhpClassHandle),
+}
+
+impl PluginValue {
+ pub fn string(s: impl Into<String>) -> PluginValue {
+ PluginValue::String(s.into().into_bytes())
+ }
+
+ /// Converts a plain data value (no handles are ever produced) coming from ported code.
+ pub fn from_php_mixed(value: &PhpMixed) -> PluginValue {
+ match value {
+ PhpMixed::Null => PluginValue::Null,
+ PhpMixed::Bool(b) => PluginValue::Bool(*b),
+ PhpMixed::Int(n) => PluginValue::Int(*n),
+ PhpMixed::Float(f) => PluginValue::Float(*f),
+ PhpMixed::String(s) => PluginValue::String(s.clone().into_bytes()),
+ PhpMixed::List(items) => {
+ PluginValue::List(items.iter().map(PluginValue::from_php_mixed).collect())
+ }
+ PhpMixed::Array(map) => PluginValue::Array(
+ map.iter()
+ .map(|(k, v)| (k.clone().into_bytes(), PluginValue::from_php_mixed(v)))
+ .collect(),
+ ),
+ PhpMixed::Object(map) => PluginValue::Object(
+ map.iter()
+ .map(|(k, v)| (k.clone().into_bytes(), PluginValue::from_php_mixed(v)))
+ .collect(),
+ ),
+ }
+ }
+
+ /// Converts back to `PhpMixed` for callers outside the plugin boundary. Handles have no
+ /// `PhpMixed` counterpart and fail. Non-UTF-8 bytes are replaced, matching how the previous
+ /// scalar-only response parser exposed PHP strings to `PhpMixed` consumers.
+ pub fn to_php_mixed(&self) -> anyhow::Result<PhpMixed> {
+ Ok(match self {
+ PluginValue::Null => PhpMixed::Null,
+ PluginValue::Bool(b) => PhpMixed::Bool(*b),
+ PluginValue::Int(n) => PhpMixed::Int(*n),
+ PluginValue::Float(f) => PhpMixed::Float(*f),
+ PluginValue::String(bytes) => {
+ PhpMixed::String(String::from_utf8_lossy(bytes).into_owned())
+ }
+ PluginValue::List(items) => PhpMixed::List(
+ items
+ .iter()
+ .map(PluginValue::to_php_mixed)
+ .collect::<anyhow::Result<_>>()?,
+ ),
+ PluginValue::Array(map) | PluginValue::Object(map) => PhpMixed::Array(
+ map.iter()
+ .map(|(k, v)| Ok((String::from_utf8_lossy(k).into_owned(), v.to_php_mixed()?)))
+ .collect::<anyhow::Result<_>>()?,
+ ),
+ PluginValue::RustHandle(_) | PluginValue::PhpHandle(_) | PluginValue::PhpClass(_) => {
+ bail!("a handle descriptor cannot be represented as PhpMixed: {self:?}")
+ }
+ })
+ }
+}
+
+/// Maximum nesting depth the decoder accepts before rejecting the payload, so a corrupted or
+/// hostile payload cannot overflow the stack.
+pub const MAX_DECODE_DEPTH: usize = 512;
+
+const RUST_HANDLE_KEY: &[u8] = b"__rhandle";
+const CLASS_KEY: &[u8] = b"__class";
+const EPOCH_KEY: &[u8] = b"__epoch";
+const SNAPSHOT_KEY: &[u8] = b"__snapshot";
+const PHP_HANDLE_KEY: &[u8] = b"__phandle";
+const IMPLEMENTS_KEY: &[u8] = b"__implements";
+const PHP_CLASS_KEY: &[u8] = b"__pclass";
+
+/// Encodes a `PluginValue` in PHP `serialize()` grammar, byte-compatible with what the PHP core
+/// implementation produces under `serialize_precision=-1`.
+pub fn serialize(value: &PluginValue) -> Vec<u8> {
+ let mut out = Vec::new();
+ serialize_into(value, &mut out);
+ out
+}
+
+fn serialize_into(value: &PluginValue, out: &mut Vec<u8>) {
+ match value {
+ PluginValue::Null => out.extend_from_slice(b"N;"),
+ PluginValue::Bool(b) => {
+ out.extend_from_slice(if *b { b"b:1;" } else { b"b:0;" });
+ }
+ PluginValue::Int(n) => {
+ out.extend_from_slice(b"i:");
+ out.extend_from_slice(n.to_string().as_bytes());
+ out.push(b';');
+ }
+ PluginValue::Float(f) => {
+ out.extend_from_slice(b"d:");
+ let mut repr = String::new();
+ shirabe_php_src::zend::zend_smart_str::smart_str_append_double(
+ &mut repr, *f, -1, false,
+ );
+ out.extend_from_slice(repr.as_bytes());
+ out.push(b';');
+ }
+ PluginValue::String(bytes) => serialize_bytes(bytes, out),
+ PluginValue::List(items) => {
+ out.extend_from_slice(b"a:");
+ out.extend_from_slice(items.len().to_string().as_bytes());
+ out.extend_from_slice(b":{");
+ for (index, item) in items.iter().enumerate() {
+ out.extend_from_slice(b"i:");
+ out.extend_from_slice(index.to_string().as_bytes());
+ out.push(b';');
+ serialize_into(item, out);
+ }
+ out.push(b'}');
+ }
+ // An object lands on the PHP side as a plain array: `allowed_classes: false` bans `O:`
+ // records from the wire, so `Object` is a write-only label (see docs/dev/php-rpc.md).
+ PluginValue::Array(map) | PluginValue::Object(map) => serialize_map(map, out),
+ PluginValue::RustHandle(handle) => {
+ let mut map: IndexMap<Vec<u8>, PluginValue> = IndexMap::new();
+ map.insert(
+ RUST_HANDLE_KEY.to_vec(),
+ PluginValue::Int(i64::try_from(handle.rhandle).expect("rhandle exceeds i64")),
+ );
+ map.insert(
+ CLASS_KEY.to_vec(),
+ PluginValue::string(handle.class.clone()),
+ );
+ map.insert(
+ EPOCH_KEY.to_vec(),
+ PluginValue::Int(i64::try_from(handle.epoch).expect("epoch exceeds i64")),
+ );
+ if let Some(snapshot) = &handle.snapshot {
+ map.insert(SNAPSHOT_KEY.to_vec(), PluginValue::Array(snapshot.clone()));
+ }
+ serialize_map(&map, out);
+ }
+ PluginValue::PhpHandle(handle) => {
+ let mut map: IndexMap<Vec<u8>, PluginValue> = IndexMap::new();
+ map.insert(
+ PHP_HANDLE_KEY.to_vec(),
+ PluginValue::Int(i64::try_from(handle.phandle).expect("phandle exceeds i64")),
+ );
+ map.insert(
+ CLASS_KEY.to_vec(),
+ PluginValue::string(handle.class.clone()),
+ );
+ map.insert(
+ IMPLEMENTS_KEY.to_vec(),
+ PluginValue::List(
+ handle
+ .implements
+ .iter()
+ .map(|name| PluginValue::string(name.clone()))
+ .collect(),
+ ),
+ );
+ serialize_map(&map, out);
+ }
+ PluginValue::PhpClass(handle) => {
+ let mut map: IndexMap<Vec<u8>, PluginValue> = IndexMap::new();
+ map.insert(
+ PHP_CLASS_KEY.to_vec(),
+ PluginValue::string(handle.class.clone()),
+ );
+ serialize_map(&map, out);
+ }
+ }
+}
+
+fn serialize_bytes(bytes: &[u8], out: &mut Vec<u8>) {
+ out.extend_from_slice(b"s:");
+ out.extend_from_slice(bytes.len().to_string().as_bytes());
+ out.extend_from_slice(b":\"");
+ out.extend_from_slice(bytes);
+ out.extend_from_slice(b"\";");
+}
+
+fn serialize_map(map: &IndexMap<Vec<u8>, PluginValue>, out: &mut Vec<u8>) {
+ out.extend_from_slice(b"a:");
+ out.extend_from_slice(map.len().to_string().as_bytes());
+ out.extend_from_slice(b":{");
+ for (key, value) in map {
+ match canonical_int_key(key) {
+ Some(n) => {
+ out.extend_from_slice(b"i:");
+ out.extend_from_slice(n.to_string().as_bytes());
+ out.push(b';');
+ }
+ None => serialize_bytes(key, out),
+ }
+ serialize_into(value, out);
+ }
+ out.push(b'}');
+}
+
+/// PHP canonicalizes array keys: a string key that is the canonical decimal form of an integer
+/// (no leading zeros, no `-0`, within the platform int range) is stored as an int key, so such a
+/// key can never appear as `s:...` on the wire.
+fn canonical_int_key(key: &[u8]) -> Option<i64> {
+ if key == b"0" {
+ return Some(0);
+ }
+ let digits = key.strip_prefix(b"-").unwrap_or(key);
+ match digits {
+ [b'1'..=b'9', rest @ ..] if rest.iter().all(u8::is_ascii_digit) => {
+ std::str::from_utf8(key).ok()?.parse().ok()
+ }
+ _ => None,
+ }
+}
+
+/// Decodes a whole `serialize()` payload into a `PluginValue`, rejecting trailing garbage.
+///
+/// The decoder never produces `Object`: PHP's wire format erases the array/object distinction,
+/// and object revival is banned anyway (`allowed_classes: false` on the PHP side). Arrays whose
+/// keys are exactly `0..N` decode as `List`; anything else decodes as `Array`. Arrays carrying
+/// the reserved handle-descriptor key sets decode as the corresponding handle.
+pub fn unserialize(payload: &[u8]) -> anyhow::Result<PluginValue> {
+ let mut pos = 0;
+ let value = parse_value(payload, &mut pos)?;
+ if pos != payload.len() {
+ bail!("trailing garbage after serialized value at byte {pos}");
+ }
+ Ok(value)
+}
+
+/// One lexed step of a serialized payload: either a complete non-array value, or the opening of
+/// an array whose entries follow.
+enum Lex {
+ Value(PluginValue),
+ ArrayOpen(usize),
+}
+
+/// An in-progress array while parsing iteratively. The parser deliberately does not recurse:
+/// nesting depth must never translate into call stack depth, so a hostile or corrupted payload
+/// cannot overflow the stack (the explicit depth cap exists on top of that).
+struct ArrayFrame {
+ entries: IndexMap<Vec<u8>, PluginValue>,
+ count: usize,
+ parsed: usize,
+ is_list: bool,
+ pending_key: Option<Vec<u8>>,
+}
+
+fn parse_value(payload: &[u8], pos: &mut usize) -> anyhow::Result<PluginValue> {
+ let mut stack: Vec<ArrayFrame> = Vec::new();
+ let mut completed: Option<PluginValue> = None;
+
+ loop {
+ if let Some(value) = completed.take() {
+ match stack.last_mut() {
+ None => return Ok(value),
+ Some(frame) => {
+ let key = frame
+ .pending_key
+ .take()
+ .expect("a completed value always follows a parsed key");
+ frame.entries.insert(key, value);
+ frame.parsed += 1;
+ }
+ }
+ }
+
+ if let Some(frame) = stack.last_mut()
+ && frame.pending_key.is_none()
+ {
+ if frame.parsed == frame.count {
+ if payload.get(*pos) != Some(&b'}') {
+ bail!("expected closing brace at byte {}", *pos);
+ }
+ *pos += 1;
+ let frame = stack.pop().expect("frame was just observed");
+ completed = Some(finish_array(frame)?);
+ continue;
+ }
+ let index = frame.parsed as i64;
+ match lex(payload, pos)? {
+ Lex::Value(PluginValue::Int(n)) => {
+ frame.is_list &= n == index;
+ frame.pending_key = Some(n.to_string().into_bytes());
+ }
+ Lex::Value(PluginValue::String(bytes)) => {
+ frame.is_list = false;
+ frame.pending_key = Some(bytes);
+ }
+ Lex::Value(other) => bail!("array key is neither int nor string: {other:?}"),
+ Lex::ArrayOpen(_) => bail!("array key is neither int nor string"),
+ }
+ continue;
+ }
+
+ match lex(payload, pos)? {
+ Lex::Value(value) => completed = Some(value),
+ Lex::ArrayOpen(count) => {
+ if stack.len() >= MAX_DECODE_DEPTH {
+ bail!(
+ "serialized value exceeds the maximum nesting depth of {MAX_DECODE_DEPTH}"
+ );
+ }
+ stack.push(ArrayFrame {
+ entries: IndexMap::new(),
+ count,
+ parsed: 0,
+ is_list: true,
+ pending_key: None,
+ });
+ }
+ }
+ }
+}
+
+fn lex(payload: &[u8], pos: &mut usize) -> anyhow::Result<Lex> {
+ let Some(tag) = payload.get(*pos..*pos + 2) else {
+ bail!("truncated serialized value at byte {}", *pos);
+ };
+ *pos += 2;
+ match tag {
+ b"N;" => Ok(Lex::Value(PluginValue::Null)),
+ b"b:" => match take_until(payload, pos, b';')? {
+ b"0" => Ok(Lex::Value(PluginValue::Bool(false))),
+ b"1" => Ok(Lex::Value(PluginValue::Bool(true))),
+ other => bail!(
+ "malformed bool payload: {:?}",
+ String::from_utf8_lossy(other)
+ ),
+ },
+ b"i:" => {
+ let bytes = take_until(payload, pos, b';')?;
+ let n = std::str::from_utf8(bytes).ok().and_then(|s| s.parse().ok());
+ match n {
+ Some(n) => Ok(Lex::Value(PluginValue::Int(n))),
+ None => bail!(
+ "malformed int payload: {:?}",
+ String::from_utf8_lossy(bytes)
+ ),
+ }
+ }
+ b"d:" => {
+ let bytes = take_until(payload, pos, b';')?;
+ let f = std::str::from_utf8(bytes).ok().and_then(|s| match s {
+ // Rust's float parser accepts these spellings too, but be explicit about the
+ // exact special forms PHP emits.
+ "INF" => Some(f64::INFINITY),
+ "-INF" => Some(f64::NEG_INFINITY),
+ "NAN" => Some(f64::NAN),
+ _ => s.parse().ok(),
+ });
+ match f {
+ Some(f) => Ok(Lex::Value(PluginValue::Float(f))),
+ None => bail!(
+ "malformed float payload: {:?}",
+ String::from_utf8_lossy(bytes)
+ ),
+ }
+ }
+ b"s:" => Ok(Lex::Value(PluginValue::String(parse_string_body(
+ payload, pos,
+ )?))),
+ b"a:" => {
+ let count_bytes = take_until(payload, pos, b':')?;
+ let count: usize = std::str::from_utf8(count_bytes)
+ .ok()
+ .and_then(|s| s.parse().ok())
+ .ok_or_else(|| {
+ anyhow::anyhow!(
+ "malformed array count: {:?}",
+ String::from_utf8_lossy(count_bytes)
+ )
+ })?;
+ if payload.get(*pos) != Some(&b'{') {
+ bail!("expected opening brace at byte {}", *pos);
+ }
+ *pos += 1;
+ Ok(Lex::ArrayOpen(count))
+ }
+ _ => bail!(
+ "unknown serialized type tag {:?} at byte {}",
+ String::from_utf8_lossy(tag),
+ *pos - 2
+ ),
+ }
+}
+
+fn finish_array(frame: ArrayFrame) -> anyhow::Result<PluginValue> {
+ if let Some(handle) = decode_handle(&frame.entries)? {
+ return Ok(handle);
+ }
+ Ok(if frame.is_list {
+ PluginValue::List(frame.entries.into_values().collect())
+ } else {
+ PluginValue::Array(frame.entries)
+ })
+}
+
+fn parse_string_body(payload: &[u8], pos: &mut usize) -> anyhow::Result<Vec<u8>> {
+ let len_bytes = take_until(payload, pos, b':')?;
+ let len: usize = std::str::from_utf8(len_bytes)
+ .ok()
+ .and_then(|s| s.parse().ok())
+ .ok_or_else(|| {
+ anyhow::anyhow!(
+ "malformed string length: {:?}",
+ String::from_utf8_lossy(len_bytes)
+ )
+ })?;
+ if payload.get(*pos) != Some(&b'"') {
+ bail!("expected opening quote at byte {}", *pos);
+ }
+ *pos += 1;
+ let Some(bytes) = payload.get(*pos..*pos + len) else {
+ bail!("truncated string body at byte {}", *pos);
+ };
+ *pos += len;
+ if payload.get(*pos..*pos + 2) != Some(b"\";") {
+ bail!("expected closing quote at byte {}", *pos);
+ }
+ *pos += 2;
+ Ok(bytes.to_vec())
+}
+
+/// Recognizes the reserved handle-descriptor arrays by their exact key sets.
+fn decode_handle(entries: &IndexMap<Vec<u8>, PluginValue>) -> anyhow::Result<Option<PluginValue>> {
+ let get = |key: &[u8]| entries.get(key);
+
+ if entries.len() == 1 {
+ if let Some(value) = get(PHP_CLASS_KEY) {
+ let PluginValue::String(class) = value else {
+ bail!("__pclass descriptor with a non-string class: {value:?}");
+ };
+ return Ok(Some(PluginValue::PhpClass(PhpClassHandle {
+ class: descriptor_utf8(class, "__pclass")?,
+ })));
+ }
+ return Ok(None);
+ }
+
+ if let Some(value) = get(RUST_HANDLE_KEY) {
+ let allowed = entries
+ .keys()
+ .all(|k| k == RUST_HANDLE_KEY || k == CLASS_KEY || k == EPOCH_KEY || k == SNAPSHOT_KEY);
+ if !allowed || entries.len() < 3 {
+ bail!("malformed __rhandle descriptor: {entries:?}");
+ }
+ let rhandle = descriptor_u64(value, "__rhandle")?;
+ let class = descriptor_class(get(CLASS_KEY), "__rhandle")?;
+ let epoch = descriptor_u64(
+ get(EPOCH_KEY).ok_or_else(|| anyhow::anyhow!("__rhandle descriptor lacks __epoch"))?,
+ "__epoch",
+ )?;
+ let snapshot = match get(SNAPSHOT_KEY) {
+ None => None,
+ Some(PluginValue::Array(map)) => Some(map.clone()),
+ Some(PluginValue::List(items)) => Some(
+ items
+ .iter()
+ .enumerate()
+ .map(|(i, v)| (i.to_string().into_bytes(), v.clone()))
+ .collect(),
+ ),
+ Some(other) => bail!("__snapshot is not an array: {other:?}"),
+ };
+ return Ok(Some(PluginValue::RustHandle(RustObjHandle {
+ rhandle,
+ class,
+ epoch,
+ snapshot,
+ })));
+ }
+
+ if let Some(value) = get(PHP_HANDLE_KEY) {
+ let allowed = entries
+ .keys()
+ .all(|k| k == PHP_HANDLE_KEY || k == CLASS_KEY || k == IMPLEMENTS_KEY);
+ if !allowed || entries.len() != 3 {
+ bail!("malformed __phandle descriptor: {entries:?}");
+ }
+ let phandle = descriptor_u64(value, "__phandle")?;
+ let class = descriptor_class(get(CLASS_KEY), "__phandle")?;
+ let implements = match get(IMPLEMENTS_KEY) {
+ Some(PluginValue::List(items)) => items
+ .iter()
+ .map(|item| match item {
+ PluginValue::String(name) => descriptor_utf8(name, "__implements"),
+ other => bail!("__implements entry is not a string: {other:?}"),
+ })
+ .collect::<anyhow::Result<_>>()?,
+ other => bail!("__implements is not a list: {other:?}"),
+ };
+ return Ok(Some(PluginValue::PhpHandle(PhpObjHandle {
+ phandle,
+ class,
+ implements,
+ })));
+ }
+
+ Ok(None)
+}
+
+fn descriptor_u64(value: &PluginValue, what: &str) -> anyhow::Result<u64> {
+ match value {
+ PluginValue::Int(n) => u64::try_from(*n)
+ .map_err(|_| anyhow::anyhow!("{what} descriptor holds a negative id: {n}")),
+ other => bail!("{what} descriptor id is not an int: {other:?}"),
+ }
+}
+
+fn descriptor_class(value: Option<&PluginValue>, what: &str) -> anyhow::Result<String> {
+ match value {
+ Some(PluginValue::String(class)) => descriptor_utf8(class, what),
+ other => bail!("{what} descriptor lacks a string __class: {other:?}"),
+ }
+}
+
+fn descriptor_utf8(bytes: &[u8], what: &str) -> anyhow::Result<String> {
+ String::from_utf8(bytes.to_vec())
+ .map_err(|_| anyhow::anyhow!("{what} descriptor holds a non-UTF-8 class name"))
+}
+
+fn take_until<'a>(payload: &'a [u8], pos: &mut usize, terminator: u8) -> anyhow::Result<&'a [u8]> {
+ let start = *pos;
+ let Some(offset) = payload
+ .get(start..)
+ .and_then(|rest| rest.iter().position(|&b| b == terminator))
+ else {
+ bail!("unterminated field at byte {start}");
+ };
+ let bytes = &payload[start..start + offset];
+ *pos = start + offset + 1;
+ Ok(bytes)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn roundtrip(value: PluginValue) {
+ let encoded = serialize(&value);
+ let decoded = unserialize(&encoded).expect("decode failed");
+ assert_eq!(
+ decoded,
+ value,
+ "encoded form: {:?}",
+ String::from_utf8_lossy(&encoded)
+ );
+ }
+
+ #[test]
+ fn encodes_scalars_like_php() {
+ assert_eq!(serialize(&PluginValue::Null), b"N;");
+ assert_eq!(serialize(&PluginValue::Bool(true)), b"b:1;");
+ assert_eq!(serialize(&PluginValue::Bool(false)), b"b:0;");
+ assert_eq!(serialize(&PluginValue::Int(-42)), b"i:-42;");
+ assert_eq!(serialize(&PluginValue::Float(1.5)), b"d:1.5;");
+ assert_eq!(serialize(&PluginValue::Float(2.0)), b"d:2;");
+ assert_eq!(serialize(&PluginValue::Float(1e17)), b"d:1.0E+17;");
+ assert_eq!(serialize(&PluginValue::string("ab")), b"s:2:\"ab\";");
+ assert_eq!(
+ serialize(&PluginValue::String(vec![0xff, 0x00, 0xfe])),
+ b"s:3:\"\xff\x00\xfe\";"
+ );
+ }
+
+ #[test]
+ fn encodes_arrays_with_php_key_canonicalization() {
+ let mut map: IndexMap<Vec<u8>, PluginValue> = IndexMap::new();
+ map.insert(b"5".to_vec(), PluginValue::Int(1));
+ map.insert(b"05".to_vec(), PluginValue::Int(2));
+ map.insert(b"-0".to_vec(), PluginValue::Int(3));
+ map.insert(b"x".to_vec(), PluginValue::Int(4));
+ assert_eq!(
+ serialize(&PluginValue::Array(map)),
+ b"a:4:{i:5;i:1;s:2:\"05\";i:2;s:2:\"-0\";i:3;s:1:\"x\";i:4;}".as_slice(),
+ );
+ }
+
+ #[test]
+ fn object_is_encode_only_and_collapses_to_array() {
+ let mut map: IndexMap<Vec<u8>, PluginValue> = IndexMap::new();
+ map.insert(b"a".to_vec(), PluginValue::Int(1));
+ let encoded = serialize(&PluginValue::Object(map.clone()));
+ assert_eq!(encoded, serialize(&PluginValue::Array(map.clone())));
+ assert_eq!(unserialize(&encoded).unwrap(), PluginValue::Array(map));
+ }
+
+ #[test]
+ fn roundtrips_composites() {
+ roundtrip(PluginValue::List(vec![
+ PluginValue::Null,
+ PluginValue::Bool(true),
+ PluginValue::Int(7),
+ PluginValue::Float(0.5),
+ PluginValue::String(vec![0x80, 0x81]),
+ ]));
+
+ let mut inner: IndexMap<Vec<u8>, PluginValue> = IndexMap::new();
+ inner.insert(vec![0xff, b'k'], PluginValue::string("v"));
+ inner.insert(b"10".to_vec(), PluginValue::List(vec![]));
+ roundtrip(PluginValue::Array(inner));
+ }
+
+ #[test]
+ fn roundtrips_handles() {
+ roundtrip(PluginValue::RustHandle(RustObjHandle {
+ rhandle: 3,
+ class: "Composer\\Script\\Event".to_string(),
+ epoch: 1,
+ snapshot: None,
+ }));
+ roundtrip(PluginValue::PhpHandle(PhpObjHandle {
+ phandle: 8,
+ class: "MyPlugin".to_string(),
+ implements: vec!["Composer\\Plugin\\PluginInterface".to_string()],
+ }));
+ roundtrip(PluginValue::PhpClass(PhpClassHandle {
+ class: "MyPlugin".to_string(),
+ }));
+ }
+
+ #[test]
+ fn decodes_sparse_int_keys_as_array_and_reencodes_identically() {
+ let wire = b"a:2:{i:5;i:1;i:0;i:2;}".as_slice();
+ let decoded = unserialize(wire).unwrap();
+ let PluginValue::Array(ref map) = decoded else {
+ panic!("expected an array, got {decoded:?}");
+ };
+ assert_eq!(map.get(b"5".as_slice()), Some(&PluginValue::Int(1)));
+ assert_eq!(serialize(&decoded), wire);
+ }
+
+ #[test]
+ fn rejects_over_deep_nesting() {
+ let mut payload = Vec::new();
+ for _ in 0..(MAX_DECODE_DEPTH + 2) {
+ payload.extend_from_slice(b"a:1:{i:0;");
+ }
+ payload.extend_from_slice(b"N;");
+ payload.extend(std::iter::repeat_n(b'}', MAX_DECODE_DEPTH + 2));
+ let err = unserialize(&payload).unwrap_err();
+ assert!(err.to_string().contains("nesting depth"), "{err}");
+ }
+
+ #[test]
+ fn rejects_trailing_garbage_and_truncation() {
+ assert!(unserialize(b"i:42;i:43;").is_err());
+ assert!(unserialize(b"s:5:\"ab\";").is_err());
+ assert!(unserialize(b"a:2:{i:0;i:1;}").is_err());
+ }
+
+ #[test]
+ fn php_mixed_conversions() {
+ let mixed = PhpMixed::Array(
+ [
+ ("a".to_string(), PhpMixed::Int(1)),
+ ("b".to_string(), PhpMixed::List(vec![PhpMixed::Null])),
+ ]
+ .into_iter()
+ .collect(),
+ );
+ let value = PluginValue::from_php_mixed(&mixed);
+ assert_eq!(value.to_php_mixed().unwrap(), mixed);
+
+ let handle = PluginValue::PhpClass(PhpClassHandle {
+ class: "X".to_string(),
+ });
+ assert!(handle.to_php_mixed().is_err());
+ }
+}
diff --git a/crates/shirabe-php-rpc/tests/oracle.rs b/crates/shirabe-php-rpc/tests/oracle.rs
new file mode 100644
index 00000000..4220788a
--- /dev/null
+++ b/crates/shirabe-php-rpc/tests/oracle.rs
@@ -0,0 +1,199 @@
+//! Oracle tests: the `PluginValue` codec against the real PHP `serialize()`/`unserialize()`.
+//!
+//! Encode direction: bytes produced by the Rust encoder are unserialized and re-serialized by
+//! the PHP core implementation; the result must be byte-identical. Decode direction: bytes
+//! produced by PHP `serialize()` must decode into a `PluginValue` whose re-encoding is
+//! byte-identical. Floats (serialize_precision), non-UTF-8 byte strings and deep nesting are the
+//! focus areas.
+
+use indexmap::IndexMap;
+use shirabe_external_packages::symfony::process::PhpExecutableFinder;
+use shirabe_php_rpc::value::{serialize, unserialize};
+use shirabe_php_rpc::{PluginValue, call_function};
+
+fn php_available() -> bool {
+ PhpExecutableFinder::new().find(false).is_some()
+}
+
+/// Feeds raw serialize() bytes through the PHP core codec and returns what PHP re-serializes.
+fn php_reserialize(bytes: &[u8]) -> Vec<u8> {
+ let outcome = call_function(
+ "__shirabe_oracle_roundtrip",
+ vec![PluginValue::String(bytes.to_vec())],
+ )
+ .expect("oracle roundtrip request failed");
+ match outcome.expect("oracle roundtrip threw") {
+ PluginValue::String(bytes) => bytes,
+ other => panic!("oracle roundtrip returned a non-string: {other:?}"),
+ }
+}
+
+/// Runs a PHP snippet and returns its `return` value.
+fn php_eval(code: &str) -> PluginValue {
+ call_function("__shirabe_eval", vec![PluginValue::string(code)])
+ .expect("eval request failed")
+ .expect("eval threw")
+}
+
+fn assert_php_agrees(value: &PluginValue) {
+ let encoded = serialize(value);
+ let reserialized = php_reserialize(&encoded);
+ assert_eq!(
+ String::from_utf8_lossy(&reserialized),
+ String::from_utf8_lossy(&encoded),
+ "PHP re-serialized {value:?} differently"
+ );
+}
+
+#[test]
+fn encode_direction_matches_php_for_scalars_and_floats() {
+ if !php_available() {
+ return;
+ }
+
+ let floats = [
+ 0.0,
+ -0.0,
+ 1.5,
+ 0.1,
+ 2.0,
+ -2.0,
+ 100.0,
+ 1e15,
+ 1e16,
+ 1e17,
+ 1e18,
+ 1e20,
+ 1.5e20,
+ 1e-4,
+ 1e-5,
+ 12345.6789e-9,
+ 1e-300,
+ f64::MAX,
+ 5e-324,
+ 1.0 / 3.0,
+ 0.30000000000000004,
+ f64::NAN,
+ f64::INFINITY,
+ f64::NEG_INFINITY,
+ ];
+ for f in floats {
+ assert_php_agrees(&PluginValue::Float(f));
+ }
+
+ for value in [
+ PluginValue::Null,
+ PluginValue::Bool(true),
+ PluginValue::Bool(false),
+ PluginValue::Int(0),
+ PluginValue::Int(i64::MAX),
+ PluginValue::Int(i64::MIN),
+ PluginValue::string(""),
+ PluginValue::string("héllo wörld"),
+ ] {
+ assert_php_agrees(&value);
+ }
+}
+
+#[test]
+fn encode_direction_matches_php_for_non_utf8_bytes() {
+ if !php_available() {
+ return;
+ }
+
+ assert_php_agrees(&PluginValue::String(vec![0xff, 0x00, 0xfe, 0x80, b'"']));
+ assert_php_agrees(&PluginValue::String((0u8..=255).collect()));
+
+ let mut map: IndexMap<Vec<u8>, PluginValue> = IndexMap::new();
+ map.insert(vec![0xff, 0x00], PluginValue::String(vec![0x80]));
+ map.insert(b"05".to_vec(), PluginValue::Bool(true));
+ map.insert(b"5".to_vec(), PluginValue::Null);
+ assert_php_agrees(&PluginValue::Array(map));
+}
+
+#[test]
+fn encode_direction_matches_php_for_nested_arrays() {
+ if !php_available() {
+ return;
+ }
+
+ let mut inner: IndexMap<Vec<u8>, PluginValue> = IndexMap::new();
+ inner.insert(b"a".to_vec(), PluginValue::Int(1));
+ inner.insert(
+ b"b".to_vec(),
+ PluginValue::List(vec![
+ PluginValue::Float(0.1),
+ PluginValue::string("x"),
+ PluginValue::List(vec![]),
+ ]),
+ );
+ assert_php_agrees(&PluginValue::Array(inner));
+
+ let mut deep = PluginValue::Null;
+ for _ in 0..64 {
+ deep = PluginValue::List(vec![deep]);
+ }
+ assert_php_agrees(&deep);
+}
+
+#[test]
+fn decode_direction_matches_php_serialize_output() {
+ if !php_available() {
+ return;
+ }
+
+ let snippets = [
+ // Mixed key types: PHP canonicalizes "5" to an int key, keeps "05" as a string.
+ r#"return serialize(["a" => 1, 5 => true, "05" => [1, 2, [0.5]], "z" => null]);"#,
+ // Non-UTF-8 byte strings, both as values and as keys.
+ "return serialize([\"\\xff\\x00key\" => \"\\x80\\x81\", 0 => \"plain\"]);",
+ // Floats straight from the PHP formatter.
+ r#"return serialize([0.1, 2.0, 1e17, 1e-5, -0.0, NAN, INF, -INF, 1/3]);"#,
+ // A sparse int-keyed array (not a list).
+ r#"return serialize([3 => "c", 1 => "a"]);"#,
+ // Deep nesting built in a loop.
+ r#"$v = "leaf"; for ($i = 0; $i < 256; $i++) { $v = [$v]; } return serialize($v);"#,
+ ];
+
+ for snippet in snippets {
+ let PluginValue::String(php_bytes) = php_eval(snippet) else {
+ panic!("snippet did not return a string: {snippet}");
+ };
+ let decoded = unserialize(&php_bytes)
+ .unwrap_or_else(|e| panic!("failed to decode PHP output for `{snippet}`: {e:#}"));
+ let reencoded = serialize(&decoded);
+ assert_eq!(
+ String::from_utf8_lossy(&reencoded),
+ String::from_utf8_lossy(&php_bytes),
+ "re-encoding diverged for `{snippet}`"
+ );
+ }
+}
+
+#[test]
+fn decode_direction_distinguishes_lists_from_maps() {
+ if !php_available() {
+ return;
+ }
+
+ let PluginValue::String(bytes) = php_eval(r#"return serialize([10, 20, 30]);"#) else {
+ panic!("expected serialized bytes");
+ };
+ assert_eq!(
+ unserialize(&bytes).unwrap(),
+ PluginValue::List(vec![
+ PluginValue::Int(10),
+ PluginValue::Int(20),
+ PluginValue::Int(30),
+ ]),
+ );
+
+ let PluginValue::String(bytes) = php_eval(r#"return serialize([1 => 10, 0 => 20]);"#) else {
+ panic!("expected serialized bytes");
+ };
+ let decoded = unserialize(&bytes).unwrap();
+ assert!(
+ matches!(decoded, PluginValue::Array(_)),
+ "out-of-order int keys must not decode as a list: {decoded:?}"
+ );
+}
diff --git a/crates/shirabe-php-src/Cargo.toml b/crates/shirabe-php-src/Cargo.toml
index e2028c40..ae73c313 100644
--- a/crates/shirabe-php-src/Cargo.toml
+++ b/crates/shirabe-php-src/Cargo.toml
@@ -3,6 +3,9 @@ name = "shirabe-php-src"
version.workspace = true
edition.workspace = true
license = "BSD-3-Clause AND Zlib"
+# The module tree mirrors php-src's own tree, so `src/main.rs` is the `main/` directory of
+# php-src, not a binary entry point.
+autobins = false
[lints]
workspace = true
diff --git a/crates/shirabe-php-src/src/lib.rs b/crates/shirabe-php-src/src/lib.rs
index 1e2c2a0f..a5682e24 100644
--- a/crates/shirabe-php-src/src/lib.rs
+++ b/crates/shirabe-php-src/src/lib.rs
@@ -1,5 +1,6 @@
//! Rust port from the original C implementation in php-src.
//! See `LICENSE.md` at the repository root.
+#![allow(special_module_name)]
//!
//! Rules for this crate:
//!
@@ -16,4 +17,9 @@
//! /// php-src: ext/standard/strnatcmp.c `strnatcmp_ex` (PHP 8.5.2)
//! ```
+// The module mirrors php-src's `main/` directory; it is not a binary entry point (autobins is
+// disabled in Cargo.toml). `special_module_name` is allowed crate-wide because the item-level
+// attribute does not reach this early-pass lint.
+pub mod main;
pub mod standard;
+pub mod zend;
diff --git a/crates/shirabe-php-src/src/main.rs b/crates/shirabe-php-src/src/main.rs
new file mode 100644
index 00000000..d2bbafb0
--- /dev/null
+++ b/crates/shirabe-php-src/src/main.rs
@@ -0,0 +1 @@
+pub mod snprintf;
diff --git a/crates/shirabe-php-src/src/main/snprintf.rs b/crates/shirabe-php-src/src/main/snprintf.rs
new file mode 100644
index 00000000..f1411d6e
--- /dev/null
+++ b/crates/shirabe-php-src/src/main/snprintf.rs
@@ -0,0 +1,83 @@
+/// php-src: main/snprintf.c `php_gcvt` (PHP 8.5.8)
+///
+/// Only the `ndigit < 0` path (dtoa mode 0, the shortest round-trip representation used when
+/// `serialize_precision=-1`) is ported; the fixed-precision mode 2 path is not needed yet.
+/// The digit extraction delegates to Rust's own shortest round-trip float formatting, which
+/// produces the same digit string as `zend_dtoa` in mode 0 (both compute the unique shortest
+/// decimal that round-trips), so only the digit placement logic is ported here.
+pub fn php_gcvt(value: f64, ndigit: i32, dec_point: char, exponent: char) -> String {
+ let mode = if ndigit >= 0 { 2 } else { 0 };
+ if mode != 0 {
+ todo!("php_gcvt is only ported for ndigit < 0 (serialize_precision=-1)");
+ }
+ let ndigit = 17i32;
+
+ let (sign, digits, decpt) = dtoa_shortest(value);
+
+ let mut buf = String::new();
+ if sign {
+ buf.push('-');
+ }
+
+ if if decpt < 0 {
+ decpt < -3
+ } else {
+ decpt > ndigit
+ } {
+ // exponential format (e.g. 1.0E+17)
+ let exp = decpt - 1;
+ let mut chars = digits.chars();
+ buf.push(chars.next().expect("dtoa always yields at least one digit"));
+ buf.push(dec_point);
+ let rest = chars.as_str();
+ if rest.is_empty() {
+ buf.push('0');
+ } else {
+ buf.push_str(rest);
+ }
+ buf.push(exponent);
+ if exp < 0 {
+ buf.push('-');
+ } else {
+ buf.push('+');
+ }
+ buf.push_str(&exp.abs().to_string());
+ } else if decpt > 0 {
+ // standard format, integer part present
+ let decpt = decpt as usize;
+ if digits.len() <= decpt {
+ buf.push_str(&digits);
+ for _ in digits.len()..decpt {
+ buf.push('0');
+ }
+ } else {
+ buf.push_str(&digits[..decpt]);
+ buf.push(dec_point);
+ buf.push_str(&digits[decpt..]);
+ }
+ } else {
+ // standard format, 0.000ddd
+ buf.push('0');
+ buf.push(dec_point);
+ for _ in decpt..0 {
+ buf.push('0');
+ }
+ buf.push_str(&digits);
+ }
+
+ buf
+}
+
+/// php-src: Zend/zend_strtod.c `zend_dtoa` mode 0 equivalent: the shortest round-trip digit
+/// string of `|value|`, its sign, and the decimal point position (`value = 0.digits * 10^decpt`).
+/// Implemented on top of Rust's `{:e}` formatting, which is also shortest-round-trip.
+fn dtoa_shortest(value: f64) -> (bool, String, i32) {
+ let sign = value.is_sign_negative();
+ let formatted = format!("{:e}", value.abs());
+ let (mantissa, exp) = formatted
+ .split_once('e')
+ .expect("`{:e}` always contains an exponent");
+ let digits: String = mantissa.chars().filter(|c| *c != '.').collect();
+ let exp: i32 = exp.parse().expect("`{:e}` exponent is a decimal integer");
+ (sign, digits, exp + 1)
+}
diff --git a/crates/shirabe-php-src/src/zend.rs b/crates/shirabe-php-src/src/zend.rs
new file mode 100644
index 00000000..a11d8e55
--- /dev/null
+++ b/crates/shirabe-php-src/src/zend.rs
@@ -0,0 +1 @@
+pub mod zend_smart_str;
diff --git a/crates/shirabe-php-src/src/zend/zend_smart_str.rs b/crates/shirabe-php-src/src/zend/zend_smart_str.rs
new file mode 100644
index 00000000..b4a9f2ec
--- /dev/null
+++ b/crates/shirabe-php-src/src/zend/zend_smart_str.rs
@@ -0,0 +1,73 @@
+use crate::main::snprintf::php_gcvt;
+
+/// php-src: Zend/zend_smart_str.c `smart_str_append_double` (PHP 8.5.8), folding in the `%H`
+/// NAN/INF handling from main/snprintf.c `format_converter` that the original reaches through
+/// `snprintf(buf, sizeof(buf), "%.*H", precision, num)`.
+pub fn smart_str_append_double(dest: &mut String, num: f64, precision: i32, zero_fraction: bool) {
+ if num.is_nan() {
+ dest.push_str("NAN");
+ return;
+ }
+ if num.is_infinite() {
+ dest.push_str(if num > 0.0 { "INF" } else { "-INF" });
+ return;
+ }
+ let buf = php_gcvt(num, precision, '.', 'E');
+ let had_period = buf.contains('.');
+ dest.push_str(&buf);
+ if zero_fraction && !had_period {
+ dest.push_str(".0");
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn serialize_repr(num: f64) -> String {
+ let mut out = String::new();
+ smart_str_append_double(&mut out, num, -1, false);
+ out
+ }
+
+ // Expected strings are the output of `serialize()` under PHP 8.5.8 with
+ // serialize_precision=-1 (without the `d:`/`;` wrapper).
+ #[test]
+ fn matches_php_serialize_output() {
+ assert_eq!(serialize_repr(0.0), "0");
+ assert_eq!(serialize_repr(-0.0), "-0");
+ assert_eq!(serialize_repr(1.5), "1.5");
+ assert_eq!(serialize_repr(0.1), "0.1");
+ assert_eq!(serialize_repr(2.0), "2");
+ assert_eq!(serialize_repr(-2.0), "-2");
+ assert_eq!(serialize_repr(100.0), "100");
+ assert_eq!(serialize_repr(1e15), "1000000000000000");
+ assert_eq!(serialize_repr(1e16), "10000000000000000");
+ assert_eq!(serialize_repr(1e17), "1.0E+17");
+ assert_eq!(serialize_repr(1e18), "1.0E+18");
+ assert_eq!(serialize_repr(1e20), "1.0E+20");
+ assert_eq!(serialize_repr(1.5e20), "1.5E+20");
+ assert_eq!(serialize_repr(1e-4), "0.0001");
+ assert_eq!(serialize_repr(1e-5), "1.0E-5");
+ assert_eq!(serialize_repr(12345.6789e-9), "1.23456789E-5");
+ assert_eq!(serialize_repr(1e-300), "1.0E-300");
+ assert_eq!(serialize_repr(f64::MAX), "1.7976931348623157E+308");
+ assert_eq!(serialize_repr(5e-324), "5.0E-324");
+ assert_eq!(serialize_repr(1.0 / 3.0), "0.3333333333333333");
+ assert_eq!(serialize_repr(0.30000000000000004), "0.30000000000000004");
+ assert_eq!(serialize_repr(f64::NAN), "NAN");
+ assert_eq!(serialize_repr(f64::INFINITY), "INF");
+ assert_eq!(serialize_repr(f64::NEG_INFINITY), "-INF");
+ }
+
+ #[test]
+ fn zero_fraction_appends_dot_zero_to_integral_values() {
+ let mut out = String::new();
+ smart_str_append_double(&mut out, 2.0, -1, true);
+ assert_eq!(out, "2.0");
+
+ let mut out = String::new();
+ smart_str_append_double(&mut out, 1e17, -1, true);
+ assert_eq!(out, "1.0E+17");
+ }
+}
diff --git a/docs/dev/php-rpc.md b/docs/dev/php-rpc.md
index 6e0bc40c..b24f72d9 100644
--- a/docs/dev/php-rpc.md
+++ b/docs/dev/php-rpc.md
@@ -1,66 +1,132 @@
# PHP RPC
-Composer can require a specific PHP version or loaded extensions, i.e., platform requirements.
-To mimic this behavior needs a real PHP runtime.
+Composer can require a specific PHP version or loaded extensions, i.e., platform requirements,
+and its plugin/script machinery executes real PHP code. To mimic this behavior needs a real PHP
+runtime.
-This document describes a first design of PHP runtime: a `shirabe-php-rpc` crate that spawns the
-system PHP as a child process and asks it for runtime information over a Unix domain socket.
+The `shirabe-php-rpc` crate spawns the system PHP as a child process and talks to it over a Unix
+domain socket. There is exactly one child process per Shirabe process, shared by every caller;
+it hosts both the simple runtime queries (`get_php_version`, `has_constant`, ...) and the plugin
+protocol.
-## Scope
+## Locating PHP
-The crate supports exactly one interaction pattern, and nothing else:
+The existing `PhpExecutableFinder` class resolves the PHP binary. The child is started with
+`-d serialize_precision=-1` so the wire codec's float formatting is pinned to the default PHP
+behavior.
-> Rust calls a named PHP function, passing a single string argument, and receives a single value
-> back.
+## Transport
-- Rust to PHP only. PHP never calls back into Rust.
-- Exactly one argument, and it must be a string.
-- Return values are scalars (string / int / float / bool / null) or arrays of them.
-- Every failure `panic!`s: a PHP exception (not currently possible — the glue script never lets
- one escape), a serialization/deserialization failure, a missing PHP function, a crashed child,
- etc. None are handled as recoverable errors. This is not the final design — a future revision
- will replace these panics with proper `Result` propagation — but panicking beats silently
- returning a plausible-looking default.
-- Windows is unsupported and `panic!`s for now.
+- A Unix domain socket (no Windows support for now), bound in a `0700` temp dir with the socket
+ file itself chmodded to `0600`.
+- The PHP glue code (`php/worker.php`) and the proxy stub classes (`php/stubs/`) are embedded in
+ the Rust binary and written to the temp dir at spawn time, so both halves of the protocol are
+ always the same commit.
-## Locating PHP
+### Frame layout
-Reuse the existing `PhpExecutableFinder` class to resolve the PHP binary.
+```
+[u64 length LE] -- number of bytes that follow (1 + 8 + payload length)
+[u8 tag] -- message tag, see below
+[u64 corr_id LE] -- correlation id; 0 for one-way notifications
+[payload] -- the remaining fields as one PHP-serialize()d list
+```
-## Transport
+`length` is validated against `MAX_FRAME_LEN` (256 MiB) before any allocation; an oversized
+frame is a fatal channel error, not an allocation attempt.
+
+### Message tags
+
+| tag | name | direction | payload fields |
+|---|---|---|---|
+| `0x00` | `CallFunction` | Rust→PHP | `function_name`, `args`, `out_param_positions` |
+| `0x01` | `CallStaticMethod` | Rust→PHP | `pclass`, `method_name`, `args`, `out_param_positions` |
+| `0x02` | `NewObject` | Rust→PHP | `pclass`, `ctor_args` |
+| `0x03` | `CallPhpMethod` | Rust→PHP | `phandle`, `method_name`, `args`, `out_param_positions` |
+| `0x04` | `CallRustMethod` | PHP→Rust | `rhandle`, `method_name`, `args`, `out_param_positions` |
+| `0x05` | `Return` | both | `value`, `out_params` |
+| `0x06` | `Throw` | both | `exception_class`, `message`, `code` |
+| `0x07` | `ReleaseRustHandle` | PHP→Rust | `rhandle` |
+| `0x08` | `ReleasePhpHandle` | Rust→PHP | `phandle` |
+| `0x09` | `EpochBump` | Rust→PHP | `rhandle`, `epoch` |
+
+The Rust side allocates odd correlation ids, the PHP side even ones. `NewObject` and
+`CallPhpMethod` are protocol receptacles: the worker currently answers them with an explicit
+`Throw` (the P table is not implemented yet).
+
+### Values: `PluginValue` and the codec
+
+Payloads are encoded with a Rust reimplementation of the PHP `serialize()` grammar
+(`src/value.rs`), byte-compatible with the PHP core implementation under
+`serialize_precision=-1` (the float formatting itself is ported in `shirabe-php-src`). The value
+model is `PluginValue`: PHP scalars, byte strings (`Vec<u8>` — non-UTF-8 round-trips
+losslessly), lists, ordered maps, and three handle descriptor kinds encoded as reserved arrays:
+
+- `{__rhandle, __class, __epoch[, __snapshot]}` — entity lives on the Rust side
+- `{__phandle, __class, __implements}` — entity lives in the PHP child
+- `{__pclass}` — a PHP class name
+
+`PluginValue::Object` is encode-only: the wire erases the array/object distinction and object
+revival is banned (`unserialize(..., ['allowed_classes' => false])` is enforced on the PHP
+side), so the decoder only produces `List` (contiguous 0-based int keys) or `Array`. The
+decoder is iterative (input nesting never becomes call-stack depth) and additionally rejects
+payloads nested deeper than 512 levels.
+
+The codec is verified against the real PHP `serialize()`/`unserialize()` by oracle tests
+(`tests/oracle.rs`), with floats, non-UTF-8 byte strings and deep nesting as focus areas.
+
+## Concurrency and reentrancy
+
+A logical call session is serialized by a thread-ID based reentrant session lock
+(`src/session.rs`): the owning thread may nest calls freely (a `CallRustMethod` handler can
+itself call back into PHP), while other OS threads block until the whole outer session
+completes. This keeps the single-child-process invariant under `cargo test`'s parallel harness.
+The worker mutex itself is only held per frame, not across a call.
+
+While a Rust-initiated call waits for its `Return`, incoming `CallRustMethod` frames are
+dispatched to the caller-supplied `RustMethodDispatcher` (the cooperative loop); with no
+dispatcher active they are answered with an explicit `Throw`, never a silent null. Rust handle
+0 is reserved for the runtime service endpoint (e.g. `__shirabe_find_file`, which the worker's
+script-class autoloader uses to ask the Rust-side `ClassLoader` where a class file lives).
+
+The PHP side mirrors this: its top level is a standing serve loop, and `callRust` drives the
+same dispatch while waiting for its own `Return`.
-- A Unix domain socket. (No Windows support for now)
-- The PHP glue code is a small script written to a temporary file.
-- Message frame: `[usize length (little-endian)][payload]`.
- - Request payload: the PHP function name as raw bytes, followed by a `\0` byte and the string
- argument (function names are static literals and never contain `\0`, so the first `\0`
- unambiguously separates name from argument).
- - Response payload: `serialize()` of the function's return value.
+## Failure model
-The PHP worker is a single read-eval-respond loop: read a framed function name and argument, call
-the matching entry in a fixed dispatch table (`defined`, `constant`), send back
-`serialize($result)`.
+- The outer `anyhow::Result` of `call_function`/`call_static_method` is the fatal lane: dead
+ worker (EOF, with the child's exit status attached as context), broken framing, oversized
+ frames.
+- The inner `Result<PluginValue, PhpThrow>` is the recoverable lane: a PHP exception crossing
+ the boundary as a `Throw` frame.
+- A frame that decodes to something protocol-invalid is a bug in Shirabe itself (both halves
+ ship in the same commit) and panics; the PHP side symmetrically dies so Rust observes EOF.
+- The legacy scalar query API (`get_php_version` etc.) keeps its historical contract: every
+ failure panics.
-## Global state and public API
+## Worker dispatch table
-PHP runtime information (e.g., process handle) is held as process-global state
-rather than threaded through call sites for now.
-The crate exposes plain free functions. For example:
+`CallFunction` first consults the worker's dispatch table (composite queries like `diagnose`,
+Shirabe-internal helpers prefixed `__shirabe_`), then falls back to calling the named PHP
+function; an unknown name is an explicit error. Notable internal helpers:
-* get_php_version()
-* has_constant()
-* get_constant()
+- `__shirabe_eval` — runs a Rust-generated PHP snippet and returns its `return` value (used by
+ the `scripts` Command-class execution path).
+- `__shirabe_require` — `require_once` a file (e.g. an autoloader) into the worker.
+- `__shirabe_enable_script_autoloader` — registers the autoloader that resolves classes through
+ the Rust-side `ClassLoader` via handle 0.
+- `__shirabe_oracle_roundtrip` — codec oracle support for tests.
-The connection is a process-global `static` (e.g. `OnceLock<Mutex<Worker>>`), lazily initialized on
-the first call: the first call spawns the child, performs the handshake, and caches the connection.
-Commands that never query PHP never start it. The child lives for the rest of the process and is
-left to be reaped at exit (no explicit shutdown message).
+## Proxy stubs
-A future revision threads this runtime information through arguments or embeds it in structs; for now
-callers just reach for the global getter.
+`php/stubs/` holds hand-written proxy stub classes (currently `Composer\EventDispatcher\Event`
+and `Composer\Script\Event`), written in the shape the future stub generator will output. They
+are autoloaded with highest priority so a proxied FQCN can never be shadowed by the real
+implementation. Stubs are interned per rhandle (`WeakReference`-based registry) so identity
+(`===`) holds, and their destructors send `ReleaseRustHandle`.
-## Out of scope
+## Out of scope (deferred)
-Deferred things: multiple/non-string arguments, non-scalar return values, PHP to Rust callbacks
-and re-entrancy, object handles / proxies / identity, stub generation, error
-propagation, GC / lifecycle, and Windows support.
+The P table (PHP-owned objects crossing to Rust), `NewObject`/`CallPhpMethod` execution,
+out-parameter write-back at the call sites, epoch-based cache invalidation on the PHP side,
+error-class reconstruction across the boundary, and Windows support.