//! JSON ↔ Rhai `Dynamic` value bridge. //! //! Originally inline in `engine.rs`; moved here for v1.1.0 so future //! service modules (KV in v1.1.1, docs in v1.1.2, …) can convert //! values without `engine.rs` being the only owner of the conversions. //! Behaviour is unchanged from the pre-extraction implementation — //! `sdk_contract.rs::json_round_trip_preserves_nested_shapes` pins the //! observable round-trip. use rhai::{Dynamic, EvalAltResult, Map}; use serde_json::Value as Json; use tokio::runtime::Handle as TokioHandle; /// Run an async future inside the synchronous Rhai context. /// /// `LocalExecutorClient` wraps script execution in `spawn_blocking`, so /// the current Tokio runtime is reachable via `Handle::current()`. We /// block on it directly; we are NOT calling this from an async task, /// so blocking is the correct primitive. /// /// Prefix each error string with `service` so a script reading the /// runtime error message learns which SDK surface threw it. /// /// # Errors /// /// Wraps the future's error variant or "no tokio runtime available" in /// `EvalAltResult::ErrorRuntime`, suitable to return from a Rhai- /// registered fn. pub fn block_on(service: &str, fut: F) -> Result> where F: std::future::Future>, E: std::fmt::Display, { let handle = TokioHandle::try_current().map_err(|e| -> Box { EvalAltResult::ErrorRuntime( format!("{service}: no tokio runtime available: {e}").into(), rhai::Position::NONE, ) .into() })?; handle.block_on(fut).map_err(|err| -> Box { EvalAltResult::ErrorRuntime(format!("{service}: {err}").into(), rhai::Position::NONE).into() }) } /// Build a Rhai runtime error from a message. Returns the boxed error /// directly because every caller needs a `Box` (Rhai's error /// type) — the shared home for the pattern the SDK bridges (secrets, email, /// users, …) previously each copied. #[allow(clippy::unnecessary_box_returns)] pub(crate) fn runtime_err(msg: &str) -> Box { EvalAltResult::ErrorRuntime(msg.into(), rhai::Position::NONE).into() } /// Convert a `serde_json::Value` into a Rhai `Dynamic` suitable for /// pushing into a script's scope. Numbers prefer the narrowest type /// (`i64` over `f64`); anything that can't round-trip falls back to a /// string so the script always sees a defined value. pub fn json_to_dynamic(value: Json) -> Dynamic { match value { Json::Null => Dynamic::UNIT, Json::Bool(b) => b.into(), Json::Number(n) => { if let Some(i) = n.as_i64() { i.into() } else if let Some(f) = n.as_f64() { f.into() } else { n.to_string().into() } } Json::String(s) => s.into(), Json::Array(arr) => arr .into_iter() .map(json_to_dynamic) .collect::>() .into(), Json::Object(obj) => { let mut m = Map::new(); for (k, v) in obj { m.insert(k.into(), json_to_dynamic(v)); } Dynamic::from(m) } } } /// Hard ceiling on the byte size of a single Rhai→JSON materialization. /// /// The per-element Rhai sandbox caps (`max_string_size` 64 KiB, /// `max_array_size`/`max_map_size` 10 000) do NOT bound the *materialized* /// total: Rhai shares strings/arrays by `Rc`, so a 10 000-element array of one /// aliased 64 KiB string is cheap to build (~10 000 ops) yet dealiases to /// ~640 MiB of distinct JSON. Without a ceiling a handful of anonymous requests /// could OOM the node. `dynamic_to_json_capped` charges a running byte budget /// and bails as soon as it is exceeded, so the transient allocation is bounded /// to roughly this ceiling regardless of aliasing. This is far above any /// legitimate single value (the KV/docs value caps are 256 KiB); it is a /// last-resort anti-OOM rail, not a business limit. pub const MAX_JSON_MATERIALIZE_BYTES: usize = 16 * 1024 * 1024; /// A Rhai value was too large to materialize into JSON within its byte budget. #[derive(Debug, Clone, Copy)] pub struct JsonSizeError { pub limit: usize, } impl std::fmt::Display for JsonSizeError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!( f, "value too large to serialize to JSON (exceeds the {}-byte limit)", self.limit ) } } impl std::error::Error for JsonSizeError {} impl From for Box { fn from(e: JsonSizeError) -> Self { runtime_err(&e.to_string()) } } /// Convert a Rhai `Dynamic` back to a `serde_json::Value`, **infallibly** and /// **bounded** by [`MAX_JSON_MATERIALIZE_BYTES`]. A value that would exceed the /// ceiling collapses to a short marker string rather than OOMing — safe for /// best-effort paths (logging) where a hard error is undesirable. Paths where /// silently substituting a marker would be wrong (writes, the HTTP response /// body, `invoke` args) must use [`dynamic_to_json_capped`] and surface the /// error instead. /// /// Custom Rhai types (timestamps, user-registered modules) fall back to their /// `Display` form so they appear as strings rather than failing the build. #[must_use] pub fn dynamic_to_json(value: &Dynamic) -> Json { dynamic_to_json_capped(value, MAX_JSON_MATERIALIZE_BYTES) .unwrap_or_else(|_| Json::String("".to_string())) } /// Bounded `Dynamic`→JSON conversion: materializes `value`, charging a running /// byte budget starting at `max_bytes`, and returns [`JsonSizeError`] the moment /// the budget is exceeded — so it never builds the full oversized tree. /// /// # Errors /// Returns [`JsonSizeError`] if the materialized JSON would exceed `max_bytes`. pub fn dynamic_to_json_capped(value: &Dynamic, max_bytes: usize) -> Result { let mut remaining = max_bytes; to_json_bounded(value, &mut remaining, max_bytes) } /// Deduct `n` bytes from `remaining`, or fail if the budget is exhausted. fn charge(remaining: &mut usize, n: usize, limit: usize) -> Result<(), JsonSizeError> { match remaining.checked_sub(n) { Some(left) => { *remaining = left; Ok(()) } None => Err(JsonSizeError { limit }), } } fn to_json_bounded( value: &Dynamic, remaining: &mut usize, limit: usize, ) -> Result { if value.is_unit() { charge(remaining, 4, limit)?; return Ok(Json::Null); } if let Ok(b) = value.as_bool() { charge(remaining, 5, limit)?; return Ok(Json::Bool(b)); } if let Ok(i) = value.as_int() { charge(remaining, 8, limit)?; return Ok(Json::Number(i.into())); } if let Ok(f) = value.as_float() { charge(remaining, 8, limit)?; return Ok(serde_json::Number::from_f64(f).map_or(Json::Null, Json::Number)); } if value.is_string() { let s = value.clone().into_string().unwrap_or_default(); charge(remaining, s.len() + 2, limit)?; return Ok(Json::String(s)); } if let Some(arr) = value.clone().try_cast::() { charge(remaining, 2, limit)?; // "[]" let mut out = Vec::with_capacity(arr.len().min(1024)); for v in &arr { charge(remaining, 1, limit)?; // "," out.push(to_json_bounded(v, remaining, limit)?); } return Ok(Json::Array(out)); } if let Some(map) = value.clone().try_cast::() { charge(remaining, 2, limit)?; // "{}" let mut out = serde_json::Map::new(); for (k, v) in map { charge(remaining, k.len() + 4, limit)?; // "key":, out.insert(k.to_string(), to_json_bounded(&v, remaining, limit)?); } return Ok(Json::Object(out)); } let s = value.to_string(); charge(remaining, s.len() + 2, limit)?; Ok(Json::String(s)) } #[cfg(test)] mod tests { use super::*; use rhai::{Array, Dynamic}; #[test] fn small_value_round_trips_under_the_cap() { let d = Dynamic::from("hello"); assert_eq!( dynamic_to_json_capped(&d, MAX_JSON_MATERIALIZE_BYTES).unwrap(), Json::String("hello".into()) ); } #[test] fn aliased_large_array_is_rejected_not_materialized() { // The OOM regression: one 64 KiB string aliased 10 000×. Cheap to build // (Rc-shared), ~640 MiB if dealiased. The bounded converter must bail on // the byte budget instead of allocating the full tree. let big = "x".repeat(64 * 1024); let mut arr = Array::new(); for _ in 0..10_000 { arr.push(Dynamic::from(big.clone())); // ImmutableString: shared, cheap } let d = Dynamic::from(arr); // A 1 MiB budget is far below the ~640 MiB dealiased size → must error. let err = dynamic_to_json_capped(&d, 1024 * 1024).unwrap_err(); assert_eq!(err.limit, 1024 * 1024); } #[test] fn infallible_wrapper_yields_marker_instead_of_oom() { // ~300 × 64 KiB aliased ≈ 19 MiB dealiased, just over the 16 MiB default // ceiling — enough to trip the infallible wrapper's marker fallback // without paying to build a pathologically huge test array. let big = "x".repeat(64 * 1024); let mut arr = Array::new(); for _ in 0..300 { arr.push(Dynamic::from(big.clone())); } // The infallible path must NOT OOM — it collapses to a marker string. let out = dynamic_to_json(&Dynamic::from(arr)); assert_eq!(out, Json::String("".into())); } }