From 2f28d8112970960dbb9b6b582a3c6cd259337d21 Mon Sep 17 00:00:00 2001 From: nsfisis Date: Mon, 3 Aug 2026 01:05:57 +0900 Subject: feat(php-rpc): rework the RPC channel into the tagged plugin protocol Replace the name\0arg framing with the plugin wire protocol: tagged frames with corr_id multiplexing, a MAX_FRAME_LEN bound, a thread-ID based reentrant SessionLock, and the PluginValue codec (encoder plus the first recursive decoder, iterative with a 512-level depth cap). Float formatting is ported from php-src into shirabe-php-src so the encoder is byte-compatible with serialize() under serialize_precision=-1, which the spawned worker now pins. The worker gains a standing dispatch loop, CallRustMethod reentrancy, hand-written Event proxy stubs, and explicit-error answers for everything not implemented yet. The public query API (get_php_version and friends) is unchanged and now rides the new protocol; the codec is verified against real PHP by roundtrip oracle tests covering floats, non-UTF-8 bytes and deep nesting. Co-Authored-By: Claude Fable 5 --- crates/shirabe-php-rpc/Cargo.toml | 1 + .../php/stubs/Composer/EventDispatcher/Event.php | 59 ++ .../php/stubs/Composer/Script/Event.php | 37 ++ crates/shirabe-php-rpc/php/worker.php | 407 ++++++++++-- crates/shirabe-php-rpc/src/frame.rs | 462 +++++++++++++ crates/shirabe-php-rpc/src/lib.rs | 547 ++++++++-------- crates/shirabe-php-rpc/src/session.rs | 110 ++++ crates/shirabe-php-rpc/src/value.rs | 723 +++++++++++++++++++++ crates/shirabe-php-rpc/tests/oracle.rs | 199 ++++++ 9 files changed, 2210 insertions(+), 335 deletions(-) create mode 100644 crates/shirabe-php-rpc/php/stubs/Composer/EventDispatcher/Event.php create mode 100644 crates/shirabe-php-rpc/php/stubs/Composer/Script/Event.php create mode 100644 crates/shirabe-php-rpc/src/frame.rs create mode 100644 crates/shirabe-php-rpc/src/session.rs create mode 100644 crates/shirabe-php-rpc/src/value.rs create mode 100644 crates/shirabe-php-rpc/tests/oracle.rs (limited to 'crates/shirabe-php-rpc') 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 @@ +__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 @@ +__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 @@ */ + 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 */ + 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, + out_param_positions: Vec, + }, + CallStaticMethod { + corr_id: u64, + pclass: String, + method_name: String, + args: Vec, + out_param_positions: Vec, + }, + NewObject { + corr_id: u64, + pclass: String, + ctor_args: Vec, + }, + CallPhpMethod { + corr_id: u64, + phandle: u64, + method_name: String, + args: Vec, + out_param_positions: Vec, + }, + CallRustMethod { + corr_id: u64, + rhandle: u64, + method_name: String, + args: Vec, + out_param_positions: Vec, + }, + Return { + corr_id: u64, + value: PluginValue, + out_params: IndexMap, + }, + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { } } +/// 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, + out_param_positions: &[u32], + ) -> Result; +} + +/// 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, +) -> anyhow::Result> { + call_function_with_dispatcher(name, args, None) +} + +pub fn call_function_with_dispatcher( + name: &str, + args: Vec, + dispatcher: Option<&mut dyn RustMethodDispatcher>, +) -> anyhow::Result> { + 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, + dispatcher: Option<&mut dyn RustMethodDispatcher>, +) -> anyhow::Result> { + 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> { + // 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> { - 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> = LazyLock::new(|| { Mutex::new( @@ -338,16 +535,23 @@ static WORKER: LazyLock> = 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 { + 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 { @@ -360,13 +564,33 @@ fn spawn_worker() -> anyhow::Result { 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,279 +617,34 @@ fn spawn_worker() -> anyhow::Result { }) } -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> { - 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 { - 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:;`, `d:;`, `s::"";`, `a::{...}`. -fn parse_value(payload: &[u8], pos: &mut usize) -> Option { - 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 `:"";` tail of a serialized string. -fn parse_string_body(payload: &[u8], pos: &mut usize) -> Option { - 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 `:{...}` 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 { - 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 = 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 { - 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}" - ); - } - - #[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)) + // 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_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 = [ - ("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 = [("curl".to_string(), PhpMixed::Bool(false))] - .into_iter() - .collect(); - let expected: IndexMap = [ - ("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() { @@ -705,20 +684,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() { 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>, + 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 = 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`), 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, 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, +} + +/// 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), + List(Vec), + Array(IndexMap, PluginValue>), + Object(IndexMap, PluginValue>), + RustHandle(RustObjHandle), + PhpHandle(PhpObjHandle), + PhpClass(PhpClassHandle), +} + +impl PluginValue { + pub fn string(s: impl Into) -> 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 { + 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::>()?, + ), + 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::>()?, + ), + 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 { + let mut out = Vec::new(); + serialize_into(value, &mut out); + out +} + +fn serialize_into(value: &PluginValue, out: &mut Vec) { + 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, 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, 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, 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) { + 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, PluginValue>, out: &mut Vec) { + 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 { + 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 { + 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, PluginValue>, + count: usize, + parsed: usize, + is_list: bool, + pending_key: Option>, +} + +fn parse_value(payload: &[u8], pos: &mut usize) -> anyhow::Result { + let mut stack: Vec = Vec::new(); + let mut completed: Option = 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 { + 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 { + 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> { + 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, PluginValue>) -> anyhow::Result> { + 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::>()?, + 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 { + 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 { + 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::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, 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, 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, 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 { + 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, 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, 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:?}" + ); +} -- cgit v1.3.1