M7: WASM plugins (#101)

Sandboxed WebAssembly plugins whose API is the command registry:

- figcraft-plugin (new crate, L2): the figcraft-plugin.json manifest
  (menu commands with parameters, read/write access, command allowlist)
  and the sandbox: wasmi (pure Rust, no JIT, also in the web build) with
  fuel metering, a memory cap, no WASI and seven host functions (call,
  selection, reply_len, reply_read, log, notify, set_output).
- Engine: plugin.list / plugin.import / plugin.remove / plugin.run,
  plugins.runLast (Alt+Cmd+P; Ctrl+Alt+Shift+P on Windows/Linux) and
  plugins.manage. A run is one undo step (Session::grouped, which
  command.batch now uses too) and is rolled back when it fails. Files,
  other documents, the undo history, dialogs and plugins are off limits;
  read-only plugins can't change the document. Installed plugins live in
  the preferences; development plugins re-read their module every run.
- UI: installed plugins in the Plugins menu, a parameters form drawn
  from the manifest, Manage plugins, Plugins > Development > Import
  plugin from manifest.
- plugins/: a no_std Rust SDK and two example plugins (Tidy names,
  Swatch grid), built for wasm32 by `cargo xtask plugins` (part of
  `cargo xtask wasm`).
- Docs: docs/plugins.md (manifest, permissions, limits, ABI, SDK),
  docs/mcp.md, docs/control-protocol.md, README, ROADMAP, parity.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
BFlat
2026-10-11 00:42:17 +07:00
committed by GitHub
co-authored by Claude Opus 5.5
parent d452da3452
commit 041b3f4e5d
38 changed files with 3110 additions and 62 deletions
+13
View File
@@ -0,0 +1,13 @@
//! FigCraft plugins: the manifest (`figcraft-plugin.json`) and the WebAssembly sandbox a plugin
//! runs in. The engine decides what a plugin may do (its permissions, which commands it may call,
//! one undo step per run); this crate only runs modules safely: a metered interpreter with a memory
//! cap, no WASI and a handful of host functions. See `docs/plugins.md`.
pub mod manifest;
mod sandbox;
pub use manifest::{API_VERSION, Access, MANIFEST_FILE, Manifest, Param, ParamKind, Permissions, PluginCommand};
pub use sandbox::{ENTRY, Host, IMPORT_MODULE, Limits, PluginError, RunOutput, check_module, run};
#[cfg(test)]
mod tests;
+337
View File
@@ -0,0 +1,337 @@
//! The plugin manifest, `figcraft-plugin.json`: who the plugin is, which module to run, the menu
//! commands it adds (with their parameters) and the permissions it asks for.
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
/// The manifest's file name.
pub const MANIFEST_FILE: &str = "figcraft-plugin.json";
/// The plugin API version this build implements (`api` in the manifest).
pub const API_VERSION: u32 = 1;
/// Largest manifest read.
pub const MAX_MANIFEST_BYTES: usize = 256 * 1024;
const MAX_ID: usize = 128;
const MAX_NAME: usize = 64;
const MAX_LABEL: usize = 80;
const MAX_TEXT: usize = 1000;
const MAX_COMMANDS: usize = 64;
const MAX_PARAMS: usize = 32;
const MAX_OPTIONS: usize = 64;
const MAX_ALLOW: usize = 256;
/// Longest text parameter value.
const MAX_TEXT_VALUE: usize = 10_000;
/// A parsed, validated manifest.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Manifest {
/// Stable id, e.g. `org.example.tidy-names` (letters, digits, `.`, `-`, `_`).
pub id: String,
/// Shown in the Plugins menu.
pub name: String,
#[serde(default)]
pub version: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub description: String,
/// The WebAssembly module, relative to the manifest (`plugin.wasm`).
pub main: String,
/// The plugin API version the plugin was written for.
#[serde(default = "api_version")]
pub api: u32,
#[serde(default)]
pub permissions: Permissions,
/// Menu commands; a manifest without any gets one, `run`.
#[serde(default)]
pub commands: Vec<PluginCommand>,
}
fn api_version() -> u32 {
API_VERSION
}
/// What a plugin may do.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct Permissions {
/// `read`: commands that leave the document as it is; `write`: edits too.
#[serde(default)]
pub access: Access,
/// The commands the plugin may call: exact ids or prefixes ending in `*` (`node.*`). Empty: any
/// command its access allows.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub commands: Vec<String>,
}
impl Permissions {
/// Whether the allowlist admits `command`.
pub fn allows(&self, command: &str) -> bool {
self.commands.is_empty()
|| self.commands.iter().any(|p| match p.strip_suffix('*') {
Some(prefix) => command.starts_with(prefix),
None => p == command,
})
}
}
/// Read-only or read-write document access.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Access {
#[default]
Read,
Write,
}
/// One menu command of a plugin.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct PluginCommand {
/// Passed to the plugin as `command`.
pub id: String,
pub label: String,
/// Parameters asked for before the command runs (a small form in the app).
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub params: Vec<Param>,
}
/// A command parameter: a form field in the app, a key of `params` everywhere.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Param {
pub name: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub label: String,
#[serde(rename = "type", default)]
pub kind: ParamKind,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub default: Option<Value>,
/// The choices of a `select`.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub options: Vec<String>,
/// Bounds of a `number`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub min: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max: Option<f64>,
}
/// The kind of value a parameter takes.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ParamKind {
#[default]
Text,
Number,
Boolean,
Select,
}
impl Param {
/// The label shown in the form.
pub fn title(&self) -> &str {
if self.label.is_empty() { &self.name } else { &self.label }
}
/// The value used when none is given.
pub fn default_value(&self) -> Value {
if let Some(d) = self.default.as_ref().and_then(|d| self.coerce(d).ok()) {
return d;
}
match self.kind {
ParamKind::Text => Value::String(String::new()),
ParamKind::Number => serde_json::json!(self.clamp(0.0)),
ParamKind::Boolean => Value::Bool(false),
ParamKind::Select => self.options.first().map_or(Value::Null, |o| Value::String(o.clone())),
}
}
/// `v` as this parameter's kind, or why it can't be.
pub fn coerce(&self, v: &Value) -> Result<Value, String> {
let name = &self.name;
match self.kind {
ParamKind::Text => match v {
Value::String(s) if s.chars().count() <= MAX_TEXT_VALUE => Ok(v.clone()),
Value::String(_) => Err(format!("`{name}` is longer than {MAX_TEXT_VALUE} characters")),
Value::Number(n) => Ok(Value::String(n.to_string())),
_ => Err(format!("`{name}` must be text")),
},
ParamKind::Number => {
let n = match v {
Value::Number(n) => n.as_f64(),
Value::String(s) => s.trim().parse::<f64>().ok(),
_ => None,
}
.filter(|n| n.is_finite())
.ok_or_else(|| format!("`{name}` must be a number"))?;
Ok(serde_json::json!(self.clamp(n)))
}
ParamKind::Boolean => v.as_bool().map(Value::Bool).ok_or_else(|| format!("`{name}` must be true or false")),
ParamKind::Select => match v.as_str() {
Some(s) if self.options.iter().any(|o| o == s) => Ok(v.clone()),
_ => Err(format!("`{name}` must be one of: {}", self.options.join(", "))),
},
}
}
/// `n` within the number's bounds.
fn clamp(&self, n: f64) -> f64 {
n.max(self.min.unwrap_or(f64::MIN)).min(self.max.unwrap_or(f64::MAX))
}
}
impl PluginCommand {
/// `given` with every declared parameter checked and the missing ones filled with their
/// defaults. Keys the command doesn't declare pass through.
pub fn resolve_params(&self, given: &Value) -> Result<Value, String> {
let mut out = match given {
Value::Object(m) => m.clone(),
Value::Null => Map::new(),
_ => return Err("`params` must be an object".into()),
};
for p in &self.params {
let v = match out.get(&p.name) {
Some(v) if !v.is_null() => p.coerce(v)?,
_ => p.default_value(),
};
out.insert(p.name.clone(), v);
}
Ok(Value::Object(out))
}
}
impl Manifest {
/// Parse and validate manifest JSON.
pub fn parse(text: &str) -> Result<Manifest, String> {
if text.len() > MAX_MANIFEST_BYTES {
return Err(format!("the manifest is larger than {} KB", MAX_MANIFEST_BYTES / 1024));
}
let m: Manifest = serde_json::from_str(text).map_err(|e| format!("manifest: {e}"))?;
m.validated()
}
/// Validate a manifest given as JSON.
pub fn from_value(v: &Value) -> Result<Manifest, String> {
Manifest::parse(&v.to_string())
}
/// The command `id`, or the first one when `id` is `None` or empty.
pub fn command(&self, id: Option<&str>) -> Option<&PluginCommand> {
match id.filter(|i| !i.is_empty()) {
Some(id) => self.commands.iter().find(|c| c.id == id),
None => self.commands.first(),
}
}
/// Check every field and fill in the defaults (a `run` command when there is none).
fn validated(mut self) -> Result<Manifest, String> {
check_id("id", &self.id, MAX_ID)?;
self.name = clean("name", &self.name, MAX_NAME)?;
if self.name.is_empty() {
return Err("manifest: `name` is empty".into());
}
self.version = clean("version", &self.version, 32)?;
self.description = clean("description", &self.description, MAX_TEXT)?;
check_main(&self.main)?;
if self.api == 0 || self.api > API_VERSION {
return Err(format!("manifest: plugin API {} isn't supported (this FigCraft runs API {API_VERSION})", self.api));
}
if self.permissions.commands.len() > MAX_ALLOW {
return Err(format!("manifest: more than {MAX_ALLOW} entries in `permissions.commands`"));
}
for p in &self.permissions.commands {
let bare = p.strip_suffix('*').unwrap_or(p);
if p.is_empty() || p.len() > MAX_ID || bare.contains('*') || !bare.chars().all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '_') {
return Err(format!("manifest: `{}` in `permissions.commands` isn't a command id or `prefix.*`", truncate(p)));
}
}
if self.commands.is_empty() {
self.commands.push(PluginCommand { id: "run".into(), label: "Run".into(), params: Vec::new() });
}
if self.commands.len() > MAX_COMMANDS {
return Err(format!("manifest: more than {MAX_COMMANDS} commands"));
}
if let Some(dup) = first_duplicate(self.commands.iter().map(|c| c.id.as_str())) {
return Err(format!("manifest: two commands are called `{}`", truncate(dup)));
}
for c in &mut self.commands {
check_id("command id", &c.id, MAX_NAME)?;
c.label = clean("command label", &c.label, MAX_LABEL)?;
if c.label.is_empty() {
c.label = c.id.clone();
}
if c.params.len() > MAX_PARAMS {
return Err(format!("manifest: command `{}` has more than {MAX_PARAMS} parameters", c.id));
}
for p in &mut c.params {
check_id("parameter name", &p.name, MAX_NAME)?;
p.label = clean("parameter label", &p.label, MAX_LABEL)?;
if p.options.len() > MAX_OPTIONS {
return Err(format!("manifest: parameter `{}` has more than {MAX_OPTIONS} options", p.name));
}
for o in &mut p.options {
*o = clean("option", o, MAX_LABEL)?;
}
if p.kind == ParamKind::Select && p.options.is_empty() {
return Err(format!("manifest: select parameter `{}` has no options", p.name));
}
if p.min.is_some_and(|v| !v.is_finite())
|| p.max.is_some_and(|v| !v.is_finite())
|| matches!((p.min, p.max), (Some(a), Some(b)) if a > b)
{
return Err(format!("manifest: parameter `{}` has bad bounds", p.name));
}
}
if let Some(dup) = first_duplicate(c.params.iter().map(|p| p.name.as_str())) {
return Err(format!("manifest: command `{}` declares `{dup}` twice", c.id));
}
}
Ok(self)
}
}
/// The first name that occurs twice.
fn first_duplicate<'a>(names: impl Iterator<Item = &'a str>) -> Option<&'a str> {
let mut seen = std::collections::BTreeSet::new();
names.into_iter().find(|n| !seen.insert(*n))
}
/// An id: 1..=`max` ASCII letters, digits, `.`, `-` or `_`.
fn check_id(what: &str, id: &str, max: usize) -> Result<(), String> {
if id.is_empty() || id.len() > max || !id.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_')) {
return Err(format!("manifest: {what} `{}` must be 1–{max} letters, digits, `.`, `-` or `_`", truncate(id)));
}
Ok(())
}
/// Trimmed text of at most `max` characters without control characters.
fn clean(what: &str, s: &str, max: usize) -> Result<String, String> {
let s = s.trim();
if s.chars().count() > max {
return Err(format!("manifest: {what} is longer than {max} characters"));
}
if s.chars().any(char::is_control) {
return Err(format!("manifest: {what} contains control characters"));
}
Ok(s.to_string())
}
/// `main` names a `.wasm` file next to (or below) the manifest.
fn check_main(main: &str) -> Result<(), String> {
let bad = main.is_empty()
|| main.len() > 256
|| !main.to_ascii_lowercase().ends_with(".wasm")
|| main.starts_with(['/', '\\'])
|| main.contains(':')
|| main.chars().any(char::is_control)
|| main.split(['/', '\\']).any(|part| part == ".." || part.is_empty());
if bad {
return Err(format!("manifest: `main` must be a relative path to a .wasm file inside the plugin's folder (got `{}`)", truncate(main)));
}
Ok(())
}
/// At most 40 characters of `s`, for error messages.
fn truncate(s: &str) -> String {
let mut out: String = s.chars().take(40).collect();
if s.chars().count() > 40 {
out.push('…');
}
out
}
+329
View File
@@ -0,0 +1,329 @@
//! The sandbox: a plugin module runs in a metered WebAssembly interpreter (wasmi; no JIT) with a
//! memory cap, and sees the outside world only through the host functions below. There is no WASI:
//! no file system, network, clock or randomness.
//!
//! # ABI (version 1)
//!
//! The module exports its `memory` and `figcraft_run(input_len: i32) -> i32`. Host functions are
//! imported from the module `figcraft`; strings are UTF-8 `(ptr, len)` pairs in the plugin's memory.
//!
//! | Import | Signature | Does |
//! |---|---|---|
//! | `call` | `(cmd_ptr, cmd_len, params_ptr, params_len) -> i32` | Runs a command with JSON params. 0: ok, the reply holds the result JSON; 1: refused or failed, the reply holds the message. |
//! | `selection` | `() -> i32` | The reply becomes the selected layer ids (a JSON array); returns its length. |
//! | `reply_len` | `() -> i32` | Length of the reply. |
//! | `reply_read` | `(ptr, len) -> i32` | Copies up to `len` bytes of the reply to `ptr`; returns the count. |
//! | `log` | `(ptr, len)` | A line for the run's log. |
//! | `notify` | `(ptr, len)` | A short message shown to the user. |
//! | `set_output` | `(ptr, len)` | The run's result (JSON or text), or its error message when it fails. |
//!
//! When `figcraft_run` starts, the reply holds the input JSON (`input_len` bytes):
//! `{"command", "params", "selection"}`. It returns 0 on success; anything else fails the run.
use wasmi::{Caller, Config, Engine, Extern, FuncType, Linker, Module, Store, StoreLimits, StoreLimitsBuilder, TrapCode, ValType};
/// The module host functions are imported from.
pub const IMPORT_MODULE: &str = "figcraft";
/// The exported entry point.
pub const ENTRY: &str = "figcraft_run";
/// The host functions: (name, params, results), all i32.
const IMPORTS: &[(&str, usize, usize)] =
&[("call", 4, 1), ("selection", 0, 1), ("reply_len", 0, 1), ("reply_read", 2, 1), ("log", 2, 0), ("notify", 2, 0), ("set_output", 2, 0)];
/// What a run may use.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Limits {
/// Instruction budget (wasmi fuel). Each host call costs [`Limits::call_fuel`] more.
pub fuel: u64,
pub call_fuel: u64,
/// Linear memory cap in bytes.
pub memory_bytes: usize,
/// Largest module accepted.
pub module_bytes: usize,
/// Host calls per run.
pub max_calls: u32,
/// Largest string passed either way (command params, results, logs).
pub max_message: usize,
/// Log lines kept per run.
pub max_logs: usize,
}
impl Default for Limits {
fn default() -> Self {
Self {
fuel: 1_000_000_000,
call_fuel: 10_000,
memory_bytes: 64 * 1024 * 1024,
module_bytes: 16 * 1024 * 1024,
max_calls: 100_000,
max_message: 16 * 1024 * 1024,
max_logs: 1000,
}
}
}
/// Why a run failed.
#[derive(Debug, Clone, PartialEq, thiserror::Error)]
pub enum PluginError {
#[error("not a valid plugin module: {0}")]
InvalidModule(String),
#[error("the plugin ran out of its instruction budget (an endless loop?)")]
OutOfFuel,
#[error("the plugin used more than its memory limit")]
OutOfMemory,
#[error("the plugin crashed: {0}")]
Trap(String),
#[error("the plugin went over a limit: {0}")]
Limit(String),
#[error("{0}")]
Failed(String),
}
/// What the sandbox asks of its host (the engine): run commands, read the selection, log.
pub trait Host {
/// Run `command` with `params` (JSON text): the result JSON, or why it was refused or failed.
fn call(&mut self, command: &str, params: &str) -> Result<String, String>;
/// The selected layer ids as a JSON array.
fn selection(&mut self) -> String;
/// A message for the user.
fn notify(&mut self, message: &str);
/// A line of the run's log, as the plugin writes it (it's also in [`RunOutput::logs`]).
fn log(&mut self, _line: &str) {}
}
/// A finished run.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct RunOutput {
/// What the plugin passed to `set_output` (empty when nothing).
pub output: String,
pub logs: Vec<String>,
/// Host calls made.
pub calls: u32,
/// Fuel used.
pub fuel: u64,
}
struct State<'h> {
host: &'h mut dyn Host,
limits: Limits,
store_limits: StoreLimits,
reply: Vec<u8>,
out: RunOutput,
/// A limit the host tripped (reported instead of the trap it causes).
abort: Option<PluginError>,
}
fn engine() -> Engine {
let mut c = Config::default();
c.consume_fuel(true);
Engine::new(&c)
}
fn func_type(params: usize, results: usize) -> FuncType {
FuncType::new(vec![ValType::I32; params], vec![ValType::I32; results])
}
/// Compile and check a module: its size, that it imports only FigCraft's host functions (no WASI)
/// and exports `memory` and `figcraft_run`.
pub fn check_module(bytes: &[u8], limits: &Limits) -> Result<(), PluginError> {
compile(&engine(), bytes, limits).map(|_| ())
}
fn compile(engine: &Engine, bytes: &[u8], limits: &Limits) -> Result<Module, PluginError> {
if bytes.len() > limits.module_bytes {
return Err(PluginError::InvalidModule(format!("larger than {} MB", limits.module_bytes / (1024 * 1024))));
}
if !bytes.starts_with(b"\0asm") {
return Err(PluginError::InvalidModule("not a WebAssembly binary".into()));
}
let module = Module::new(engine, bytes).map_err(|e| PluginError::InvalidModule(e.to_string()))?;
for imp in module.imports() {
let known = IMPORTS.iter().find(|(n, ..)| imp.module() == IMPORT_MODULE && *n == imp.name());
match (known, imp.ty().func()) {
(Some((_, p, r)), Some(ty)) if *ty == func_type(*p, *r) => {}
(Some(_), _) => {
return Err(PluginError::InvalidModule(format!("`{}.{}` is imported with the wrong signature", imp.module(), imp.name())));
}
(None, _) => {
return Err(PluginError::InvalidModule(format!(
"it imports `{}.{}`, which FigCraft doesn't provide (plugins get no file system, network or clock)",
imp.module(),
imp.name()
)));
}
}
}
let export = |name: &str| module.exports().find(|e| e.name() == name).map(|e| e.ty().clone());
if !export("memory").is_some_and(|t| t.memory().is_some()) {
return Err(PluginError::InvalidModule("it doesn't export its `memory`".into()));
}
if !export(ENTRY).is_some_and(|t| t.func() == Some(&func_type(1, 1))) {
return Err(PluginError::InvalidModule(format!("it doesn't export `{ENTRY}(input_len: i32) -> i32`")));
}
Ok(module)
}
/// Run a plugin module: `input` is the JSON handed to `figcraft_run`.
pub fn run(bytes: &[u8], input: &str, limits: &Limits, host: &mut dyn Host) -> Result<RunOutput, PluginError> {
let engine = engine();
let module = compile(&engine, bytes, limits)?;
if input.len() > limits.max_message {
return Err(PluginError::Limit("the input is too large".into()));
}
let store_limits = StoreLimitsBuilder::new().memory_size(limits.memory_bytes).memories(1).tables(4).table_elements(100_000).instances(1).build();
let state = State { host, limits: *limits, store_limits, reply: input.as_bytes().to_vec(), out: RunOutput::default(), abort: None };
let mut store = Store::new(&engine, state);
store.limiter(|s| &mut s.store_limits);
store.set_fuel(limits.fuel).map_err(|e| PluginError::Trap(e.to_string()))?;
let linker = linker(&engine)?;
let instance = linker.instantiate_and_start(&mut store, &module).map_err(|e| classify(&mut store, &e, true))?;
let entry = instance.get_typed_func::<i32, i32>(&store, ENTRY).map_err(|e| PluginError::InvalidModule(e.to_string()))?;
let input_len = i32::try_from(input.len()).map_err(|_| PluginError::Limit("the input is too large".into()))?;
let status = entry.call(&mut store, input_len).map_err(|e| classify(&mut store, &e, false))?;
let fuel = limits.fuel.saturating_sub(store.get_fuel().unwrap_or(0));
let mut out = std::mem::take(&mut store.data_mut().out);
out.fuel = fuel;
if status != 0 {
let msg = if out.output.trim().is_empty() { format!("the plugin failed (status {status})") } else { out.output };
return Err(PluginError::Failed(msg));
}
Ok(out)
}
/// The error a failed instantiation or call stands for: a limit the host tripped first, else the
/// trap.
fn classify(store: &mut Store<State<'_>>, e: &wasmi::Error, instantiating: bool) -> PluginError {
if let Some(abort) = store.data_mut().abort.take() {
return abort;
}
match e.as_trap_code() {
Some(TrapCode::OutOfFuel) => PluginError::OutOfFuel,
Some(TrapCode::GrowthOperationLimited) => PluginError::OutOfMemory,
Some(code) => PluginError::Trap(code.to_string()),
// A memory larger than the cap fails instantiation.
None if instantiating && e.to_string().to_ascii_lowercase().contains("limit") => PluginError::OutOfMemory,
None if instantiating => PluginError::InvalidModule(e.to_string()),
None => PluginError::Trap(e.to_string()),
}
}
type Ctx<'a, 'h> = Caller<'a, State<'h>>;
/// Stop the run because of `err` (the trap it causes reports `err`).
fn abort(caller: &mut Ctx<'_, '_>, err: PluginError) -> wasmi::Error {
let msg = err.to_string();
caller.data_mut().abort = Some(err);
wasmi::Error::new(msg)
}
/// Charge a host call: fuel and the call count.
fn charge(caller: &mut Ctx<'_, '_>) -> Result<(), wasmi::Error> {
let cost = caller.data().limits.call_fuel;
let left = caller.get_fuel()?;
if left < cost {
return Err(abort(caller, PluginError::OutOfFuel));
}
caller.set_fuel(left - cost)?;
let st = caller.data_mut();
st.out.calls = st.out.calls.saturating_add(1);
if st.out.calls > st.limits.max_calls {
let n = st.limits.max_calls;
return Err(abort(caller, PluginError::Limit(format!("more than {n} host calls"))));
}
Ok(())
}
/// The bytes at `(ptr, len)` in the plugin's memory.
fn read_bytes(caller: &mut Ctx<'_, '_>, ptr: i32, len: i32) -> Result<Vec<u8>, wasmi::Error> {
let (ptr, len) = (ptr as u32 as usize, len as u32 as usize);
if len > caller.data().limits.max_message {
return Err(abort(caller, PluginError::Limit(format!("a {len}-byte string is too long"))));
}
let Some(mem) = caller.get_export("memory").and_then(Extern::into_memory) else {
return Err(abort(caller, PluginError::InvalidModule("no memory".into())));
};
match ptr.checked_add(len).and_then(|end| mem.data(&*caller).get(ptr..end)) {
Some(b) => Ok(b.to_vec()),
None => Err(wasmi::Error::from(TrapCode::MemoryOutOfBounds)),
}
}
/// The UTF-8 text at `(ptr, len)`.
fn read_str(caller: &mut Ctx<'_, '_>, ptr: i32, len: i32) -> Result<String, wasmi::Error> {
let bytes = read_bytes(caller, ptr, len)?;
String::from_utf8(bytes).map_err(|_| abort(caller, PluginError::Trap("a string isn't valid UTF-8".into())))
}
/// Make `bytes` the reply; returns its length.
fn set_reply(caller: &mut Ctx<'_, '_>, bytes: Vec<u8>) -> Result<i32, wasmi::Error> {
let max = caller.data().limits.max_message;
let bytes = if bytes.len() > max { format!("the result is larger than {max} bytes").into_bytes() } else { bytes };
let len = i32::try_from(bytes.len()).unwrap_or(i32::MAX);
caller.data_mut().reply = bytes;
Ok(len)
}
fn linker<'h>(engine: &Engine) -> Result<Linker<State<'h>>, PluginError> {
let mut l = <Linker<State<'h>>>::new(engine);
let err = |e: wasmi::errors::LinkerError| PluginError::InvalidModule(e.to_string());
l.func_wrap(IMPORT_MODULE, "call", |mut c: Ctx<'_, 'h>, cp: i32, cl: i32, pp: i32, pl: i32| -> Result<i32, wasmi::Error> {
charge(&mut c)?;
let cmd = read_str(&mut c, cp, cl)?;
let params = read_str(&mut c, pp, pl)?;
let params = if params.trim().is_empty() { "{}".to_string() } else { params };
let (status, reply) = match c.data_mut().host.call(&cmd, &params) {
Ok(v) => (0, v),
Err(m) => (1, m),
};
set_reply(&mut c, reply.into_bytes())?;
Ok(status)
})
.map_err(err)?;
l.func_wrap(IMPORT_MODULE, "selection", |mut c: Ctx<'_, 'h>| -> Result<i32, wasmi::Error> {
charge(&mut c)?;
let sel = c.data_mut().host.selection();
set_reply(&mut c, sel.into_bytes())
})
.map_err(err)?;
l.func_wrap(IMPORT_MODULE, "reply_len", |c: Ctx<'_, 'h>| -> i32 { i32::try_from(c.data().reply.len()).unwrap_or(i32::MAX) }).map_err(err)?;
l.func_wrap(IMPORT_MODULE, "reply_read", |mut c: Ctx<'_, 'h>, ptr: i32, len: i32| -> Result<i32, wasmi::Error> {
let (ptr, want) = (ptr as u32 as usize, len as u32 as usize);
let Some(mem) = c.get_export("memory").and_then(Extern::into_memory) else {
return Err(abort(&mut c, PluginError::InvalidModule("no memory".into())));
};
let (data, st) = mem.data_and_store_mut(&mut c);
let n = want.min(st.reply.len());
let src = st.reply.get(..n).ok_or(TrapCode::MemoryOutOfBounds)?;
let dst = ptr.checked_add(n).and_then(|end| data.get_mut(ptr..end)).ok_or(TrapCode::MemoryOutOfBounds)?;
dst.copy_from_slice(src);
Ok(i32::try_from(n).unwrap_or(i32::MAX))
})
.map_err(err)?;
l.func_wrap(IMPORT_MODULE, "log", |mut c: Ctx<'_, 'h>, ptr: i32, len: i32| -> Result<(), wasmi::Error> {
charge(&mut c)?;
let line = read_str(&mut c, ptr, len)?;
let st = c.data_mut();
st.host.log(&line);
if st.out.logs.len() < st.limits.max_logs {
st.out.logs.push(line);
}
Ok(())
})
.map_err(err)?;
l.func_wrap(IMPORT_MODULE, "notify", |mut c: Ctx<'_, 'h>, ptr: i32, len: i32| -> Result<(), wasmi::Error> {
charge(&mut c)?;
let msg = read_str(&mut c, ptr, len)?;
let msg: String = msg.chars().filter(|ch| !ch.is_control()).take(200).collect();
c.data_mut().host.notify(&msg);
Ok(())
})
.map_err(err)?;
l.func_wrap(IMPORT_MODULE, "set_output", |mut c: Ctx<'_, 'h>, ptr: i32, len: i32| -> Result<(), wasmi::Error> {
let out = read_str(&mut c, ptr, len)?;
c.data_mut().out.output = out;
Ok(())
})
.map_err(err)?;
Ok(l)
}
+291
View File
@@ -0,0 +1,291 @@
//! The sandbox (modules assembled from WAT text at test time) and the manifest.
use serde_json::{Value, json};
use crate::{Access, Host, Limits, Manifest, PluginError, check_module, run};
/// Records what a plugin asked for and answers `call` with a canned reply.
#[derive(Default)]
struct Mock {
calls: Vec<(String, String)>,
notes: Vec<String>,
refuse: bool,
}
impl Host for Mock {
fn call(&mut self, command: &str, params: &str) -> Result<String, String> {
self.calls.push((command.into(), params.into()));
if self.refuse { Err(format!("`{command}` refused")) } else { Ok(format!("{{\"ran\":\"{command}\"}}")) }
}
fn selection(&mut self) -> String {
"[2,3]".into()
}
fn notify(&mut self, message: &str) {
self.notes.push(message.into());
}
}
const IMPORTS: &str = r#"
(import "figcraft" "call" (func $call (param i32 i32 i32 i32) (result i32)))
(import "figcraft" "selection" (func $selection (result i32)))
(import "figcraft" "reply_len" (func $reply_len (result i32)))
(import "figcraft" "reply_read" (func $reply_read (param i32 i32) (result i32)))
(import "figcraft" "log" (func $log (param i32 i32)))
(import "figcraft" "notify" (func $notify (param i32 i32)))
(import "figcraft" "set_output" (func $set_output (param i32 i32)))
"#;
/// A module with the host imports, one page of memory, `data` segments and `figcraft_run`'s body.
fn module(data: &str, body: &str) -> Vec<u8> {
module_with(data, body, "(memory (export \"memory\") 1)", "")
}
fn module_with(data: &str, body: &str, memory: &str, extra: &str) -> Vec<u8> {
let wat = format!("(module {IMPORTS} {memory} {data} {extra} (func (export \"figcraft_run\") (param $n i32) (result i32) {body}))");
wat::parse_str(&wat).unwrap_or_else(|e| panic!("{e}\n{wat}"))
}
fn small() -> Limits {
Limits { fuel: 2_000_000, ..Limits::default() }
}
fn go(bytes: &[u8], input: &str) -> (Result<crate::RunOutput, PluginError>, Mock) {
let mut host = Mock::default();
let r = run(bytes, input, &small(), &mut host);
(r, host)
}
#[test]
fn echoes_input_calls_commands_and_reads_replies() {
let m = module(
r#"(data (i32.const 0) "shape.rectangle") (data (i32.const 32) "{\"x\":1}") (data (i32.const 64) "made it")"#,
r#"
;; The input is waiting in the reply buffer.
(drop (call $reply_read (i32.const 1024) (local.get $n)))
(call $log (i32.const 1024) (local.get $n))
(if (i32.ne (call $call (i32.const 0) (i32.const 15) (i32.const 32) (i32.const 7)) (i32.const 0)) (then (return (i32.const 1))))
;; Hand the command's reply back as the output.
(call $set_output (i32.const 2048) (call $reply_read (i32.const 2048) (call $reply_len)))
(call $notify (i32.const 64) (i32.const 7))
(i32.const 0)"#,
);
check_module(&m, &Limits::default()).unwrap();
let (r, host) = go(&m, r#"{"command":"run"}"#);
let out = r.unwrap();
assert_eq!(out.output, r#"{"ran":"shape.rectangle"}"#);
assert_eq!(out.logs, vec![r#"{"command":"run"}"#.to_string()]);
assert_eq!(host.calls, vec![("shape.rectangle".into(), r#"{"x":1}"#.into())]);
assert_eq!(host.notes, vec!["made it".to_string()]);
assert_eq!(out.calls, 3);
assert!(out.fuel > 0);
}
#[test]
fn selection_and_refusals_reach_the_plugin() {
let m = module(
r#"(data (i32.const 0) "node.rename")"#,
r#"
(drop (call $selection))
(drop (call $reply_read (i32.const 512) (i32.const 64)))
;; Empty params count as {}; status 1 when refused, the message in the reply.
(if (i32.eq (call $call (i32.const 0) (i32.const 11) (i32.const 0) (i32.const 0)) (i32.const 1))
(then (call $set_output (i32.const 1024) (call $reply_read (i32.const 1024) (call $reply_len)))))
(i32.const 0)"#,
);
let mut host = Mock { refuse: true, ..Mock::default() };
let out = run(&m, "{}", &small(), &mut host).unwrap();
assert_eq!(out.output, "`node.rename` refused");
assert_eq!(host.calls[0].1, "{}");
}
#[test]
fn a_nonzero_status_fails_the_run_with_its_output() {
let m = module(r#"(data (i32.const 0) "nothing selected")"#, "(call $set_output (i32.const 0) (i32.const 16)) (i32.const 2)");
assert_eq!(go(&m, "{}").0, Err(PluginError::Failed("nothing selected".into())));
let m = module("", "(i32.const 7)");
assert!(matches!(go(&m, "{}").0, Err(PluginError::Failed(m)) if m.contains("status 7")));
}
#[test]
fn an_endless_loop_runs_out_of_fuel() {
let m = module("", "(loop $l (br $l)) (i32.const 0)");
assert_eq!(go(&m, "{}").0, Err(PluginError::OutOfFuel));
// Host calls cost fuel too, and their number is capped.
let m = module("", "(loop $l (call $log (i32.const 0) (i32.const 0)) (br $l)) (i32.const 0)");
assert_eq!(go(&m, "{}").0, Err(PluginError::OutOfFuel));
let mut host = Mock::default();
let limits = Limits { max_calls: 10, ..small() };
assert!(matches!(run(&m, "{}", &limits, &mut host), Err(PluginError::Limit(_))));
}
#[test]
fn memory_is_capped() {
// Declared larger than the cap: refused at instantiation.
let big = module_with("", "(i32.const 0)", "(memory (export \"memory\") 2000)", "");
assert_eq!(go(&big, "{}").0, Err(PluginError::OutOfMemory));
// Growing past the cap fails (memory.grow returns -1) and the plugin can tell.
let grow = module(
r#"(data (i32.const 0) "refused")"#,
r#"(if (i32.eq (memory.grow (i32.const 1500)) (i32.const -1)) (then (call $set_output (i32.const 0) (i32.const 7)))) (i32.const 0)"#,
);
assert_eq!(go(&grow, "{}").0.unwrap().output, "refused");
// Within the cap it grows.
let ok = module("", "(drop (memory.grow (i32.const 10))) (i32.const 0)");
assert!(go(&ok, "{}").0.is_ok());
}
#[test]
fn traps_fail_cleanly() {
let cases = [
module("", "unreachable"),
module("", "(i32.div_u (i32.const 1) (i32.const 0))"),
// Out-of-bounds strings and replies.
module("", "(call $log (i32.const 65530) (i32.const 100)) (i32.const 0)"),
module("", "(call $log (i32.const -1) (i32.const 2)) (i32.const 0)"),
module("", "(drop (call $reply_read (i32.const 65535) (i32.const 100))) (i32.const 0)"),
// Not UTF-8.
module(r#"(data (i32.const 0) "\ff\fe")"#, "(call $log (i32.const 0) (i32.const 2)) (i32.const 0)"),
// Endless recursion exhausts the interpreter's stack, not the app's.
module_with("", "(call $rec) (i32.const 0)", "(memory (export \"memory\") 1)", "(func $rec (call $rec))"),
];
for m in &cases {
let r = go(m, "{}").0;
assert!(matches!(r, Err(PluginError::Trap(_)) | Err(PluginError::Limit(_))), "{r:?}");
}
// A huge length is refused before anything is read.
let m = module("", "(call $log (i32.const 0) (i32.const 2147483647)) (i32.const 0)");
assert!(matches!(go(&m, "{}").0, Err(PluginError::Limit(_))));
}
#[test]
fn modules_without_the_abi_are_refused() {
let invalid = |wat: &str| {
let bytes = wat::parse_str(wat).unwrap();
let e = check_module(&bytes, &Limits::default()).unwrap_err();
assert!(matches!(e, PluginError::InvalidModule(_)), "{e:?}");
assert_eq!(go(&bytes, "{}").0.unwrap_err(), e);
e.to_string()
};
let wasi = invalid(
r#"(module (import "wasi_snapshot_preview1" "fd_write" (func (param i32 i32 i32 i32) (result i32)))
(memory (export "memory") 1) (func (export "figcraft_run") (param i32) (result i32) (i32.const 0)))"#,
);
assert!(wasi.contains("no file system"), "{wasi}");
invalid(
r#"(module (import "figcraft" "call" (func (param i32) (result i32))) (memory (export "memory") 1) (func (export "figcraft_run") (param i32) (result i32) (i32.const 0)))"#,
);
invalid(
r#"(module (import "figcraft" "launch_rockets" (func)) (memory (export "memory") 1) (func (export "figcraft_run") (param i32) (result i32) (i32.const 0)))"#,
);
invalid(r#"(module (func (export "figcraft_run") (param i32) (result i32) (i32.const 0)))"#);
invalid(r#"(module (memory (export "memory") 1))"#);
invalid(r#"(module (memory (export "memory") 1) (func (export "figcraft_run") (result i32) (i32.const 0)))"#);
for junk in [&b""[..], b"\0asm", b"\0asm\x01\0\0\0\x01", b"not wasm at all", &[0xff; 64]] {
assert!(matches!(check_module(junk, &Limits::default()), Err(PluginError::InvalidModule(_))));
}
let tiny = Limits { module_bytes: 8, ..Limits::default() };
assert!(matches!(check_module(&module("", "(i32.const 0)"), &tiny), Err(PluginError::InvalidModule(m)) if m.contains("larger")));
}
#[test]
fn mangled_modules_never_panic() {
// A cheap fuzz: flip bytes of a valid module all over and run what still compiles.
let base = module(
r#"(data (i32.const 0) "node.get")"#,
"(drop (call $call (i32.const 0) (i32.const 8) (i32.const 0) (i32.const 0))) (loop $l (br_if $l (i32.const 0))) (i32.const 0)",
);
let mut seed = 0x2545_f491_u32;
let mut next = || {
seed ^= seed << 13;
seed ^= seed >> 17;
seed ^= seed << 5;
seed
};
let limits = Limits { fuel: 100_000, ..Limits::default() };
for _ in 0..600 {
let mut m = base.clone();
for _ in 0..1 + next() % 4 {
let i = next() as usize % m.len();
m[i] = next() as u8;
}
if next() % 5 == 0 {
m.truncate(next() as usize % m.len());
}
let _ = check_module(&m, &limits);
let mut host = Mock::default();
let _ = run(&m, "{}", &limits, &mut host);
}
}
fn manifest(extra: Value) -> Result<Manifest, String> {
let mut v = json!({"id": "org.example.tidy", "name": "Tidy", "version": "1.0.0", "main": "tidy.wasm"});
if let (Some(o), Some(e)) = (v.as_object_mut(), extra.as_object()) {
o.extend(e.clone());
}
Manifest::from_value(&v)
}
#[test]
fn manifests_validate_and_fill_defaults() {
let m = manifest(json!({})).unwrap();
assert_eq!(m.permissions.access, Access::Read, "read-only unless asked");
assert_eq!(m.commands.len(), 1, "one `run` command when none are listed");
assert_eq!(m.command(None).unwrap().id, "run");
let m = manifest(json!({
"permissions": {"access": "write", "commands": ["node.*", "shape.rectangle"]},
"commands": [{"id": "grid", "label": "Swatch grid", "params": [
{"name": "columns", "type": "number", "default": 4, "min": 1, "max": 12},
{"name": "palette", "type": "text", "default": "#f00,#0f0"},
{"name": "rounded", "type": "boolean"},
{"name": "size", "type": "select", "options": ["S", "M", "L"], "default": "M"}
]}, {"id": "other", "label": ""}]
}))
.unwrap();
assert!(m.permissions.allows("node.rename") && m.permissions.allows("shape.rectangle"));
assert!(!m.permissions.allows("shape.ellipse") && !m.permissions.allows("file.save"));
assert_eq!(m.command(Some("other")).unwrap().label, "other", "an empty label falls back to the id");
let grid = m.command(Some("grid")).unwrap();
let p = grid.resolve_params(&json!({"columns": 40, "extra": [1]})).unwrap();
assert_eq!(p, json!({"columns": 12.0, "palette": "#f00,#0f0", "rounded": false, "size": "M", "extra": [1]}));
assert_eq!(grid.resolve_params(&json!({"columns": "3"})).unwrap()["columns"], json!(3.0));
assert!(grid.resolve_params(&json!({"size": "XL"})).is_err());
assert!(grid.resolve_params(&json!({"rounded": "yes"})).is_err());
assert!(grid.resolve_params(&json!({"columns": "NaN"})).is_err());
assert!(grid.resolve_params(&json!([1])).is_err());
assert!(m.command(Some("missing")).is_none());
}
#[test]
fn bad_manifests_are_refused() {
let bad = [
json!({"id": ""}),
json!({"id": "has space"}),
json!({"id": "x".repeat(200)}),
json!({"name": ""}),
json!({"name": "line\nbreak"}),
json!({"main": "../escape.wasm"}),
json!({"main": "/abs/plugin.wasm"}),
json!({"main": "C:\\plugin.wasm"}),
json!({"main": "plugin.js"}),
json!({"main": "a//b.wasm"}),
json!({"api": 2}),
json!({"api": 0}),
json!({"permissions": {"access": "root"}}),
json!({"permissions": {"commands": ["*.x"]}}),
json!({"permissions": {"commands": [""]}}),
json!({"commands": [{"id": "a", "label": "A"}, {"id": "a", "label": "B"}]}),
json!({"commands": [{"id": "a", "label": "A", "params": [{"name": "s", "type": "select"}]}]}),
json!({"commands": [{"id": "a", "label": "A", "params": [{"name": "n", "type": "number", "min": 5, "max": 1}]}]}),
json!({"commands": [{"id": "a", "label": "A", "params": [{"name": "n"}, {"name": "n"}]}]}),
json!({"commands": [{"id": "a", "label": "A", "params": [{"name": "n", "type": "color"}]}]}),
json!({"commands": "run"}),
];
for extra in bad {
assert!(manifest(extra.clone()).is_err(), "accepted {extra}");
}
let deep = "[".repeat(10_000);
let huge = format!("{{\"id\":\"{}\"}}", "a".repeat(300_000));
for junk in ["", "null", "[]", "{", "\"id\"", "{\"id\": 5}", &deep, &huge] {
assert!(Manifest::parse(junk).is_err(), "accepted {}", junk.chars().take(40).collect::<String>());
}
}