mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-30 21:36:46 -06:00
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:
@@ -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;
|
||||
@@ -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
|
||||
}
|
||||
@@ -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, ¶ms) {
|
||||
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)
|
||||
}
|
||||
@@ -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>());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user