mod abi;
mod engine;
use std::{path::PathBuf, time::Duration};
use engine::Guest;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use tocat_api::{
Boundaries, BuildCtx, ByteSize, Ctx, Execution, Needs, Plugin, PluginError, PluginFactory,
Result, Stage,
};
pub const NAME: &str = "wasm";
fn default_fuel() -> u64 {
100_000_000
}
fn default_memory() -> ByteSize {
ByteSize(64 * 1024 * 1024)
}
fn empty_options() -> Value {
Value::Object(serde_json::Map::new())
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(rename_all = "kebab-case", deny_unknown_fields)]
pub struct WasmConfig {
#[serde(alias = "path", alias = "file")]
pub module: PathBuf,
#[serde(default = "default_fuel")]
pub fuel: u64,
#[serde(default = "default_memory")]
pub memory_max: ByteSize,
#[serde(default = "empty_options")]
pub config: Value,
}
pub struct Wasm {
guest: Guest,
tick: Option<Duration>,
boundaries: Boundaries,
needs: Needs,
}
impl std::fmt::Debug for Wasm {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Wasm")
.field("guest", &"<guest>")
.field("tick", &self.tick)
.field("boundaries", &self.boundaries)
.field("needs", &self.needs)
.finish()
}
}
impl Plugin for Wasm {
fn name(&self) -> &str {
NAME
}
fn tick_interval(&self) -> Option<Duration> {
self.tick
}
fn boundaries(&self) -> Boundaries {
self.boundaries
}
fn needs(&self) -> Needs {
self.needs
}
fn on_bytes(&mut self, ctx: &mut Ctx<'_>, input: &[u8]) -> Result<()> {
self.guest.on_bytes(input)?;
self.drain(ctx)
}
fn on_eof(&mut self, ctx: &mut Ctx<'_>) -> Result<()> {
self.guest.on_eof()?;
self.drain(ctx)
}
fn on_tick(&mut self, ctx: &mut Ctx<'_>) -> Result<()> {
self.guest.on_tick()?;
self.drain(ctx)
}
}
impl Wasm {
fn drain(&mut self, ctx: &mut Ctx<'_>) -> Result<()> {
let outbox = self.guest.outbox()?;
let memory = self.guest.memory();
let stride = abi::LOG_RECORD_LEN;
let logs = abi::slice(
memory,
outbox.logs.ptr,
outbox.logs.len.saturating_mul(stride),
)?;
for record in logs.chunks_exact(stride as usize) {
let field = |at: usize| {
u32::from_le_bytes([record[at], record[at + 1], record[at + 2], record[at + 3]])
};
let message = abi::slice(memory, field(4), field(8))?;
ctx.log(abi::log_level(field(0)), &String::from_utf8_lossy(message));
}
match outbox.emit {
abi::EMIT_PASSTHROUGH => ctx.pass_through(),
abi::EMIT_DROP => ctx.drop_chunk(),
abi::EMIT_BUFFERED => {
let bytes = abi::slice(memory, outbox.bytes.ptr, outbox.bytes.len)?;
let bounds = abi::bounds(memory, outbox.bounds, outbox.bytes.len)?;
let mut start = 0;
for end in bounds {
if end >= start {
ctx.forward(&bytes[start..end]);
ctx.boundary();
start = end;
}
}
ctx.forward(&bytes[start..]);
}
other => {
return Err(PluginError::runtime(
NAME,
format!("guest set an unknown emit kind: {other}"),
));
}
}
if outbox.has(abi::FLAG_REARM) {
ctx.rearm();
}
if outbox.has(abi::FLAG_PACE) {
ctx.pace(Duration::from_nanos(outbox.pace_ns));
}
if outbox.has(abi::FLAG_HALT) || outbox.has(abi::FLAG_ERROR) {
let message = abi::slice(memory, outbox.message.ptr, outbox.message.len)?;
let message = String::from_utf8_lossy(message).into_owned();
if outbox.has(abi::FLAG_ERROR) {
return Err(PluginError::runtime(NAME, message));
}
ctx.halt(&message);
}
Ok(())
}
}
pub struct WasmFactory;
impl PluginFactory for WasmFactory {
fn name(&self) -> &str {
NAME
}
fn description(&self) -> &str {
"run a WebAssembly guest as a stage"
}
fn execution(&self) -> Execution {
Execution::Detached
}
fn build(&self, ctx: &mut BuildCtx<'_>) -> Result<Stage> {
let config: WasmConfig = ctx.config()?;
let pre = engine::load(&config.module)?;
let options = serde_json::to_vec(&config.config)
.map_err(|e| PluginError::config(NAME, format!("config: {e}")))?;
let guest = Guest::new(&pre, config.memory_max.bytes(), config.fuel, &options)?;
Ok(Stage::filter(Wasm {
tick: guest.tick_interval(),
boundaries: guest.boundaries(),
needs: guest.needs(),
guest,
}))
}
}
#[cfg(test)]
mod tests {
use serde_json::json;
use tocat_api::{ChannelId, Direction, EffectSink, Emission, Emit, LogLevel, PipelineMeta};
use super::*;
const PASSTHROUGH: &str = r#"
(module
(memory (export "memory") 1)
(data (i32.const 0) "\01\00\00\00")
(func (export "tocat_abi_version") (result i32) (i32.const 2))
(func (export "tocat_outbox") (result i32) (i32.const 0))
(func (export "tocat_alloc") (param i32) (result i32) (i32.const 64))
(func (export "tocat_on_bytes") (param i32 i32)))
"#;
const FRAMED: &str = r#"
(module
(memory (export "memory") 1)
;; emit = 2 (buffered), bytes at 128 len 6, bounds at 120 len 1
(data (i32.const 0) "\02\00\00\00\80\00\00\00\06\00\00\00\78\00\00\00\01\00\00\00")
(data (i32.const 120) "\03\00\00\00")
(data (i32.const 128) "abcdef")
(func (export "tocat_abi_version") (result i32) (i32.const 2))
(func (export "tocat_outbox") (result i32) (i32.const 0))
(func (export "tocat_alloc") (param i32) (result i32) (i32.const 256))
(func (export "tocat_on_bytes") (param i32 i32)))
"#;
const SPINS: &str = r#"
(module
(memory (export "memory") 1)
(func (export "tocat_abi_version") (result i32) (i32.const 2))
(func (export "tocat_outbox") (result i32) (i32.const 0))
(func (export "tocat_alloc") (param i32) (result i32) (i32.const 64))
(func (export "tocat_on_bytes") (param i32 i32)
(loop $forever (br $forever))))
"#;
const IMPORTS: &str = r#"
(module
(import "wasi_snapshot_preview1" "fd_write"
(func $fd_write (param i32 i32 i32 i32) (result i32)))
(memory (export "memory") 1)
(func (export "tocat_abi_version") (result i32) (i32.const 2))
(func (export "tocat_outbox") (result i32) (i32.const 0))
(func (export "tocat_alloc") (param i32) (result i32) (i32.const 64))
(func (export "tocat_on_bytes") (param i32 i32)))
"#;
const REFUSES: &str = r#"
(module
(memory (export "memory") 1)
(func (export "tocat_abi_version") (result i32) (i32.const 2))
(func (export "tocat_outbox") (result i32) (i32.const 0))
(func (export "tocat_alloc") (param i32) (result i32) (i32.const 0))
(func (export "tocat_on_bytes") (param i32 i32)))
"#;
#[derive(Default)]
struct Recorder {
logs: Vec<String>,
halts: Vec<String>,
}
impl EffectSink for Recorder {
fn write(&mut self, _channel: ChannelId, _bytes: &[u8]) {}
fn log(&mut self, _level: LogLevel, _stage: &str, message: &str) {
self.logs.push(message.to_string());
}
fn halt(&mut self, _stage: &str, reason: &str) {
self.halts.push(reason.to_string());
}
}
fn meta() -> PipelineMeta {
PipelineMeta::new(Direction::SourceToSink, "tcp://a", "STDIO")
}
fn guest(wat: &str, fuel: u64) -> Result<Wasm> {
let pre = engine::compile(wat)?;
let guest = Guest::new(&pre, 1 << 20, fuel, b"{}")?;
Ok(Wasm {
tick: guest.tick_interval(),
boundaries: guest.boundaries(),
needs: guest.needs(),
guest,
})
}
fn feed(plugin: &mut Wasm, sink: &mut Recorder, input: &[u8]) -> Result<Emission> {
let meta = meta();
let mut emission = Emission::new();
{
let mut ctx = Ctx::new(&meta, NAME, input, &mut emission, sink);
plugin.on_bytes(&mut ctx, input)?;
}
Ok(emission)
}
#[test]
fn passthrough_costs_nothing() {
let mut plugin = guest(PASSTHROUGH, default_fuel()).expect("build");
let mut sink = Recorder::default();
let emission = feed(&mut plugin, &mut sink, b"ping").expect("on_bytes");
assert_eq!(emission.emit(), Emit::Passthrough);
assert!(emission.bytes().is_empty());
}
#[test]
fn a_guest_frames_its_own_units() {
let mut plugin = guest(FRAMED, default_fuel()).expect("build");
let mut sink = Recorder::default();
let emission = feed(&mut plugin, &mut sink, b"ignored").expect("on_bytes");
assert_eq!(emission.emit(), Emit::Buffered);
assert_eq!(emission.bytes(), b"abcdef");
assert_eq!(emission.bounds().to_vec(), vec![3usize]);
}
#[test]
fn a_runaway_guest_fails_the_path_rather_than_hanging_it() {
let mut plugin = guest(SPINS, 100_000).expect("build");
let mut sink = Recorder::default();
let error = feed(&mut plugin, &mut sink, b"ping").expect_err("should trap");
assert!(error.to_string().contains("tocat_on_bytes"));
}
#[test]
fn a_guest_that_imports_anything_is_refused() {
let error = guest(IMPORTS, default_fuel()).expect_err("should refuse");
let message = error.to_string();
assert!(message.contains("wasi_snapshot_preview1"));
assert!(message.contains("fd_write"));
}
#[test]
fn a_refused_chunk_is_an_error_rather_than_a_write_to_address_zero() {
let mut plugin = guest(REFUSES, default_fuel()).expect("build");
let mut sink = Recorder::default();
let error = feed(&mut plugin, &mut sink, b"ping").expect_err("should refuse");
assert!(error.to_string().contains("refused"));
}
#[test]
fn config_defaults_are_the_documented_ones() {
let config: WasmConfig =
serde_json::from_value(json!({ "module": "guest.wasm" })).expect("config");
assert_eq!(config.fuel, default_fuel());
assert_eq!(config.memory_max.bytes(), 64 * 1024 * 1024);
assert!(config.config.is_object());
}
}