//! Bounded, stateless ES modules with JSON text boundaries. No host capabilities. use rquickjs::{Context, Ctx, Function, Module, Promise, Runtime, Value, context::intrinsic}; use std::{ cell::Cell, rc::Rc, time::{Duration, Instant}, }; /// Requested limits. Values are clamped by the evaluator, including zero. #[derive(Clone, Copy, Debug, Default)] pub struct Limits { pub max_time_ms: Option, pub max_memory_bytes: Option, pub max_stack_bytes: Option, } impl Limits { /// Effective time (1..1000ms), heap (256KiB..32MiB), stack (4..16KiB). pub fn clamped(self) -> Self { Self { max_time_ms: Some(self.max_time_ms.unwrap_or(100).clamp(1, 1000)), max_memory_bytes: Some( self.max_memory_bytes .unwrap_or(8 * 1024 * 1024) .clamp(256 * 1024, 32 * 1024 * 1024), ), max_stack_bytes: Some( self.max_stack_bytes .unwrap_or(16 * 1024) .clamp(4 * 1024, 16 * 1024), ), } } } /// Stable error categories, independent of QuickJS's exception wording. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ErrorKind { Limit, Syntax, Throw, NonJson, Engine, } /// An evaluation failure. Error text can include script-provided messages. #[derive(Debug, thiserror::Error)] #[error("{message}")] pub struct Error { pub kind: ErrorKind, pub message: String, } fn failure(kind: ErrorKind, message: impl Into) -> Error { Error { kind, message: message.into(), } } struct Budget { start: Instant, time: Duration, ticks: Cell, limited: Cell, } impl Budget { // A finite job/callback allowance is also needed for a stream of tiny jobs // and for hosts with a stalled monotonic clock. Not an instruction count. fn step(&self) -> bool { self.ticks.set(self.ticks.get().saturating_add(1)); let stop = self.ticks.get() >= 100_000 || self.start.elapsed() >= self.time; self.limited.set(self.limited.get() || stop); self.limited.get() } fn check(&self) -> Result<(), Error> { if self.step() { Err(failure( ErrorKind::Limit, "JavaScript execution limit exceeded", )) } else { Ok(()) } } } unsafe extern "C" { fn sausage_js_had_oom(ctx: *mut std::ffi::c_void) -> std::ffi::c_int; } fn memory_limited(ctx: &Ctx<'_>) -> bool { // The live Ctx owns this context and holds the runtime lock. unsafe { sausage_js_had_oom(ctx.as_raw().as_ptr().cast()) != 0 } } fn js_error(ctx: &Ctx<'_>, err: rquickjs::Error, phase: ErrorKind, budget: &Budget) -> Error { if memory_limited(ctx) { return failure(ErrorKind::Limit, "JavaScript memory limit exceeded"); } if budget.limited.get() { return failure(ErrorKind::Limit, "JavaScript execution limit exceeded"); } let mut kind = phase; let mut message = err.to_string(); if matches!(err, rquickjs::Error::Exception) { let thrown = ctx.catch(); if let Some(object) = thrown.as_object() { if let Ok(text) = object.get::<_, String>("message") { message = text; } // Only genuine engine Error instances determine resource/syntax kinds; // plain thrown objects remain throws even if they mimic the message. if thrown.is_error() { if message.contains("out of memory") || message.contains("stack size exceeded") { kind = ErrorKind::Limit; } else if object .get::<_, String>("name") .is_ok_and(|n| n == "SyntaxError") { kind = ErrorKind::Syntax; } } } else if let Some(text) = thrown.as_string() && let Ok(text) = text.to_string() { message = text; } } else if matches!(err, rquickjs::Error::Allocation) { kind = ErrorKind::Limit; } if memory_limited(ctx) { return failure( ErrorKind::Limit, "JavaScript memory limit exceeded while reading error", ); } if budget.check().is_err() { return failure( ErrorKind::Limit, "JavaScript execution limit exceeded while reading error", ); } failure(kind, message) } fn settle<'js>( ctx: &Ctx<'js>, promise: Promise<'js>, budget: &Budget, ) -> Result, Error> { loop { budget.check()?; if let Some(result) = promise.result::() { return result.map_err(|e| js_error(ctx, e, ErrorKind::Throw, budget)); } // This is synchronous queue draining, not polling an external resource. if !ctx.execute_pending_job() { return Err(failure( ErrorKind::Engine, "Promise is pending with no runnable jobs (no ambient async capabilities)", )); } } } /// Compile an ES module, call its selected export (default `default`) with parsed /// JSON, settle its returned Promise, and JSON.stringify the result. Fresh runtime /// each call. Only LLRT's atob/btoa globals are retained, with no module loader /// or ambient host capabilities. JSON.stringify /// semantics apply (unsupported object properties omitted, nonfinite => null). /// Limits cover compilation, execution, job draining and serialization; QuickJS /// interrupts are cooperative, not preemptive deadlines for every C builtin. pub fn evaluate( script: &str, input: &str, export: Option<&str>, limits: Limits, ) -> Result { let limits = limits.clamped(); let budget = Rc::new(Budget { start: Instant::now(), time: Duration::from_millis(limits.max_time_ms.unwrap_or(100)), ticks: Cell::new(0), limited: Cell::new(false), }); let rt = Runtime::new().map_err(|e| failure(ErrorKind::Engine, e.to_string()))?; rt.set_memory_limit(limits.max_memory_bytes.unwrap_or(8 * 1024 * 1024)); rt.set_max_stack_size(limits.max_stack_bytes.unwrap_or(16 * 1024)); let cb = budget.clone(); rt.set_interrupt_handler(Some(Box::new(move || cb.step()))); // LLRT's BasePrimordials needs Date during initialization, as well as the // pure collection/regexp/typed-array intrinsics. Date is removed before // user code runs; no network, filesystem, process or timer modules load. let context = Context::custom::<( intrinsic::Eval, intrinsic::Json, intrinsic::Promise, intrinsic::Date, intrinsic::MapSet, intrinsic::RegExp, intrinsic::Proxy, intrinsic::TypedArrays, )>(&rt) .map_err(|e| failure(ErrorKind::Limit, format!("context creation failed: {e}")))?; context.with(|ctx| { llrt_buffer::init(&ctx) .map_err(|e| js_error(&ctx, e, ErrorKind::Engine, &budget))?; // Retain only atob/btoa from the published buffer initializer. Buffer's // external allocations bypass the heap limit; Blob/File are unnecessary // and File exposes the clock. Buffer has both a global lexical class // binding and a globalThis property: clear both. Date is init-only. // Disable QuickJS's structured stack hook before it can expose call sites. ctx.eval::<(), _>("Object.defineProperty(Error, 'prepareStackTrace', { value: undefined, writable: false, configurable: false }); Buffer = undefined; delete globalThis.Buffer; delete Math.random; delete globalThis.Date; delete globalThis.Blob; delete globalThis.File; for (const name of ['WebAssembly', 'SharedArrayBuffer', 'Atomics', 'Intl', '__infra', '__bootstrap', 'queueMicrotask']) delete globalThis[name];") .map_err(|e| js_error(&ctx, e, ErrorKind::Engine, &budget))?; let input = ctx .json_parse(input) .map_err(|e| js_error(&ctx, e, ErrorKind::Syntax, &budget))?; let module = Module::declare(ctx.clone(), "jsrun:entry", script) .map_err(|e| js_error(&ctx, e, ErrorKind::Syntax, &budget))?; let (module, ready) = module .eval() .map_err(|e| js_error(&ctx, e, ErrorKind::Throw, &budget))?; settle(&ctx, ready, &budget)?; let function: Function = module .get(export.unwrap_or("default")) .map_err(|e| js_error(&ctx, e, ErrorKind::Engine, &budget))?; let mut value: Value = function .call((input,)) .map_err(|e| js_error(&ctx, e, ErrorKind::Throw, &budget))?; if let Some(promise) = value.as_promise() { value = settle(&ctx, promise.clone(), &budget)?; } let text = ctx .json_stringify(value) .map_err(|e| js_error(&ctx, e, ErrorKind::NonJson, &budget))? .ok_or_else(|| { failure( ErrorKind::NonJson, "result is undefined, a function, or otherwise not JSON serializable", ) })? .to_string() .map_err(|e| js_error(&ctx, e, ErrorKind::NonJson, &budget))?; budget.check()?; Ok(text) }) } #[cfg(test)] mod tests;