diff options
| author | nsfisis <nsfisis@gmail.com> | 2026-08-07 05:11:55 +0900 |
|---|---|---|
| committer | nsfisis <nsfisis@gmail.com> | 2026-08-07 05:11:55 +0900 |
| commit | 9a393adc0ace86cac788723b524e83c63dfc91c1 (patch) | |
| tree | fadac10efa6518c1a5d4d16f829da6969229ce09 /crates/shirabe-php-rpc | |
| parent | 14a8a474120ef4289625f05ba5d87fa9e9d19542 (diff) | |
| download | php-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>
Diffstat (limited to 'crates/shirabe-php-rpc')
| -rw-r--r-- | crates/shirabe-php-rpc/php/runtime/Shirabe/MaterializedValue.php | 98 | ||||
| -rw-r--r-- | crates/shirabe-php-rpc/php/worker.php | 22 | ||||
| -rw-r--r-- | crates/shirabe-php-rpc/src/lib.rs | 2 | ||||
| -rw-r--r-- | crates/shirabe-php-rpc/src/value.rs | 290 | ||||
| -rw-r--r-- | crates/shirabe-php-rpc/tests/oracle.rs | 79 |
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() { |
