M7: plugin panels and settings (#124)

Plugin ABI v2: a plugin can show a panel (text, number, checkbox, select, colour, list, notes, buttons) rendered with FigCraft's widgets, react to events with its state kept between calls (each editing callback one undo step, full sandbox limits on every call), and store per-plugin settings (64 KB). plugin.ui / plugin.event / plugin.close drive panels from the CLI and MCP; v1 plugins run unchanged. The Swatch grid example gains a Palettes panel that remembers the last palette.
This commit is contained in:
BFlat
2026-10-11 14:45:19 +07:00
committed by GitHub
parent 09a3b74466
commit a003e3de4f
27 changed files with 1639 additions and 233 deletions
+7 -4
View File
@@ -1,13 +1,16 @@
//! 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`.
//! one undo step per run or panel callback, where settings are kept); 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};
pub use manifest::{API_VERSION, Access, MANIFEST_FILE, Manifest, Panel, Param, ParamKind, Permissions, PluginCommand};
pub use sandbox::{ENTRY, Host, IMPORT_MODULE, Instance, Limits, PluginError, RunOutput, UiChange, check_module, run};
#[cfg(test)]
mod tests;
#[cfg(test)]
mod v2_tests;
+136 -37
View File
@@ -1,5 +1,6 @@
//! 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.
//! commands it adds (with their parameters) and the permissions it asks for. Also the panel a
//! plugin shows while it runs (ABI v2), whose controls are parameters too.
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
@@ -7,7 +8,7 @@ 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;
pub const API_VERSION: u32 = 2;
/// Largest manifest read.
pub const MAX_MANIFEST_BYTES: usize = 256 * 1024;
@@ -19,6 +20,9 @@ const MAX_COMMANDS: usize = 64;
const MAX_PARAMS: usize = 32;
const MAX_OPTIONS: usize = 64;
const MAX_ALLOW: usize = 256;
/// Largest panel spec a plugin may show, and its most controls.
pub const MAX_PANEL_BYTES: usize = 64 * 1024;
const MAX_CONTROLS: usize = 64;
/// Longest text parameter value.
const MAX_TEXT_VALUE: usize = 10_000;
@@ -35,7 +39,8 @@ pub struct Manifest {
pub description: String,
/// The WebAssembly module, relative to the manifest (`plugin.wasm`).
pub main: String,
/// The plugin API version the plugin was written for.
/// The plugin API version the plugin was written for (1 when not given; panels and settings
/// need 2).
#[serde(default = "api_version")]
pub api: u32,
#[serde(default)]
@@ -45,8 +50,9 @@ pub struct Manifest {
pub commands: Vec<PluginCommand>,
}
/// A manifest without `api` was written for the first one.
fn api_version() -> u32 {
API_VERSION
1
}
/// What a plugin may do.
@@ -95,14 +101,17 @@ pub struct PluginCommand {
/// A command parameter: a form field in the app, a key of `params` everywhere.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Param {
/// Empty only for a panel's notes.
#[serde(default)]
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")]
/// In a panel, the control's current value (`value` there).
#[serde(default, alias = "value", skip_serializing_if = "Option::is_none")]
pub default: Option<Value>,
/// The choices of a `select`.
/// The choices of a `select` or `list`.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub options: Vec<String>,
/// Bounds of a `number`.
@@ -121,6 +130,14 @@ pub enum ParamKind {
Number,
Boolean,
Select,
/// A hex colour, `#RRGGBB` or `#RRGGBBAA`.
Color,
/// One of `options`, shown as a list rather than a menu.
List,
/// Panels only: a button (it has no value).
Button,
/// Panels only: text (`label`, up to 1000 characters).
Note,
}
impl Param {
@@ -129,6 +146,11 @@ impl Param {
if self.label.is_empty() { &self.name } else { &self.label }
}
/// Whether it holds a value (not a button or a note).
pub fn is_field(&self) -> bool {
!matches!(self.kind, ParamKind::Button | ParamKind::Note)
}
/// 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()) {
@@ -138,7 +160,9 @@ impl Param {
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())),
ParamKind::Select | ParamKind::List => self.options.first().map_or(Value::Null, |o| Value::String(o.clone())),
ParamKind::Color => Value::String("#000000".into()),
ParamKind::Button | ParamKind::Note => Value::Null,
}
}
@@ -163,13 +187,47 @@ impl Param {
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() {
ParamKind::Select | ParamKind::List => 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(", "))),
},
ParamKind::Color => v
.as_str()
.filter(|s| s.len() <= 9)
.and_then(figcraft_color::Color::from_hex)
.map(|c| Value::String(c.to_css_hex()))
.ok_or_else(|| format!("`{name}` must be a hex colour like #FF5A5F")),
ParamKind::Button | ParamKind::Note => Ok(Value::Null),
}
}
/// Check a parameter or panel control: its name, label, options and bounds. A default that
/// doesn't fit its kind is dropped.
fn check(&mut self) -> Result<(), String> {
if !(self.kind == ParamKind::Note && self.name.is_empty()) {
check_id("parameter name", &self.name, MAX_NAME)?;
}
let max = if self.kind == ParamKind::Note { MAX_TEXT } else { MAX_LABEL };
self.label = clean("parameter label", &self.label, max)?;
if self.options.len() > MAX_OPTIONS {
return Err(format!("parameter `{}` has more than {MAX_OPTIONS} options", self.name));
}
for o in &mut self.options {
*o = clean("option", o, MAX_LABEL)?;
}
if matches!(self.kind, ParamKind::Select | ParamKind::List) && self.options.is_empty() {
return Err(format!("parameter `{}` has no options", self.name));
}
if self.min.is_some_and(|v| !v.is_finite())
|| self.max.is_some_and(|v| !v.is_finite())
|| matches!((self.min, self.max), (Some(a), Some(b)) if a > b)
{
return Err(format!("parameter `{}` has bad bounds", self.name));
}
self.default = self.default.as_ref().and_then(|d| self.coerce(d).ok());
Ok(())
}
/// `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))
@@ -221,34 +279,39 @@ impl Manifest {
/// Check every field and fill in the defaults (a `run` command when there is none).
fn validated(mut self) -> Result<Manifest, String> {
self.check().map_err(|e| format!("manifest: {e}"))?;
Ok(self)
}
fn check(&mut self) -> Result<(), 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());
return Err("`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));
return Err(format!("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`"));
return Err(format!("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)));
return Err(format!("`{}` 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"));
return Err(format!("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)));
return Err(format!("two commands are called `{}`", truncate(dup)));
}
for c in &mut self.commands {
check_id("command id", &c.id, MAX_NAME)?;
@@ -257,32 +320,68 @@ impl Manifest {
c.label = c.id.clone();
}
if c.params.len() > MAX_PARAMS {
return Err(format!("manifest: command `{}` has more than {MAX_PARAMS} parameters", c.id));
return Err(format!("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));
p.check()?;
if !p.is_field() {
return Err(format!("parameter `{}`: buttons and notes only go in panels", 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));
return Err(format!("command `{}` declares `{dup}` twice", c.id));
}
}
Ok(self)
Ok(())
}
}
/// A panel a plugin shows while it runs (ABI v2): a title and controls drawn with FigCraft's own
/// widgets. Fields hold values; a button press or a changed value calls the plugin back.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct Panel {
#[serde(default, skip_serializing_if = "String::is_empty")]
pub title: String,
#[serde(default)]
pub controls: Vec<Param>,
}
impl Panel {
/// Parse and validate a panel spec (it comes from the plugin: untrusted).
pub fn parse(text: &str) -> Result<Panel, String> {
if text.len() > MAX_PANEL_BYTES {
return Err(format!("the panel is larger than {} KB", MAX_PANEL_BYTES / 1024));
}
let mut p: Panel = serde_json::from_str(text).map_err(|e| format!("panel: {e}"))?;
p.title = clean("title", &p.title, MAX_NAME).map_err(|e| format!("panel: {e}"))?;
if p.controls.len() > MAX_CONTROLS {
return Err(format!("panel: more than {MAX_CONTROLS} controls"));
}
for c in &mut p.controls {
c.check().map_err(|e| format!("panel: {e}"))?;
}
if let Some(dup) = first_duplicate(p.controls.iter().map(|c| c.name.as_str()).filter(|n| !n.is_empty())) {
return Err(format!("panel: two controls are called `{dup}`"));
}
Ok(p)
}
/// The control called `name`.
pub fn control(&self, name: &str) -> Option<&Param> {
self.controls.iter().find(|c| !c.name.is_empty() && c.name == name)
}
/// Its fields' values: a field's own value when the spec gives one, else its value in `old`
/// (what the user entered before the plugin updated the panel), else its default.
pub fn values(&self, old: &Map<String, Value>) -> Map<String, Value> {
self.controls
.iter()
.filter(|c| c.is_field())
.map(|c| {
let v = c.default.clone().or_else(|| old.get(&c.name).and_then(|v| c.coerce(v).ok())).unwrap_or_else(|| c.default_value());
(c.name.clone(), v)
})
.collect()
}
}
@@ -295,7 +394,7 @@ fn first_duplicate<'a>(names: impl Iterator<Item = &'a str>) -> Option<&'a str>
/// 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)));
return Err(format!("{what} `{}` must be 1–{max} letters, digits, `.`, `-` or `_`", truncate(id)));
}
Ok(())
}
@@ -304,10 +403,10 @@ fn check_id(what: &str, id: &str, max: usize) -> Result<(), String> {
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"));
return Err(format!("{what} is longer than {max} characters"));
}
if s.chars().any(char::is_control) {
return Err(format!("manifest: {what} contains control characters"));
return Err(format!("{what} contains control characters"));
}
Ok(s.to_string())
}
@@ -322,7 +421,7 @@ fn check_main(main: &str) -> Result<(), String> {
|| 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)));
return Err(format!("`main` must be a relative path to a .wasm file inside the plugin's folder (got `{}`)", truncate(main)));
}
Ok(())
}
+257 -70
View File
@@ -2,7 +2,7 @@
//! 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)
//! # ABI (version 2; version 1 is the first seven imports)
//!
//! 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.
@@ -16,11 +16,25 @@
//! | `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. |
//! | `ui_show` | `(ptr, len) -> i32` | Shows (or replaces) the plugin's panel: a [`Panel`] as JSON. 1: invalid, the reply says why. |
//! | `ui_close` | `()` | Closes the panel. |
//! | `setting_get` | `(key_ptr, key_len) -> i32` | 0: the reply holds the stored JSON; 1: no such setting. |
//! | `setting_set` | `(key_ptr, key_len, value_ptr, value_len) -> i32` | Stores JSON under the key. 1: refused, the reply says why. |
//! | `setting_delete` | `(key_ptr, key_len)` | Forgets a setting. |
//!
//! 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.
//! `{"api", "command", "params", "selection"}`, plus `event` and `values` when a panel calls the
//! plugin back. It returns 0 on success; anything else fails the run.
//!
//! An [`Instance`] keeps the module's memory between calls, so a plugin with a panel open keeps its
//! state from one callback to the next. Every call gets the full [`Limits`] again.
use wasmi::{Caller, Config, Engine, Extern, FuncType, Linker, Module, Store, StoreLimits, StoreLimitsBuilder, TrapCode, ValType};
use wasmi::{
Caller, Config, Engine, Extern, FuncType, Linker, Module, Store, StoreLimits, StoreLimitsBuilder, TrapCode, TypedFunc, TypedResumableCall, Val,
ValType,
};
use crate::manifest::Panel;
/// The module host functions are imported from.
pub const IMPORT_MODULE: &str = "figcraft";
@@ -28,8 +42,20 @@ pub const IMPORT_MODULE: &str = "figcraft";
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)];
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),
("ui_show", 2, 1),
("ui_close", 0, 0),
("setting_get", 2, 1),
("setting_set", 4, 1),
("setting_delete", 2, 0),
];
/// What a run may use.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -80,7 +106,8 @@ pub enum PluginError {
Failed(String),
}
/// What the sandbox asks of its host (the engine): run commands, read the selection, log.
/// What the sandbox asks of its host (the engine): run commands, read the selection, log, keep
/// settings.
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>;
@@ -90,6 +117,22 @@ pub trait Host {
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) {}
/// The plugin's setting `key` (JSON text).
fn setting(&mut self, _key: &str) -> Option<String> {
None
}
/// Store (`Some`) or forget (`None`) setting `key`, or say why not.
fn set_setting(&mut self, _key: &str, _value: Option<&str>) -> Result<(), String> {
Err("settings aren't available here".into())
}
}
/// What a run asked of the panel.
#[derive(Clone, Debug, PartialEq)]
pub enum UiChange {
/// Show this panel (or replace the one showing).
Show(Panel),
Close,
}
/// A finished run.
@@ -102,16 +145,88 @@ pub struct RunOutput {
pub calls: u32,
/// Fuel used.
pub fuel: u64,
/// The last `ui_show` / `ui_close` of the run.
pub ui: Option<UiChange>,
}
struct State<'h> {
host: &'h mut dyn Host,
/// A host call that needs the [`Host`]: the plugin is paused while the host answers it (see
/// [`Instance::call`]), so the store never borrows the host and can outlive one call.
enum Request {
Call(String, String),
Selection,
Log(String),
Notify(String),
SettingGet(String),
SettingSet(String, String),
SettingDelete(String),
}
struct State {
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>,
request: Option<Request>,
}
impl State {
/// Make `bytes` the reply; returns its length.
fn set_reply(&mut self, bytes: Vec<u8>) -> i32 {
let max = self.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);
self.reply = bytes;
len
}
/// Answer a paused host call; the host function's result, if it has one.
fn answer(&mut self, host: &mut dyn Host, request: Request) -> Option<i32> {
match request {
Request::Call(cmd, params) => {
let (status, reply) = match host.call(&cmd, &params) {
Ok(v) => (0, v),
Err(m) => (1, m),
};
self.set_reply(reply.into_bytes());
Some(status)
}
Request::Selection => Some(self.set_reply(host.selection().into_bytes())),
Request::Log(line) => {
host.log(&line);
if self.out.logs.len() < self.limits.max_logs {
self.out.logs.push(line);
}
None
}
Request::Notify(msg) => {
host.notify(&msg);
None
}
Request::SettingGet(key) => Some(match host.setting(&key) {
Some(v) => {
self.set_reply(v.into_bytes());
0
}
None => {
self.set_reply(Vec::new());
1
}
}),
Request::SettingSet(key, value) => Some(match host.set_setting(&key, Some(&value)) {
Ok(()) => 0,
Err(m) => {
self.set_reply(m.into_bytes());
1
}
}),
Request::SettingDelete(key) => {
let _ = host.set_setting(&key, None);
None
}
}
}
}
fn engine() -> Engine {
@@ -164,36 +279,80 @@ fn compile(engine: &Engine, bytes: &[u8], limits: &Limits) -> Result<Module, Plu
Ok(module)
}
/// Run a plugin module: `input` is the JSON handed to `figcraft_run`.
/// Run a plugin module once: `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()));
Instance::new(bytes, limits)?.call(input, host)
}
/// A started plugin module whose memory lasts from one call to the next.
pub struct Instance {
store: Store<State>,
entry: TypedFunc<i32, i32>,
}
impl std::fmt::Debug for Instance {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Instance").finish_non_exhaustive()
}
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));
}
impl Instance {
/// Compile, check and start a module. A start function can't call host functions.
pub fn new(bytes: &[u8], limits: &Limits) -> Result<Self, PluginError> {
let engine = engine();
let module = compile(&engine, bytes, limits)?;
let store_limits =
StoreLimitsBuilder::new().memory_size(limits.memory_bytes).memories(1).tables(4).table_elements(100_000).instances(1).build();
let state = State { limits: *limits, store_limits, reply: Vec::new(), out: RunOutput::default(), abort: None, request: 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()))?;
Ok(Self { store, entry })
}
/// Call `figcraft_run` with `input`, with the full limits. Host calls that need `host` pause
/// the plugin, are answered here and resume it.
pub fn call(&mut self, input: &str, host: &mut dyn Host) -> Result<RunOutput, PluginError> {
let limits = self.store.data().limits;
let input_len = i32::try_from(input.len()).ok().filter(|_| input.len() <= limits.max_message);
let input_len = input_len.ok_or_else(|| PluginError::Limit("the input is too large".into()))?;
let st = self.store.data_mut();
st.reply = input.as_bytes().to_vec();
st.out = RunOutput::default();
st.abort = None;
st.request = None;
self.store.set_fuel(limits.fuel).map_err(|e| PluginError::Trap(e.to_string()))?;
let mut call = self.entry.call_resumable(&mut self.store, input_len).map_err(|e| classify(&mut self.store, &e, false))?;
let status = loop {
match call {
TypedResumableCall::Finished(status) => break status,
TypedResumableCall::OutOfFuel(_) => return Err(PluginError::OutOfFuel),
TypedResumableCall::HostTrap(paused) => {
let Some(request) = self.store.data_mut().request.take() else {
return Err(classify(&mut self.store, paused.host_error(), false));
};
let results: Vec<Val> = self.store.data_mut().answer(host, request).map(Val::I32).into_iter().collect();
call = paused.resume(&mut self.store, &results).map_err(|e| classify(&mut self.store, &e, false))?;
}
}
};
let fuel = limits.fuel.saturating_sub(self.store.get_fuel().unwrap_or(0));
let mut out = std::mem::take(&mut self.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)
}
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 {
fn classify(store: &mut Store<State>, e: &wasmi::Error, instantiating: bool) -> PluginError {
if let Some(abort) = store.data_mut().abort.take() {
return abort;
}
@@ -208,17 +367,25 @@ fn classify(store: &mut Store<State<'_>>, e: &wasmi::Error, instantiating: bool)
}
}
type Ctx<'a, 'h> = Caller<'a, State<'h>>;
type Ctx<'a> = Caller<'a, State>;
/// Stop the run because of `err` (the trap it causes reports `err`).
fn abort(caller: &mut Ctx<'_, '_>, err: PluginError) -> wasmi::Error {
fn abort(caller: &mut Ctx<'_>, err: PluginError) -> wasmi::Error {
let msg = err.to_string();
caller.data_mut().abort = Some(err);
wasmi::Error::new(msg)
}
/// Pause the plugin until the host answers `request`.
// shortcut: a host function tail-called (`return_call`) from `figcraft_run` itself can't be
// resumed and fails the run; fine for compiled plugins, which don't do that.
fn ask(caller: &mut Ctx<'_>, request: Request) -> wasmi::Error {
caller.data_mut().request = Some(request);
wasmi::Error::new("a host function was called outside figcraft_run")
}
/// Charge a host call: fuel and the call count.
fn charge(caller: &mut Ctx<'_, '_>) -> Result<(), wasmi::Error> {
fn charge(caller: &mut Ctx<'_>) -> Result<(), wasmi::Error> {
let cost = caller.data().limits.call_fuel;
let left = caller.get_fuel()?;
if left < cost {
@@ -235,7 +402,7 @@ fn charge(caller: &mut Ctx<'_, '_>) -> Result<(), wasmi::Error> {
}
/// 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> {
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"))));
@@ -250,44 +417,29 @@ fn read_bytes(caller: &mut Ctx<'_, '_>, ptr: i32, len: i32) -> Result<Vec<u8>, w
}
/// The UTF-8 text at `(ptr, len)`.
fn read_str(caller: &mut Ctx<'_, '_>, ptr: i32, len: i32) -> Result<String, wasmi::Error> {
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);
fn linker(engine: &Engine) -> Result<Linker<State>, PluginError> {
let mut l = <Linker<State>>::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> {
l.func_wrap(IMPORT_MODULE, "call", |mut c: Ctx<'_>, 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)
Err(ask(&mut c, Request::Call(cmd, params)))
})
.map_err(err)?;
l.func_wrap(IMPORT_MODULE, "selection", |mut c: Ctx<'_, 'h>| -> Result<i32, wasmi::Error> {
l.func_wrap(IMPORT_MODULE, "selection", |mut c: Ctx<'_>| -> Result<i32, wasmi::Error> {
charge(&mut c)?;
let sel = c.data_mut().host.selection();
set_reply(&mut c, sel.into_bytes())
Err(ask(&mut c, Request::Selection))
})
.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> {
l.func_wrap(IMPORT_MODULE, "reply_len", |c: Ctx<'_>| -> 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<'_>, 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())));
@@ -300,30 +452,65 @@ fn linker<'h>(engine: &Engine) -> Result<Linker<State<'h>>, PluginError> {
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> {
l.func_wrap(IMPORT_MODULE, "log", |mut c: Ctx<'_>, 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(())
Err(ask(&mut c, Request::Log(line)))
})
.map_err(err)?;
l.func_wrap(IMPORT_MODULE, "notify", |mut c: Ctx<'_, 'h>, ptr: i32, len: i32| -> Result<(), wasmi::Error> {
l.func_wrap(IMPORT_MODULE, "notify", |mut c: Ctx<'_>, 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(())
Err(ask(&mut c, Request::Notify(msg)))
})
.map_err(err)?;
l.func_wrap(IMPORT_MODULE, "set_output", |mut c: Ctx<'_, 'h>, ptr: i32, len: i32| -> Result<(), wasmi::Error> {
l.func_wrap(IMPORT_MODULE, "set_output", |mut c: Ctx<'_>, 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)?;
l.func_wrap(IMPORT_MODULE, "ui_show", |mut c: Ctx<'_>, ptr: i32, len: i32| -> Result<i32, wasmi::Error> {
charge(&mut c)?;
let spec = read_str(&mut c, ptr, len)?;
let st = c.data_mut();
Ok(match Panel::parse(&spec) {
Ok(panel) => {
st.out.ui = Some(UiChange::Show(panel));
0
}
Err(why) => {
st.set_reply(why.into_bytes());
1
}
})
})
.map_err(err)?;
l.func_wrap(IMPORT_MODULE, "ui_close", |mut c: Ctx<'_>| -> Result<(), wasmi::Error> {
charge(&mut c)?;
c.data_mut().out.ui = Some(UiChange::Close);
Ok(())
})
.map_err(err)?;
l.func_wrap(IMPORT_MODULE, "setting_get", |mut c: Ctx<'_>, kp: i32, kl: i32| -> Result<i32, wasmi::Error> {
charge(&mut c)?;
let key = read_str(&mut c, kp, kl)?;
Err(ask(&mut c, Request::SettingGet(key)))
})
.map_err(err)?;
l.func_wrap(IMPORT_MODULE, "setting_set", |mut c: Ctx<'_>, kp: i32, kl: i32, vp: i32, vl: i32| -> Result<i32, wasmi::Error> {
charge(&mut c)?;
let key = read_str(&mut c, kp, kl)?;
let value = read_str(&mut c, vp, vl)?;
Err(ask(&mut c, Request::SettingSet(key, value)))
})
.map_err(err)?;
l.func_wrap(IMPORT_MODULE, "setting_delete", |mut c: Ctx<'_>, kp: i32, kl: i32| -> Result<(), wasmi::Error> {
charge(&mut c)?;
let key = read_str(&mut c, kp, kl)?;
Err(ask(&mut c, Request::SettingDelete(key)))
})
.map_err(err)?;
Ok(l)
}
+2 -2
View File
@@ -268,7 +268,7 @@ fn bad_manifests_are_refused() {
json!({"main": "C:\\plugin.wasm"}),
json!({"main": "plugin.js"}),
json!({"main": "a//b.wasm"}),
json!({"api": 2}),
json!({"api": 3}),
json!({"api": 0}),
json!({"permissions": {"access": "root"}}),
json!({"permissions": {"commands": ["*.x"]}}),
@@ -277,7 +277,7 @@ fn bad_manifests_are_refused() {
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": [{"id": "a", "label": "A", "params": [{"name": "n", "type": "date"}]}]}),
json!({"commands": "run"}),
];
for extra in bad {
+175
View File
@@ -0,0 +1,175 @@
//! ABI v2 in the sandbox: an instance keeps its memory between calls, panels are validated and
//! reported, settings go through the host.
use std::collections::BTreeMap;
use serde_json::{Map, json};
use crate::{Host, Instance, Limits, Manifest, Panel, ParamKind, PluginError, UiChange};
/// A host that keeps settings.
#[derive(Default)]
struct Settings(BTreeMap<String, String>);
impl Host for Settings {
fn call(&mut self, _command: &str, _params: &str) -> Result<String, String> {
Ok("{}".into())
}
fn selection(&mut self) -> String {
"[]".into()
}
fn notify(&mut self, _message: &str) {}
fn setting(&mut self, key: &str) -> Option<String> {
self.0.get(key).cloned()
}
fn set_setting(&mut self, key: &str, value: Option<&str>) -> Result<(), String> {
match value {
Some(v) if v.len() > 10 => Err("too big".into()),
Some(v) => {
self.0.insert(key.into(), v.into());
Ok(())
}
None => {
self.0.remove(key);
Ok(())
}
}
}
}
fn v2_module(data: &str, body: &str) -> Vec<u8> {
let wat = format!(
r#"(module
(import "figcraft" "reply_len" (func $reply_len (result i32)))
(import "figcraft" "reply_read" (func $reply_read (param i32 i32) (result i32)))
(import "figcraft" "set_output" (func $set_output (param i32 i32)))
(import "figcraft" "ui_show" (func $ui_show (param i32 i32) (result i32)))
(import "figcraft" "ui_close" (func $ui_close))
(import "figcraft" "setting_get" (func $setting_get (param i32 i32) (result i32)))
(import "figcraft" "setting_set" (func $setting_set (param i32 i32 i32 i32) (result i32)))
(import "figcraft" "setting_delete" (func $setting_delete (param i32 i32)))
(memory (export "memory") 1)
{data}
(func (export "figcraft_run") (param $n i32) (result i32) {body}))"#
);
wat::parse_str(&wat).unwrap_or_else(|e| panic!("{e}\n{wat}"))
}
const PANEL: &str = r#"{"title":"T","controls":[{"type":"button","name":"go","label":"Go"}]}"#;
#[test]
fn an_instance_keeps_its_memory_and_reports_panel_changes() {
// A counter at 100 ('0' + calls): call 1 shows a panel, call 2 an invalid one (the refusal is
// its output), call 3 closes it.
let bad = r#"{"controls":[{"type":"rocket"}]}"#;
let m = v2_module(
&format!(
r#"(data (i32.const 100) "0") (data (i32.const 200) "{}") (data (i32.const 1000) "{}")"#,
PANEL.replace('"', "\\\""),
bad.replace('"', "\\\"")
),
&format!(
r#"(i32.store8 (i32.const 100) (i32.add (i32.load8_u (i32.const 100)) (i32.const 1)))
(if (i32.eq (i32.load8_u (i32.const 100)) (i32.const 49)) (then (drop (call $ui_show (i32.const 200) (i32.const {})))))
(if (i32.eq (i32.load8_u (i32.const 100)) (i32.const 50))
(then (if (call $ui_show (i32.const 1000) (i32.const {}))
(then (call $set_output (i32.const 2000) (call $reply_read (i32.const 2000) (call $reply_len))) (return (i32.const 0))))))
(if (i32.eq (i32.load8_u (i32.const 100)) (i32.const 51)) (then (call $ui_close)))
(call $set_output (i32.const 100) (i32.const 1))
(i32.const 0)"#,
PANEL.len(),
bad.len()
),
);
let mut inst = Instance::new(&m, &Limits::default()).unwrap();
let mut host = Settings::default();
let first = inst.call("{}", &mut host).unwrap();
assert_eq!((first.output.as_str(), first.ui), ("1", Some(UiChange::Show(Panel::parse(PANEL).unwrap()))));
let second = inst.call("{}", &mut host).unwrap();
assert!(second.output.contains("panel:") && second.ui.is_none(), "{second:?}");
let third = inst.call("{}", &mut host).unwrap();
assert_eq!((third.output.as_str(), third.ui), ("3", Some(UiChange::Close)));
}
#[test]
fn settings_go_through_the_host() {
let m = v2_module(
r#"(data (i32.const 0) "k") (data (i32.const 10) "[1,2]") (data (i32.const 20) "\"far too long\"")"#,
r#"(if (call $setting_set (i32.const 0) (i32.const 1) (i32.const 10) (i32.const 5)) (then (return (i32.const 2))))
;; Refused by the host: status 1.
(if (i32.eqz (call $setting_set (i32.const 0) (i32.const 1) (i32.const 20) (i32.const 14))) (then (return (i32.const 3))))
(if (call $setting_get (i32.const 0) (i32.const 1)) (then (return (i32.const 4))))
(call $set_output (i32.const 100) (call $reply_read (i32.const 100) (call $reply_len)))
(call $setting_delete (i32.const 0) (i32.const 1))
(if (i32.eqz (call $setting_get (i32.const 0) (i32.const 1))) (then (return (i32.const 5))))
(i32.const 0)"#,
);
let mut host = Settings::default();
let out = crate::run(&m, "{}", &Limits::default(), &mut host).unwrap();
assert_eq!(out.output, "[1,2]");
assert!(host.0.is_empty());
// Settings cost host calls like any other.
let limits = Limits { max_calls: 2, ..Limits::default() };
assert!(matches!(crate::run(&m, "{}", &limits, &mut host), Err(PluginError::Limit(_))));
}
#[test]
fn panels_are_validated_and_keep_entered_values() {
let p = Panel::parse(
r##"{"title": "Swatches", "controls": [
{"type": "list", "name": "palette", "label": "Palette", "options": ["Warm", "Cool"], "value": "Cool"},
{"type": "note", "label": "Pick a palette"},
{"type": "color", "name": "accent", "value": "#f0a"},
{"type": "number", "name": "n", "min": 1, "max": 4, "value": 9},
{"type": "text", "name": "t"},
{"type": "button", "name": "go", "label": "Go"}
]}"##,
)
.unwrap();
assert_eq!(p.control("accent").unwrap().default, Some(json!("#FF00AA")));
assert_eq!(p.control("n").unwrap().default, Some(json!(4.0)), "values are clamped");
assert_eq!(p.control("go").unwrap().kind, ParamKind::Button);
let mut old = Map::new();
old.insert("t".into(), json!("typed"));
old.insert("palette".into(), json!("Warm"));
assert_eq!(
json!(p.values(&old)),
json!({"palette": "Cool", "accent": "#FF00AA", "n": 4.0, "t": "typed"}),
"the spec's values win, then the entered ones"
);
let deep = format!("{{\"controls\": {}{}}}", "[".repeat(5000), "]".repeat(5000));
let many = json!({"controls": (0..65).map(|i| json!({"type": "button", "name": format!("b{i}")})).collect::<Vec<_>>()}).to_string();
let junk = [
"",
"null",
r#"{"controls": 5}"#,
r#"{"controls": [{"type": "rocket", "name": "x"}]}"#,
r#"{"controls": [{"type": "button"}]}"#,
r#"{"controls": [{"type": "list", "name": "x"}]}"#,
r#"{"controls": [{"type": "button", "name": "a"}, {"type": "text", "name": "a"}]}"#,
r#"{"controls": [{"type": "number", "name": "a", "min": 3, "max": 1}]}"#,
r#"{"title": "line\nbreak"}"#,
&deep,
&many,
];
for j in junk {
assert!(Panel::parse(j).is_err(), "accepted {}", j.chars().take(60).collect::<String>());
}
let huge = format!(r#"{{"controls": [{{"type": "note", "label": "{}"}}]}}"#, "x".repeat(70_000));
assert!(Panel::parse(&huge).unwrap_err().contains("larger"));
let long_note = format!(r#"{{"controls": [{{"type": "note", "label": "{}"}}]}}"#, "x".repeat(1001));
assert!(Panel::parse(&long_note).is_err());
// Junk values are dropped, not kept.
let p = Panel::parse(
r#"{"controls": [{"type": "color", "name": "c", "value": {"deep": [1, 2]}}, {"type": "boolean", "name": "b", "value": "yes"}]}"#,
)
.unwrap();
assert_eq!(json!(p.values(&Map::new())), json!({"c": "#000000", "b": false}));
// Buttons and notes don't go in manifests; colours and lists do.
let manifest = |kind: &str| {
let params = json!([{"name": "x", "type": kind, "options": ["a"]}]);
Manifest::from_value(&json!({"id": "a.b", "name": "A", "main": "a.wasm", "commands": [{"id": "c", "label": "C", "params": params}]}))
};
assert!(manifest("button").is_err() && manifest("note").is_err());
assert!(manifest("color").is_ok() && manifest("list").is_ok());
}