Fix #121: sandboxed WebAssembly plug-ins: object filters and live effects

VectorCraft had an Additional Plug-ins Folder preference but nothing loaded from it. Port
PhotoCraft's wasmi plug-in host (same sandbox, manifest and parameter schema) as the new
vectorcraft-plugins crate (L2), extended for vector documents:

- Object filters read the selected paths and compound paths as JSON (native PathData and
  Paint forms: geometry, fills, strokes, gradients, opacity) and return the objects to keep,
  change, add or delete; the output is validated in full and applied as one undo step
  (plugin.run). A failing plug-in leaves the document untouched.
- Live-effect plug-ins are geometry effects in the appearance stack (effect id
  plugin.<id>), re-evaluated (and cached) when the object or parameters change, editable,
  exported and baked by Expand Appearance; a missing plug-in leaves the geometry as is.
- plugin.list / info / run / install / remove / reload; the preference folder installs
  every *.wasm at startup and when it changes; File > Open of a .wasm installs it (web).
- Object > Plug-ins and Effect > Plug-ins menus with dialogs generated from the parameter
  schema (live preview), the Appearance panel's fx menu, docs/plugins.md, an original
  example plug-in (Desaturate, a Rust crate outside the workspace) and fuzzing of the
  plug-in boundary (engine/tests/plugin_fuzz.rs).

Tool plug-ins and paint-changing live effects are left for later (docs/plugins.md).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
bflatasticandClaude Opus 5.5 committed 2026-10-06 00:19:41 +07:00
1 parent cccdf96cc1
commit 811ad1efd8
45 files changed
+3712 -31

No files matched your search

+21
View File
@@ -0,0 +1,21 @@
[package]
name = "vectorcraft-plugins"
description = "Sandboxed WebAssembly plug-ins for VectorCraft: a resource-limited wasmi host for object filters and live effects"
version.workspace = true
edition.workspace = true
license.workspace = true
[lints]
workspace = true
[dependencies]
vectorcraft-geom = { workspace = true }
vectorcraft-color = { workspace = true }
vectorcraft-doc = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
wasmi = { workspace = true }
[dev-dependencies]
wat = { workspace = true }
+105
View File
@@ -0,0 +1,105 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "memchr"
version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]]
name = "proc-macro2"
version = "1.0.107"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
dependencies = [
"proc-macro2",
]
[[package]]
name = "serde"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
dependencies = [
"serde_core",
]
[[package]]
name = "serde_core"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "serde_json"
version = "1.0.151"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
dependencies = [
"itoa",
"memchr",
"serde",
"serde_core",
"zmij",
]
[[package]]
name = "syn"
version = "3.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "unicode-ident"
version = "1.0.26"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954"
[[package]]
name = "vectorcraft-plugin-desaturate"
version = "1.0.0"
dependencies = [
"serde_json",
]
[[package]]
name = "zmij"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
+23
View File
@@ -0,0 +1,23 @@
[package]
name = "vectorcraft-plugin-desaturate"
description = "Example VectorCraft WebAssembly object filter plug-in: Desaturate"
version = "1.0.0"
edition = "2024"
license = "MIT OR Apache-2.0"
publish = false
# Not a member of the VectorCraft workspace: it builds for wasm32-unknown-unknown only.
[workspace]
[lib]
crate-type = ["cdylib"]
[dependencies]
serde_json = "1"
[profile.release]
opt-level = "z"
lto = true
codegen-units = 1
panic = "abort"
strip = true
+9
View File
@@ -0,0 +1,9 @@
#!/bin/sh
# Builds the Desaturate example plug-in and refreshes the test fixture built from it.
# Needs the wasm32-unknown-unknown target: `rustup target add wasm32-unknown-unknown`.
set -eu
cd "$(dirname "$0")"
cargo build --release --target wasm32-unknown-unknown
out=target/wasm32-unknown-unknown/release/vectorcraft_plugin_desaturate.wasm
cp "$out" ../tests/fixtures/desaturate.wasm
echo "built $out ($(wc -c < "$out") bytes) -> crates/plugins/tests/fixtures/desaturate.wasm"
+104
View File
@@ -0,0 +1,104 @@
//! Desaturate: an example VectorCraft object filter plug-in (plug-in ABI v1, see
//! `docs/plugins.md`).
//!
//! It reads the selected paths as JSON, moves every colour (solid fills and strokes, gradient
//! stops, freeform gradient points) towards grey by `amount` percent, keeping each colour in its
//! own colour model, and hands the objects back. Only `serde_json` is used: no imports, no WASI.
use serde_json::{Value, json};
const MANIFEST: &str = r#"{
"id": "org.vectorcraft.example.desaturate",
"name": "Desaturate",
"version": "1.0.0",
"kind": "filter",
"author": "VectorCraft contributors",
"description": "Moves the colours of the selected paths towards grey; an example of the plug-in ABI.",
"params": {
"amount": {"type": "number", "min": 0, "max": 100, "default": 100}
}
}"#;
#[unsafe(no_mangle)]
pub extern "C" fn vc_abi_version() -> u32 {
1
}
/// `len << 32 | ptr` of the UTF-8 manifest JSON.
#[unsafe(no_mangle)]
pub extern "C" fn vc_manifest() -> u64 {
pack(MANIFEST.as_bytes())
}
/// A block of `size` bytes the host writes the input and the parameters into. Never freed: every
/// run gets a fresh instance.
#[unsafe(no_mangle)]
pub extern "C" fn vc_alloc(size: u32) -> u32 {
let mut block = Vec::<u8>::with_capacity(size as usize);
let ptr = block.as_mut_ptr();
std::mem::forget(block);
ptr as u32
}
/// Runs the filter: `input` holds `{"objects": [...]}`, `params` the parameters. Returns the
/// output JSON as `len << 32 | ptr`, or a negative error code.
///
/// # Safety
/// The host guarantees both blocks came from `vc_alloc` and hold `*_len` bytes.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn vc_run(input: *const u8, input_len: u32, params: *const u8, params_len: u32) -> i64 {
// SAFETY: see the function's contract.
let (input, params) = unsafe { (std::slice::from_raw_parts(input, input_len as usize), std::slice::from_raw_parts(params, params_len as usize)) };
let (Ok(mut doc), Ok(params)) = (serde_json::from_slice::<Value>(input), serde_json::from_slice::<Value>(params)) else { return -1 };
let amount = (params["amount"].as_f64().unwrap_or(100.0) / 100.0).clamp(0.0, 1.0);
if doc["objects"].as_array().is_none_or(|o| o.is_empty()) {
return output(&json!({"error": "Select some paths to desaturate."}));
}
desaturate(&mut doc, amount);
output(&json!({"objects": doc["objects"]}))
}
/// Every colour (an object with a `model`) inside `v`, moved `k` of the way to grey.
fn desaturate(v: &mut Value, k: f64) {
match v {
Value::Object(o) => {
let model = o.get("model").and_then(Value::as_str).map(str::to_string);
let get = |key: &str| o.get(key).and_then(Value::as_f64).unwrap_or(0.0);
let new: Option<Vec<(&str, f64)>> = match model.as_deref() {
Some("rgb") => {
let (r, g, b) = (get("r"), get("g"), get("b"));
let y = 0.2126 * r + 0.7152 * g + 0.0722 * b;
Some(vec![("r", r + (y - r) * k), ("g", g + (y - g) * k), ("b", b + (y - b) * k)])
}
Some("cmyk") => {
let (c, m, y) = (get("c"), get("m"), get("y"));
let mean = (c + m + y) / 3.0;
Some(vec![("c", c + (mean - c) * k), ("m", m + (mean - m) * k), ("y", y + (mean - y) * k)])
}
Some("lab") => Some(vec![("a", get("a") * (1.0 - k)), ("b", get("b") * (1.0 - k))]),
_ => None,
};
match new {
Some(new) => {
for (key, x) in new {
o.insert(key.into(), json!(x));
}
}
None => o.values_mut().for_each(|c| desaturate(c, k)),
}
}
Value::Array(a) => a.iter_mut().for_each(|c| desaturate(c, k)),
_ => {}
}
}
/// Keeps `v` serialised in memory for the host to read, as `len << 32 | ptr`.
fn output(v: &Value) -> i64 {
let Ok(bytes) = serde_json::to_vec(v) else { return -2 };
let bytes = bytes.leak();
pack(bytes) as i64
}
fn pack(bytes: &[u8]) -> u64 {
((bytes.len() as u64) << 32) | bytes.as_ptr() as u64
}
+130
View File
@@ -0,0 +1,130 @@
//! Live-effect plug-ins: a plug-in of kind `effect` rewrites one object's geometry each time it is
//! drawn, from its appearance stack (an `Effect` record with id `plugin.<plug-in id>`).
//!
//! Drawing evaluates effects often (every frame, hit tests, bounds), so results are cached by
//! plug-in installation, parameters and input geometry: the plug-in runs again only when one of
//! them changes. Failures are cached too (the geometry is then drawn unchanged) and reported by
//! [`last_error`].
use std::collections::HashMap;
use std::hash::{Hash, Hasher};
use std::sync::Mutex;
use serde_json::{Value, json};
use vectorcraft_geom::{FillRule, PathData, Rect};
use crate::objects::{decode, output_geometry};
use crate::{Error, Kind, Limits, Result, registry};
/// The effect id prefix of live-effect plug-ins: `plugin.<plug-in id>`.
pub const PREFIX: &str = "plugin.";
/// Wall-clock budget of one live-effect run (drawing waits for it), in milliseconds.
pub const WALL_TIME_MS: u64 = 5_000;
/// Cached results kept before the cache starts over.
const CACHE_SIZE: usize = 512;
/// The plug-in id of effect id `id` (`plugin.<id>`).
pub fn plugin_id(effect_id: &str) -> Option<&str> {
effect_id.strip_prefix(PREFIX)
}
/// The effect id of plug-in `id`.
pub fn effect_id(plugin_id: &str) -> String {
format!("{PREFIX}{plugin_id}")
}
/// An installed live-effect plug-in by its effect id.
pub fn installed(effect_id: &str) -> Option<std::sync::Arc<crate::Plugin>> {
registry::get(plugin_id(effect_id)?).filter(|p| p.manifest().kind == Kind::Effect)
}
#[derive(PartialEq)]
struct Key {
serial: u64,
params: String,
bounds: [u64; 4],
rule: FillRule,
path: PathData,
}
impl Key {
fn hash64(&self) -> u64 {
let mut h = std::collections::hash_map::DefaultHasher::new();
(self.serial, &self.params, self.bounds, self.rule).hash(&mut h);
for sp in &self.path.subpaths {
sp.closed.hash(&mut h);
for a in &sp.anchors {
for p in [a.p, a.h_in, a.h_out] {
(p.x.to_bits(), p.y.to_bits()).hash(&mut h);
}
}
}
h.finish()
}
}
type Cache = HashMap<u64, (Key, Option<PathData>)>;
static CACHE: Mutex<Option<Cache>> = Mutex::new(None);
static ERRORS: Mutex<Option<HashMap<String, String>>> = Mutex::new(None);
/// The last error of live-effect plug-in `id` (cleared when it next succeeds).
pub fn last_error(id: &str) -> Option<String> {
ERRORS.lock().unwrap_or_else(|e| e.into_inner()).as_ref()?.get(id).cloned()
}
fn note(id: &str, r: &Result<PathData>) {
let mut errors = ERRORS.lock().unwrap_or_else(|e| e.into_inner());
let errors = errors.get_or_insert_with(HashMap::new);
match r {
Ok(_) => errors.remove(id),
Err(e) => errors.insert(id.to_string(), e.to_string()),
};
}
/// Runs live-effect plug-in `id` on `path` (whose reference box is `bounds`): the new geometry,
/// or `None` when the plug-in isn't installed, isn't an effect, or fails (the caller then keeps
/// the geometry).
pub fn apply(id: &str, params: &Value, path: &PathData, bounds: Rect, rule: FillRule) -> Option<PathData> {
let plugin = registry::get(id).filter(|p| p.manifest().kind == Kind::Effect)?;
let resolved = Value::Object(plugin.manifest().resolve_params(params).ok()?);
let key = Key {
serial: plugin.serial(),
params: resolved.to_string(),
bounds: [bounds.x0, bounds.y0, bounds.x1, bounds.y1].map(f64::to_bits),
rule,
path: path.clone(),
};
let h = key.hash64();
if let Some((k, out)) = CACHE.lock().unwrap_or_else(|e| e.into_inner()).as_ref().and_then(|c| c.get(&h))
&& *k == key
{
return out.clone();
}
let r = run(&plugin, resolved, path, bounds, rule);
note(id, &r);
let out = r.ok();
let mut cache = CACHE.lock().unwrap_or_else(|e| e.into_inner());
let cache = cache.get_or_insert_with(HashMap::new);
if cache.len() >= CACHE_SIZE {
cache.clear();
}
cache.insert(h, (key, out.clone()));
out
}
fn run(plugin: &crate::Plugin, mut params: Value, path: &PathData, bounds: Rect, rule: FillRule) -> Result<PathData> {
let b = json!([bounds.x0, bounds.y0, bounds.x1, bounds.y1]);
params["_context"] = json!({"mode": "effect", "bounds": b});
let rule = match rule {
FillRule::NonZero => "nonzero",
FillRule::EvenOdd => "evenodd",
};
let input = json!({"objects": [{"type": "path", "path": path, "fillRule": rule, "bounds": b}]});
let (input, params) = (serde_json::to_vec(&input), serde_json::to_vec(&params));
let (Ok(input), Ok(params)) = (input, params) else { return Err(Error::Params("can't encode the input".into())) };
let limits = Limits { wall_time_ms: WALL_TIME_MS, ..plugin.limits().clone() };
Ok(output_geometry(decode(&plugin.run_limited(&input, &params, &limits)?)?))
}
+59
View File
@@ -0,0 +1,59 @@
//! Sandboxed WebAssembly plug-ins for VectorCraft.
//!
//! A plug-in is a WebAssembly module that implements the VectorCraft plug-in ABI (v1, documented
//! in `docs/plugins.md`). It runs in [`wasmi`], a pure-Rust interpreter, with:
//!
//! - **no host imports**: no WASI, file system, network, clock or randomness. A module that
//! imports anything is rejected;
//! - an **instruction budget** (fuel) per call, metered in slices so a wall-clock deadline is
//! also enforced (native builds), so infinite loops end with an error;
//! - a **linear-memory cap**, a **recursion-depth cap** and wasmi's strict module limits;
//! - a **fresh instance per run**, so no state leaks between calls.
//!
//! Every failure (malformed module, trap, exhausted budget, bad output) is an [`Error`]; nothing
//! a module does can panic or hang the host.
//!
//! Plug-ins see the document as JSON ([`objects`]): the selected paths and compound paths with
//! their geometry, fills, strokes (gradients included) and opacity. An **object filter** returns
//! the objects to keep, change or add, applied to the document as one edit
//! ([`objects::apply_output`]); a **live effect** ([`effect`]) rewrites one object's geometry each
//! time it is drawn, from an appearance stack. The process-wide [`registry`] holds the installed
//! plug-ins.
#![forbid(unsafe_code)]
pub mod effect;
pub mod manifest;
pub mod objects;
pub mod registry;
mod runtime;
pub mod wat;
pub use manifest::{Kind, Manifest, ParamSpec};
pub use runtime::{ABI_VERSION, Limits, Plugin};
/// A plug-in error. Every way a module can misbehave ends here, never in a panic.
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum Error {
#[error("not a valid plug-in module: {0}")]
Module(String),
#[error("plug-in ABI: {0}")]
Abi(String),
#[error("plug-in manifest: {0}")]
Manifest(String),
#[error("plug-in trapped: {0}")]
Trap(String),
#[error("plug-in exceeded its {0}")]
Limit(String),
#[error("plug-in parameters: {0}")]
Params(String),
#[error("plug-in output: {0}")]
Output(String),
#[error("plug-in failed: {0}")]
Failed(String),
#[error("no plug-in {0:?} is installed")]
NotFound(String),
#[error("{0}")]
Io(String),
}
pub type Result<T> = std::result::Result<T, Error>;
+331
View File
@@ -0,0 +1,331 @@
//! The plug-in manifest (`vc_manifest`) and parameter schema.
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value, json};
use crate::{Error, Result};
/// Longest manifest the host reads.
pub const MAX_MANIFEST_BYTES: usize = 64 * 1024;
/// Most parameters a plug-in may declare.
pub const MAX_PARAMS: usize = 64;
/// What a plug-in is.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum Kind {
/// An object filter: reads the selected paths and returns the objects to keep, change or add
/// (Object › Plug-ins, `plugin.run`).
Filter,
/// A live effect: rewrites one object's geometry each time it is drawn, from its appearance
/// stack (Effect › Plug-ins, `effect.apply`).
Effect,
}
impl Kind {
pub fn name(self) -> &'static str {
match self {
Kind::Filter => "filter",
Kind::Effect => "effect",
}
}
}
/// One declared parameter.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "camelCase")]
pub enum ParamSpec {
/// A real number in `min..=max`.
Number { min: f64, max: f64, default: f64 },
/// A whole number in `min..=max`.
Int {
#[serde(default = "int_min")]
min: i64,
#[serde(default = "int_max")]
max: i64,
#[serde(default)]
default: i64,
},
/// A checkbox.
Bool {
#[serde(default)]
default: bool,
},
/// One of `options` (a dropdown).
Choice {
options: Vec<String>,
#[serde(default)]
default: Option<String>,
},
}
fn int_min() -> i64 {
-1_000_000
}
fn int_max() -> i64 {
1_000_000
}
impl ParamSpec {
/// The value a parameter takes when it isn't given.
pub fn default_value(&self) -> Value {
match self {
ParamSpec::Number { default, .. } => json!(default),
ParamSpec::Int { default, .. } => json!(default),
ParamSpec::Bool { default } => json!(default),
ParamSpec::Choice { options, default } => json!(default.clone().or_else(|| options.first().cloned()).unwrap_or_default()),
}
}
}
/// The manifest a plug-in returns from `vc_manifest` (UTF-8 JSON).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Manifest {
/// Stable id, `[A-Za-z0-9._-]{1,64}` (reverse-DNS style is recommended: `org.example.tidy`).
pub id: String,
/// Menu label.
pub name: String,
#[serde(default)]
pub version: String,
pub kind: Kind,
#[serde(default)]
pub description: String,
#[serde(default)]
pub author: String,
/// Parameters in declaration (dialog) order; a JSON object `{"name": spec, …}` in the manifest.
#[serde(default, deserialize_with = "ordered_params", serialize_with = "params_as_object")]
pub params: Vec<(String, ParamSpec)>,
}
impl Manifest {
/// Parses and validates manifest JSON.
pub fn parse(bytes: &[u8]) -> Result<Manifest> {
if bytes.len() > MAX_MANIFEST_BYTES {
return Err(Error::Manifest(format!("manifest is {} bytes (limit {MAX_MANIFEST_BYTES})", bytes.len())));
}
let text = std::str::from_utf8(bytes).map_err(|_| Error::Manifest("manifest is not UTF-8".into()))?;
let man: Manifest = serde_json::from_str(text).map_err(|e| Error::Manifest(format!("manifest: {e}")))?;
man.validate()?;
Ok(man)
}
/// The manifest of a module still being loaded.
pub(crate) fn placeholder() -> Manifest {
Manifest {
id: String::new(),
name: String::new(),
version: String::new(),
kind: Kind::Filter,
description: String::new(),
author: String::new(),
params: Vec::new(),
}
}
fn validate(&self) -> Result<()> {
if !valid_id(&self.id) {
return Err(Error::Manifest(format!("invalid id {:?}: use 1-64 characters from A-Z a-z 0-9 . _ -", self.id)));
}
let name = self.name.trim();
if name.is_empty() || name.chars().count() > 64 || name.chars().any(char::is_control) {
return Err(Error::Manifest("`name` must be 1-64 printable characters".into()));
}
if self.version.len() > 32 || self.description.len() > 1024 || self.author.len() > 128 {
return Err(Error::Manifest("`version`, `description` or `author` is too long".into()));
}
for (k, spec) in &self.params {
if !valid_key(k) {
return Err(Error::Manifest(format!("invalid parameter name {k:?}")));
}
match spec {
ParamSpec::Number { min, max, default } => {
if !(min.is_finite() && max.is_finite() && default.is_finite()) || min > max {
return Err(Error::Manifest(format!("parameter `{k}`: bad range")));
}
}
ParamSpec::Int { min, max, .. } if min > max => return Err(Error::Manifest(format!("parameter `{k}`: bad range"))),
ParamSpec::Choice { options, default } => {
if options.is_empty()
|| options.len() > 64
|| options.iter().any(|o| o.is_empty() || o.len() > 64 || o.chars().any(char::is_control))
{
return Err(Error::Manifest(format!("parameter `{k}`: choices must be 1-64 non-empty, printable strings")));
}
if default.as_ref().is_some_and(|d| !options.contains(d)) {
return Err(Error::Manifest(format!("parameter `{k}`: default is not one of the options")));
}
}
_ => {}
}
}
Ok(())
}
/// The parameters documented the way command and effect parameters are
/// (`{amount: 0..100 (50), mode: "soft"|"hard" (soft), mono: bool (false)}`).
pub fn params_doc(&self) -> String {
let parts: Vec<String> = self
.params
.iter()
.map(|(k, spec)| match spec {
ParamSpec::Number { min, max, default } => format!("{k}: {min}..{max} ({default})"),
ParamSpec::Int { min, max, default } => format!("{k}: int {min}..{max} ({default})"),
ParamSpec::Bool { default } => format!("{k}: bool ({default})"),
ParamSpec::Choice { options, .. } => {
let opts: Vec<String> = options.iter().map(|o| format!("{o:?}")).collect();
format!("{k}: {} ({})", opts.join("|"), spec.default_value().as_str().unwrap_or_default())
}
})
.collect();
format!("{{{}}}", parts.join(", "))
}
/// Every parameter at its default value.
pub fn defaults(&self) -> Map<String, Value> {
self.params.iter().map(|(k, spec)| (k.clone(), spec.default_value())).collect()
}
/// Validates user `params` against the schema: missing keys take their defaults, numbers are
/// clamped into range (whole numbers rounded), unknown keys are dropped; a value of the wrong
/// type is an error.
pub fn resolve_params(&self, params: &Value) -> Result<Map<String, Value>> {
let given = match params {
Value::Null => &Map::new(),
Value::Object(m) => m,
_ => return Err(Error::Params("plug-in parameters must be a JSON object".into())),
};
let mut out = Map::new();
for (k, spec) in &self.params {
let Some(v) = given.get(k).filter(|v| !v.is_null()) else {
out.insert(k.clone(), spec.default_value());
continue;
};
let wrong = |what: &str| Error::Params(format!("`{k}` must be {what}"));
let resolved = match spec {
ParamSpec::Number { min, max, .. } => json!(v.as_f64().filter(|f| f.is_finite()).ok_or_else(|| wrong("a number"))?.clamp(*min, *max)),
ParamSpec::Int { min, max, .. } => {
let f = v.as_f64().filter(|f| f.is_finite()).ok_or_else(|| wrong("a number"))?;
json!((f.round().clamp(*min as f64, *max as f64)) as i64)
}
ParamSpec::Bool { .. } => json!(v.as_bool().ok_or_else(|| wrong("true or false"))?),
ParamSpec::Choice { options, .. } => {
let s = v.as_str().ok_or_else(|| wrong("a string"))?;
if !options.iter().any(|o| o == s) {
return Err(Error::Params(format!("`{k}` must be one of {}", options.join(", "))));
}
json!(s)
}
};
out.insert(k.clone(), resolved);
}
Ok(out)
}
}
/// Reads the `params` object keeping its key order (serde_json's `Map` would sort it).
fn ordered_params<'de, D: serde::Deserializer<'de>>(d: D) -> std::result::Result<Vec<(String, ParamSpec)>, D::Error> {
struct V;
impl<'de> serde::de::Visitor<'de> for V {
type Value = Vec<(String, ParamSpec)>;
fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
f.write_str("an object of parameter specs")
}
fn visit_unit<E: serde::de::Error>(self) -> std::result::Result<Self::Value, E> {
Ok(Vec::new())
}
fn visit_map<A: serde::de::MapAccess<'de>>(self, mut map: A) -> std::result::Result<Self::Value, A::Error> {
let mut out: Vec<(String, ParamSpec)> = Vec::new();
while let Some((k, v)) = map.next_entry::<String, ParamSpec>()? {
if out.len() >= MAX_PARAMS {
return Err(serde::de::Error::custom(format!("too many parameters (limit {MAX_PARAMS})")));
}
if out.iter().any(|(o, _)| *o == k) {
return Err(serde::de::Error::custom(format!("parameter `{k}` is declared twice")));
}
out.push((k, v));
}
Ok(out)
}
}
d.deserialize_any(V)
}
fn params_as_object<S: serde::Serializer>(p: &[(String, ParamSpec)], s: S) -> std::result::Result<S::Ok, S::Error> {
use serde::ser::SerializeMap;
let mut m = s.serialize_map(Some(p.len()))?;
for (k, v) in p {
m.serialize_entry(k, v)?;
}
m.end()
}
/// `[A-Za-z0-9._-]{1,64}`.
pub fn valid_id(id: &str) -> bool {
(1..=64).contains(&id.len()) && id.bytes().all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-'))
}
/// `[A-Za-z0-9_]{1,64}`, not starting with `_` and not a key the commands and dialogs use
/// themselves.
fn valid_key(k: &str) -> bool {
(1..=64).contains(&k.len())
&& k.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_')
&& !k.starts_with('_')
&& !matches!(k, "id" | "ids" | "params" | "preview")
}
#[cfg(test)]
mod tests {
use super::*;
const FULL: &str = r#"{"id":"org.example.tone","name":"Tone","version":"1.2","kind":"effect",
"params":{"amount":{"type":"number","min":0,"max":100,"default":50},
"mode":{"type":"choice","options":["soft","hard"],"default":"hard"},
"mono":{"type":"bool"},"seed":{"type":"int","min":0,"max":9}}}"#;
#[test]
fn parses_and_keeps_param_order() {
let m = Manifest::parse(FULL.as_bytes()).unwrap();
assert_eq!(m.id, "org.example.tone");
assert_eq!(m.kind, Kind::Effect);
assert_eq!(m.params.iter().map(|p| p.0.as_str()).collect::<Vec<_>>(), ["amount", "mode", "mono", "seed"]);
assert_eq!(m.params_doc(), r#"{amount: 0..100 (50), mode: "soft"|"hard" (hard), mono: bool (false), seed: int 0..9 (0)}"#);
assert_eq!(Value::Object(m.defaults()), json!({"amount": 50.0, "mode": "hard", "mono": false, "seed": 0}));
}
#[test]
fn resolves_defaults_clamps_and_rejects_wrong_types() {
let m = Manifest::parse(FULL.as_bytes()).unwrap();
let p = m.resolve_params(&json!({"amount": 500, "seed": 3.6, "junk": 1})).unwrap();
assert_eq!(Value::Object(p), json!({"amount": 100.0, "mode": "hard", "mono": false, "seed": 4}));
assert!(m.resolve_params(&json!({"amount": "lots"})).is_err());
assert!(m.resolve_params(&json!({"mode": "medium"})).is_err());
assert!(m.resolve_params(&json!({"mono": 1})).is_err());
assert!(m.resolve_params(&json!([1, 2])).is_err());
assert!(m.resolve_params(&Value::Null).is_ok());
}
#[test]
fn rejects_bad_manifests() {
for bad in [
"",
"[]",
"not json",
r#"{"id":"","name":"x","kind":"filter"}"#,
r#"{"id":"a b","name":"x","kind":"filter"}"#,
r#"{"id":"a","name":"","kind":"filter"}"#,
r#"{"id":"a","name":"x","kind":"panel"}"#,
r#"{"id":"a","name":"x"}"#,
r#"{"id":"a","name":"x","kind":"filter","params":{"r":{"type":"number","min":5,"max":1,"default":1}}}"#,
r#"{"id":"a","name":"x","kind":"filter","params":{"c":{"type":"choice","options":[]}}}"#,
r#"{"id":"a","name":"x","kind":"filter","params":{"__x":{"type":"bool"}}}"#,
r#"{"id":"a","name":"x","kind":"filter","params":{"preview":{"type":"bool"}}}"#,
r#"{"id":"a","name":"x","kind":"filter","params":{"a":{"type":"bool"},"a":{"type":"bool"}}}"#,
r#"{"id":"a","name":"x","kind":"filter","params":[1]}"#,
] {
assert!(Manifest::parse(bad.as_bytes()).is_err(), "{bad}");
}
assert!(Manifest::parse(&[0xff, 0xfe]).is_err());
assert!(Manifest::parse(&vec![b' '; MAX_MANIFEST_BYTES + 1]).is_err());
}
}
+597
View File
@@ -0,0 +1,597 @@
//! The object model plug-ins read and write: paths and compound paths as JSON.
//!
//! The host hands a plug-in `{"objects": [...]}` (see [`encode`]) and reads the same shape back
//! ([`decode`]). Geometry and paint use the native file format's forms (`PathData`, `Paint`), so
//! nothing is lost on the way through; everything a plug-in returns is validated (finite,
//! in-range coordinates, capped counts, clamped colours) before it reaches the document.
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use serde_json::{Value, json};
use vectorcraft_color::{Color, Paint};
use vectorcraft_doc::{Appearance, AppearanceItem, Document, FillLayer, Node, NodeId, NodeKind, StrokeLayer};
use vectorcraft_geom::{FillRule, PathData, Point, Rect};
use crate::{Error, Result};
/// Coordinates a plug-in may return, in points (the document's own canvas limit).
pub const MAX_COORD: f64 = 4.0e6;
/// Most objects one output may hold.
pub const MAX_OBJECTS: usize = 100_000;
/// Most anchor points one output may hold, over all objects.
pub const MAX_ANCHORS: usize = 4_000_000;
/// Most fills or strokes per object, gradient stops per gradient and freeform points or lines
/// per freeform gradient.
pub const MAX_ITEMS: usize = 256;
/// Thickest stroke a plug-in may set, in points.
pub const MAX_STROKE_WIDTH: f64 = 1000.0;
/// One object returned by a plug-in. `None` fields keep the object's value (new objects take the
/// defaults: a path, non-zero rule, no fills or strokes, full opacity).
#[derive(Clone, Debug, Default, PartialEq)]
pub struct OutObject {
/// The input object this updates; `None`: a new object.
pub id: Option<NodeId>,
/// `"compound"` (true) or `"path"` (false).
pub compound: Option<bool>,
/// `Some("")` clears the name.
pub name: Option<String>,
pub path: Option<PathData>,
pub rule: Option<FillRule>,
/// Fill paints in paint order (bottom first).
pub fills: Option<Vec<Paint>>,
pub strokes: Option<Vec<OutStroke>>,
pub opacity: Option<f32>,
}
/// One stroke of an [`OutObject`].
#[derive(Clone, Debug, PartialEq)]
pub struct OutStroke {
pub paint: Paint,
/// `None`: keep the stroke's weight (1 pt for a new stroke).
pub width: Option<f64>,
}
fn rule_name(r: FillRule) -> &'static str {
match r {
FillRule::NonZero => "nonzero",
FillRule::EvenOdd => "evenodd",
}
}
fn rect_json(r: Option<Rect>) -> Value {
r.map_or(Value::Null, |r| json!([r.x0, r.y0, r.x1, r.y1]))
}
fn paint_json(p: &Paint) -> Value {
match p {
Paint::None => Value::Null,
p => serde_json::to_value(p).unwrap_or(Value::Null),
}
}
/// The geometry of a path or compound path (a compound's members' subpaths in order) and its fill
/// rule. `None` for other objects.
pub fn geometry(n: &Node) -> Option<(PathData, FillRule, bool)> {
match &n.kind {
NodeKind::Path { path, rule, .. } => Some((path.clone(), *rule, false)),
NodeKind::Compound { children, rule } => {
Some((PathData::new(children.iter().filter_map(|c| c.path_data()).flat_map(|p| p.subpaths.iter().cloned()).collect()), *rule, true))
}
_ => None,
}
}
/// A path or compound path as plug-ins see it (`None` for other objects): `{id, type, name?, path,
/// fillRule, fills, strokes, opacity, bounds}`.
pub fn encode(n: &Node) -> Option<Value> {
let (path, rule, compound) = geometry(n)?;
let fills: Vec<Value> =
n.appearance.items.iter().filter_map(|i| if let AppearanceItem::Fill(f) = i { Some(paint_json(&f.paint)) } else { None }).collect();
let strokes: Vec<Value> = n
.appearance
.items
.iter()
.filter_map(|i| if let AppearanceItem::Stroke(s) = i { Some(json!({"paint": paint_json(&s.paint), "width": s.width})) } else { None })
.collect();
let mut o = json!({
"id": n.id.0, "type": if compound { "compound" } else { "path" }, "path": path, "fillRule": rule_name(rule),
"fills": fills, "strokes": strokes, "opacity": n.opacity, "bounds": rect_json(path.bounds()),
});
if let Some(name) = &n.name {
o["name"] = json!(name);
}
Some(o)
}
/// The input document of a filter run: `{"objects": [...]}` for `ids` (paths and compound paths;
/// others are left out).
pub fn encode_input(doc: &Document, ids: &[NodeId]) -> Vec<u8> {
let objects: Vec<Value> = ids.iter().filter_map(|id| doc.node(*id)).filter_map(encode).collect();
serde_json::to_vec(&json!({ "objects": objects })).unwrap_or_default()
}
fn out_err(at: &str, msg: impl std::fmt::Display) -> Error {
Error::Output(format!("{at}: {msg}"))
}
fn check_point(p: Point, at: &str) -> Result<()> {
if p.x.is_finite() && p.y.is_finite() && p.x.abs() <= MAX_COORD && p.y.abs() <= MAX_COORD {
Ok(())
} else {
Err(out_err(at, format!("coordinate out of range (limit ±{MAX_COORD} pt)")))
}
}
/// A path from plug-in output: native `PathData` JSON, every coordinate finite and in range, empty
/// subpaths dropped; `anchors` counts towards [`MAX_ANCHORS`].
fn decode_path(v: &Value, at: &str, anchors: &mut usize) -> Result<PathData> {
let mut path: PathData = serde_json::from_value(v.clone()).map_err(|e| out_err(at, e))?;
path.subpaths.retain(|sp| !sp.anchors.is_empty());
*anchors = anchors.saturating_add(path.anchor_count());
if *anchors > MAX_ANCHORS {
return Err(out_err(at, format!("too many anchor points (limit {MAX_ANCHORS})")));
}
for (_, _, a) in path.anchors() {
for p in [a.p, a.h_in, a.h_out] {
check_point(p, at)?;
}
}
if path.is_empty() {
return Err(out_err(at, "the path has no anchor points"));
}
Ok(path)
}
fn unit(v: f32) -> f32 {
v.clamp(0.0, 1.0)
}
/// `c` with its components in range.
fn clamp_color(c: Color) -> Color {
match c {
Color::Rgb { r, g, b } => Color::Rgb { r: unit(r), g: unit(g), b: unit(b) },
Color::Cmyk { c, m, y, k } => Color::Cmyk { c: unit(c), m: unit(m), y: unit(y), k: unit(k) },
Color::Gray { k } => Color::Gray { k: unit(k) },
Color::Lab { l, a, b } => Color::Lab { l: l.clamp(0.0, 100.0), a: a.clamp(-128.0, 127.0), b: b.clamp(-128.0, 127.0) },
}
}
/// A paint from plug-in output (`null`: none), with colours, opacities and offsets clamped and
/// gradient and pattern placements checked.
fn decode_paint(v: &Value, at: &str) -> Result<Paint> {
if v.is_null() {
return Ok(Paint::None);
}
let mut p: Paint = serde_json::from_value(v.clone()).map_err(|e| out_err(at, e))?;
match &mut p {
Paint::None => {}
Paint::Solid { color, tint, .. } => {
*color = clamp_color(*color);
*tint = unit(*tint);
}
Paint::Gradient(g) => {
let stops = &mut g.gradient.stops;
if stops.is_empty() || stops.len() > MAX_ITEMS {
return Err(out_err(at, format!("a gradient needs 1-{MAX_ITEMS} stops")));
}
for s in stops.iter_mut() {
s.offset = unit(s.offset);
s.opacity = unit(s.opacity);
s.midpoint = s.midpoint.clamp(0.13, 0.87);
s.tint = unit(s.tint);
s.color = clamp_color(s.color);
}
stops.sort_by(|a, b| a.offset.total_cmp(&b.offset));
g.angle = g.angle.rem_euclid(360.0);
if let Some(geom) = &mut g.geom {
check_point(geom.start, at)?;
check_point(geom.end, at)?;
if let Some(f) = geom.focal {
check_point(f, at)?;
}
geom.aspect = if geom.aspect.is_finite() { geom.aspect.clamp(0.01, 100.0) } else { 1.0 };
}
if let Some(ff) = &mut g.freeform {
if ff.points.len() > MAX_ITEMS || ff.lines.len() > MAX_ITEMS || ff.lines.iter().any(|l| l.len() > MAX_ITEMS) {
return Err(out_err(at, format!("a freeform gradient holds at most {MAX_ITEMS} points and lines")));
}
let n = ff.points.len();
if ff.lines.iter().flatten().any(|i| *i >= n) {
return Err(out_err(at, "a freeform gradient line names a point it doesn't have"));
}
for pt in &mut ff.points {
check_point(pt.at, at)?;
pt.color = clamp_color(pt.color);
pt.opacity = unit(pt.opacity);
pt.spread = unit(pt.spread);
}
}
}
Paint::Pattern { xf, .. } => {
if !xf.as_coeffs().iter().all(|c| c.is_finite() && c.abs() <= MAX_COORD) {
return Err(out_err(at, "pattern transform out of range"));
}
}
}
Ok(p)
}
fn opt<'a>(o: &'a serde_json::Map<String, Value>, key: &str) -> Option<&'a Value> {
o.get(key).filter(|v| !v.is_null())
}
fn decode_object(v: &Value, at: &str, anchors: &mut usize) -> Result<OutObject> {
let o = v.as_object().ok_or_else(|| out_err(at, "an object must be a JSON object"))?;
let mut out = OutObject::default();
if let Some(id) = opt(o, "id") {
out.id = Some(NodeId(id.as_u64().ok_or_else(|| out_err(at, "`id` must be the id of an input object"))?));
}
if let Some(t) = opt(o, "type") {
out.compound = Some(match t.as_str() {
Some("path") => false,
Some("compound") => true,
_ => return Err(out_err(at, "`type` must be \"path\" or \"compound\"")),
});
}
if let Some(n) = o.get("name") {
let name = match n {
Value::Null => String::new(),
n => n.as_str().ok_or_else(|| out_err(at, "`name` must be a string"))?.chars().filter(|c| !c.is_control()).take(256).collect(),
};
out.name = Some(name);
}
if let Some(p) = opt(o, "path") {
out.path = Some(decode_path(p, &format!("{at}.path"), anchors)?);
}
if let Some(r) = opt(o, "fillRule") {
out.rule = Some(match r.as_str().map(str::to_ascii_lowercase).as_deref() {
Some("nonzero") => FillRule::NonZero,
Some("evenodd") => FillRule::EvenOdd,
_ => return Err(out_err(at, "`fillRule` must be \"nonzero\" or \"evenodd\"")),
});
}
let list = |key: &str| -> Result<Option<&Vec<Value>>> {
match opt(o, key) {
None => Ok(None),
Some(Value::Array(a)) if a.len() <= MAX_ITEMS => Ok(Some(a)),
Some(_) => Err(out_err(at, format!("`{key}` must be an array of at most {MAX_ITEMS} entries"))),
}
};
if let Some(fills) = list("fills")? {
out.fills = Some(fills.iter().enumerate().map(|(i, f)| decode_paint(f, &format!("{at}.fills[{i}]"))).collect::<Result<_>>()?);
}
if let Some(strokes) = list("strokes")? {
let mut v = Vec::with_capacity(strokes.len());
for (i, s) in strokes.iter().enumerate() {
let at = format!("{at}.strokes[{i}]");
let s = s.as_object().ok_or_else(|| out_err(&at, "a stroke must be {\"paint\", \"width\"}"))?;
let paint = decode_paint(s.get("paint").unwrap_or(&Value::Null), &at)?;
let width = match opt(s, "width") {
None => None,
Some(w) => Some(w.as_f64().ok_or_else(|| out_err(&at, "`width` must be a number"))?.clamp(0.0, MAX_STROKE_WIDTH)),
};
v.push(OutStroke { paint, width });
}
out.strokes = Some(v);
}
if let Some(op) = opt(o, "opacity") {
out.opacity = Some(unit(op.as_f64().ok_or_else(|| out_err(at, "`opacity` must be a number"))? as f32));
}
Ok(out)
}
/// Parses a plug-in's output: `{"objects": [...]}`, or `{"error": "message"}` when the plug-in
/// declined (the message reaches the user).
pub fn decode(bytes: &[u8]) -> Result<Vec<OutObject>> {
let v: Value = serde_json::from_slice(bytes).map_err(|e| Error::Output(format!("not JSON: {e}")))?;
if let Some(e) = v.get("error").filter(|e| !e.is_null()) {
let msg: String = e.as_str().map_or_else(|| e.to_string(), str::to_string).chars().filter(|c| !c.is_control()).take(300).collect();
return Err(Error::Failed(msg));
}
let objects = v.get("objects").and_then(Value::as_array).ok_or_else(|| Error::Output("expected {\"objects\": [...]}".into()))?;
if objects.len() > MAX_OBJECTS {
return Err(Error::Output(format!("{} objects (limit {MAX_OBJECTS})", objects.len())));
}
let mut anchors = 0;
objects.iter().enumerate().map(|(i, o)| decode_object(o, &format!("objects[{i}]"), &mut anchors)).collect()
}
/// The paints of `ap`'s fills (`fill`) or strokes, in paint order.
fn paints(ap: &Appearance, fill: bool) -> Vec<&Paint> {
ap.items.iter().filter(|i| i.is_fill() == fill).map(AppearanceItem::paint).collect()
}
/// `p` without swatch links: a plug-in's colours are its own, so only paints it returned
/// unchanged keep theirs.
fn unlinked(mut p: Paint) -> Paint {
match &mut p {
Paint::Solid { swatch, tint, .. } => {
*swatch = None;
*tint = 1.0;
}
Paint::Gradient(g) => {
g.swatch = None;
for s in &mut g.gradient.stops {
s.swatch = None;
s.tint = 1.0;
}
}
Paint::None | Paint::Pattern { .. } => {}
}
p
}
/// `p` as it may enter `doc`: unchanged from `was` (the paint in the same slot before), or
/// without swatch links; a pattern must exist in the document.
fn admit(doc: &Document, p: Paint, was: Option<&Paint>, at: &str) -> Result<Paint> {
if let Paint::Pattern { pattern, .. } = &p
&& doc.pattern(pattern).is_none()
{
return Err(out_err(at, format!("the document has no pattern {pattern:?}")));
}
Ok(if was == Some(&p) { p } else { unlinked(p) })
}
/// Sets the paints of `ap`'s fills (`fill`) or strokes to `new`, in paint order: extra items are
/// removed (from the top), missing ones added above the last item of their kind (fills at the
/// bottom, strokes on top when the object has none).
fn set_items(ap: &mut Appearance, fill: bool, new: Vec<(Paint, Option<f64>)>) {
let idx: Vec<usize> = ap.items.iter().enumerate().filter(|(_, i)| i.is_fill() == fill).map(|(k, _)| k).collect();
for &i in idx.iter().skip(new.len()).rev() {
ap.remove_item(i);
}
let kept = new.len().min(idx.len());
let mut at = idx.get(..kept).and_then(<[usize]>::last).map_or(if fill { 0 } else { ap.items.len() }, |i| i + 1);
for (k, (paint, width)) in new.into_iter().enumerate() {
match idx.get(k).and_then(|&i| ap.items.get_mut(i)) {
Some(AppearanceItem::Fill(f)) => f.paint = paint,
Some(AppearanceItem::Stroke(s)) => {
s.paint = paint;
if let Some(w) = width {
s.width = w;
}
}
None => {
let item = if fill {
AppearanceItem::Fill(FillLayer::new(paint))
} else {
AppearanceItem::Stroke(StrokeLayer::new(paint, width.unwrap_or(1.0)))
};
ap.insert_item(at, item);
at += 1;
}
}
}
}
/// A compound path's members: one path per subpath of `path`, keeping the ids of `old` members.
fn members(doc: &mut Document, old: &[Arc<Node>], path: PathData) -> Vec<Arc<Node>> {
path.subpaths
.into_iter()
.enumerate()
.map(|(i, sp)| {
let id = old.get(i).map_or_else(|| doc.alloc_id(), |c| c.id);
Arc::new(Node::path(id, PathData::single(sp), Appearance::default()))
})
.collect()
}
/// Writes `o` into `n` (an input object, or a fresh node for a new one).
fn write(doc: &mut Document, n: &mut Node, o: OutObject, at: &str) -> Result<()> {
let (cur_path, cur_rule, cur_compound) = geometry(n).unwrap_or((PathData::default(), FillRule::NonZero, false));
let compound = o.compound.unwrap_or(cur_compound);
let rule = o.rule.unwrap_or(cur_rule);
match (&mut n.kind, compound) {
(NodeKind::Path { path, rule: r, live, .. }, false) => {
if let Some(p) = o.path {
*path = p;
*live = None;
}
*r = rule;
}
(NodeKind::Compound { children, rule: r }, true) => {
if let Some(p) = o.path {
*children = members(doc, children, p);
}
*r = rule;
}
(NodeKind::Compound { .. }, false) => {
n.kind = NodeKind::Path { path: o.path.unwrap_or(cur_path), rule, live: None, clipping: false, guide: false };
}
(_, true) => n.kind = NodeKind::Compound { children: members(doc, &[], o.path.unwrap_or(cur_path)), rule },
(_, false) => n.kind = NodeKind::Path { path: o.path.unwrap_or(cur_path), rule, live: None, clipping: false, guide: false },
}
for (fill, new) in [
(true, o.fills.map(|f| f.into_iter().map(|p| (p, None)).collect::<Vec<_>>())),
(false, o.strokes.map(|s| s.into_iter().map(|s| (s.paint, s.width)).collect())),
] {
let Some(new) = new else { continue };
let was = paints(&n.appearance, fill);
let admitted = new
.into_iter()
.enumerate()
.map(|(i, (p, w))| Ok((admit(doc, p, was.get(i).copied(), &format!("{at}.{}[{i}]", if fill { "fills" } else { "strokes" }))?, w)))
.collect::<Result<Vec<_>>>()?;
set_items(&mut n.appearance, fill, admitted);
}
if let Some(op) = o.opacity {
n.opacity = op;
}
if let Some(name) = o.name {
n.name = Some(name).filter(|s| !s.trim().is_empty());
}
Ok(())
}
/// The paths and compound paths a filter reads from the selection `selected`, in paint order:
/// selected ones, and those inside selected groups and layers; hidden, locked, guide and clipping
/// paths are left out (so are paths inside compound paths, blends and other live objects).
pub fn filter_targets(doc: &Document, selected: &[NodeId]) -> Vec<NodeId> {
fn visit(list: &[Arc<Node>], sel: &HashSet<NodeId>, inside: bool, out: &mut Vec<NodeId>) {
for n in list {
if !n.visible || n.locked {
continue;
}
let picked = inside || sel.contains(&n.id);
match &n.kind {
NodeKind::Path { guide: false, clipping: false, .. } | NodeKind::Compound { .. } if picked => out.push(n.id),
NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } => visit(children, sel, picked, out),
_ => {}
}
}
}
let sel: HashSet<NodeId> = selected.iter().copied().collect();
let mut out = vec![];
visit(&doc.layers, &sel, false, &mut out);
out
}
/// The tree edits of [`apply_output`], made in one pass over the document.
#[derive(Default)]
struct Plan {
replace: HashMap<NodeId, Node>,
delete: HashSet<NodeId>,
before: HashMap<NodeId, Vec<Node>>,
after: HashMap<NodeId, Vec<Node>>,
/// New objects that go on top of a container (`None`: the top-level list).
top: Option<(Option<NodeId>, Vec<Node>)>,
}
impl Plan {
fn pending(&self) -> bool {
!(self.replace.is_empty() && self.delete.is_empty() && self.before.is_empty() && self.after.is_empty() && self.top.is_none())
}
/// The edited `list` (the children of `parent`), `None` when nothing in it changed.
fn list(&mut self, parent: Option<NodeId>, list: &[Arc<Node>]) -> Option<Vec<Arc<Node>>> {
let mut out: Option<Vec<Arc<Node>>> = None;
for (i, c) in list.iter().enumerate() {
if !self.pending() {
if let Some(o) = &mut out {
o.extend(list.iter().skip(i).cloned());
}
return out;
}
let mut emitted: Vec<Arc<Node>> = self.before.remove(&c.id).unwrap_or_default().into_iter().map(Arc::new).collect();
let mut changed = !emitted.is_empty();
if self.delete.remove(&c.id) {
changed = true;
} else if let Some(r) = self.replace.remove(&c.id) {
emitted.push(Arc::new(r));
changed = true;
} else if let Some(ch) = match &c.kind {
NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } => self.list(Some(c.id), children),
_ => None,
} {
let mut m = (**c).clone();
if let Some(slot) = m.children_mut() {
*slot = ch;
}
emitted.push(Arc::new(m));
changed = true;
} else {
emitted.push(c.clone());
}
if let Some(a) = self.after.remove(&c.id) {
emitted.extend(a.into_iter().map(Arc::new));
changed = true;
}
if changed && out.is_none() {
out = Some(list.iter().take(i).cloned().collect());
}
if let Some(o) = &mut out {
o.extend(emitted);
}
}
if self.top.as_ref().is_some_and(|(p, _)| *p == parent)
&& let Some((_, nodes)) = self.top.take()
{
out.get_or_insert_with(|| list.to_vec()).extend(nodes.into_iter().map(Arc::new));
}
out
}
}
/// Applies an object filter's output to `doc`.
///
/// `inputs` are the objects the plug-in was given, in paint order ([`filter_targets`]). Output
/// objects with an `id` update that input object (keys they leave out keep their values); objects
/// without one are new and go just above the object listed before them, or, listed before any
/// input object, just below the first one listed after them (at the first input object's place
/// when the output keeps none; on top of `parent` when there were no inputs). Input objects missing
/// from the output are deleted. Returns the resulting objects in output order (to select).
pub fn apply_output(doc: &mut Document, inputs: &[NodeId], out: Vec<OutObject>, parent: Option<NodeId>) -> Result<Vec<NodeId>> {
let wanted: HashSet<NodeId> = inputs.iter().copied().collect();
let mut originals: HashMap<NodeId, Node> = HashMap::new();
doc.walk(|n| {
if wanted.contains(&n.id) {
originals.insert(n.id, n.clone());
}
});
let mut plan = Plan::default();
let mut result = Vec::with_capacity(out.len());
// The run of new objects since the last input object listed, and that object.
let mut run: Vec<Node> = vec![];
let mut prev: Option<NodeId> = None;
for (i, o) in out.into_iter().enumerate() {
let at = format!("objects[{i}]");
match o.id {
Some(id) => {
let mut n = originals.remove(&id).ok_or_else(|| {
let why = if wanted.contains(&id) { "is listed twice" } else { "is not the id of an input object" };
out_err(&at, format!("{} {why}", id.0))
})?;
write(doc, &mut n, o, &at)?;
if !run.is_empty() {
let nodes = std::mem::take(&mut run);
match prev {
Some(p) => plan.after.insert(p, nodes),
None => plan.before.insert(id, nodes),
};
}
plan.replace.insert(id, n);
prev = Some(id);
result.push(id);
}
None => {
if o.path.is_none() {
return Err(out_err(&at, "a new object needs a `path`"));
}
let mut n = Node::path(doc.alloc_id(), PathData::default(), Appearance::default());
write(doc, &mut n, o, &at)?;
result.push(n.id);
run.push(n);
}
}
}
if !run.is_empty() {
match prev.or(inputs.first().copied()) {
Some(p) if prev.is_some() => {
plan.after.insert(p, run);
}
Some(first) => {
plan.before.insert(first, run);
}
None => plan.top = Some((parent, run)),
}
}
// Input objects the output left out are deleted.
plan.delete = originals.into_keys().collect();
if let Some(list) = plan.list(None, &doc.layers) {
doc.layers = list;
}
if plan.pending() {
return Err(Error::Output("the objects to change are no longer in the document".into()));
}
Ok(result)
}
/// The geometry of an effect's output: every returned object's path, in order.
pub fn output_geometry(objects: Vec<OutObject>) -> PathData {
PathData::new(objects.into_iter().filter_map(|o| o.path).flat_map(|p| p.subpaths).collect())
}
#[cfg(test)]
mod tests;
+160
View File
@@ -0,0 +1,160 @@
use super::*;
use vectorcraft_geom::SubPath;
fn square(x: f64) -> PathData {
PathData::single(SubPath::polyline(&[Point::new(x, 0.0), Point::new(x + 10.0, 0.0), Point::new(x + 10.0, 10.0), Point::new(x, 10.0)], true))
}
/// A document with paths A, B and C (bottom to top) on its layer, plus a locked path and a guide.
fn doc() -> (Document, Vec<NodeId>, NodeId) {
let mut d = Document::new(200.0, 200.0);
let layer = d.layers[0].id;
let mut ids = vec![];
for (i, name) in ["A", "B", "C"].into_iter().enumerate() {
let mut n = Node::path(d.alloc_id(), square(i as f64 * 20.0), Appearance::basic(Paint::solid(Color::rgb(1.0, 0.0, 0.0)), Paint::None, 1.0));
n.name = Some(name.into());
ids.push(d.insert(Some(layer), usize::MAX, n).unwrap());
}
let mut locked = Node::path(d.alloc_id(), square(80.0), Appearance::default());
locked.locked = true;
d.insert(Some(layer), usize::MAX, locked).unwrap();
let guide = Node::new(d.alloc_id(), NodeKind::Path { path: square(90.0), rule: FillRule::NonZero, live: None, clipping: false, guide: true });
d.insert(Some(layer), usize::MAX, guide).unwrap();
(d, ids, layer)
}
fn names(d: &Document, layer: NodeId) -> Vec<String> {
d.children(Some(layer)).unwrap().iter().map(|n| n.name.clone().unwrap_or_else(|| "?".into())).collect()
}
fn decode_json(v: Value) -> Result<Vec<OutObject>> {
decode(v.to_string().as_bytes())
}
#[test]
fn targets_are_selected_paths_in_paint_order() {
let (mut d, ids, layer) = doc();
assert_eq!(filter_targets(&d, &[ids[2], ids[0]]), [ids[0], ids[2]]);
// A selected layer gives its paths, without the locked one and the guide.
assert_eq!(filter_targets(&d, &[layer]), ids);
d.node_mut(ids[1]).unwrap().visible = false;
assert_eq!(filter_targets(&d, &[layer]), [ids[0], ids[2]]);
}
#[test]
fn objects_round_trip_through_the_json_model() {
let (mut d, ids, _) = doc();
let mut g = vectorcraft_color::GradientPaint::new(vectorcraft_color::Gradient::default());
g.gradient.stops[0].swatch = Some("White".into());
d.node_mut(ids[0]).unwrap().appearance.set_stroke(Paint::Gradient(Box::new(g)));
let input: Value = serde_json::from_slice(&encode_input(&d, &ids)).unwrap();
let a = &input["objects"][0];
assert_eq!((a["type"].as_str(), a["name"].as_str(), a["fillRule"].as_str()), (Some("path"), Some("A"), Some("nonzero")));
assert_eq!(a["fills"][0]["color"], json!({"model": "rgb", "r": 1.0, "g": 0.0, "b": 0.0}));
assert_eq!(a["strokes"][0]["paint"]["type"], "gradient");
assert_eq!(a["bounds"], json!([0.0, 0.0, 10.0, 10.0]));
// Echoed back unchanged, nothing changes (swatch links included).
let before = d.clone();
let out = decode(&serde_json::to_vec(&input).unwrap()).unwrap();
assert_eq!(apply_output(&mut d, &ids, out, None).unwrap(), ids);
assert_eq!(d, before);
}
#[test]
fn outputs_update_add_and_delete_in_place() {
let (mut d, ids, layer) = doc();
let [a, b, c] = [ids[0].0, ids[1].0, ids[2].0];
let new = |name: &str| json!({"name": name, "path": square(5.0), "fills": [{"type": "solid", "color": {"model": "gray", "k": 0.5}}]});
// A recoloured, B dropped, C kept; new objects around them.
let out = json!({"objects": [new("n1"), {"id": a, "fills": [null, {"type": "solid", "color": {"model": "rgb", "r": 5, "g": 0, "b": 0}}]}, new("n2"), {"id": c}, new("n3")]});
let sel = apply_output(&mut d, &ids, decode_json(out).unwrap(), None).unwrap();
assert_eq!(names(&d, layer), ["n1", "A", "n2", "C", "n3", "?", "?"]);
assert_eq!(sel.len(), 5);
assert!(d.node(NodeId(b)).is_none());
let na = d.node(NodeId(a)).unwrap();
let fills = paints(&na.appearance, true);
assert_eq!(fills, [&Paint::None, &Paint::solid(Color::rgb(1.0, 0.0, 0.0))], "a second fill was added; colours are clamped");
let n1 = d.node(sel[0]).unwrap();
assert_eq!(n1.appearance.fill_paint(), Paint::solid(Color::gray(0.5)));
assert!(n1.appearance.stroke().is_none());
}
#[test]
fn new_objects_take_the_place_of_replaced_inputs_or_go_on_top() {
let (mut d, ids, layer) = doc();
let out = json!({"objects": [{"name": "X", "path": square(0.0)}]});
apply_output(&mut d, &ids[1..], decode_json(out.clone()).unwrap(), None).unwrap();
assert_eq!(names(&d, layer), ["A", "X", "?", "?"], "B and C replaced by X at B's place");
// No inputs (a generator): on top of the given container.
let (mut d, _, layer) = doc();
apply_output(&mut d, &[], decode_json(out).unwrap(), Some(layer)).unwrap();
assert_eq!(names(&d, layer).last().map(String::as_str), Some("X"));
}
#[test]
fn geometry_type_and_strokes_change() {
let (mut d, ids, _) = doc();
let a = ids[0];
let two = PathData::new(square(0.0).subpaths.into_iter().chain(square(2.0).subpaths).collect());
let blue = json!({"type": "solid", "color": {"model": "rgb", "r": 0, "g": 0, "b": 1}});
let out = json!({"objects": [{"id": a.0, "type": "compound", "path": two, "fillRule": "evenodd", "strokes": [{"paint": blue, "width": 4000}], "opacity": 0.5, "name": ""}]});
apply_output(&mut d, &[a], decode_json(out).unwrap(), None).unwrap();
let n = d.node(a).unwrap();
let NodeKind::Compound { children, rule } = &n.kind else { panic!("not a compound: {:?}", n.kind) };
assert_eq!((children.len(), *rule), (2, FillRule::EvenOdd));
assert_eq!(n.appearance.stroke().unwrap().width, MAX_STROKE_WIDTH);
assert_eq!((n.opacity, n.name.as_deref()), (0.5, None));
assert_eq!(geometry(n).unwrap().0, two);
// And back to a single path; strokes removed.
let out = json!({"objects": [{"id": a.0, "type": "path", "strokes": []}]});
apply_output(&mut d, &[a], decode_json(out).unwrap(), None).unwrap();
let n = d.node(a).unwrap();
assert!(matches!(&n.kind, NodeKind::Path { path, .. } if *path == two));
assert!(n.appearance.stroke().is_none());
}
#[test]
fn changed_paints_lose_swatch_links_and_patterns_must_exist() {
let (mut d, ids, _) = doc();
let a = ids[0];
d.node_mut(a).unwrap().appearance.set_fill(Paint::Solid { color: Color::rgb(1.0, 0.0, 0.0), swatch: Some("Red".into()), tint: 0.5 });
let linked = json!({"type": "solid", "color": {"model": "rgb", "r": 1.0, "g": 0.0, "b": 0.0}, "swatch": "Red", "tint": 0.5});
apply_output(&mut d, &[a], decode_json(json!({"objects": [{"id": a.0, "fills": [linked]}]})).unwrap(), None).unwrap();
assert!(matches!(d.node(a).unwrap().appearance.fill_paint(), Paint::Solid { swatch: Some(_), .. }), "unchanged: kept");
let other = json!({"type": "solid", "color": {"model": "rgb", "r": 0.0, "g": 1.0, "b": 0.0}, "swatch": "Red", "tint": 0.5});
apply_output(&mut d, &[a], decode_json(json!({"objects": [{"id": a.0, "fills": [other]}]})).unwrap(), None).unwrap();
assert_eq!(d.node(a).unwrap().appearance.fill_paint(), Paint::solid(Color::rgb(0.0, 1.0, 0.0)));
let pattern = json!({"objects": [{"id": a.0, "fills": [{"type": "pattern", "pattern": "Dots"}]}]});
assert!(apply_output(&mut d, &[a], decode_json(pattern.clone()).unwrap(), None).is_err());
d.patterns.push(serde_json::from_value(json!({"name": "Dots", "tile": {"x0": 0, "y0": 0, "x1": 10, "y1": 10}, "art": []})).unwrap());
assert!(apply_output(&mut d, &[a], decode_json(pattern).unwrap(), None).is_ok());
}
#[test]
fn bad_outputs_are_refused() {
let (d, ids, _) = doc();
let a = ids[0].0;
let far = PathData::single(SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(1e9, 0.0)], false));
for bad in [
json!([]),
json!({"objects": 3}),
json!({"objects": [7]}),
json!({"objects": [{"id": 999}]}),
json!({"objects": [{"id": a}, {"id": a}]}),
json!({"objects": [{"name": "no path"}]}),
json!({"objects": [{"id": a, "path": far}]}),
json!({"objects": [{"id": a, "path": {"subpaths": []}}]}),
json!({"objects": [{"id": a, "type": "text"}]}),
json!({"objects": [{"id": a, "fillRule": "odd"}]}),
json!({"objects": [{"id": a, "fills": [{"type": "gradient", "gradient": {"kind": "Linear", "stops": []}}]}]}),
json!({"objects": [{"id": a, "fills": [{"type": "solid"}]}]}),
json!({"objects": [{"id": a, "strokes": [3]}]}),
json!({"objects": [{"id": a, "opacity": "full"}]}),
json!({"error": "nothing to do"}),
] {
let mut d = d.clone();
let r = decode_json(bad.clone()).and_then(|out| apply_output(&mut d, &ids, out, None));
assert!(r.is_err(), "{bad}");
}
assert!(decode(b"\xff\xfe").is_err());
}
+111
View File
@@ -0,0 +1,111 @@
//! The process-wide set of installed plug-ins.
//!
//! Plug-ins are installed per process, not per document: live effects are drawn from every open
//! document (and from scratch sessions such as previews), and they must find the same plug-ins.
use std::collections::{BTreeMap, HashSet};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex, RwLock};
#[cfg(not(target_arch = "wasm32"))]
use crate::Error;
use crate::{Limits, Plugin, Result};
static PLUGINS: RwLock<BTreeMap<String, Arc<Plugin>>> = RwLock::new(BTreeMap::new());
static REVISION: AtomicU64 = AtomicU64::new(0);
/// Installs `plugin`, replacing one with the same id.
pub fn install(mut plugin: Plugin) -> Arc<Plugin> {
plugin.set_serial(REVISION.fetch_add(1, Ordering::Relaxed) + 1);
let p = Arc::new(plugin);
PLUGINS.write().unwrap_or_else(|e| e.into_inner()).insert(p.id().to_string(), p.clone());
p
}
/// Loads `bytes` with the default limits and installs the plug-in.
pub fn install_bytes(bytes: &[u8]) -> Result<Arc<Plugin>> {
Ok(install(Plugin::load(bytes, Limits::default())?))
}
pub fn get(id: &str) -> Option<Arc<Plugin>> {
PLUGINS.read().unwrap_or_else(|e| e.into_inner()).get(id).cloned()
}
/// Installed plug-ins, sorted by id.
pub fn list() -> Vec<Arc<Plugin>> {
PLUGINS.read().unwrap_or_else(|e| e.into_inner()).values().cloned().collect()
}
/// Uninstalls `id`; false if it wasn't installed.
pub fn remove(id: &str) -> bool {
let gone = PLUGINS.write().unwrap_or_else(|e| e.into_inner()).remove(id).is_some();
if gone {
REVISION.fetch_add(1, Ordering::Relaxed);
}
gone
}
/// Changes whenever a plug-in is installed or removed (for caches such as menus).
pub fn revision() -> u64 {
REVISION.load(Ordering::Relaxed)
}
/// `s` as a `&'static str`, for catalogues that hold static strings (effect and menu labels). Each
/// distinct string is stored once, so memory stays bounded by the plug-ins ever installed.
pub fn intern(s: &str) -> &'static str {
static SET: Mutex<Option<HashSet<&'static str>>> = Mutex::new(None);
let mut set = SET.lock().unwrap_or_else(|e| e.into_inner());
let set = set.get_or_insert_with(HashSet::new);
if let Some(v) = set.get(s) {
return v;
}
let v: &'static str = Box::leak(s.to_string().into_boxed_str());
set.insert(v);
v
}
/// Reads a `.wasm` file (refusing anything that isn't a regular file or exceeds the module limit)
/// and loads it.
#[cfg(not(target_arch = "wasm32"))]
pub fn load_file(path: &std::path::Path, limits: Limits) -> Result<Plugin> {
use std::io::Read;
let meta = std::fs::metadata(path).map_err(|e| Error::Io(format!("{}: {e}", path.display())))?;
if !meta.is_file() {
return Err(Error::Io(format!("{} is not a file", path.display())));
}
if meta.len() > limits.max_module_bytes as u64 {
return Err(Error::Module(format!("{} is {} bytes (limit {})", path.display(), meta.len(), limits.max_module_bytes)));
}
let f = std::fs::File::open(path).map_err(|e| Error::Io(format!("{}: {e}", path.display())))?;
let mut bytes = Vec::new();
f.take(limits.max_module_bytes as u64 + 1).read_to_end(&mut bytes).map_err(|e| Error::Io(format!("{}: {e}", path.display())))?;
Ok(Plugin::load(&bytes, limits)?.with_source(path.display().to_string()))
}
/// What [`load_folder`] did.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct FolderReport {
/// Ids of the plug-ins installed.
pub loaded: Vec<String>,
/// `(file, error)` for each `.wasm` that failed to load.
pub failed: Vec<(String, String)>,
}
/// Installs every `*.wasm` directly in `dir` (not recursive), in file-name order. A bad module is
/// reported and skipped; the others still load.
#[cfg(not(target_arch = "wasm32"))]
pub fn load_folder(dir: &std::path::Path) -> Result<FolderReport> {
let rd = std::fs::read_dir(dir).map_err(|e| Error::Io(format!("{}: {e}", dir.display())))?;
let mut files: Vec<std::path::PathBuf> =
rd.filter_map(|e| e.ok().map(|e| e.path())).filter(|p| p.extension().is_some_and(|x| x.eq_ignore_ascii_case("wasm"))).collect();
files.sort();
files.truncate(1000);
let mut report = FolderReport::default();
for f in files {
match load_file(&f, Limits::default()) {
Ok(p) => report.loaded.push(install(p).id().to_string()),
Err(e) => report.failed.push((f.display().to_string(), e.to_string())),
}
}
Ok(report)
}
+275
View File
@@ -0,0 +1,275 @@
//! The wasmi host: loading, instantiating and calling a plug-in under resource limits.
use wasmi::{
Config, EnforcedLimits, Engine, Instance, Linker, Memory, Module, Store, StoreLimits, StoreLimitsBuilder, TypedFunc, TypedResumableCall,
WasmParams, WasmResults,
};
use crate::manifest::{MAX_MANIFEST_BYTES, Manifest};
use crate::{Error, Result};
/// The plug-in ABI version this host implements (`vc_abi_version` must return it).
pub const ABI_VERSION: i32 = 1;
/// Fuel handed out per slice; between slices the host checks the deadline.
const FUEL_SLICE: u64 = 20_000_000;
/// Resource limits for running plug-ins.
#[derive(Clone, Debug, PartialEq)]
pub struct Limits {
/// Largest module accepted.
pub max_module_bytes: usize,
/// Cap on a plug-in's linear memory. The input and the parameters may use half of it.
pub max_memory_bytes: usize,
/// Largest output JSON read back.
pub max_output_bytes: usize,
/// Instructions (fuel) any call may use, plus [`Limits::fuel_per_byte`] per byte of input for
/// `vc_run`.
pub fuel_base: u64,
pub fuel_per_byte: u64,
/// Deepest call nesting.
pub max_recursion_depth: usize,
/// Wall-clock budget for one run, in milliseconds. Native builds only: the web build has no
/// monotonic clock in `std`, so the fuel budget alone bounds it there.
pub wall_time_ms: u64,
}
impl Default for Limits {
fn default() -> Self {
Self {
max_module_bytes: 32 << 20,
max_memory_bytes: 512 << 20,
max_output_bytes: 64 << 20,
fuel_base: 50_000_000,
fuel_per_byte: 4_000,
max_recursion_depth: 1024,
wall_time_ms: 60_000,
}
}
}
/// A loaded, validated plug-in. Cheap to share (`Arc`) and safe to run from several threads:
/// every run gets its own store and instance.
pub struct Plugin {
manifest: Manifest,
engine: Engine,
module: Module,
limits: Limits,
size: usize,
source: Option<String>,
serial: u64,
}
impl std::fmt::Debug for Plugin {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Plugin").field("manifest", &self.manifest).field("size", &self.size).field("source", &self.source).finish()
}
}
/// When a run must stop (native: a wall-clock deadline).
struct Deadline {
#[cfg(not(target_arch = "wasm32"))]
at: std::time::Instant,
}
impl Deadline {
fn new(limits: &Limits) -> Self {
#[cfg(target_arch = "wasm32")]
let _ = limits;
Self {
#[cfg(not(target_arch = "wasm32"))]
at: std::time::Instant::now() + std::time::Duration::from_millis(limits.wall_time_ms),
}
}
fn check(&self) -> Result<()> {
#[cfg(not(target_arch = "wasm32"))]
if std::time::Instant::now() >= self.at {
return Err(Error::Limit("time budget".into()));
}
Ok(())
}
}
/// One live instance: a store with its own memory limit, and the module's exports.
struct Live {
store: Store<StoreLimits>,
instance: Instance,
memory: Memory,
}
impl Live {
fn func<P: WasmParams, R: WasmResults>(&self, name: &str) -> Result<TypedFunc<P, R>> {
self.instance.get_typed_func::<P, R>(&self.store, name).map_err(|e| Error::Abi(format!("export `{name}`: {e}")))
}
/// The `len << 32 | ptr` block a plug-in returned, copied out (at most `max` bytes).
fn read_packed(&self, packed: i64, what: &str, max: usize) -> Result<Vec<u8>> {
let packed = packed as u64;
let (ptr, len) = ((packed & 0xffff_ffff) as usize, (packed >> 32) as usize);
if len > max {
return Err(Error::Limit(format!("output size ({what} is {len} bytes, limit {max})")));
}
let bytes = self.memory.data(&self.store).get(ptr..ptr.saturating_add(len));
bytes.map(<[u8]>::to_vec).ok_or_else(|| Error::Abi(format!("{what} points outside memory")))
}
}
fn wasm_err(e: wasmi::Error) -> Error {
if e.as_trap_code() == Some(wasmi::TrapCode::OutOfFuel) { Error::Limit("instruction budget".into()) } else { Error::Trap(e.to_string()) }
}
/// `vc_run(input_ptr, input_len, params_ptr, params_len) -> len << 32 | ptr` (negative: error).
type RunArgs = (i32, i32, i32, i32);
impl Plugin {
/// Validates and loads a module: checks the ABI version, the exports and reads the manifest.
pub fn load(bytes: &[u8], limits: Limits) -> Result<Plugin> {
if bytes.len() > limits.max_module_bytes {
return Err(Error::Module(format!("module is {} bytes (limit {})", bytes.len(), limits.max_module_bytes)));
}
if !bytes.starts_with(b"\0asm") {
return Err(Error::Module("not a WebAssembly binary (missing \\0asm header)".into()));
}
let mut config = Config::default();
config.consume_fuel(true).enforced_limits(EnforcedLimits::strict()).set_max_recursion_depth(limits.max_recursion_depth.max(16));
let engine = Engine::new(&config);
let module = Module::new(&engine, bytes).map_err(|e| Error::Module(e.to_string()))?;
if let Some(imp) = module.imports().next() {
return Err(Error::Abi(format!(
"the module imports `{}.{}`, but plug-ins get no host imports (no WASI, files or network)",
imp.module(),
imp.name()
)));
}
let mut p = Plugin { manifest: Manifest::placeholder(), engine, module, limits, size: bytes.len(), source: None, serial: 0 };
let deadline = Deadline::new(&p.limits);
let mut live = p.instantiate()?;
let version: TypedFunc<(), i32> = live.func("vc_abi_version")?;
let v = p.call(&mut live, &version, (), p.limits.fuel_base, &deadline)?;
if v != ABI_VERSION {
return Err(Error::Abi(format!("vc_abi_version returned {v}; this host implements version {ABI_VERSION}")));
}
let manifest: TypedFunc<(), i64> = live.func("vc_manifest")?;
let packed = p.call(&mut live, &manifest, (), p.limits.fuel_base, &deadline)?;
let text = live.read_packed(packed, "vc_manifest", MAX_MANIFEST_BYTES).map_err(|e| match e {
Error::Limit(_) => Error::Manifest(format!("manifest is too long (limit {MAX_MANIFEST_BYTES} bytes)")),
e => e,
})?;
p.manifest = Manifest::parse(&text)?;
// The other exports must exist with the right types.
live.func::<i32, i32>("vc_alloc")?;
live.func::<RunArgs, i64>("vc_run")?;
Ok(p)
}
pub fn manifest(&self) -> &Manifest {
&self.manifest
}
pub fn id(&self) -> &str {
&self.manifest.id
}
pub fn limits(&self) -> &Limits {
&self.limits
}
/// Module size in bytes.
pub fn size(&self) -> usize {
self.size
}
/// Where the module was loaded from, if it came from a file.
pub fn source(&self) -> Option<&str> {
self.source.as_deref()
}
pub fn with_source(mut self, source: impl Into<String>) -> Self {
self.source = Some(source.into());
self
}
/// A number unique to this installation (re-installing a plug-in gives it a new one), so
/// caches of its results can tell versions apart. 0 until installed.
pub fn serial(&self) -> u64 {
self.serial
}
pub(crate) fn set_serial(&mut self, serial: u64) {
self.serial = serial;
}
fn instantiate(&self) -> Result<Live> {
let limits =
StoreLimitsBuilder::new().memory_size(self.limits.max_memory_bytes).memories(1).tables(4).table_elements(100_000).instances(1).build();
let mut store = Store::new(&self.engine, limits);
store.limiter(|l| l);
// The start function (if any) runs on this fuel.
store.set_fuel(self.limits.fuel_base).map_err(wasm_err)?;
let linker = Linker::<StoreLimits>::new(&self.engine);
let instance = linker.instantiate_and_start(&mut store, &self.module).map_err(wasm_err)?;
let memory =
instance.get_memory(&store, "memory").ok_or_else(|| Error::Abi("the module must export its linear memory as `memory`".into()))?;
Ok(Live { store, instance, memory })
}
/// Calls `f` with at most `fuel` instructions, in slices, checking the deadline between them.
fn call<P: WasmParams, R: WasmResults>(&self, live: &mut Live, f: &TypedFunc<P, R>, params: P, fuel: u64, deadline: &Deadline) -> Result<R> {
let mut left = fuel;
let give = left.min(FUEL_SLICE);
left -= give;
live.store.set_fuel(give).map_err(wasm_err)?;
let mut call = f.call_resumable(&mut live.store, params).map_err(wasm_err)?;
loop {
match call {
TypedResumableCall::Finished(r) => return Ok(r),
TypedResumableCall::HostTrap(_) => return Err(Error::Trap("unexpected host trap".into())),
TypedResumableCall::OutOfFuel(inv) => {
let need = inv.required_fuel();
if left == 0 || need > left {
return Err(Error::Limit("instruction budget".into()));
}
deadline.check()?;
let give = left.min(FUEL_SLICE.max(need));
left -= give;
live.store.set_fuel(give).map_err(wasm_err)?;
call = inv.resume(&mut live.store).map_err(wasm_err)?;
}
}
}
}
/// Runs `vc_run` on `input` (the objects JSON) with `params` (the parameters JSON) in a fresh
/// instance and returns the output JSON bytes.
pub fn run(&self, input: &[u8], params: &[u8]) -> Result<Vec<u8>> {
self.run_limited(input, params, &self.limits)
}
/// [`Plugin::run`] with other time and instruction budgets (live effects get a shorter one).
pub fn run_limited(&self, input: &[u8], params: &[u8], limits: &Limits) -> Result<Vec<u8>> {
// wasmi reports misbehaving modules as errors; a panic would be a bug in it, and must
// still not take the document down (native builds; wasm aborts on panic anyway).
let run = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| self.run_inner(input, params, limits)));
run.map_err(|_| Error::Trap("the runtime panicked".into()))?
}
fn run_inner(&self, input: &[u8], params: &[u8], limits: &Limits) -> Result<Vec<u8>> {
let total = input.len().checked_add(params.len()).filter(|b| *b <= i32::MAX as usize && *b <= self.limits.max_memory_bytes / 2);
total.ok_or_else(|| Error::Limit("memory budget (the selection is too large to hand over)".into()))?;
let deadline = Deadline::new(limits);
let mut live = self.instantiate()?;
let alloc: TypedFunc<i32, i32> = live.func("vc_alloc")?;
let run: TypedFunc<RunArgs, i64> = live.func("vc_run")?;
let place = |live: &mut Live, bytes: &[u8]| -> Result<usize> {
let ptr = self.call(live, &alloc, bytes.len().max(1) as i32, limits.fuel_base, &deadline)? as u32 as usize;
if ptr == 0 {
return Err(Error::Failed("vc_alloc returned 0 (out of memory)".into()));
}
let outside = || Error::Abi("vc_alloc returned a block outside memory".into());
let end = ptr.checked_add(bytes.len()).ok_or_else(outside)?;
live.memory.data_mut(&mut live.store).get_mut(ptr..end).ok_or_else(outside)?.copy_from_slice(bytes);
Ok(ptr)
};
let iptr = place(&mut live, input)?;
let pptr = place(&mut live, params)?;
let fuel = limits.fuel_base.saturating_add((input.len() as u64).saturating_mul(limits.fuel_per_byte));
let packed = self.call(&mut live, &run, (iptr as i32, input.len() as i32, pptr as i32, params.len() as i32), fuel, &deadline)?;
if packed < 0 {
return Err(Error::Failed(format!("vc_run returned error code {packed}")));
}
live.read_packed(packed, "the output", limits.max_output_bytes)
}
}
+47
View File
@@ -0,0 +1,47 @@
//! Minimal plug-ins in WebAssembly text (WAT): the skeleton `docs/plugins.md` describes, which the
//! tests turn into modules (with the `wat` crate) to exercise the host.
/// Where [`returning`] keeps its output in memory (the manifest sits at 16).
const OUTPUT_AT: usize = 0x1_0000;
/// `s` as the body of a WAT string literal (every byte that isn't a letter, digit or space
/// escaped).
pub fn string(s: &str) -> String {
s.bytes().map(|b| if b.is_ascii_alphanumeric() || b == b' ' { char::from(b).to_string() } else { format!("\\{b:02x}") }).collect()
}
/// A plug-in module: `manifest` (JSON), a `vc_run` whose body is `run` (parameters `$in $il $pp
/// $pl`, result i64), extra module fields `extra` and `pages` of initial memory. `vc_alloc` grows
/// memory by whole pages, so its blocks never overlap data segments.
pub fn module(manifest: &str, run: &str, extra: &str, pages: u32) -> String {
format!(
r#"(module
{extra}
(memory (export "memory") {pages})
(data (i32.const 16) "{data}")
(func (export "vc_abi_version") (result i32) (i32.const 1))
(func (export "vc_manifest") (result i64) (i64.or (i64.shl (i64.const {len}) (i64.const 32)) (i64.const 16)))
(func (export "vc_alloc") (param $n i32) (result i32) (local $old i32)
(local.set $old (memory.grow (i32.shr_u (i32.add (local.get $n) (i32.const 65535)) (i32.const 16))))
(if (result i32) (i32.eq (local.get $old) (i32.const -1)) (then (i32.const 0)) (else (i32.mul (local.get $old) (i32.const 65536)))))
(func (export "vc_run") (param $in i32) (param $il i32) (param $pp i32) (param $pl i32) (result i64)
{run}))"#,
data = string(manifest),
len = manifest.len(),
)
}
/// A `vc_run` body returning the input unchanged.
pub const ECHO: &str = "(i64.or (i64.shl (i64.extend_i32_u (local.get $il)) (i64.const 32)) (i64.extend_i32_u (local.get $in)))";
/// A plug-in that always returns `output` (whatever it is given).
pub fn returning(manifest: &str, output: &str) -> String {
let run = format!("(i64.or (i64.shl (i64.const {}) (i64.const 32)) (i64.const {OUTPUT_AT}))", output.len());
let extra = format!(r#"(data (i32.const {OUTPUT_AT}) "{}")"#, string(output));
module(manifest, &run, &extra, (OUTPUT_AT + output.len()).div_ceil(0x1_0000) as u32 + 1)
}
/// The manifest of a test plug-in `id` of `kind` with `params` (a JSON object of specs).
pub fn manifest(id: &str, kind: &str, params: &str) -> String {
format!(r#"{{"id":"{id}","name":"Test {id}","version":"1","kind":"{kind}","params":{params}}}"#)
}
+208
View File
@@ -0,0 +1,208 @@
//! The plug-in ABI end to end: the example Desaturate plug-in, and hostile modules (infinite
//! loops, memory bombs, out-of-bounds access, bad manifests and outputs…), which must all fail
//! with an error, quickly.
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use std::time::{Duration, Instant};
use serde_json::{Value, json};
use vectorcraft_plugins::{Error, Kind, Limits, Plugin, registry, wat as tpl};
/// The example Desaturate plug-in, built from `crates/plugins/example`.
const DESATURATE: &[u8] = include_bytes!("fixtures/desaturate.wasm");
const MANIFEST: &str = r#"{"id":"test.plugin","name":"Test","version":"1","kind":"filter"}"#;
fn build(src: &str) -> Vec<u8> {
wat::parse_str(src).unwrap_or_else(|e| panic!("bad test WAT: {e}\n{src}"))
}
/// A filter whose `vc_run` body is `run`, with `extra` module fields.
fn module(manifest: &str, run: &str, extra: &str) -> Vec<u8> {
build(&tpl::module(manifest, run, extra, 1))
}
fn ok_module(run: &str) -> Vec<u8> {
module(MANIFEST, run, "")
}
const INPUT: &str = r#"{"objects":[]}"#;
/// Runs `bytes` on an empty input; returns the error and how long it took.
fn run_err(bytes: &[u8], limits: Limits) -> (Error, Duration) {
let t = Instant::now();
let r = Plugin::load(bytes, limits).and_then(|p| p.run(INPUT.as_bytes(), b"{}"));
(r.expect_err("hostile module must fail"), t.elapsed())
}
#[test]
fn example_desaturate_loads_and_reports_its_manifest() {
let p = Plugin::load(DESATURATE, Limits::default()).unwrap();
let m = p.manifest();
assert_eq!(m.id, "org.vectorcraft.example.desaturate");
assert_eq!(m.name, "Desaturate");
assert_eq!(m.kind, Kind::Filter);
assert_eq!(m.params_doc(), "{amount: 0..100 (100)}");
}
#[test]
fn example_desaturate_moves_every_colour_model_to_grey() {
let p = Plugin::load(DESATURATE, Limits::default()).unwrap();
let input = json!({"objects": [{"id": 3, "type": "path", "fills": [
{"type": "solid", "color": {"model": "rgb", "r": 1.0, "g": 0.0, "b": 0.0}},
{"type": "gradient", "gradient": {"kind": "Linear", "stops": [
{"offset": 0.0, "color": {"model": "lab", "l": 50.0, "a": 40.0, "b": -20.0}},
{"offset": 1.0, "color": {"model": "cmyk", "c": 0.9, "m": 0.0, "y": 0.3, "k": 0.1}}]}}]}]});
let out = p.run(input.to_string().as_bytes(), br#"{"amount": 100}"#).unwrap();
let out: Value = serde_json::from_slice(&out).unwrap();
let fills = &out["objects"][0]["fills"];
let rgb = &fills[0]["color"];
assert!((rgb["r"].as_f64().unwrap() - 0.2126).abs() < 1e-9 && rgb["r"] == rgb["g"] && rgb["g"] == rgb["b"], "{rgb}");
let stops = &fills[1]["gradient"]["stops"];
assert_eq!((stops[0]["color"]["a"].as_f64(), stops[0]["color"]["b"].as_f64()), (Some(0.0), Some(0.0)));
let cmyk = &stops[1]["color"];
assert!((cmyk["c"].as_f64().unwrap() - 0.4).abs() < 1e-9 && cmyk["k"] == 0.1, "{cmyk}");
assert_eq!(out["objects"][0]["id"], 3, "ids come back, so the objects are updated in place");
// Half way.
let half: Value = serde_json::from_slice(&p.run(input.to_string().as_bytes(), br#"{"amount": 50}"#).unwrap()).unwrap();
assert!((half["objects"][0]["fills"][0]["color"]["r"].as_f64().unwrap() - 0.6063).abs() < 1e-9);
// Nothing to work on: the plug-in's own message.
let none = p.run(INPUT.as_bytes(), b"{}").unwrap();
assert!(matches!(vectorcraft_plugins::objects::decode(&none), Err(Error::Failed(m)) if m.contains("Select some paths")));
}
#[test]
fn input_and_params_reach_the_plugin_and_output_comes_back() {
let p = Plugin::load(&build(&tpl::module(MANIFEST, tpl::ECHO, "", 1)), Limits::default()).unwrap();
let input = br#"{"objects":[{"id":1}]}"#;
assert_eq!(p.run(input, b"{}").unwrap(), input);
// Returns the parameters instead.
let params = module(MANIFEST, "(i64.or (i64.shl (i64.extend_i32_u (local.get $pl)) (i64.const 32)) (i64.extend_i32_u (local.get $pp)))", "");
let p = Plugin::load(&params, Limits::default()).unwrap();
assert_eq!(p.run(input, br#"{"amount":5}"#).unwrap(), br#"{"amount":5}"#);
let fixed = Plugin::load(&build(&tpl::returning(MANIFEST, r#"{"objects":[]}"#)), Limits::default()).unwrap();
assert_eq!(fixed.run(input, b"{}").unwrap(), br#"{"objects":[]}"#);
}
#[test]
fn hostile_modules_fail_quickly() {
let quick = Duration::from_secs(5);
let cases: Vec<(&str, Vec<u8>)> = vec![
("infinite loop", ok_module("(loop $l (br $l)) (i64.const 0)")),
("memory.grow bomb, then trap", ok_module("(loop $l (br_if $l (i32.ne (memory.grow (i32.const 512)) (i32.const -1)))) unreachable")),
("memory.grow forever", ok_module("(loop $l (drop (memory.grow (i32.const 1))) (br $l)) (i64.const 0)")),
("out-of-bounds write", ok_module("(i32.store (i32.const -4) (i32.const 0)) (i64.const 0)")),
("trap", ok_module("unreachable")),
("division by zero", ok_module("(drop (i32.div_u (i32.const 1) (i32.const 0))) (i64.const 0)")),
("deep recursion", module(MANIFEST, "(call $r) (i64.const 0)", "(func $r (call $r))")),
("error code", ok_module("(i64.const -7)")),
("output outside memory", ok_module("(i64.const 0x00000100fffffff0)")),
("huge output", ok_module("(i64.const 0x7fffffff00000000)")),
(
"wrong ABI version",
build(&tpl::module(MANIFEST, "(i64.const 0)", "", 1).replace("(result i32) (i32.const 1))", "(result i32) (i32.const 2))")),
),
("bad manifest", module("not json", "(i64.const 0)", "")),
("manifest with a bad id", module(r#"{"id":"a b","name":"x","kind":"filter"}"#, "(i64.const 0)", "")),
("manifest of the wrong kind", module(r#"{"id":"a","name":"x","kind":"panel"}"#, "(i64.const 0)", "")),
("start function loops", module(MANIFEST, "(i64.const 0)", "(func $spin (loop $l (br $l))) (start $spin)")),
(
"WASI import",
module(MANIFEST, "(i64.const 0)", r#"(import "wasi_snapshot_preview1" "fd_write" (func (param i32 i32 i32 i32) (result i32)))"#),
),
];
for (name, bytes) in cases {
let (e, t) = run_err(&bytes, Limits::default());
assert!(t < quick, "{name} took {t:?}");
eprintln!("{name}: {e} ({t:?})");
}
}
#[test]
fn malformed_and_oversized_modules_are_refused() {
let half = &DESATURATE[..DESATURATE.len() / 2];
for (name, bytes) in [("empty", &b""[..]), ("garbage", &b"\0asm\x01\0\0\0\xff\xff\xff"[..]), ("not wasm", &b"MZ\x90\0"[..]), ("truncated", half)]
{
assert!(matches!(Plugin::load(bytes, Limits::default()), Err(Error::Module(_))), "{name}");
}
let tiny = Limits { max_module_bytes: 100, ..Limits::default() };
assert!(matches!(Plugin::load(DESATURATE, tiny), Err(Error::Module(_))));
// Huge initial memory, manifest outside memory, missing exports, bogus allocations.
let huge = build(r#"(module (memory (export "memory") 60000))"#);
assert!(Plugin::load(&huge, Limits::default()).is_err());
let no_run = build(
r#"(module (memory (export "memory") 1) (func (export "vc_abi_version") (result i32) (i32.const 1))
(func (export "vc_manifest") (result i64) (i64.const 0)))"#,
);
assert!(Plugin::load(&no_run, Limits::default()).is_err());
let src = r#"(module (memory (export "memory") 1) (func (export "vc_abi_version") (result i32) (i32.const 1))
(func (export "vc_manifest") (result i64) (i64.const 0x00000064ffffff00))
(func (export "vc_alloc") (param i32) (result i32) (i32.const 16))
(func (export "vc_run") (param i32 i32 i32 i32) (result i64) (i64.const 0)))"#;
assert!(matches!(Plugin::load(&build(src), Limits::default()), Err(Error::Abi(_))));
let bogus_alloc = build(&tpl::module(MANIFEST, "(i64.const 0)", "", 1).replace(
"(func (export \"vc_alloc\") (param $n i32) (result i32) (local $old i32)",
"(func (export \"vc_alloc\") (param $n i32) (result i32) (local $old i32) (return (i32.const -16))",
));
let (e, _) = run_err(&bogus_alloc, Limits::default());
assert!(matches!(e, Error::Abi(_)), "{e}");
}
#[test]
fn budgets_stop_runaway_plugins() {
let spin = ok_module("(loop $l (br $l)) (i64.const 0)");
let (e, _) = run_err(&spin, Limits { fuel_base: 1_000_000, fuel_per_byte: 10, ..Limits::default() });
assert_eq!(e, Error::Limit("instruction budget".into()));
}
#[test]
fn wall_clock_budget_stops_runaway_plugins() {
let spin = ok_module("(loop $l (br $l)) (i64.const 0)");
// Wall-clock budget, with an effectively unlimited instruction budget.
let (e, t) = run_err(&spin, Limits { fuel_base: u64::MAX / 4, wall_time_ms: 300, ..Limits::default() });
assert_eq!(e, Error::Limit("time budget".into()));
assert!(t < Duration::from_secs(3), "{t:?}");
}
#[test]
fn memory_cap_limits_input_and_growth() {
let echo = build(&tpl::module(MANIFEST, tpl::ECHO, "", 1));
let p = Plugin::load(&echo, Limits { max_memory_bytes: 1 << 20, ..Limits::default() }).unwrap();
assert!(p.run(&vec![b' '; 300 << 10], b"{}").is_ok(), "fits in half the memory");
assert!(matches!(p.run(&vec![b' '; 600 << 10], b"{}"), Err(Error::Limit(_))), "doesn't");
// Growing past the cap fails inside the module (memory.grow returns -1): vc_alloc gives 0.
let p = Plugin::load(&echo, Limits { max_memory_bytes: 64 << 10, ..Limits::default() }).unwrap();
assert!(matches!(p.run(b"{}", b"{}"), Err(Error::Failed(m)) if m.contains("vc_alloc")));
}
#[test]
fn registry_install_list_remove() {
// Its own id: the registry is process-wide and tests run in parallel.
let id = "test.registry";
let p = registry::install_bytes(&module(r#"{"id":"test.registry","name":"R","kind":"filter"}"#, "(i64.const 0)", "")).unwrap();
assert_eq!(p.id(), id);
let rev = registry::revision();
assert!(registry::list().iter().any(|p| p.id() == id));
assert!(registry::get(id).is_some());
assert!(registry::remove(id));
assert!(!registry::remove(id));
assert!(registry::get(id).is_none());
assert!(registry::revision() > rev);
assert!(std::ptr::eq(registry::intern("Abc"), registry::intern("Abc")), "interned once");
}
#[test]
fn folder_loading_skips_bad_files() {
let dir = std::env::temp_dir().join(format!("vectorcraft-plugins-test-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("a-desaturate.wasm"), DESATURATE).unwrap();
std::fs::write(dir.join("b-broken.wasm"), b"\0asm junk").unwrap();
std::fs::write(dir.join("notes.txt"), b"ignored").unwrap();
let r = registry::load_folder(&dir).unwrap();
assert_eq!(r.loaded, ["org.vectorcraft.example.desaturate"]);
assert_eq!(r.failed.len(), 1);
assert!(r.failed[0].0.ends_with("b-broken.wasm"));
assert!(registry::load_folder(&dir.join("missing")).is_err());
assert!(registry::load_file(&dir, Limits::default()).is_err(), "a directory is not a module");
std::fs::remove_dir_all(&dir).ok();
}
Binary file not shown.