Files
PiCloud/crates/executor-core/src/sdk/bridge.rs
MechaCat02 bdcd3dc7f4 fix(executor): bound Rhai→JSON materialization to prevent anonymous OOM
The per-element Rhai sandbox caps (max_string_size 64 KiB, max_array_size /
max_map_size 10 000) do NOT bound the *materialized* size: Rhai shares strings
and arrays by Rc, so a 10 000-element array of one aliased 64 KiB string is
cheap to build (~10 000 ops, far under the 1 M op budget) yet dealiases to
~640 MiB of distinct JSON. Every Dynamic→JSON conversion deep-copies the
aliases; the HTTP response body path had NO size cap at all, and the KV/docs
value caps run only AFTER full materialization — so a handful of anonymous
requests could OOM the node (32 concurrent × ~640 MiB ≈ 20 GiB) on the stated
consumer-hardware target.

Add a byte-budgeted materializer that bails the moment the budget is exceeded,
so the transient allocation is bounded regardless of aliasing:
- bridge.rs: `dynamic_to_json_capped(value, max) -> Result<Json, JsonSizeError>`
  (charges a running budget) + `MAX_JSON_MATERIALIZE_BYTES` (16 MiB hard rail,
  far above the 256 KiB business caps). `dynamic_to_json` stays infallible for
  best-effort paths (logging) but is now internally bounded — it collapses an
  over-limit value to a marker string instead of OOMing.
- Route every user-value boundary through the fail-closed capped form: KV
  set/set_if (+ the CAS `expected`), docs create/update/find, secrets set,
  http request body, workflow input, `json::stringify`, and the HTTP RESPONSE
  body (previously the one fully-uncapped exit → now a 500). `invoke` args and
  the pubsub/queue message materializers get the same byte budget in place.
- `impl From<JsonSizeError> for Box<EvalAltResult>` so SDK sites protect
  themselves with a bare `?`.

Pinned by bridge unit tests: an aliased 10 000×64 KiB array errors on the
budget rather than materializing, and the infallible wrapper yields a marker
instead of OOMing. Workspace 914 + journeys 157/157 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-13 21:50:16 +02:00

258 lines
9.7 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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<T, E, F>(service: &str, fut: F) -> Result<T, Box<EvalAltResult>>
where
F: std::future::Future<Output = Result<T, E>>,
E: std::fmt::Display,
{
let handle = TokioHandle::try_current().map_err(|e| -> Box<EvalAltResult> {
EvalAltResult::ErrorRuntime(
format!("{service}: no tokio runtime available: {e}").into(),
rhai::Position::NONE,
)
.into()
})?;
handle.block_on(fut).map_err(|err| -> Box<EvalAltResult> {
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<EvalAltResult>` (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> {
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::<Vec<Dynamic>>()
.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<JsonSizeError> for Box<EvalAltResult> {
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("<value too large>".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<Json, JsonSizeError> {
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<Json, JsonSizeError> {
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::<rhai::Array>() {
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::<Map>() {
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("<value too large>".into()));
}
}