From 2f28d8112970960dbb9b6b582a3c6cd259337d21 Mon Sep 17 00:00:00 2001 From: nsfisis Date: Mon, 3 Aug 2026 01:05:57 +0900 Subject: feat(php-rpc): rework the RPC channel into the tagged plugin protocol Replace the name\0arg framing with the plugin wire protocol: tagged frames with corr_id multiplexing, a MAX_FRAME_LEN bound, a thread-ID based reentrant SessionLock, and the PluginValue codec (encoder plus the first recursive decoder, iterative with a 512-level depth cap). Float formatting is ported from php-src into shirabe-php-src so the encoder is byte-compatible with serialize() under serialize_precision=-1, which the spawned worker now pins. The worker gains a standing dispatch loop, CallRustMethod reentrancy, hand-written Event proxy stubs, and explicit-error answers for everything not implemented yet. The public query API (get_php_version and friends) is unchanged and now rides the new protocol; the codec is verified against real PHP by roundtrip oracle tests covering floats, non-UTF-8 bytes and deep nesting. Co-Authored-By: Claude Fable 5 --- crates/shirabe-php-rpc/tests/oracle.rs | 199 +++++++++++++++++++++++++++++++++ 1 file changed, 199 insertions(+) create mode 100644 crates/shirabe-php-rpc/tests/oracle.rs (limited to 'crates/shirabe-php-rpc/tests/oracle.rs') diff --git a/crates/shirabe-php-rpc/tests/oracle.rs b/crates/shirabe-php-rpc/tests/oracle.rs new file mode 100644 index 00000000..4220788a --- /dev/null +++ b/crates/shirabe-php-rpc/tests/oracle.rs @@ -0,0 +1,199 @@ +//! Oracle tests: the `PluginValue` codec against the real PHP `serialize()`/`unserialize()`. +//! +//! Encode direction: bytes produced by the Rust encoder are unserialized and re-serialized by +//! the PHP core implementation; the result must be byte-identical. Decode direction: bytes +//! produced by PHP `serialize()` must decode into a `PluginValue` whose re-encoding is +//! byte-identical. Floats (serialize_precision), non-UTF-8 byte strings and deep nesting are the +//! focus areas. + +use indexmap::IndexMap; +use shirabe_external_packages::symfony::process::PhpExecutableFinder; +use shirabe_php_rpc::value::{serialize, unserialize}; +use shirabe_php_rpc::{PluginValue, call_function}; + +fn php_available() -> bool { + PhpExecutableFinder::new().find(false).is_some() +} + +/// Feeds raw serialize() bytes through the PHP core codec and returns what PHP re-serializes. +fn php_reserialize(bytes: &[u8]) -> Vec { + let outcome = call_function( + "__shirabe_oracle_roundtrip", + vec![PluginValue::String(bytes.to_vec())], + ) + .expect("oracle roundtrip request failed"); + match outcome.expect("oracle roundtrip threw") { + PluginValue::String(bytes) => bytes, + other => panic!("oracle roundtrip returned a non-string: {other:?}"), + } +} + +/// Runs a PHP snippet and returns its `return` value. +fn php_eval(code: &str) -> PluginValue { + call_function("__shirabe_eval", vec![PluginValue::string(code)]) + .expect("eval request failed") + .expect("eval threw") +} + +fn assert_php_agrees(value: &PluginValue) { + let encoded = serialize(value); + let reserialized = php_reserialize(&encoded); + assert_eq!( + String::from_utf8_lossy(&reserialized), + String::from_utf8_lossy(&encoded), + "PHP re-serialized {value:?} differently" + ); +} + +#[test] +fn encode_direction_matches_php_for_scalars_and_floats() { + if !php_available() { + return; + } + + let floats = [ + 0.0, + -0.0, + 1.5, + 0.1, + 2.0, + -2.0, + 100.0, + 1e15, + 1e16, + 1e17, + 1e18, + 1e20, + 1.5e20, + 1e-4, + 1e-5, + 12345.6789e-9, + 1e-300, + f64::MAX, + 5e-324, + 1.0 / 3.0, + 0.30000000000000004, + f64::NAN, + f64::INFINITY, + f64::NEG_INFINITY, + ]; + for f in floats { + assert_php_agrees(&PluginValue::Float(f)); + } + + for value in [ + PluginValue::Null, + PluginValue::Bool(true), + PluginValue::Bool(false), + PluginValue::Int(0), + PluginValue::Int(i64::MAX), + PluginValue::Int(i64::MIN), + PluginValue::string(""), + PluginValue::string("héllo wörld"), + ] { + assert_php_agrees(&value); + } +} + +#[test] +fn encode_direction_matches_php_for_non_utf8_bytes() { + if !php_available() { + return; + } + + assert_php_agrees(&PluginValue::String(vec![0xff, 0x00, 0xfe, 0x80, b'"'])); + assert_php_agrees(&PluginValue::String((0u8..=255).collect())); + + let mut map: IndexMap, PluginValue> = IndexMap::new(); + map.insert(vec![0xff, 0x00], PluginValue::String(vec![0x80])); + map.insert(b"05".to_vec(), PluginValue::Bool(true)); + map.insert(b"5".to_vec(), PluginValue::Null); + assert_php_agrees(&PluginValue::Array(map)); +} + +#[test] +fn encode_direction_matches_php_for_nested_arrays() { + if !php_available() { + return; + } + + let mut inner: IndexMap, PluginValue> = IndexMap::new(); + inner.insert(b"a".to_vec(), PluginValue::Int(1)); + inner.insert( + b"b".to_vec(), + PluginValue::List(vec![ + PluginValue::Float(0.1), + PluginValue::string("x"), + PluginValue::List(vec![]), + ]), + ); + assert_php_agrees(&PluginValue::Array(inner)); + + let mut deep = PluginValue::Null; + for _ in 0..64 { + deep = PluginValue::List(vec![deep]); + } + assert_php_agrees(&deep); +} + +#[test] +fn decode_direction_matches_php_serialize_output() { + if !php_available() { + return; + } + + let snippets = [ + // Mixed key types: PHP canonicalizes "5" to an int key, keeps "05" as a string. + r#"return serialize(["a" => 1, 5 => true, "05" => [1, 2, [0.5]], "z" => null]);"#, + // Non-UTF-8 byte strings, both as values and as keys. + "return serialize([\"\\xff\\x00key\" => \"\\x80\\x81\", 0 => \"plain\"]);", + // Floats straight from the PHP formatter. + r#"return serialize([0.1, 2.0, 1e17, 1e-5, -0.0, NAN, INF, -INF, 1/3]);"#, + // A sparse int-keyed array (not a list). + r#"return serialize([3 => "c", 1 => "a"]);"#, + // Deep nesting built in a loop. + r#"$v = "leaf"; for ($i = 0; $i < 256; $i++) { $v = [$v]; } return serialize($v);"#, + ]; + + 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:#}")); + let reencoded = serialize(&decoded); + assert_eq!( + String::from_utf8_lossy(&reencoded), + String::from_utf8_lossy(&php_bytes), + "re-encoding diverged for `{snippet}`" + ); + } +} + +#[test] +fn decode_direction_distinguishes_lists_from_maps() { + if !php_available() { + return; + } + + let PluginValue::String(bytes) = php_eval(r#"return serialize([10, 20, 30]);"#) else { + panic!("expected serialized bytes"); + }; + assert_eq!( + unserialize(&bytes).unwrap(), + PluginValue::List(vec![ + PluginValue::Int(10), + PluginValue::Int(20), + PluginValue::Int(30), + ]), + ); + + let PluginValue::String(bytes) = php_eval(r#"return serialize([1 => 10, 0 => 20]);"#) else { + panic!("expected serialized bytes"); + }; + let decoded = unserialize(&bytes).unwrap(); + assert!( + matches!(decoded, PluginValue::Array(_)), + "out-of-order int keys must not decode as a list: {decoded:?}" + ); +} -- cgit v1.3.1