244 lines
9.2 KiB
Rust
244 lines
9.2 KiB
Rust
//! 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<u64>,
|
|
pub max_memory_bytes: Option<usize>,
|
|
pub max_stack_bytes: Option<usize>,
|
|
}
|
|
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<String>) -> Error {
|
|
Error {
|
|
kind,
|
|
message: message.into(),
|
|
}
|
|
}
|
|
|
|
struct Budget {
|
|
start: Instant,
|
|
time: Duration,
|
|
ticks: Cell<u32>,
|
|
limited: Cell<bool>,
|
|
}
|
|
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<Value<'js>, Error> {
|
|
loop {
|
|
budget.check()?;
|
|
if let Some(result) = promise.result::<Value>() {
|
|
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<String, Error> {
|
|
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;
|