aboutsummaryrefslogtreecommitdiffhomepage
path: root/crates/shirabe-php-rpc
diff options
context:
space:
mode:
Diffstat (limited to 'crates/shirabe-php-rpc')
-rw-r--r--crates/shirabe-php-rpc/php/runtime/Shirabe/MaterializedValue.php98
-rw-r--r--crates/shirabe-php-rpc/php/worker.php22
-rw-r--r--crates/shirabe-php-rpc/src/lib.rs2
-rw-r--r--crates/shirabe-php-rpc/src/value.rs290
-rw-r--r--crates/shirabe-php-rpc/tests/oracle.rs79
5 files changed, 361 insertions, 130 deletions
diff --git a/crates/shirabe-php-rpc/php/runtime/Shirabe/MaterializedValue.php b/crates/shirabe-php-rpc/php/runtime/Shirabe/MaterializedValue.php
index eee48266..c16b4e18 100644
--- a/crates/shirabe-php-rpc/php/runtime/Shirabe/MaterializedValue.php
+++ b/crates/shirabe-php-rpc/php/runtime/Shirabe/MaterializedValue.php
@@ -3,10 +3,12 @@
// The PHP half of the materialized-value codec (crates/shirabe/src/plugin/php_plugin_value.rs).
// An object whose entity lives on the Rust side crosses as a handle, but an immutable value
// has no entity to point at: the child holds a genuine instance of the real class instead, and
-// the wire carries the class name plus the constructor arguments needed to rebuild it.
+// the wire carries the object record `serialize()` writes for it, which `unserialize()` revives
+// without running a constructor.
//
-// Only the classes listed in CLASSES cross this way. An unknown class name is an explicit
-// error rather than a `new $class`, so the descriptor can never name an arbitrary class.
+// CLASSES is the whole vocabulary that may cross this way: it is both the set of classes handed
+// to `serialize()` in place of a handle descriptor, and the `allowed_classes` list every frame
+// payload is unserialized under, so a payload can never name another class.
namespace Shirabe;
@@ -18,7 +20,7 @@ use Composer\Semver\Constraint\MultiConstraint;
final class MaterializedValue
{
- private const CLASSES = [
+ public const CLASSES = [
Link::class,
Constraint::class,
MultiConstraint::class,
@@ -29,86 +31,22 @@ final class MaterializedValue
];
/**
- * @param array{__pnew: string, __args?: array, __calls?: array} $descriptor
+ * The object to serialize onto the wire in place of $value, or null when the Rust side has
+ * no value to rebuild it as (it then crosses as a P-table entity).
*/
- public static function build(array $descriptor): object
+ public static function forWire(object $value): ?object
{
- $class = $descriptor['__pnew'];
- if (!\in_array($class, self::CLASSES, true)) {
- throw new \RuntimeException(
- "the class {$class} cannot be materialized in the plugin runtime"
- );
- }
- $value = new $class(...array_values($descriptor['__args'] ?? []));
- foreach ($descriptor['__calls'] ?? [] as [$method, $args]) {
- $value->$method(...array_values($args));
- }
- return $value;
- }
-
- /**
- * The descriptor for a value the Rust side rebuilds by value, or null when the object is
- * not one of them (it then crosses as a P-table entity).
- *
- * @return ?array{__pnew: string, __args: array, __calls?: array}
- */
- public static function describe(object $value): ?array
- {
- if ($value instanceof Link) {
- return [
- '__pnew' => Link::class,
- '__args' => [
- self::field($value, 'source'),
- self::field($value, 'target'),
- $value->getConstraint(),
- self::field($value, 'description'),
- // Not getPrettyConstraint(): that throws when the link was built without
- // one, and an absent pretty constraint has to cross as absent.
- self::field($value, 'prettyConstraint'),
- ],
- ];
- }
- if ($value instanceof Constraint) {
- return self::constraint($value, [$value->getOperator(), $value->getVersion()]);
- }
- if ($value instanceof MultiConstraint) {
- return self::constraint($value, [$value->getConstraints(), $value->isConjunctive()]);
- }
- if ($value instanceof MatchAllConstraint || $value instanceof MatchNoneConstraint) {
- return self::constraint($value, []);
- }
if ($value instanceof \DateTimeInterface) {
- return [
- '__pnew' => $value instanceof \DateTime ? \DateTime::class : \DateTimeImmutable::class,
- // DATE_ATOM widened by the microseconds a PHP date carries, so the instant
- // crosses at the full precision this side can represent.
- '__args' => [$value->format('Y-m-d\TH:i:s.uP')],
- ];
+ // The Rust side holds instants in UTC and carries no timezone database, so the date
+ // crosses rebased on UTC. Going through the offset rather than the zone keeps the
+ // instant exact across an ambiguous wall clock, and drops any subclass a plugin
+ // brought along.
+ return (new \DateTimeImmutable($value->format('Y-m-d H:i:s.uP')))
+ ->setTimezone(new \DateTimeZone('UTC'));
}
- return null;
- }
- /**
- * No constraint constructor takes the pretty string, and whether it was ever set is
- * observable through getPrettyString(), so it travels as a post-construction call.
- *
- * @param list<mixed> $args
- * @return array{__pnew: string, __args: list<mixed>, __calls: list<array{string, list<mixed>}>}
- */
- private static function constraint(object $value, array $args): array
- {
- return [
- '__pnew' => \get_class($value),
- '__args' => $args,
- '__calls' => [['setPrettyString', [self::field($value, 'prettyString')]]],
- ];
- }
-
- /** Reads a protected field these value classes expose no getter for. */
- private static function field(object $value, string $name)
- {
- $property = new \ReflectionProperty($value, $name);
- (\PHP_VERSION_ID < 80100) and $property->setAccessible(true);
- return $property->getValue($value);
+ // Not instanceof: a subclass has state and behaviour of its own that no Rust value
+ // carries, so it crosses as an entity instead.
+ return \in_array(\get_class($value), self::CLASSES, true) ? $value : null;
}
}
diff --git a/crates/shirabe-php-rpc/php/worker.php b/crates/shirabe-php-rpc/php/worker.php
index 092f535f..15b38770 100644
--- a/crates/shirabe-php-rpc/php/worker.php
+++ b/crates/shirabe-php-rpc/php/worker.php
@@ -227,9 +227,11 @@ final class ShirabeRpcRuntime
// A natively-constructed dual-mode instance falls through to the P table below.
}
if (is_object($value)) {
- $materialized = \Shirabe\MaterializedValue::describe($value);
+ // A materialized value needs no descriptor: it crosses as the object record
+ // serialize() writes for it, which the Rust side decodes into its own value.
+ $materialized = \Shirabe\MaterializedValue::forWire($value);
if ($materialized !== null) {
- return array_map([self::class, 'toWire'], $materialized);
+ return $materialized;
}
return ShirabePhpObjectRegistry::descriptor($value);
}
@@ -242,7 +244,10 @@ final class ShirabeRpcRuntime
return $value;
}
- /** Converts a decoded wire value: handle descriptor arrays become live objects. */
+ /**
+ * Converts a decoded wire value: handle descriptor arrays become live objects. A
+ * materialized value arrives as a real instance already, revived by unserialize().
+ */
public static function fromWire($value)
{
if (!is_array($value)) {
@@ -261,9 +266,6 @@ final class ShirabeRpcRuntime
if (isset($value['__pclass']) && count($value) === 1) {
return $value['__pclass'];
}
- if (isset($value['__pnew'])) {
- return \Shirabe\MaterializedValue::build(array_map([self::class, 'fromWire'], $value));
- }
return array_map([self::class, 'fromWire'], $value);
}
@@ -287,7 +289,7 @@ final class ShirabeRpcRuntime
if ($inId !== $corrId) {
self::fail("protocol violation: response for unexpected corr_id {$inId}");
}
- $fields = unserialize($payload, ['allowed_classes' => false]);
+ $fields = unserialize($payload, ['allowed_classes' => \Shirabe\MaterializedValue::CLASSES]);
if (!is_array($fields)) {
self::fail('protocol violation: unparseable response payload');
}
@@ -314,7 +316,7 @@ final class ShirabeRpcRuntime
public static function dispatchRequest(int $tag, int $corrId, string $payload): void
{
- $fields = unserialize($payload, ['allowed_classes' => false]);
+ $fields = unserialize($payload, ['allowed_classes' => \Shirabe\MaterializedValue::CLASSES]);
if (!is_array($fields)) {
self::fail('protocol violation: unparseable frame payload');
}
@@ -588,7 +590,9 @@ ShirabeRpcRuntime::$dispatch = [
// 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_oracle_roundtrip' => static fn($args) => serialize(
+ unserialize($args[0], ['allowed_classes' => \Shirabe\MaterializedValue::CLASSES])
+ ),
'__shirabe_require' => static function ($args) {
require_once $args[0];
// The required file may have registered further prepending autoloaders (a Composer
diff --git a/crates/shirabe-php-rpc/src/lib.rs b/crates/shirabe-php-rpc/src/lib.rs
index 0c697e63..69790695 100644
--- a/crates/shirabe-php-rpc/src/lib.rs
+++ b/crates/shirabe-php-rpc/src/lib.rs
@@ -4,7 +4,7 @@ pub mod frame;
pub mod session;
pub mod value;
-pub use value::{PhpClassHandle, PhpObjHandle, PluginValue, RustObjHandle};
+pub use value::{PhpClassHandle, PhpObjHandle, PhpObject, PluginValue, RustObjHandle};
use frame::Frame;
use indexmap::IndexMap;
diff --git a/crates/shirabe-php-rpc/src/value.rs b/crates/shirabe-php-rpc/src/value.rs
index e05ba6db..c19c386f 100644
--- a/crates/shirabe-php-rpc/src/value.rs
+++ b/crates/shirabe-php-rpc/src/value.rs
@@ -36,11 +36,55 @@ pub struct PhpClassHandle {
pub class: String,
}
-/// The value model of the plugin RPC boundary: PHP scalars, arrays, and handle descriptors.
+/// The `O:` record of a serialized PHP object: a class name and a property table, with the
+/// property names carrying PHP's visibility mangling (hence the accessors below rather than
+/// bare names).
+#[derive(Debug, Clone, PartialEq)]
+pub struct PhpObject {
+ pub class: String,
+ pub props: IndexMap<Vec<u8>, PluginValue>,
+}
+
+impl PhpObject {
+ pub fn new(class: impl Into<String>) -> PhpObject {
+ PhpObject {
+ class: class.into(),
+ props: IndexMap::new(),
+ }
+ }
+
+ /// A public property keeps its declared name.
+ pub fn public(&self, name: &str) -> Option<&PluginValue> {
+ self.props.get(name.as_bytes())
+ }
+
+ pub fn set_public(&mut self, name: &str, value: PluginValue) {
+ self.props.insert(name.as_bytes().to_vec(), value);
+ }
+
+ /// PHP mangles a protected property name to `\0*\0name`.
+ pub fn protected(&self, name: &str) -> Option<&PluginValue> {
+ self.props.get(protected_key(name).as_slice())
+ }
+
+ pub fn set_protected(&mut self, name: &str, value: PluginValue) {
+ self.props.insert(protected_key(name), value);
+ }
+}
+
+fn protected_key(name: &str) -> Vec<u8> {
+ let mut key = b"\0*\0".to_vec();
+ key.extend_from_slice(name.as_bytes());
+ key
+}
+
+/// The value model of the plugin RPC boundary: PHP scalars, arrays, object records, 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.
+/// `Array`. An encoded `Object` lands on the PHP side as a plain array. A class-tagged object
+/// record is `PhpObject` instead, and does round-trip.
#[derive(Debug, Clone, PartialEq)]
pub enum PluginValue {
Null,
@@ -51,6 +95,7 @@ pub enum PluginValue {
List(Vec<PluginValue>),
Array(IndexMap<Vec<u8>, PluginValue>),
Object(IndexMap<Vec<u8>, PluginValue>),
+ PhpObject(PhpObject),
RustHandle(RustObjHandle),
PhpHandle(PhpObjHandle),
PhpClass(PhpClassHandle),
@@ -108,8 +153,13 @@ impl PluginValue {
.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:?}")
+ PluginValue::PhpObject(_)
+ | PluginValue::RustHandle(_)
+ | PluginValue::PhpHandle(_)
+ | PluginValue::PhpClass(_) => {
+ bail!(
+ "an object record or handle descriptor cannot be represented as PhpMixed: {self:?}"
+ )
}
})
}
@@ -168,9 +218,25 @@ fn serialize_into(value: &PluginValue, out: &mut Vec<u8>) {
}
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).
+ // An object with no class lands on the PHP side as a plain array: the wire has no shape
+ // for it, so `Object` is a write-only label (see docs/dev/php-rpc.md).
PluginValue::Array(map) | PluginValue::Object(map) => serialize_map(map, out),
+ PluginValue::PhpObject(object) => {
+ out.extend_from_slice(b"O:");
+ out.extend_from_slice(object.class.len().to_string().as_bytes());
+ out.extend_from_slice(b":\"");
+ out.extend_from_slice(object.class.as_bytes());
+ out.extend_from_slice(b"\":");
+ out.extend_from_slice(object.props.len().to_string().as_bytes());
+ out.extend_from_slice(b":{");
+ for (name, value) in &object.props {
+ // A property name is always written as a string, even a numeric one, and never
+ // as the int key an array of the same shape would get.
+ serialize_bytes(name, out);
+ serialize_into(value, out);
+ }
+ out.push(b'}');
+ }
PluginValue::RustHandle(handle) => {
let mut map: IndexMap<Vec<u8>, PluginValue> = IndexMap::new();
map.insert(
@@ -267,10 +333,10 @@ fn canonical_int_key(key: &[u8]) -> Option<i64> {
/// 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.
+/// The decoder never produces `Object`: PHP's wire format erases the array/object distinction
+/// for a class-less object. 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, and an `O:` record decodes as `PhpObject`.
pub fn unserialize(payload: &[u8]) -> anyhow::Result<PluginValue> {
let mut pos = 0;
let value = parse_value(payload, &mut pos)?;
@@ -280,18 +346,24 @@ pub fn unserialize(payload: &[u8]) -> anyhow::Result<PluginValue> {
Ok(value)
}
-/// One lexed step of a serialized payload: either a complete non-array value, or the opening of
-/// an array whose entries follow.
+/// One lexed step of a serialized payload: a complete scalar, the opening of a container whose
+/// entries follow, or a back-reference to an earlier value.
enum Lex {
Value(PluginValue),
- ArrayOpen(usize),
+ /// The entry count, and the class name of an object record.
+ ContainerOpen(usize, Option<String>),
+ BackReference(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 {
+/// An in-progress array or object record 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 Frame {
entries: IndexMap<Vec<u8>, PluginValue>,
+ /// The class name of an object record; `None` for an array.
+ class: Option<String>,
+ /// This container's own number in the payload's value numbering.
+ number: usize,
count: usize,
parsed: usize,
is_list: bool,
@@ -299,8 +371,14 @@ struct ArrayFrame {
}
fn parse_value(payload: &[u8], pos: &mut usize) -> anyhow::Result<PluginValue> {
- let mut stack: Vec<ArrayFrame> = Vec::new();
+ let mut stack: Vec<Frame> = Vec::new();
let mut completed: Option<PluginValue> = None;
+ // PHP numbers every value of a payload from 1 in document order — never a key, but including
+ // a back-reference itself — and an `r:` record names one of those numbers. Only an object can
+ // be named that way (an array crosses by copy), so only objects are kept for resolution;
+ // holding every value would cost a slot per scalar for payloads that never reference one.
+ let mut numbered_objects: IndexMap<usize, PluginValue> = IndexMap::new();
+ let mut values = 0;
loop {
if let Some(value) = completed.take() {
@@ -326,7 +404,12 @@ fn parse_value(payload: &[u8], pos: &mut usize) -> anyhow::Result<PluginValue> {
}
*pos += 1;
let frame = stack.pop().expect("frame was just observed");
- completed = Some(finish_array(frame)?);
+ let number = frame.number;
+ let value = finish_frame(frame)?;
+ if matches!(value, PluginValue::PhpObject(_)) {
+ numbered_objects.insert(number, value.clone());
+ }
+ completed = Some(value);
continue;
}
let index = frame.parsed as i64;
@@ -340,21 +423,36 @@ fn parse_value(payload: &[u8], pos: &mut usize) -> anyhow::Result<PluginValue> {
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"),
+ Lex::ContainerOpen(..) | Lex::BackReference(_) => {
+ bail!("array key is neither int nor string")
+ }
}
continue;
}
+ values += 1;
match lex(payload, pos)? {
Lex::Value(value) => completed = Some(value),
- Lex::ArrayOpen(count) => {
+ Lex::BackReference(number) => match numbered_objects.get(&number) {
+ // An immutable value has no identity to preserve on this side, so a repeated
+ // instance decodes as a copy of the one it names.
+ Some(value) => completed = Some(value.clone()),
+ None => bail!(
+ "back-reference r:{number} at byte {} does not name a completed object; a \
+ cyclic object graph cannot cross the plugin boundary",
+ *pos
+ ),
+ },
+ Lex::ContainerOpen(count, class) => {
if stack.len() >= MAX_DECODE_DEPTH {
bail!(
"serialized value exceeds the maximum nesting depth of {MAX_DECODE_DEPTH}"
);
}
- stack.push(ArrayFrame {
+ stack.push(Frame {
entries: IndexMap::new(),
+ class,
+ number: values,
count,
parsed: 0,
is_list: true,
@@ -412,23 +510,32 @@ fn lex(payload: &[u8], pos: &mut usize) -> anyhow::Result<Lex> {
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);
+ b"a:" => Ok(Lex::ContainerOpen(parse_entry_count(payload, pos)?, None)),
+ b"O:" => {
+ let class = parse_quoted(payload, pos, b':')?;
+ let class = String::from_utf8(class)
+ .map_err(|_| anyhow::anyhow!("object record with a non-UTF-8 class name"))?;
+ Ok(Lex::ContainerOpen(
+ parse_entry_count(payload, pos)?,
+ Some(class),
+ ))
+ }
+ b"r:" => {
+ let bytes = take_until(payload, pos, b';')?;
+ match std::str::from_utf8(bytes).ok().and_then(|s| s.parse().ok()) {
+ Some(number) => Ok(Lex::BackReference(number)),
+ None => bail!(
+ "malformed back-reference: {:?}",
+ String::from_utf8_lossy(bytes)
+ ),
}
- *pos += 1;
- Ok(Lex::ArrayOpen(count))
}
+ // A PHP reference aliases a variable; nothing on this side can carry that aliasing, and
+ // no value the boundary produces is written by reference.
+ b"R:" => bail!(
+ "a PHP reference (`R:`) at byte {} cannot cross the plugin boundary",
+ *pos - 2
+ ),
_ => bail!(
"unknown serialized type tag {:?} at byte {}",
String::from_utf8_lossy(tag),
@@ -437,7 +544,13 @@ fn lex(payload: &[u8], pos: &mut usize) -> anyhow::Result<Lex> {
}
}
-fn finish_array(frame: ArrayFrame) -> anyhow::Result<PluginValue> {
+fn finish_frame(frame: Frame) -> anyhow::Result<PluginValue> {
+ if let Some(class) = frame.class {
+ return Ok(PluginValue::PhpObject(PhpObject {
+ class,
+ props: frame.entries,
+ }));
+ }
if let Some(handle) = decode_handle(&frame.entries)? {
return Ok(handle);
}
@@ -448,7 +561,31 @@ fn finish_array(frame: ArrayFrame) -> anyhow::Result<PluginValue> {
})
}
+/// The `<count>:{` a container's entries follow.
+fn parse_entry_count(payload: &[u8], pos: &mut usize) -> anyhow::Result<usize> {
+ 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 entry count: {:?}",
+ String::from_utf8_lossy(count_bytes)
+ )
+ })?;
+ if payload.get(*pos) != Some(&b'{') {
+ bail!("expected opening brace at byte {}", *pos);
+ }
+ *pos += 1;
+ Ok(count)
+}
+
fn parse_string_body(payload: &[u8], pos: &mut usize) -> anyhow::Result<Vec<u8>> {
+ parse_quoted(payload, pos, b';')
+}
+
+/// A length-prefixed quoted run: `<len>:"<bytes>"` followed by `terminator`.
+fn parse_quoted(payload: &[u8], pos: &mut usize, terminator: u8) -> anyhow::Result<Vec<u8>> {
let len_bytes = take_until(payload, pos, b':')?;
let len: usize = std::str::from_utf8(len_bytes)
.ok()
@@ -467,7 +604,7 @@ fn parse_string_body(payload: &[u8], pos: &mut usize) -> anyhow::Result<Vec<u8>>
bail!("truncated string body at byte {}", *pos);
};
*pos += len;
- if payload.get(*pos..*pos + 2) != Some(b"\";") {
+ if payload.get(*pos) != Some(&b'"') || payload.get(*pos + 1) != Some(&terminator) {
bail!("expected closing quote at byte {}", *pos);
}
*pos += 2;
@@ -638,6 +775,81 @@ mod tests {
assert_eq!(unserialize(&encoded).unwrap(), PluginValue::Array(map));
}
+ /// The bytes are what PHP writes for
+ /// `new MatchAllConstraint()` and `new DateTimeImmutable('2026-08-07 12:34:56.123456', new
+ /// DateTimeZone('UTC'))`: a protected property is mangled, a public one is not, and both are
+ /// written as string keys.
+ #[test]
+ fn encodes_object_records_like_php() {
+ let mut constraint = PhpObject::new("Composer\\Semver\\Constraint\\MatchAllConstraint");
+ constraint.set_protected("prettyString", PluginValue::Null);
+ assert_eq!(
+ serialize(&PluginValue::PhpObject(constraint)),
+ b"O:45:\"Composer\\Semver\\Constraint\\MatchAllConstraint\":1:{s:15:\"\0*\0prettyString\";N;}".as_slice(),
+ );
+
+ let mut date = PhpObject::new("DateTimeImmutable");
+ date.set_public("date", PluginValue::string("2026-08-07 12:34:56.123456"));
+ date.set_public("timezone_type", PluginValue::Int(3));
+ date.set_public("timezone", PluginValue::string("UTC"));
+ assert_eq!(
+ serialize(&PluginValue::PhpObject(date)),
+ b"O:17:\"DateTimeImmutable\":3:{s:4:\"date\";s:26:\"2026-08-07 12:34:56.123456\";s:13:\"timezone_type\";i:3;s:8:\"timezone\";s:3:\"UTC\";}".as_slice(),
+ );
+ }
+
+ #[test]
+ fn roundtrips_object_records() {
+ let mut inner = PhpObject::new("Composer\\Semver\\Constraint\\Constraint");
+ inner.set_protected("operator", PluginValue::Int(4));
+ inner.set_protected("version", PluginValue::string("1.0.0"));
+ let mut outer = PhpObject::new("Composer\\Package\\Link");
+ outer.set_protected("source", PluginValue::string("a/b"));
+ outer.set_protected("constraint", PluginValue::PhpObject(inner.clone()));
+ roundtrip(PluginValue::PhpObject(outer.clone()));
+
+ // Visibility is part of the property name: neither accessor sees the other's key.
+ assert_eq!(inner.protected("operator"), Some(&PluginValue::Int(4)));
+ assert_eq!(inner.public("operator"), None);
+ assert_eq!(outer.protected("prettyConstraint"), None);
+ }
+
+ /// PHP numbers every value of a payload, including the ones inside an object and the
+ /// back-references themselves, so resolving `r:` means counting exactly the same way. Both
+ /// payloads below are PHP's own output for `[$c, $c]` and `[$c, $c, $e, $e]`.
+ #[test]
+ fn resolves_back_references_by_phps_value_numbering() {
+ let decoded =
+ unserialize(b"a:2:{i:0;O:8:\"stdClass\":1:{s:1:\"p\";i:1;}i:1;r:2;}").unwrap();
+ let PluginValue::List(items) = decoded else {
+ panic!("expected a list, got {decoded:?}");
+ };
+ assert_eq!(items[0], items[1]);
+
+ let decoded = unserialize(
+ b"a:4:{i:0;O:8:\"stdClass\":1:{s:1:\"p\";i:1;}i:1;r:2;i:2;O:8:\"stdClass\":1:{s:1:\"q\";i:2;}i:3;r:5;}",
+ )
+ .unwrap();
+ let PluginValue::List(items) = decoded else {
+ panic!("expected a list, got {decoded:?}");
+ };
+ assert_eq!(items[0], items[1]);
+ assert_eq!(items[2], items[3]);
+ assert_ne!(items[0], items[2]);
+ }
+
+ #[test]
+ fn rejects_cyclic_object_graphs_and_php_references() {
+ let err = unserialize(b"O:8:\"stdClass\":1:{s:4:\"self\";r:1;}").unwrap_err();
+ assert!(err.to_string().contains("cyclic"), "{err}");
+
+ let err = unserialize(b"a:2:{i:0;i:1;i:1;R:2;}").unwrap_err();
+ assert!(err.to_string().contains("PHP reference"), "{err}");
+
+ let err = unserialize(b"a:1:{i:0;r:9;}").unwrap_err();
+ assert!(err.to_string().contains("r:9"), "{err}");
+ }
+
#[test]
fn roundtrips_composites() {
roundtrip(PluginValue::List(vec![
diff --git a/crates/shirabe-php-rpc/tests/oracle.rs b/crates/shirabe-php-rpc/tests/oracle.rs
index 4220788a..e1e1143b 100644
--- a/crates/shirabe-php-rpc/tests/oracle.rs
+++ b/crates/shirabe-php-rpc/tests/oracle.rs
@@ -9,7 +9,7 @@
use indexmap::IndexMap;
use shirabe_external_packages::symfony::process::PhpExecutableFinder;
use shirabe_php_rpc::value::{serialize, unserialize};
-use shirabe_php_rpc::{PluginValue, call_function};
+use shirabe_php_rpc::{PhpObject, PluginValue, call_function};
fn php_available() -> bool {
PhpExecutableFinder::new().find(false).is_some()
@@ -136,6 +136,83 @@ fn encode_direction_matches_php_for_nested_arrays() {
assert_php_agrees(&deep);
}
+/// `DateTimeImmutable` is one of the classes the worker is allowed to revive, so the record
+/// makes the whole trip: PHP builds a real date out of the bytes this side wrote, and writes the
+/// same bytes back.
+#[test]
+fn encode_direction_matches_php_for_object_records() {
+ if !php_available() {
+ return;
+ }
+
+ let mut date = PhpObject::new("DateTimeImmutable");
+ date.set_public("date", PluginValue::string("2024-03-04 05:06:07.123456"));
+ date.set_public("timezone_type", PluginValue::Int(3));
+ date.set_public("timezone", PluginValue::string("UTC"));
+ assert_php_agrees(&PluginValue::PhpObject(date.clone()));
+
+ assert_php_agrees(&PluginValue::List(vec![
+ PluginValue::PhpObject(date),
+ PluginValue::Null,
+ ]));
+}
+
+#[test]
+fn decode_direction_matches_php_for_object_records() {
+ if !php_available() {
+ return;
+ }
+
+ let snippets = [
+ r#"return serialize(new DateTimeImmutable('2024-03-04 05:06:07.123456', new DateTimeZone('UTC')));"#,
+ r#"return serialize(new DateTime('2024-03-04 05:06:07.123456', new DateTimeZone('+09:00')));"#,
+ // Every visibility, so the whole mangling vocabulary round-trips.
+ r#"class ShirabeOracleProps { public $pub = 1; protected $prot = [1, 2]; private $priv = 'x'; }
+ return serialize(new ShirabeOracleProps());"#,
+ r#"$o = new stdClass; $o->nested = new stdClass; $o->nested->deep = "\xff"; return serialize($o);"#,
+ ];
+
+ 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:#}"));
+ assert_eq!(
+ String::from_utf8_lossy(&serialize(&decoded)),
+ String::from_utf8_lossy(&php_bytes),
+ "re-encoding diverged for `{snippet}`"
+ );
+ }
+}
+
+/// PHP writes a repeated instance as a back-reference into its numbering of every value in the
+/// payload, so decoding one means counting exactly the way PHP counts.
+#[test]
+fn decode_direction_resolves_php_back_references() {
+ if !php_available() {
+ return;
+ }
+
+ let PluginValue::String(php_bytes) = php_eval(
+ r#"$c = new stdClass; $c->p = [1, "x"]; $e = new stdClass; $e->q = 2;
+ return serialize([$c, $c, $e, [$c, $e]]);"#,
+ ) else {
+ panic!("expected serialized bytes");
+ };
+ let decoded = unserialize(&php_bytes).expect("failed to decode PHP output");
+ let PluginValue::List(items) = decoded else {
+ panic!("expected a list, got {decoded:?}");
+ };
+ let PluginValue::List(inner) = &items[3] else {
+ panic!("expected a nested list, got {:?}", items[3]);
+ };
+ assert_eq!(items[0], items[1]);
+ assert_eq!(items[0], inner[0]);
+ assert_eq!(items[2], inner[1]);
+ assert_ne!(items[0], items[2]);
+}
+
#[test]
fn decode_direction_matches_php_serialize_output() {
if !php_available() {