mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-09 06:06:38 +00:00
A plug-in whose manifest gives "api": 2 and lists the new `progress` permission may import three host functions from module `env`: vc_progress(done, total), vc_cancelled() -> i32 and vc_log(level, ptr, len) -> i32. Installing checks every import and refuses, by name, one the host doesn't offer, one with another type and one the manifest doesn't allow. - Progress is clamped and passed on once per tenth of a percent (past 2,000 reports, once per 1,000 changes). A heavy filter's progress window and the export's progress window show it as a percentage bar, and their Cancel makes vc_cancelled return 1. A live effect's progress is not shown. - A run cancelled before it returns ends as cancelled, and one that returns after its time budget ran out ends at that budget, so what a plug-in returns after it sees vc_cancelled changes nothing. This also applies to a plug-in of API level 1 that finishes after a Cancel or past its time budget. - Log text must lie in the module's memory and be UTF-8. Lines go to the app's log as "<id>: <text>" with hidden characters escaped, at most 1,024 characters a line, 200 lines and 32 KiB a run, and the host reads at most 1 MiB of log text a run. - plugin.list and plugin.info give the manifest's `api`; the install prompt names the new permission in plain words. Docs: docs/plugins.md, docs/mcp.md, docs/target-app-parity.md.
233 lines
12 KiB
Rust
233 lines
12 KiB
Rust
//! 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 {
|
|
then_returning(manifest, "", output)
|
|
}
|
|
|
|
/// A plug-in whose `vc_run` runs `first` (instructions, with the locals it declares first), then
|
|
/// returns `output`.
|
|
fn then_returning(manifest: &str, first: &str, output: &str) -> String {
|
|
with_imports(manifest, "", first, output)
|
|
}
|
|
|
|
/// A plug-in with the module fields `imports` before the others, whose `vc_run` runs `first`, then
|
|
/// returns `output`.
|
|
pub fn with_imports(manifest: &str, imports: &str, first: &str, output: &str) -> String {
|
|
let run = format!("{first} (i64.or (i64.shl (i64.const {}) (i64.const 32)) (i64.const {OUTPUT_AT}))", output.len());
|
|
let extra = format!(r#"{imports} (data (i32.const {OUTPUT_AT}) "{}")"#, string(output));
|
|
module(manifest, &run, &extra, (OUTPUT_AT + output.len()).div_ceil(0x1_0000) as u32 + 1)
|
|
}
|
|
|
|
/// The host imports of plug-in API level 2 ([`crate::host::IMPORTS`]), as `$vc_progress`,
|
|
/// `$vc_cancelled` and `$vc_log`.
|
|
pub const HOST_IMPORTS: &str = r#"(import "env" "vc_progress" (func $vc_progress (param i64 i64)))
|
|
(import "env" "vc_cancelled" (func $vc_cancelled (result i32)))
|
|
(import "env" "vc_log" (func $vc_log (param i32 i32 i32) (result i32)))"#;
|
|
|
|
/// A plug-in of API level 2 (its manifest gives `api` 2 and lists `progress`) that imports the
|
|
/// host functions ([`HOST_IMPORTS`]) and whose `vc_run` runs `first`, which may call them, then
|
|
/// returns `output`.
|
|
pub fn reporting(manifest: &str, first: &str, output: &str) -> String {
|
|
with_imports(manifest, HOST_IMPORTS, first, output)
|
|
}
|
|
|
|
/// A plug-in of API level 2 that counts `steps` down, reporting each step's progress and checking
|
|
/// `vc_cancelled` before it: when that says the run was cancelled it returns `stopped`, else
|
|
/// `output` once the steps are done.
|
|
pub fn cooperating(manifest: &str, steps: u64, stopped: &str, output: &str) -> String {
|
|
let first = format!(
|
|
r#"(local $n i64) (local.set $n (i64.const {steps}))
|
|
(block $done (loop $step
|
|
(br_if $done (i64.le_s (local.get $n) (i64.const 0)))
|
|
(call $vc_progress (i64.sub (i64.const {steps}) (local.get $n)) (i64.const {steps}))
|
|
(if (call $vc_cancelled) (then (return (i64.or (i64.shl (i64.const {len}) (i64.const 32)) (i64.const {STOPPED_AT})))))
|
|
(local.set $n (i64.sub (local.get $n) (i64.const 1)))
|
|
(br $step)))"#,
|
|
len = stopped.len(),
|
|
);
|
|
let imports = format!(r#"{HOST_IMPORTS} (data (i32.const {STOPPED_AT}) "{}")"#, string(stopped));
|
|
with_imports(manifest, &imports, &first, output)
|
|
}
|
|
|
|
/// Where [`cooperating`] keeps what it returns when it is cancelled.
|
|
const STOPPED_AT: usize = 0x8000;
|
|
|
|
/// A plug-in that counts `steps` down before it returns `output`. Each step costs a few
|
|
/// instructions, so a test picks work that a light budget can't finish and a heavy one can, or
|
|
/// that runs until it is cancelled.
|
|
pub fn spinning(manifest: &str, steps: u64, output: &str) -> String {
|
|
let spin = format!(
|
|
"(local $n i64) (local.set $n (i64.const {steps}))
|
|
(block $done (loop $step
|
|
(br_if $done (i64.le_s (local.get $n) (i64.const 0)))
|
|
(local.set $n (i64.sub (local.get $n) (i64.const 1)))
|
|
(br $step)))"
|
|
);
|
|
then_returning(manifest, &spin, output)
|
|
}
|
|
|
|
/// What [`when_given`] looks through.
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
pub enum Given {
|
|
/// The input JSON (the objects).
|
|
Input,
|
|
/// The parameters JSON, `_context` included.
|
|
Params,
|
|
}
|
|
|
|
/// Where [`when_given`] keeps the text it looks for.
|
|
const NEEDLE_AT: usize = 0x8000;
|
|
|
|
/// A plug-in that returns `output` when what it is given (`given`) holds the text `needle`, and
|
|
/// its input unchanged otherwise. A live effect made from it draws one shape or the other, so a
|
|
/// test sees from the geometry whether the host handed the plug-in something.
|
|
pub fn when_given(manifest: &str, given: Given, needle: &str, output: &str) -> String {
|
|
let (src, len) = match given {
|
|
Given::Input => ("$in", "$il"),
|
|
Given::Params => ("$pp", "$pl"),
|
|
};
|
|
let n = needle.len();
|
|
// For each place `$i` the needle fits, compare it byte by byte (`$j`).
|
|
let run = format!(
|
|
r#"(local $i i32) (local $j i32)
|
|
(block $found
|
|
(block $none
|
|
(loop $at
|
|
(br_if $none (i32.gt_u (i32.add (local.get $i) (i32.const {n})) (local.get {len})))
|
|
(local.set $j (i32.const 0))
|
|
(block $next
|
|
(loop $cmp
|
|
(br_if $found (i32.eq (local.get $j) (i32.const {n})))
|
|
(br_if $next (i32.ne (i32.load8_u (i32.add (local.get {src}) (i32.add (local.get $i) (local.get $j))))
|
|
(i32.load8_u (i32.add (i32.const {NEEDLE_AT}) (local.get $j)))))
|
|
(local.set $j (i32.add (local.get $j) (i32.const 1)))
|
|
(br $cmp)))
|
|
(local.set $i (i32.add (local.get $i) (i32.const 1)))
|
|
(br $at)))
|
|
(return {ECHO}))
|
|
(i64.or (i64.shl (i64.const {out}) (i64.const 32)) (i64.const {OUTPUT_AT}))"#,
|
|
out = output.len(),
|
|
);
|
|
let extra = format!(r#"(data (i32.const {NEEDLE_AT}) "{}") (data (i32.const {OUTPUT_AT}) "{}")"#, string(needle), 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}}}"#)
|
|
}
|
|
|
|
/// The manifest of a test exporter `id` writing `extensions` (a JSON list) with `params`.
|
|
pub fn exporter(id: &str, extensions: &str, params: &str) -> String {
|
|
format!(r#"{{"id":"{id}","name":"Test {id}","version":"1","kind":"exporter","extensions":{extensions},"params":{params}}}"#)
|
|
}
|
|
|
|
/// What [`echo_exporter`] writes as its file.
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
pub enum Echo {
|
|
/// The input: the document, as the host hands it over.
|
|
Input,
|
|
/// The parameters JSON, `_context` included.
|
|
Params,
|
|
}
|
|
|
|
/// Where [`echo_exporter`] keeps the base64 alphabet, then its output's opening and closing text.
|
|
const ALPHABET_AT: usize = 0x8000;
|
|
|
|
/// An exporter (manifest `manifest`) whose file is exactly what it is given (`echo`), so a test
|
|
/// reads back what the host hands a plug-in: it returns `{"dataBase64": "…"}`, encoding the bytes
|
|
/// in base64 itself, three bytes to four characters, the last group padded with `=`.
|
|
pub fn echo_exporter(manifest: &str, echo: Echo) -> String {
|
|
let (src, len) = match echo {
|
|
Echo::Input => ("$in", "$il"),
|
|
Echo::Params => ("$pp", "$pl"),
|
|
};
|
|
let (open, close) = (r#"{"dataBase64":""#, r#""}"#);
|
|
let (open_at, close_at) = (ALPHABET_AT + 64, ALPHABET_AT + 96);
|
|
// One character of the alphabet: the six bits of `$b` from `shift` up.
|
|
let char_at = |shift: u32| {
|
|
format!("(i32.load8_u (i32.add (i32.const {ALPHABET_AT}) (i32.and (i32.shr_u (local.get $b) (i32.const {shift})) (i32.const 63))))")
|
|
};
|
|
let byte = |k: u32| format!("(i32.load8_u (i32.add (local.get {src}) (i32.add (local.get $i) (i32.const {k}))))");
|
|
let run = format!(
|
|
r#"(local $o i32) (local $n i32) (local $w i32) (local $i i32) (local $b i32) (local $rest i32)
|
|
(local.set $n (i32.add (i32.const {frame}) (i32.mul (i32.const 4) (i32.div_u (i32.add (local.get {len}) (i32.const 2)) (i32.const 3)))))
|
|
(local.set $o (memory.grow (i32.add (i32.shr_u (local.get $n) (i32.const 16)) (i32.const 1))))
|
|
(if (i32.eq (local.get $o) (i32.const -1)) (then (return (i64.const -1))))
|
|
(local.set $o (i32.mul (local.get $o) (i32.const 65536)))
|
|
(memory.copy (local.get $o) (i32.const {open_at}) (i32.const {open_len}))
|
|
(local.set $w (i32.add (local.get $o) (i32.const {open_len})))
|
|
(block $done (loop $triple
|
|
(br_if $done (i32.gt_u (i32.add (local.get $i) (i32.const 3)) (local.get {len})))
|
|
(local.set $b (i32.or (i32.or (i32.shl {b0} (i32.const 16)) (i32.shl {b1} (i32.const 8))) {b2}))
|
|
(i32.store8 (local.get $w) {c18}) (i32.store8 offset=1 (local.get $w) {c12})
|
|
(i32.store8 offset=2 (local.get $w) {c6}) (i32.store8 offset=3 (local.get $w) {c0})
|
|
(local.set $w (i32.add (local.get $w) (i32.const 4)))
|
|
(local.set $i (i32.add (local.get $i) (i32.const 3)))
|
|
(br $triple)))
|
|
(local.set $rest (i32.sub (local.get {len}) (local.get $i)))
|
|
(if (i32.ne (local.get $rest) (i32.const 0)) (then
|
|
(local.set $b (i32.shl {b0} (i32.const 16)))
|
|
(if (i32.eq (local.get $rest) (i32.const 2)) (then (local.set $b (i32.or (local.get $b) (i32.shl {b1} (i32.const 8))))))
|
|
(i32.store8 (local.get $w) {c18}) (i32.store8 offset=1 (local.get $w) {c12})
|
|
(i32.store8 offset=2 (local.get $w) (if (result i32) (i32.eq (local.get $rest) (i32.const 2)) (then {c6}) (else (i32.const 61))))
|
|
(i32.store8 offset=3 (local.get $w) (i32.const 61))
|
|
(local.set $w (i32.add (local.get $w) (i32.const 4)))))
|
|
(memory.copy (local.get $w) (i32.const {close_at}) (i32.const {close_len}))
|
|
(i64.or (i64.shl (i64.extend_i32_u (local.get $n)) (i64.const 32)) (i64.extend_i32_u (local.get $o)))"#,
|
|
frame = open.len() + close.len(),
|
|
open_len = open.len(),
|
|
close_len = close.len(),
|
|
b0 = byte(0),
|
|
b1 = byte(1),
|
|
b2 = byte(2),
|
|
c18 = char_at(18),
|
|
c12 = char_at(12),
|
|
c6 = char_at(6),
|
|
c0 = char_at(0),
|
|
);
|
|
let extra = format!(
|
|
r#"(data (i32.const {ALPHABET_AT}) "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/")
|
|
(data (i32.const {open_at}) "{}")
|
|
(data (i32.const {close_at}) "{}")"#,
|
|
string(open),
|
|
string(close)
|
|
);
|
|
module(manifest, &run, &extra, 1)
|
|
}
|