Files
sausage/host/src/cred/quickjs.rs
T

276 lines
10 KiB
Rust

//! Credential scripts run in a fresh, memory-limited WASI instance. Only two calls run at once.
use super::script::{Error, ErrorKind, Limits, Script};
use std::{
sync::{Arc, Condvar, Mutex, OnceLock, mpsc},
time::{Duration, Instant},
};
use wasmtime::{
Config, Engine, Store, StoreLimits, StoreLimitsBuilder, UpdateDeadline,
component::{Component, Linker, ResourceTable},
};
use wasmtime_wasi::{
WasiCtx, WasiCtxBuilder, WasiCtxView, WasiView,
clocks::HostWallClock,
p2::{
bindings::sync::CommandPre,
pipe::{MemoryInputPipe, MemoryOutputPipe},
},
random::Deterministic,
};
use zeroize::Zeroizing;
struct State {
wasi: WasiCtx,
table: ResourceTable,
limits: StoreLimits,
}
impl WasiView for State {
fn ctx(&mut self) -> WasiCtxView<'_> {
WasiCtxView {
ctx: &mut self.wasi,
table: &mut self.table,
}
}
}
struct FixedClock;
impl HostWallClock for FixedClock {
fn resolution(&self) -> Duration {
Duration::from_millis(1)
}
fn now(&self) -> Duration {
Duration::ZERO
}
}
struct Compiled {
engine: Engine,
pre: CommandPre<State>,
active: Mutex<usize>,
available: Condvar,
}
struct Slot<'a>(&'a Compiled);
impl Drop for Slot<'_> {
fn drop(&mut self) {
*self.0.active.lock().unwrap() -= 1;
self.0.available.notify_one();
}
}
impl Compiled {
fn new() -> Result<Arc<Self>, Error> {
let mut config = Config::new();
config.epoch_interruption(true);
// The cache also keeps warm test runs from compiling the component again.
config.cache(wasmtime::Cache::new(Default::default()).ok());
let engine = Engine::new(&config).map_err(|_| failure(ErrorKind::Engine))?;
let component = Component::new(&engine, include_bytes!("../../cred-script/script.wasm"))
.map_err(|_| failure(ErrorKind::Engine))?;
let mut linker = Linker::new(&engine);
wasmtime_wasi::p2::add_to_linker_sync(&mut linker)
.map_err(|_| failure(ErrorKind::Engine))?;
let pre = CommandPre::new(
linker
.instantiate_pre(&component)
.map_err(|_| failure(ErrorKind::Engine))?,
)
.map_err(|_| failure(ErrorKind::Engine))?;
Ok(Arc::new(Self {
engine,
pre,
active: Mutex::new(0),
available: Condvar::new(),
}))
}
fn acquire(&self, deadline: Instant) -> Result<Slot<'_>, Error> {
let mut active = self.active.lock().map_err(|_| failure(ErrorKind::Engine))?;
while *active >= 2 {
let remaining = deadline
.checked_duration_since(Instant::now())
.ok_or_else(|| failure(ErrorKind::Limit))?;
(active, _) = self
.available
.wait_timeout(active, remaining)
.map_err(|_| failure(ErrorKind::Engine))?;
}
if Instant::now() >= deadline {
return Err(failure(ErrorKind::Limit));
}
*active += 1;
Ok(Slot(self))
}
}
static COMPILED: OnceLock<Result<Arc<Compiled>, Error>> = OnceLock::new();
/// Construct once at startup. Compilation is cached; each evaluation gets a fresh Store.
#[derive(Clone)]
pub struct QuickJs {
compiled: Result<Arc<Compiled>, Error>,
}
impl Default for QuickJs {
fn default() -> Self {
Self {
compiled: COMPILED.get_or_init(Compiled::new).clone(),
}
}
}
fn failure(kind: ErrorKind) -> Error {
Error {
kind,
message: match kind {
ErrorKind::Limit => "script time, memory or stack limit exceeded",
ErrorKind::Syntax => "invalid script or JSON",
ErrorKind::Throw => "script threw or rejected",
ErrorKind::NonJson => "result is not JSON",
ErrorKind::Engine => "script engine failure",
}
.into(),
}
}
impl Script for QuickJs {
fn evaluate(
&self,
source: &str,
input: &str,
export: Option<&str>,
limits: Limits,
) -> Result<String, Error> {
let compiled = self.compiled.as_ref().map_err(Clone::clone)?;
let time = limits.max_time_ms.unwrap_or(100).clamp(1, 1000);
let heap = limits
.max_memory_bytes
.unwrap_or(8 * 1024 * 1024)
.clamp(256 * 1024, 32 * 1024 * 1024);
let deadline = Instant::now() + Duration::from_millis(time);
let _slot = compiled.acquire(deadline)?;
if source
.len()
.saturating_add(input.len())
.saturating_add(export.map_or(0, str::len))
> heap
{
return Err(failure(ErrorKind::Limit));
}
// WASI's synchronous adapter must not enter a caller's Tokio runtime.
std::thread::scope(|scope| {
let worker = std::thread::Builder::new()
.name("cred-script".into())
.spawn_scoped(scope, move || {
#[derive(serde::Serialize)]
struct Request<'a> {
source: &'a str,
input: &'a str,
export: Option<&'a str>,
max_time_ms: u64,
max_memory_bytes: usize,
}
let wire = Zeroizing::new(
serde_json::to_vec(&Request {
source,
input,
export,
max_time_ms: time,
max_memory_bytes: heap,
})
.map_err(|_| failure(ErrorKind::Engine))?,
);
let memory = heap + 8 * 1024 * 1024;
let output = MemoryOutputPipe::new(memory);
let mut wasi = WasiCtxBuilder::new();
wasi.stdin(MemoryInputPipe::new(bytes::Bytes::from_owner(wire)))
.stdout(output.clone())
.wall_clock(FixedClock)
.secure_random(Deterministic::new(vec![0]))
.insecure_random(Deterministic::new(vec![0]))
.insecure_random_seed(0)
.allow_tcp(false)
.allow_udp(false)
.allow_ip_name_lookup(false);
let mut store = Store::new(
&compiled.engine,
State {
wasi: wasi.build(),
table: ResourceTable::new(),
limits: StoreLimitsBuilder::new()
.memory_size(memory)
.memories(1)
.trap_on_grow_failure(true)
.build(),
},
);
store.limiter(|state| &mut state.limits);
store.epoch_deadline_callback(move |_| {
if Instant::now() >= deadline {
Err(wasmtime::Trap::Interrupt.into())
} else {
Ok(UpdateDeadline::Continue(1))
}
});
store.set_epoch_deadline(1);
let (done, wait) = mpsc::channel();
let engine = compiled.engine.clone();
let watchdog = std::thread::Builder::new()
.name("cred-script-deadline".into())
.spawn(move || {
if wait
.recv_timeout(deadline.saturating_duration_since(Instant::now()))
.is_err()
{
engine.increment_epoch();
}
})
.map_err(|_| failure(ErrorKind::Engine))?;
let result = compiled
.pre
.instantiate(&mut store)
.and_then(|command| command.wasi_cli_run().call_run(&mut store));
let _ = done.send(());
let _ = watchdog.join();
if Instant::now() >= deadline || !matches!(result, Ok(Ok(()))) {
return Err(failure(ErrorKind::Limit));
}
#[derive(serde::Deserialize)]
struct Response {
result: Option<String>,
kind: Option<String>,
}
let response: Response = serde_json::from_slice(&output.contents())
.map_err(|_| failure(ErrorKind::Engine))?;
if Instant::now() >= deadline {
return Err(failure(ErrorKind::Limit));
}
if let Some(result) = response.result {
return Ok(result);
}
Err(failure(match response.kind.as_deref() {
Some("Limit") => ErrorKind::Limit,
Some("Syntax") => ErrorKind::Syntax,
Some("Throw") => ErrorKind::Throw,
Some("NonJson") => ErrorKind::NonJson,
_ => ErrorKind::Engine,
}))
})
.map_err(|_| failure(ErrorKind::Engine))?;
worker.join().map_err(|_| failure(ErrorKind::Engine))?
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn concurrency_is_bounded_and_queue_uses_the_deadline() {
let runtime = QuickJs::default();
let compiled = runtime.compiled.as_ref().unwrap();
let deadline = Instant::now() + Duration::from_secs(1);
let first = compiled.acquire(deadline).unwrap();
let second = compiled.acquire(deadline).unwrap();
assert!(
matches!(compiled.acquire(Instant::now()), Err(error) if error.kind == ErrorKind::Limit)
);
drop(first);
let third = compiled.acquire(deadline).unwrap();
assert_eq!(*compiled.active.lock().unwrap(), 2);
drop((second, third));
}
}