From 9a393adc0ace86cac788723b524e83c63dfc91c1 Mon Sep 17 00:00:00 2001 From: nsfisis Date: Fri, 7 Aug 2026 05:11:55 +0900 Subject: 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) --- .../php/runtime/Shirabe/MaterializedValue.php | 98 +---- crates/shirabe-php-rpc/php/worker.php | 22 +- crates/shirabe-php-rpc/src/lib.rs | 2 +- crates/shirabe-php-rpc/src/value.rs | 290 +++++++++++-- crates/shirabe-php-rpc/tests/oracle.rs | 79 +++- crates/shirabe/src/plugin/php_plugin_value.rs | 478 ++++++++++++++------- docs/dev/php-rpc.md | 47 +- 7 files changed, 712 insertions(+), 304 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 $args - * @return array{__pnew: string, __args: list, __calls: list}>} - */ - 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, PluginValue>, +} + +impl PhpObject { + pub fn new(class: impl Into) -> 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 { + 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), Array(IndexMap, PluginValue>), Object(IndexMap, 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::>()?, ), - 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) { } 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, PluginValue> = IndexMap::new(); map.insert( @@ -267,10 +333,10 @@ fn canonical_int_key(key: &[u8]) -> Option { /// 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 { let mut pos = 0; let value = parse_value(payload, &mut pos)?; @@ -280,18 +346,24 @@ pub fn unserialize(payload: &[u8]) -> anyhow::Result { 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), + 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, PluginValue>, + /// The class name of an object record; `None` for an array. + class: Option, + /// 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 { - let mut stack: Vec = Vec::new(); + let mut stack: Vec = Vec::new(); let mut completed: Option = 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 = 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 { } *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 { 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 { 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 { } } -fn finish_array(frame: ArrayFrame) -> anyhow::Result { +fn finish_frame(frame: Frame) -> anyhow::Result { + 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 { }) } +/// The `:{` a container's entries follow. +fn parse_entry_count(payload: &[u8], pos: &mut usize) -> anyhow::Result { + 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> { + parse_quoted(payload, pos, b';') +} + +/// A length-prefixed quoted run: `:""` followed by `terminator`. +fn parse_quoted(payload: &[u8], pos: &mut usize, terminator: u8) -> anyhow::Result> { 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> 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` 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, - calls: Vec<(&str, Vec)>, -) -> 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 { @@ -99,79 +68,101 @@ fn required_string(context: &str, value: Option<&PluginValue>) -> Result Vec<(&'static str, Vec)> { - 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 { - 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 { - 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 "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 } 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 { - 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) -> 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, 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` — 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 -- cgit v1.3.1