fix(plugin): keep the plugin directory's wire format; refuse oversized save_file data

- PluginEntry no longer carries save_file: postcard is positional and existing
  messages keep their fields (docs/collab-protocol.md), so mixed app/server
  versions keep reading the directory. Manage plugins still shows "can save files".
- save_file checks the data length before reading it and refuses with status 1
  and the reason, as documented, instead of failing the run with a limit error.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Dubblbindong
2026-10-18 17:11:59 +02:00
co-authored by Claude Opus 5.5
parent 38034a4c4a
commit a370211b48
10 changed files with 20 additions and 16 deletions
-1
View File
@@ -245,7 +245,6 @@ impl Directory {
},
commands: m.permissions.commands.clone(),
widget: m.widget,
save_file: m.permissions.save_file,
bytes: bytes.len() as u64,
sha256: sha.clone(),
published: now(),
-2
View File
@@ -537,8 +537,6 @@ pub struct PluginEntry {
pub commands: Vec<String>,
/// It draws a widget.
pub widget: bool,
/// It may offer files to save (`permissions.saveFile`).
pub save_file: bool,
/// The bundle's size in bytes and its SHA-256 (hex).
pub bytes: u64,
pub sha256: String,
+1 -1
View File
@@ -288,7 +288,7 @@ pub fn specs() -> Vec<CommandSpec> {
spec!(
"plugin.directory.list",
"Browse plugin directory",
"{server? (ws://host:port/; default: the one browsed last), query?} → {server, plugins: [{id, name, version, description, author, api, access, commands, widget, save_file, bytes, sha256, published, versions, installed, update}]}",
"{server? (ws://host:port/; default: the one browsed last), query?} → {server, plugins: [{id, name, version, description, author, api, access, commands, widget, bytes, sha256, published, versions, installed, update}]}",
false,
list
),
+11
View File
@@ -195,6 +195,12 @@ struct State {
}
impl State {
/// Make `message` the reply and return status 1 (refused).
fn refuse(&mut self, message: String) -> i32 {
self.set_reply(message.into_bytes());
1
}
/// Make `bytes` the reply; returns its length.
fn set_reply(&mut self, bytes: Vec<u8>) -> i32 {
let max = self.limits.max_message;
@@ -595,6 +601,11 @@ fn linker(engine: &Engine) -> Result<Linker<State>, PluginError> {
charge(&mut c)?;
let name = read_str(&mut c, np, nl)?;
let mime = read_str(&mut c, mp, ml)?;
// Too much data is a refusal the plugin can handle, not a limit that fails the run.
let (len, max) = (dl as u32 as usize, c.data().limits.max_message);
if len > max {
return Ok(c.data_mut().refuse(format!("a file to save is limited to {max} bytes, this one has {len}")));
}
let data = read_bytes(&mut c, dp, dl)?;
Err(ask(&mut c, Request::SaveFile(name, mime, data)))
})
+6 -4
View File
@@ -98,12 +98,14 @@ fn a_host_without_saving_refuses_by_default() {
}
#[test]
fn too_much_data_is_a_limit_error_and_bad_utf8_a_trap() {
let limits = Limits { max_message: 4, ..Limits::default() };
let wasm = module("a.txt", "text/plain", b"12345", &call("a.txt", "text/plain", b"12345"));
fn too_much_data_is_refused_and_bad_utf8_a_trap() {
let limits = Limits { max_message: 64, ..Limits::default() };
let data = [b'x'; 65];
let wasm = module("a.txt", "text/plain", &data, &call("a.txt", "text/plain", &data));
let mut host = Saves::default();
// Status 1 with the reason, which the module passes on as its own failure.
let err = Instance::new(&wasm, &limits).unwrap().call("{}", &mut host).unwrap_err();
assert!(matches!(err, PluginError::Limit(_)), "{err:?}");
assert_eq!(err, PluginError::Failed("a file to save is limited to 64 bytes, this one has 65".into()));
assert!(host.0.is_empty());
// The name points at a lone 0xff byte: not UTF-8.
-1
View File
@@ -1533,7 +1533,6 @@ status = "draft — needs native review"
"read and edit the document" = "das Dokument lesen und bearbeiten"
"read the document" = "das Dokument lesen"
"reads the document" = "liest das Dokument"
"save files you choose" = "Dateien an einem von Ihnen gewählten Ort speichern"
"set-variable actions on variables that aren't in the variables JSON were skipped" = "Aktionen „Variable festlegen“ für Variablen, die nicht im Variablen-JSON sind, wurden übersprungen"
"set-variable actions with values that can't be evaluated were skipped" = "Aktionen „Variable festlegen“ mit nicht auswertbaren Werten wurden übersprungen"
"set-variable actions with values that don't fit the variable were skipped" = "Aktionen „Variable festlegen“ mit Werten, die nicht zur Variable passen, wurden übersprungen"
-1
View File
@@ -1621,7 +1621,6 @@ name = "English"
"read and edit the document" = "read and edit the document"
"read the document" = "read the document"
"reads the document" = "reads the document"
"save files you choose" = "save files you choose"
"set-variable actions on variables that aren't in the variables JSON were skipped" = "set-variable actions on variables that aren't in the variables JSON were skipped"
"set-variable actions with values that can't be evaluated were skipped" = "set-variable actions with values that can't be evaluated were skipped"
"set-variable actions with values that don't fit the variable were skipped" = "set-variable actions with values that don't fit the variable were skipped"
-1
View File
@@ -1622,7 +1622,6 @@ status = "draft — needs native review"
"read and edit the document" = "leer y editar el documento"
"read the document" = "leer el documento"
"reads the document" = "lee el documento"
"save files you choose" = "guardar archivos donde tú elijas"
"set-variable actions on variables that aren't in the variables JSON were skipped" = "se omitieron las acciones «Establecer variable» sobre variables que no están en el JSON de variables"
"set-variable actions with values that can't be evaluated were skipped" = "se omitieron las acciones «Establecer variable» con valores que no se pueden evaluar"
"set-variable actions with values that don't fit the variable were skipped" = "se omitieron las acciones «Establecer variable» con valores que no encajan con la variable"
-3
View File
@@ -477,9 +477,6 @@ fn confirm_window(app: &mut FigApp, ctx: &Context, t: &Tokens) {
if e.widget {
asks.push(tr("put a widget on the canvas").to_string());
}
if e.save_file {
asks.push(tr("save files you choose").to_string());
}
for a in asks {
ui.add(egui::Label::new(RichText::new(format!("• {a}")).font(regular(11.0)).color(t.text)).wrap());
}
+2 -2
View File
@@ -135,7 +135,7 @@ plugins: import them in the script.
| `widget.insert` | `{plugin, x?, y?, fuel?}` | Puts widget plugin `plugin`'s widget on the current page, its top-left at `x`, `y` (default: centred in the view), and selects it; the plugin draws it. One undo step. Returns `{id, plugin, state, targets, output, logs, calls, fuel}`: `targets` are the names of its clickable parts. |
| `widget.click` | `{id, target, fuel?}` | Clicks part `target` of widget `id` (or of the widget a layer `id` is in), as a click on the canvas would: the plugin is called back and redraws it. One undo step; returns like `widget.insert`. |
| `widget.list` | `{}` | `{widgets: [{id, plugin, name, page, state, targets}]}`: every widget in the file. |
| `plugin.directory.list` | `{server?, query?}` | The plugin directory at `server` (`ws://host:port/`; default: the one browsed last): `{server, plugins: [{id, name, version, description, author, api, access, commands, widget, save_file, bytes, sha256, published, versions, installed, update}]}`, the latest version of each plugin whose id, name, description or author contains `query`. `installed` is the installed version (or null), `update` whether the listed one is newer. |
| `plugin.directory.list` | `{server?, query?}` | The plugin directory at `server` (`ws://host:port/`; default: the one browsed last): `{server, plugins: [{id, name, version, description, author, api, access, commands, widget, bytes, sha256, published, versions, installed, update}]}`, the latest version of each plugin whose id, name, description or author contains `query`. `installed` is the installed version (or null), `update` whether the listed one is newer. |
| `plugin.directory.install` | `{id, version?, confirm, server?, sha256?}` | Downloads version `version` (default: the latest) and installs it, or updates the installed one. `confirm` must be `true` (installing runs the plugin's code: show its permissions first); without it nothing is downloaded. The download must match the SHA-256 the directory lists (and `sha256`, the one shown, when given). Returns `{installed, name, version, permissions, sha256, path, source, replaced}`. |
| `plugin.directory.publish` | `{path \| plugin, server}` | Publishes a plugin folder (or its `figcraft-plugin.json`), or an installed plugin, as a new version. `server` carries the server's key: `ws://host:port/?key=…`. Returns `{published, server}`. |
| `plugin.directory.unpublish` | `{id, version?, server}` | Removes one version (default: every version) from the directory; needs the key. Returns `{id, removed}`. |
@@ -225,7 +225,7 @@ plugin directory requests (`plugin.directory.*` says so). Import plugins with `p
| `author` | Optional: who made it, as you type it (64 characters at most); plugin directories show it. |
| `main` | Required. The module, a `.wasm` path relative to the manifest, inside its folder. |
| `api` | The plugin API version the plugin was written for: `1` (the default), `2` (panels and settings), `3` (widgets) or `4` (saving files). A manifest asking for a newer one than FigCraft runs is refused, so a plugin that needs panels, widgets or `saveFile` fails clearly on an older FigCraft. |
| `permissions.saveFile` | API 4: `true` lets the plugin offer a file to save (an exporter's output). FigCraft asks the user where it goes with its own Save dialog (a download in the browser); the plugin never gets a path. The install confirmation lists it. |
| `permissions.saveFile` | API 4: `true` lets the plugin offer a file to save (an exporter's output). FigCraft asks the user where it goes with its own Save dialog (a download in the browser); the plugin never gets a path. Manage plugins lists it ("can save files"); a plugin directory's entries don't carry it, so their wire format stays as it is. |
| `widget` | API 3: `true` when the plugin draws a widget (it's listed in Plugins ▸ Widgets). |
| `permissions.access` | `read` (the default): commands that leave the document as it is; such plugins also run from Dev Mode's Inspect tab. `write`: edits too. |
| `permissions.commands` | Optional allowlist: exact command ids or prefixes ending in `*` (`node.*`). Empty: any command the access allows. |