diff options
Diffstat (limited to 'crates/shirabe-php-rpc/src/value.rs')
| -rw-r--r-- | crates/shirabe-php-rpc/src/value.rs | 290 |
1 files changed, 251 insertions, 39 deletions
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