aboutsummaryrefslogtreecommitdiffhomepage
path: root/crates/shirabe-php-rpc/src
diff options
context:
space:
mode:
authornsfisis <nsfisis@gmail.com>2026-08-03 01:05:57 +0900
committernsfisis <nsfisis@gmail.com>2026-08-03 01:05:57 +0900
commit2f28d8112970960dbb9b6b582a3c6cd259337d21 (patch)
treeaf8bc223b3af9098e6925cbf6c47e198459ac818 /crates/shirabe-php-rpc/src
parentbf2c6fa58ae51f44fa0ec65c615f8e62de87812c (diff)
downloadphp-shirabe-2f28d8112970960dbb9b6b582a3c6cd259337d21.tar.gz
php-shirabe-2f28d8112970960dbb9b6b582a3c6cd259337d21.tar.zst
php-shirabe-2f28d8112970960dbb9b6b582a3c6cd259337d21.zip
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 <noreply@anthropic.com>
Diffstat (limited to 'crates/shirabe-php-rpc/src')
-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
4 files changed, 1551 insertions, 291 deletions
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());
+ }
+}