mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-14 19:26:56 -04:00
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:
1 parent
cccdf96cc1
commit
811ad1efd8
45 files changed
+3712
-31
No files matched your search
@@ -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 }
|
||||
Generated
+105
@@ -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"
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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
|
||||
}
|
||||
@@ -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(¶ms));
|
||||
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, ¶ms, &limits)?)?))
|
||||
}
|
||||
@@ -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>;
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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}}}"#)
|
||||
}
|
||||
@@ -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(¶ms, 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();
|
||||
}
|
||||
BIN
Binary file not shown.
Reference in new issue
Block a user