aboutsummaryrefslogtreecommitdiffhomepage
path: root/crates/shirabe-php-rpc/tests
diff options
context:
space:
mode:
authornsfisis <nsfisis@gmail.com>2026-08-07 05:11:55 +0900
committernsfisis <nsfisis@gmail.com>2026-08-07 05:11:55 +0900
commit9a393adc0ace86cac788723b524e83c63dfc91c1 (patch)
treefadac10efa6518c1a5d4d16f829da6969229ce09 /crates/shirabe-php-rpc/tests
parent14a8a474120ef4289625f05ba5d87fa9e9d19542 (diff)
downloadphp-shirabe-9a393adc0ace86cac788723b524e83c63dfc91c1.tar.gz
php-shirabe-9a393adc0ace86cac788723b524e83c63dfc91c1.tar.zst
php-shirabe-9a393adc0ace86cac788723b524e83c63dfc91c1.zip
feat(php-rpc): cross materialized values as PHP object records
A materialized value used to cross as a constructor call: the class name, the arguments, and any post-construction setter. Describing a real instance that way needed a ReflectionProperty read for every field the class exposes no getter for, and state no constructor takes (an unset pretty string, a Link built without a pretty constraint) had no faithful call to describe it at all. The value now crosses as the object record serialize() writes for it, which unserialize() revives without running a constructor, so both sides transfer the state itself instead of a recipe for rebuilding it. The PHP half keeps only the class list (also the allowed_classes list of every frame payload) and the UTC rebasing of dates; describe(), build() and the reflection are gone. The wire codec gains O: records (PluginValue::PhpObject) and r: back references, whose resolution reproduces PHP's numbering of every value in a payload; a cyclic object graph and a PHP reference (R:) are rejected. Two behaviours change with it: a date crosses carrying timezone_type 3 "UTC" rather than a +00:00 offset, which is what ArrayLoader builds a release date as, and a Link subclass crosses as a P-table entity instead of being silently downgraded to a plain Link. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Diffstat (limited to 'crates/shirabe-php-rpc/tests')
-rw-r--r--crates/shirabe-php-rpc/tests/oracle.rs79
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() {