aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authornsfisis <nsfisis@gmail.com>2026-08-07 05:11:55 +0900
committernsfisis <nsfisis@gmail.com>2026-08-07 05:11:55 +0900
commit9a393adc0ace86cac788723b524e83c63dfc91c1 (patch)
treefadac10efa6518c1a5d4d16f829da6969229ce09
parent14a8a474120ef4289625f05ba5d87fa9e9d19542 (diff)
downloadphp-shirabe-9a393adc0ace86cac788723b524e83c63dfc91c1.tar.gz
php-shirabe-9a393adc0ace86cac788723b524e83c63dfc91c1.tar.zst
php-shirabe-9a393adc0ace86cac788723b524e83c63dfc91c1.zip
feat(php-rpc): cross materialized values as PHP object records
A materialized value used to cross as a constructor call: the class name, the arguments, and any post-construction setter. Describing a real instance that way needed a ReflectionProperty read for every field the class exposes no getter for, and state no constructor takes (an unset pretty string, a Link built without a pretty constraint) had no faithful call to describe it at all. The value now crosses as the object record serialize() writes for it, which unserialize() revives without running a constructor, so both sides transfer the state itself instead of a recipe for rebuilding it. The PHP half keeps only the class list (also the allowed_classes list of every frame payload) and the UTC rebasing of dates; describe(), build() and the reflection are gone. The wire codec gains O: records (PluginValue::PhpObject) and r: back references, whose resolution reproduces PHP's numbering of every value in a payload; a cyclic object graph and a PHP reference (R:) are rejected. Two behaviours change with it: a date crosses carrying timezone_type 3 "UTC" rather than a +00:00 offset, which is what ArrayLoader builds a release date as, and a Link subclass crosses as a P-table entity instead of being silently downgraded to a plain Link. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
-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
-rw-r--r--crates/shirabe/src/plugin/php_plugin_value.rs478
-rw-r--r--docs/dev/php-rpc.md45
7 files changed, 711 insertions, 303 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() {
diff --git a/crates/shirabe/src/plugin/php_plugin_value.rs b/crates/shirabe/src/plugin/php_plugin_value.rs
index bbb8fb94..cdef2a79 100644
--- a/crates/shirabe/src/plugin/php_plugin_value.rs
+++ b/crates/shirabe/src/plugin/php_plugin_value.rs
@@ -3,33 +3,41 @@
//! This module has no Composer counterpart: it is part of the plugin runtime split (see
//! `docs/dev/php-rpc.md`). A proxied entity crosses as a handle, but a value object has no
//! entity to point at — the child has to hold a genuine instance of the real class. Each value
-//! is therefore described structurally (class name, constructor arguments, post-construction
-//! calls) and rebuilt on the other side; `Shirabe\MaterializedValue` in the worker is the PHP
-//! half of the same protocol, and describes those instances back in the same shape.
+//! therefore crosses as the object record PHP's `serialize()` writes for it: the class name and
+//! the property table, which `unserialize()` revives on the other side without running a
+//! constructor. `Shirabe\MaterializedValue` in the worker is the PHP half of the same protocol,
+//! and names the classes allowed to cross.
+//!
+//! Writing the fields directly is what makes the two sides equivalent: a constructor normalizes
+//! its arguments and leaves the rest of the state to setters, so a value whose state a
+//! constructor cannot express (an unset pretty string, a `Link` built without a pretty
+//! constraint) has no faithful constructor call.
use crate::package::Link;
-use chrono::{DateTime, TimeZone, Utc};
-use indexmap::IndexMap;
-use shirabe_php_rpc::{PhpThrow, PluginValue};
+use chrono::{DateTime, NaiveDateTime, TimeZone, Utc};
+use shirabe_php_rpc::{PhpObject, PhpThrow, PluginValue};
use shirabe_semver::constraint::{
AnyConstraint, MatchAllConstraint, MatchNoneConstraint, MultiConstraint, SimpleConstraint,
};
-const CLASS_KEY: &[u8] = b"__pnew";
-const ARGS_KEY: &[u8] = b"__args";
-const CALLS_KEY: &[u8] = b"__calls";
-
const LINK_CLASS: &str = "Composer\\Package\\Link";
const CONSTRAINT_CLASS: &str = "Composer\\Semver\\Constraint\\Constraint";
const MULTI_CONSTRAINT_CLASS: &str = "Composer\\Semver\\Constraint\\MultiConstraint";
const MATCH_ALL_CLASS: &str = "Composer\\Semver\\Constraint\\MatchAllConstraint";
const MATCH_NONE_CLASS: &str = "Composer\\Semver\\Constraint\\MatchNoneConstraint";
-const DATE_TIME_CLASS: &str = "DateTimeImmutable";
+const DATE_TIME_IMMUTABLE_CLASS: &str = "DateTimeImmutable";
+const DATE_TIME_CLASS: &str = "DateTime";
+
+/// The wall clock of a PHP date, at the microsecond precision the other side can represent.
+const DATE_FORMAT: &str = "%Y-%m-%d %H:%M:%S%.6f";
-/// PHP's `DateTimeInterface::ATOM` widened by the microseconds PHP dates carry: the child
-/// reconstructs the instant from this rendering, so it must keep both the offset and the full
-/// precision the other side can represent.
-const DATE_TIME_FORMAT: &str = "%Y-%m-%dT%H:%M:%S%.6f%:z";
+/// PHP's `timezone_type` for a date carrying a named zone rather than an offset or an
+/// abbreviation.
+const TIMEZONE_TYPE_IDENTIFIER: i64 = 3;
+
+/// The only zone a date may cross in: this port holds instants as `DateTime<Utc>` and carries no
+/// timezone database, so the worker rebases every date on UTC before it is serialized.
+const TIMEZONE_UTC: &str = "UTC";
fn throw(message: String) -> PhpThrow {
PhpThrow {
@@ -39,50 +47,11 @@ fn throw(message: String) -> PhpThrow {
}
}
-fn materialized(
- class: &str,
- args: Vec<PluginValue>,
- calls: Vec<(&str, Vec<PluginValue>)>,
-) -> PluginValue {
- let mut map = IndexMap::new();
- map.insert(CLASS_KEY.to_vec(), PluginValue::string(class));
- map.insert(ARGS_KEY.to_vec(), PluginValue::List(args));
- if !calls.is_empty() {
- map.insert(
- CALLS_KEY.to_vec(),
- PluginValue::List(
- calls
- .into_iter()
- .map(|(method, args)| {
- PluginValue::List(vec![
- PluginValue::string(method),
- PluginValue::List(args),
- ])
- })
- .collect(),
- ),
- );
+fn as_object(value: &PluginValue) -> Option<&PhpObject> {
+ match value {
+ PluginValue::PhpObject(object) => Some(object),
+ _ => None,
}
- PluginValue::Array(map)
-}
-
-/// The class name and constructor arguments of a materialized value, or `None` for any other
-/// value shape.
-fn as_materialized(value: &PluginValue) -> Option<(String, &[PluginValue])> {
- let map = match value {
- PluginValue::Array(map) => map,
- _ => return None,
- };
- let class = match map.get(CLASS_KEY) {
- Some(PluginValue::String(bytes)) => String::from_utf8_lossy(bytes).into_owned(),
- _ => return None,
- };
- let args = match map.get(ARGS_KEY) {
- Some(PluginValue::List(args)) => args.as_slice(),
- None => &[],
- _ => return None,
- };
- Some((class, args))
}
fn optional_string(value: Option<&PluginValue>) -> Option<String> {
@@ -99,79 +68,101 @@ fn required_string(context: &str, value: Option<&PluginValue>) -> Result<String,
}
}
-/// The `setPrettyString` call a constraint needs after construction. PHP stores the pretty
-/// string in a protected field that no constructor takes, and leaving it unset is observable
-/// (`getPrettyString()` falls back to the string form), so an unset one is sent as null.
-fn pretty_string_call(constraint: &AnyConstraint) -> Vec<(&'static str, Vec<PluginValue>)> {
- let value = match constraint.pretty_string() {
- Some(pretty) => PluginValue::string(pretty),
- None => PluginValue::Null,
- };
- vec![("setPrettyString", vec![value])]
+/// PHP's `Constraint` keeps the operator as one of its `OP_*` codes, not as the string its
+/// constructor takes.
+fn operator_from_code(code: i64) -> Option<&'static str> {
+ Some(match code {
+ SimpleConstraint::OP_EQ => SimpleConstraint::STR_OP_EQ,
+ SimpleConstraint::OP_LT => SimpleConstraint::STR_OP_LT,
+ SimpleConstraint::OP_LE => SimpleConstraint::STR_OP_LE,
+ SimpleConstraint::OP_GT => SimpleConstraint::STR_OP_GT,
+ SimpleConstraint::OP_GE => SimpleConstraint::STR_OP_GE,
+ SimpleConstraint::OP_NE => SimpleConstraint::STR_OP_NE,
+ _ => return None,
+ })
}
fn constraint_to_wire(constraint: &AnyConstraint) -> PluginValue {
- let calls = pretty_string_call(constraint);
- match constraint {
- AnyConstraint::Simple(c) => materialized(
- CONSTRAINT_CLASS,
- vec![
- PluginValue::string(c.get_operator()),
- PluginValue::string(c.get_version()),
- ],
- calls,
- ),
- AnyConstraint::Multi(c) => materialized(
- MULTI_CONSTRAINT_CLASS,
- vec![
- PluginValue::List(c.get_constraints().iter().map(constraint_to_wire).collect()),
- PluginValue::Bool(c.is_conjunctive()),
- ],
- calls,
- ),
- AnyConstraint::MatchAll(_) => materialized(MATCH_ALL_CLASS, Vec::new(), calls),
- AnyConstraint::MatchNone(_) => materialized(MATCH_NONE_CLASS, Vec::new(), calls),
- }
-}
-
-/// The pretty string carried by a materialized constraint's `setPrettyString` call.
-fn wire_pretty_string(value: &PluginValue) -> Option<String> {
- let map = match value {
- PluginValue::Array(map) => map,
- _ => return None,
+ // Whether the pretty string was ever set is observable (`getPrettyString()` falls back to the
+ // string form), so an unset one crosses as null rather than as an absent property.
+ let pretty_string = match constraint.pretty_string() {
+ Some(pretty) => PluginValue::string(pretty),
+ None => PluginValue::Null,
};
- let calls = match map.get(CALLS_KEY) {
- Some(PluginValue::List(calls)) => calls,
- _ => return None,
+ let object = match constraint {
+ AnyConstraint::Simple(c) => {
+ let mut object = PhpObject::new(CONSTRAINT_CLASS);
+ object.set_protected(
+ "operator",
+ PluginValue::Int(SimpleConstraint::get_operator_constant(c.get_operator())),
+ );
+ object.set_protected("version", PluginValue::string(c.get_version()));
+ object.set_protected("prettyString", pretty_string);
+ // A cold memo rather than missing state: PHP derives the bounds from the operator
+ // and the version the first time anything asks for them, and this port keeps no
+ // such cache to carry over.
+ object.set_protected("lowerBound", PluginValue::Null);
+ object.set_protected("upperBound", PluginValue::Null);
+ object
+ }
+ AnyConstraint::Multi(c) => {
+ let mut object = PhpObject::new(MULTI_CONSTRAINT_CLASS);
+ object.set_protected(
+ "constraints",
+ PluginValue::List(c.get_constraints().iter().map(constraint_to_wire).collect()),
+ );
+ object.set_protected("prettyString", pretty_string);
+ // A cold memo like the bounds below, of the rendering PHP derives from the
+ // constraints and the conjunctive flag.
+ object.set_protected("string", PluginValue::Null);
+ object.set_protected("conjunctive", PluginValue::Bool(c.is_conjunctive()));
+ object.set_protected("lowerBound", PluginValue::Null);
+ object.set_protected("upperBound", PluginValue::Null);
+ object
+ }
+ AnyConstraint::MatchAll(_) => {
+ let mut object = PhpObject::new(MATCH_ALL_CLASS);
+ object.set_protected("prettyString", pretty_string);
+ object
+ }
+ AnyConstraint::MatchNone(_) => {
+ let mut object = PhpObject::new(MATCH_NONE_CLASS);
+ object.set_protected("prettyString", pretty_string);
+ object
+ }
};
- calls.iter().find_map(|call| match call {
- PluginValue::List(parts) => match (parts.first(), parts.get(1)) {
- (Some(PluginValue::String(method)), Some(PluginValue::List(args)))
- if method == b"setPrettyString" =>
- {
- optional_string(args.first())
- }
- _ => None,
- },
- _ => None,
- })
+ PluginValue::PhpObject(object)
}
fn constraint_from_wire(value: &PluginValue) -> Result<AnyConstraint, PhpThrow> {
- let (class, args) = as_materialized(value).ok_or_else(|| {
+ let object = as_object(value).ok_or_else(|| {
throw(format!(
"expected a semver constraint from the plugin, got {value:?}"
))
})?;
- let pretty_string = wire_pretty_string(value);
- Ok(match class.as_str() {
- CONSTRAINT_CLASS => AnyConstraint::Simple(SimpleConstraint::new(
- required_string("a semver constraint operator", args.first())?,
- required_string("a semver constraint version", args.get(1))?,
- pretty_string,
- )),
+ let pretty_string = optional_string(object.protected("prettyString"));
+ Ok(match object.class.as_str() {
+ CONSTRAINT_CLASS => {
+ let operator = match object.protected("operator") {
+ Some(PluginValue::Int(code)) => operator_from_code(*code).ok_or_else(|| {
+ throw(format!(
+ "a semver constraint has an unknown operator: {code}"
+ ))
+ })?,
+ other => {
+ return Err(throw(format!(
+ "a semver constraint operator is not an int, got {other:?}"
+ )));
+ }
+ };
+ AnyConstraint::Simple(SimpleConstraint::new(
+ operator.to_string(),
+ required_string("a semver constraint version", object.protected("version"))?,
+ pretty_string,
+ ))
+ }
MULTI_CONSTRAINT_CLASS => {
- let constraints = match args.first() {
+ let constraints = match object.protected("constraints") {
Some(PluginValue::List(items)) => items
.iter()
.map(constraint_from_wire)
@@ -192,10 +183,8 @@ fn constraint_from_wire(value: &PluginValue) -> Result<AnyConstraint, PhpThrow>
"a MultiConstraint needs at least two constraints".to_string(),
));
}
- let conjunctive = match args.get(1) {
+ let conjunctive = match object.protected("conjunctive") {
Some(PluginValue::Bool(conjunctive)) => *conjunctive,
- // PHP defaults the parameter to true.
- None => true,
other => {
return Err(throw(format!(
"a MultiConstraint expects a bool conjunctive flag, got {other:?}"
@@ -221,69 +210,250 @@ fn constraint_from_wire(value: &PluginValue) -> Result<AnyConstraint, PhpThrow>
}
pub(crate) fn link_to_wire(link: &Link) -> PluginValue {
- materialized(
- LINK_CLASS,
- vec![
- PluginValue::string(link.get_source()),
- PluginValue::string(link.get_target()),
- constraint_to_wire(link.get_constraint()),
- PluginValue::string(link.get_description()),
- PluginValue::string(link.get_pretty_constraint()),
- ],
- Vec::new(),
- )
+ let mut object = PhpObject::new(LINK_CLASS);
+ object.set_protected("source", PluginValue::string(link.get_source()));
+ object.set_protected("target", PluginValue::string(link.get_target()));
+ object.set_protected("constraint", constraint_to_wire(link.get_constraint()));
+ object.set_protected("description", PluginValue::string(link.get_description()));
+ object.set_protected(
+ "prettyConstraint",
+ PluginValue::string(link.get_pretty_constraint()),
+ );
+ PluginValue::PhpObject(object)
}
pub(crate) fn link_from_wire(value: &PluginValue) -> Result<Link, PhpThrow> {
- let (class, args) = as_materialized(value)
+ let object = as_object(value)
.ok_or_else(|| throw(format!("expected a Link from the plugin, got {value:?}")))?;
- if class != LINK_CLASS {
+ if object.class != LINK_CLASS {
// TODO(plugin): Link subclasses have no Rust counterpart; the port models links as a
// single value type.
return Err(throw(format!(
- "the class `{class}` cannot cross the plugin boundary as a Link"
+ "the class `{}` cannot cross the plugin boundary as a Link",
+ object.class
)));
}
let constraint = constraint_from_wire(
- args.get(2)
- .ok_or_else(|| throw("a Link expects a constraint argument".to_string()))?,
+ object
+ .protected("constraint")
+ .ok_or_else(|| throw("a Link expects a constraint".to_string()))?,
)?;
// TODO(plugin): PHP's Link leaves $prettyConstraint nullable and throws from
// getPrettyConstraint() when it was never given, but this port stores a string, so a link
// a plugin built without one comes back carrying the empty string instead.
- let pretty_constraint = optional_string(args.get(4)).unwrap_or_default();
+ let pretty_constraint =
+ optional_string(object.protected("prettyConstraint")).unwrap_or_default();
Ok(Link::new(
- required_string("a Link source", args.first())?,
- required_string("a Link target", args.get(1))?,
+ required_string("a Link source", object.protected("source"))?,
+ required_string("a Link target", object.protected("target"))?,
constraint,
- optional_string(args.get(3)),
+ optional_string(object.protected("description")),
pretty_constraint,
))
}
pub(crate) fn date_time_to_wire(date: &DateTime<Utc>) -> PluginValue {
- materialized(
- DATE_TIME_CLASS,
- vec![PluginValue::string(
- date.format(DATE_TIME_FORMAT).to_string(),
- )],
- Vec::new(),
- )
+ let mut object = PhpObject::new(DATE_TIME_IMMUTABLE_CLASS);
+ object.set_public(
+ "date",
+ PluginValue::string(date.format(DATE_FORMAT).to_string()),
+ );
+ object.set_public("timezone_type", PluginValue::Int(TIMEZONE_TYPE_IDENTIFIER));
+ object.set_public("timezone", PluginValue::string(TIMEZONE_UTC));
+ PluginValue::PhpObject(object)
}
pub(crate) fn date_time_from_wire(value: &PluginValue) -> Result<DateTime<Utc>, PhpThrow> {
- let (class, args) = as_materialized(value)
+ let object = as_object(value)
.ok_or_else(|| throw(format!("expected a date from the plugin, got {value:?}")))?;
- if class != DATE_TIME_CLASS {
+ if object.class != DATE_TIME_IMMUTABLE_CLASS && object.class != DATE_TIME_CLASS {
return Err(throw(format!(
- "the class `{class}` cannot cross the plugin boundary as a date"
+ "the class `{}` cannot cross the plugin boundary as a date",
+ object.class
)));
}
- let rendered = required_string("a date", args.first())?;
- let parsed = DateTime::parse_from_str(&rendered, DATE_TIME_FORMAT).map_err(|e| {
+ let zone = optional_string(object.public("timezone"));
+ if object.public("timezone_type") != Some(&PluginValue::Int(TIMEZONE_TYPE_IDENTIFIER))
+ || zone.as_deref() != Some(TIMEZONE_UTC)
+ {
+ return Err(throw(format!(
+ "a date crosses the plugin boundary rebased on {TIMEZONE_UTC}, got the zone {zone:?}"
+ )));
+ }
+ let rendered = required_string("a date", object.public("date"))?;
+ let parsed = NaiveDateTime::parse_from_str(&rendered, DATE_FORMAT).map_err(|e| {
throw(format!(
"the date `{rendered}` from the plugin is not in the expected format: {e}"
))
})?;
- Ok(Utc.from_utc_datetime(&parsed.naive_utc()))
+ Ok(Utc.from_utc_datetime(&parsed))
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use chrono::Timelike;
+ use shirabe_php_rpc::value::serialize;
+
+ fn simple(operator: &str, version: &str, pretty_string: Option<&str>) -> AnyConstraint {
+ AnyConstraint::Simple(SimpleConstraint::new(
+ operator.to_string(),
+ version.to_string(),
+ pretty_string.map(str::to_string),
+ ))
+ }
+
+ /// The expected bytes are what PHP writes for
+ /// `new Link('a/b', 'c/d', new Constraint('>=', '1.0.0'), 'requires', '^1.0')`: the property
+ /// set, its order and the visibility mangling all have to match, or `unserialize()` would
+ /// revive an instance carrying defaults where the value has state.
+ #[test]
+ fn a_link_is_written_as_php_writes_it() {
+ let link = Link::new(
+ "a/b".to_string(),
+ "c/d".to_string(),
+ simple(">=", "1.0.0", None),
+ Some("requires".to_string()),
+ "^1.0".to_string(),
+ );
+ assert_eq!(
+ String::from_utf8_lossy(&serialize(&link_to_wire(&link))),
+ String::from_utf8_lossy(
+ b"O:21:\"Composer\\Package\\Link\":5:{\
+ s:9:\"\0*\0source\";s:3:\"a/b\";\
+ s:9:\"\0*\0target\";s:3:\"c/d\";\
+ s:13:\"\0*\0constraint\";O:37:\"Composer\\Semver\\Constraint\\Constraint\":5:{\
+ s:11:\"\0*\0operator\";i:4;\
+ s:10:\"\0*\0version\";s:5:\"1.0.0\";\
+ s:15:\"\0*\0prettyString\";N;\
+ s:13:\"\0*\0lowerBound\";N;\
+ s:13:\"\0*\0upperBound\";N;}\
+ s:14:\"\0*\0description\";s:8:\"requires\";\
+ s:19:\"\0*\0prettyConstraint\";s:4:\"^1.0\";}"
+ ),
+ );
+ }
+
+ /// The expected bytes are what PHP writes for
+ /// `new MultiConstraint([new Constraint('>=', '1.0.0'), new Constraint('<', '2.0.0')], true)`
+ /// with a pretty string set on it.
+ #[test]
+ fn a_multi_constraint_is_written_as_php_writes_it() {
+ let constraint = AnyConstraint::Multi(MultiConstraint::new(
+ vec![simple(">=", "1.0.0", None), simple("<", "2.0.0", None)],
+ true,
+ Some("^1.0".to_string()),
+ ));
+ assert_eq!(
+ String::from_utf8_lossy(&serialize(&constraint_to_wire(&constraint))),
+ String::from_utf8_lossy(
+ b"O:42:\"Composer\\Semver\\Constraint\\MultiConstraint\":6:{\
+ s:14:\"\0*\0constraints\";a:2:{\
+ i:0;O:37:\"Composer\\Semver\\Constraint\\Constraint\":5:{\
+ s:11:\"\0*\0operator\";i:4;\
+ s:10:\"\0*\0version\";s:5:\"1.0.0\";\
+ s:15:\"\0*\0prettyString\";N;\
+ s:13:\"\0*\0lowerBound\";N;\
+ s:13:\"\0*\0upperBound\";N;}\
+ i:1;O:37:\"Composer\\Semver\\Constraint\\Constraint\":5:{\
+ s:11:\"\0*\0operator\";i:1;\
+ s:10:\"\0*\0version\";s:5:\"2.0.0\";\
+ s:15:\"\0*\0prettyString\";N;\
+ s:13:\"\0*\0lowerBound\";N;\
+ s:13:\"\0*\0upperBound\";N;}}\
+ s:15:\"\0*\0prettyString\";s:4:\"^1.0\";\
+ s:9:\"\0*\0string\";N;\
+ s:14:\"\0*\0conjunctive\";b:1;\
+ s:13:\"\0*\0lowerBound\";N;\
+ s:13:\"\0*\0upperBound\";N;}"
+ ),
+ );
+ }
+
+ /// The expected bytes are what PHP writes for
+ /// `new DateTimeImmutable('2026-08-07 12:34:56.123456', new DateTimeZone('UTC'))`.
+ #[test]
+ fn a_date_is_written_as_php_writes_it() {
+ let date = Utc
+ .with_ymd_and_hms(2026, 8, 7, 12, 34, 56)
+ .unwrap()
+ .with_nanosecond(123_456_000)
+ .unwrap();
+ assert_eq!(
+ serialize(&date_time_to_wire(&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 every_constraint_shape_round_trips() {
+ let constraints = [
+ simple(">=", "1.0.0.0", None),
+ simple("!=", "2.0.0.0", Some("!=2.0")),
+ AnyConstraint::Multi(MultiConstraint::new(
+ vec![
+ simple("<", "1.0.0.0", None),
+ AnyConstraint::Multi(MultiConstraint::new(
+ vec![simple(">=", "3.0.0.0", None), simple("<", "4.0.0.0", None)],
+ true,
+ None,
+ )),
+ ],
+ false,
+ Some("<1.0 || ^3.0".to_string()),
+ )),
+ AnyConstraint::MatchAll(MatchAllConstraint::new(None)),
+ AnyConstraint::MatchNone(MatchNoneConstraint::new(Some("nothing".to_string()))),
+ ];
+ for constraint in constraints {
+ let link = Link::new(
+ "Vendor/Root".to_string(),
+ "Vendor/Dep".to_string(),
+ constraint,
+ Some(Link::TYPE_DEV_REQUIRE.to_string()),
+ "^1.0".to_string(),
+ );
+ let decoded = link_from_wire(&link_to_wire(&link)).expect("decode failed");
+ assert_eq!(link.to_string(), decoded.to_string());
+ assert_eq!(link.get_description(), decoded.get_description());
+ assert_eq!(
+ link.get_constraint().get_pretty_string(),
+ decoded.get_constraint().get_pretty_string()
+ );
+ }
+ }
+
+ #[test]
+ fn a_date_round_trips_with_microseconds() {
+ let date = Utc
+ .with_ymd_and_hms(2024, 3, 3, 20, 6, 7)
+ .unwrap()
+ .with_nanosecond(123_456_000)
+ .unwrap();
+ assert_eq!(
+ date,
+ date_time_from_wire(&date_time_to_wire(&date)).unwrap()
+ );
+ }
+
+ #[test]
+ fn a_date_outside_utc_is_rejected() {
+ let mut object = PhpObject::new(DATE_TIME_IMMUTABLE_CLASS);
+ object.set_public("date", PluginValue::string("2024-03-04 05:06:07.123456"));
+ object.set_public("timezone_type", PluginValue::Int(3));
+ object.set_public("timezone", PluginValue::string("Asia/Tokyo"));
+ let err = date_time_from_wire(&PluginValue::PhpObject(object)).unwrap_err();
+ assert!(err.message.contains("Asia/Tokyo"), "{}", err.message);
+ }
+
+ #[test]
+ fn a_foreign_class_cannot_cross_as_a_value() {
+ let mut object = PhpObject::new("MyPlugin\\OddConstraint");
+ object.set_protected("prettyString", PluginValue::Null);
+ let err = constraint_from_wire(&PluginValue::PhpObject(object)).unwrap_err();
+ assert!(err.message.contains("OddConstraint"), "{}", err.message);
+
+ let err = link_from_wire(&PluginValue::List(Vec::new())).unwrap_err();
+ assert!(err.message.contains("expected a Link"), "{}", err.message);
+ }
}
diff --git a/docs/dev/php-rpc.md b/docs/dev/php-rpc.md
index 77d0d365..392b318e 100644
--- a/docs/dev/php-rpc.md
+++ b/docs/dev/php-rpc.md
@@ -65,31 +65,38 @@ Payloads are encoded with a Rust reimplementation of the PHP `serialize()` gramm
(`src/value.rs`), byte-compatible with the PHP core implementation under
`serialize_precision=-1` (the float formatting itself is ported in `shirabe-php-src`). The value
model is `PluginValue`: PHP scalars, byte strings (`Vec<u8>` — non-UTF-8 round-trips
-losslessly), lists, ordered maps, and three handle descriptor kinds encoded as reserved arrays:
+losslessly), lists, ordered maps, object records, and three handle descriptor kinds encoded as
+reserved arrays:
- `{__rhandle, __class, __epoch[, __snapshot]}` — entity lives on the Rust side
- `{__phandle, __class, __implements}` — entity lives in the PHP child
- `{__pclass}` — a PHP class name
-- `{__pnew, __args[, __calls]}` — a materialized value (below)
-An immutable value has no entity to point at, so it crosses in neither table: the descriptor
-names the real class plus the constructor arguments (and any post-construction calls) needed to
-rebuild it, and each side builds a genuine instance of its own. `Composer\Package\Link` travels
-this way, together with the `composer/semver` constraint it holds — encoded structurally rather
-than re-parsed from its string form, which would lose the pretty strings and the conjunctive
-flag. The `\DateTimeInterface` release date uses the same shape. The two halves are
+An immutable value has no entity to point at, so it crosses in neither table: it travels as the
+object record `serialize()` writes for it (`PluginValue::PhpObject` — the class name and the
+property table, property names carrying PHP's visibility mangling), and each side rebuilds its
+own value from those fields. `unserialize()` revives the PHP one without running a constructor,
+which is what makes the two directions equivalent: a value whose state a constructor cannot
+express — an unset pretty string, a `Link` built without a pretty constraint — has no faithful
+constructor call, and no field has to be read back out through reflection.
+`Composer\Package\Link` travels this way, together with the `composer/semver` constraint it
+holds, and so does the `\DateTimeInterface` release date, rebased on UTC because the Rust side
+carries no timezone database. The two halves are
`crates/shirabe/src/plugin/php_plugin_value.rs` and `Shirabe\MaterializedValue`; the set of
-classes that may cross is a closed list on both sides, so a descriptor can never name an
-arbitrary class.
+classes that may cross is a closed list on both sides — the PHP one is the `allowed_classes`
+list of every `unserialize()` — so a record can never name an arbitrary class.
-`PluginValue::Object` is encode-only: the wire erases the array/object distinction and object
-revival is banned (`unserialize(..., ['allowed_classes' => false])` is enforced on the PHP
-side), so the decoder only produces `List` (contiguous 0-based int keys) or `Array`. The
-decoder is iterative (input nesting never becomes call-stack depth) and additionally rejects
-payloads nested deeper than 512 levels.
+`PluginValue::Object` is encode-only: the wire erases the array/object distinction for a
+class-less object, so the decoder only produces `List` (contiguous 0-based int keys) or `Array`
+for an `a:` record. A repeated object instance arrives as PHP's `r:` back-reference, which the
+decoder resolves by copying the value it names (identity means nothing to a value on this side);
+a cyclic object graph and a PHP reference (`R:`) are both rejected. The decoder is iterative
+(input nesting never becomes call-stack depth) and additionally rejects payloads nested deeper
+than 512 levels.
The codec is verified against the real PHP `serialize()`/`unserialize()` by oracle tests
-(`tests/oracle.rs`), with floats, non-UTF-8 byte strings and deep nesting as focus areas.
+(`tests/oracle.rs`), with floats, non-UTF-8 byte strings, object records and deep nesting as
+focus areas.
## Concurrency and reentrancy
@@ -184,9 +191,9 @@ by `scripts/plugin-stub-generator/generate-stubs` and must not be edited by hand
surface (`getIO()`/`getComposer()`/...) answers from the Rust handoff.
- `Shirabe\RustCommandStub` — the reverse stub for built-in commands registered into that
application.
-- `Shirabe\MaterializedValue` — the PHP half of the materialized-value codec: it builds the
- real value classes from a `__pnew` descriptor and describes such instances back in the same
- shape.
+- `Shirabe\MaterializedValue` — the PHP half of the materialized-value codec: the closed list of
+ classes `unserialize()` may revive, and the hook that hands such an instance to `serialize()`
+ in place of a handle descriptor.
- `Composer\EventDispatcher\Event` — dual-mode: revived from a Rust handle (through
`__shirabeBind`) it proxies like a generated stub, while a natively-constructed instance (real
Composer code in the worker does `new PreCommandRunEvent(...)`, whose parent constructor lands