Files
sausage/host/cred-script/src/lib.rs
T

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;