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/tests/oracle.rs | |
| 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/tests/oracle.rs')
| -rw-r--r-- | crates/shirabe-php-rpc/tests/oracle.rs | 79 |
1 files changed, 78 insertions, 1 deletions
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() { |
