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/src | |
| 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/src')
| -rw-r--r-- | crates/shirabe/src/plugin/php_plugin_value.rs | 478 |
1 files changed, 324 insertions, 154 deletions
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<Utc>` 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<PluginValue>, - calls: Vec<(&str, Vec<PluginValue>)>, -) -> 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<String> { @@ -99,79 +68,101 @@ fn required_string(context: &str, value: Option<&PluginValue>) -> Result<String, } } -/// The `setPrettyString` call a constraint needs after construction. PHP stores the pretty -/// string in a protected field that no constructor takes, and leaving it unset is observable -/// (`getPrettyString()` falls back to the string form), so an unset one is sent as null. -fn pretty_string_call(constraint: &AnyConstraint) -> Vec<(&'static str, Vec<PluginValue>)> { - 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<String> { - 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<AnyConstraint, PhpThrow> { - 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<AnyConstraint, PhpThrow> "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<AnyConstraint, PhpThrow> } 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<Link, PhpThrow> { - 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<Utc>) -> 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<DateTime<Utc>, 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); + } } |
