mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-09 04:54:45 +00:00
plugin.install wrote the grant and the module as two separate writes, and either could fail with only a log record. With a grant tied to the module's hash, the stored pair then no longer matched and the plug-in was skipped at the next start. The module is now written first and its grant only once it is there. When the grant can't be written, the store gets its old module back, or loses the new one on a first install, and a disabled plug-in stays disabled. A stored module that can't be read back is not replaced by one that needs a grant.
1171 lines
70 KiB
Rust
1171 lines
70 KiB
Rust
//! WebAssembly plug-ins (#121): object filters through `plugin.run` (one undo step,
|
||
//! transactional), live-effect plug-ins in appearance stacks, and the `plugin.*` management
|
||
//! commands.
|
||
|
||
use serde_json::{Value, json};
|
||
use vectorcraft_color::{Color, Paint};
|
||
use vectorcraft_geom::{PathData, Point, SubPath};
|
||
use vectorcraft_plugins::wat as tpl;
|
||
|
||
use crate::*;
|
||
|
||
/// The example Desaturate plug-in (built from `crates/plugins/example`).
|
||
const DESATURATE: &[u8] = include_bytes!("../../../plugins/tests/fixtures/desaturate.wasm");
|
||
const DESATURATE_ID: &str = "org.vectorcraft.example.desaturate";
|
||
|
||
fn run(s: &mut Session, cmd: &str, p: Value) -> Value {
|
||
s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}"))
|
||
}
|
||
|
||
fn install(s: &mut Session, wasm: &[u8]) -> Value {
|
||
run(s, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(wasm)}))
|
||
}
|
||
|
||
fn install_wat(s: &mut Session, src: &str) -> Value {
|
||
install(s, &wat::parse_str(src).unwrap())
|
||
}
|
||
|
||
fn session() -> Session {
|
||
let mut s = Session::new();
|
||
s.execute("file.new", &json!({"width": 400, "height": 200})).unwrap();
|
||
s
|
||
}
|
||
|
||
/// A 10 × 10 rectangle at `x` filled with `fill` and no stroke.
|
||
fn rect(s: &mut Session, x: f64, fill: &str) -> NodeId {
|
||
let id = NodeId(run(s, "shape.rectangle", json!({"x": x, "y": 10, "width": 10, "height": 10}))["id"].as_u64().unwrap());
|
||
run(s, "paint.setFill", json!({"ids": [id.0], "color": fill}));
|
||
run(s, "paint.setStroke", json!({"ids": [id.0], "none": true}));
|
||
id
|
||
}
|
||
|
||
fn node(s: &Session, id: NodeId) -> vectorcraft_doc::Node {
|
||
s.doc().unwrap().doc.node(id).unwrap().clone()
|
||
}
|
||
|
||
fn triangle() -> PathData {
|
||
PathData::single(SubPath::polyline(&[Point::new(0.0, 0.0), Point::new(30.0, 0.0), Point::new(15.0, 20.0)], true))
|
||
}
|
||
|
||
#[test]
|
||
fn object_filter_recolours_the_selection_as_one_undo_step() {
|
||
let mut s = session();
|
||
let info = install(&mut s, DESATURATE);
|
||
assert_eq!((info["id"].as_str(), info["kind"].as_str()), (Some(DESATURATE_ID), Some("filter")));
|
||
let a = rect(&mut s, 10.0, "#ff0000");
|
||
let b = rect(&mut s, 40.0, "#0000ff");
|
||
run(&mut s, "select.all", json!({}));
|
||
let undo = s.doc().unwrap().history.undo.len();
|
||
let r = run(&mut s, "plugin.run", json!({"id": DESATURATE_ID, "params": {"amount": 100}}));
|
||
assert_eq!(r["ids"], json!([a.0, b.0]));
|
||
for id in [a, b] {
|
||
let [r, g, b] = node(&s, id).appearance.fill_paint().color().unwrap().to_rgb_uncalibrated();
|
||
assert!((r - g).abs() < 1e-6 && (g - b).abs() < 1e-6, "grey: {r} {g} {b}");
|
||
}
|
||
let st = s.doc().unwrap();
|
||
assert_eq!(st.history.undo.len(), undo + 1);
|
||
assert_eq!(st.history.undo.last().unwrap().label, "Desaturate");
|
||
assert_eq!(st.selection.objects, [a, b]);
|
||
run(&mut s, "edit.undo", json!({}));
|
||
assert_eq!(node(&s, a).appearance.fill_paint(), Paint::solid(Color::rgb(1.0, 0.0, 0.0)));
|
||
// Plug-in parameters may also be top-level keys; wrong types are refused before it runs.
|
||
run(&mut s, "plugin.run", json!({"id": DESATURATE_ID, "amount": 0}));
|
||
assert_eq!(node(&s, a).appearance.fill_paint(), Paint::solid(Color::rgb(1.0, 0.0, 0.0)));
|
||
assert!(matches!(s.execute("plugin.run", &json!({"id": DESATURATE_ID, "amount": "all"})), Err(EngineError::BadParams { .. })));
|
||
}
|
||
|
||
#[test]
|
||
fn object_filters_add_replace_and_generate_objects() {
|
||
let mut s = session();
|
||
let a = rect(&mut s, 10.0, "#ff0000");
|
||
let b = rect(&mut s, 40.0, "#00ff00");
|
||
run(&mut s, "select.all", json!({}));
|
||
// Keeps A, drops B and adds a triangle above A.
|
||
let out =
|
||
json!({"objects": [{"id": a.0}, {"path": triangle(), "fills": [{"type": "solid", "color": {"model": "gray", "k": 1}}], "name": "Tri"}]});
|
||
install_wat(&mut s, &tpl::returning(&tpl::manifest("test.engine.replace", "filter", "{}"), &out.to_string()));
|
||
let r = run(&mut s, "plugin.run", json!({"id": "test.engine.replace"}));
|
||
let ids: Vec<NodeId> = r["ids"].as_array().unwrap().iter().map(|v| NodeId(v.as_u64().unwrap())).collect();
|
||
assert_eq!(ids[0], a);
|
||
let st = s.doc().unwrap();
|
||
assert!(st.doc.node(b).is_none());
|
||
let layer: Vec<NodeId> = st.doc.layers[0].children().unwrap().iter().map(|n| n.id).collect();
|
||
assert_eq!(layer, [a, ids[1]]);
|
||
assert_eq!(st.doc.node(ids[1]).unwrap().name.as_deref(), Some("Tri"));
|
||
assert_eq!(st.selection.objects, ids);
|
||
// A generator with nothing selected adds to the current layer.
|
||
let generated = json!({"objects": [{"path": triangle()}]});
|
||
install_wat(&mut s, &tpl::returning(&tpl::manifest("test.engine.generate", "filter", "{}"), &generated.to_string()));
|
||
run(&mut s, "select.none", json!({}));
|
||
let r = run(&mut s, "plugin.run", json!({"id": "test.engine.generate"}));
|
||
let made = NodeId(r["ids"][0].as_u64().unwrap());
|
||
assert_eq!(s.doc().unwrap().doc.layers[0].children().unwrap().last().unwrap().id, made);
|
||
}
|
||
|
||
/// A generator can make guides and lock what it adds, in its one undo step; those objects are left
|
||
/// out of the selection that follows, as Make Guides and Object › Lock leave them.
|
||
#[test]
|
||
fn filters_make_guides_and_locked_objects_left_unselected() {
|
||
let mut s = session();
|
||
let out = json!({"objects": [
|
||
{"path": triangle(), "name": "Guide", "guide": true},
|
||
{"path": triangle(), "name": "Locked", "locked": true},
|
||
{"path": triangle(), "name": "Plain"}]});
|
||
install_wat(&mut s, &tpl::returning(&tpl::manifest("test.engine.guides", "filter", "{}"), &out.to_string()));
|
||
let undo = s.doc().unwrap().history.undo.len();
|
||
let r = run(&mut s, "plugin.run", json!({"id": "test.engine.guides"}));
|
||
let ids: Vec<NodeId> = r["ids"].as_array().unwrap().iter().map(|v| NodeId(v.as_u64().unwrap())).collect();
|
||
assert!(matches!(node(&s, ids[0]).kind, vectorcraft_doc::NodeKind::Path { guide: true, .. }));
|
||
assert!(node(&s, ids[1]).locked);
|
||
let st = s.doc().unwrap();
|
||
assert_eq!(st.selection.objects, [ids[2]]);
|
||
assert_eq!(st.history.undo.len(), undo + 1);
|
||
}
|
||
|
||
#[test]
|
||
fn failing_plugins_leave_the_document_and_history_alone() {
|
||
let mut s = session();
|
||
rect(&mut s, 10.0, "#ff0000");
|
||
run(&mut s, "select.all", json!({}));
|
||
let man = |id: &str| tpl::manifest(id, "filter", "{}");
|
||
install_wat(&mut s, &tpl::module(&man("test.engine.trap"), "unreachable", "", 1));
|
||
install_wat(&mut s, &tpl::returning(&man("test.engine.far"), r#"{"objects":[{"path":{"subpaths":[{"anchors":[{"p":[0,0]},{"p":[1e9,0]}]}]}}]}"#));
|
||
install_wat(&mut s, &tpl::returning(&man("test.engine.declines"), r#"{"error":"Select two paths."}"#));
|
||
let before = s.doc().unwrap().doc.clone();
|
||
let undo = s.doc().unwrap().history.undo.len();
|
||
for id in ["test.engine.trap", "test.engine.far", "test.engine.declines", "test.engine.missing"] {
|
||
let e = s.execute("plugin.run", &json!({"id": id})).unwrap_err();
|
||
if id == "test.engine.declines" {
|
||
assert!(e.to_string().contains("Select two paths."), "{e}");
|
||
}
|
||
assert_eq!(s.doc().unwrap().doc, before, "{id}");
|
||
assert_eq!(s.doc().unwrap().history.undo.len(), undo, "{id}");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn live_effect_plugins_reshape_editable_art_and_expand() {
|
||
let mut s = session();
|
||
let a = rect(&mut s, 10.0, "#ff0000");
|
||
// An effect that draws a triangle, and one that hands the geometry back.
|
||
let tri = json!({"objects": [{"path": triangle()}]});
|
||
install_wat(
|
||
&mut s,
|
||
&tpl::returning(&tpl::manifest("test.engine.tri", "effect", r#"{"size":{"type":"number","min":0,"max":10,"default":3}}"#), &tri.to_string()),
|
||
);
|
||
install_wat(&mut s, &tpl::module(&tpl::manifest("test.engine.echo", "effect", "{}"), tpl::ECHO, "", 1));
|
||
let catalog = run(&mut s, "effect.list", json!({}))["catalog"].clone();
|
||
let entry = catalog.as_array().unwrap().iter().find(|e| e["id"] == "plugin.test.engine.tri").expect("listed");
|
||
assert_eq!((entry["label"].as_str(), entry["menu"].clone()), (Some("Test test.engine.tri…"), json!(["Effect", "Plug-ins"])));
|
||
assert_eq!(entry["defaults"], json!({"size": 3.0}));
|
||
// A filter can't be applied as an effect, nor an effect run as a filter.
|
||
install(&mut s, DESATURATE);
|
||
assert!(s.execute("effect.apply", &json!({"ids": [a.0], "effect": format!("plugin.{DESATURATE_ID}")})).is_err());
|
||
assert!(s.execute("plugin.run", &json!({"id": "test.engine.tri"})).is_err());
|
||
|
||
let effected = |s: &Session| {
|
||
let n = node(s, a);
|
||
let path = n.path_data().unwrap().clone();
|
||
let b = path.bounds().unwrap();
|
||
vectorcraft_render::effects::apply_geometry(&n.appearance.effects, &path, b)
|
||
};
|
||
run(&mut s, "effect.apply", json!({"ids": [a.0], "effect": "plugin.test.engine.echo"}));
|
||
assert_eq!(effected(&s), *node(&s, a).path_data().unwrap(), "identity");
|
||
// Re-evaluated when the object changes (non-destructive).
|
||
run(&mut s, "select.all", json!({}));
|
||
run(&mut s, "object.move", json!({"dx": 50, "dy": 0}));
|
||
assert_eq!(effected(&s), *node(&s, a).path_data().unwrap());
|
||
run(&mut s, "effect.apply", json!({"ids": [a.0], "effect": "plugin.test.engine.tri", "params": {"size": 4}}));
|
||
assert_eq!(effected(&s), triangle());
|
||
assert_eq!(node(&s, a).appearance.effects[1].params, json!({"size": 4}));
|
||
// Uninstalled: the geometry is drawn as it is.
|
||
run(&mut s, "plugin.remove", json!({"id": "test.engine.tri"}));
|
||
assert_eq!(effected(&s), *node(&s, a).path_data().unwrap());
|
||
install_wat(&mut s, &tpl::returning(&tpl::manifest("test.engine.tri", "effect", "{}"), &tri.to_string()));
|
||
// Expand Appearance bakes it.
|
||
run(&mut s, "select.all", json!({}));
|
||
run(&mut s, "effect.expandAppearance", json!({}));
|
||
let mut baked = None;
|
||
s.doc().unwrap().doc.walk(|n| {
|
||
if let Some(p) = n.path_data() {
|
||
baked = Some(p.clone());
|
||
}
|
||
});
|
||
assert_eq!(baked.unwrap().bounds(), triangle().bounds());
|
||
}
|
||
|
||
/// A live effect is given its object's fills and strokes, read only, and which of them it sits on;
|
||
/// a change of paint runs it again.
|
||
#[test]
|
||
fn live_effects_are_given_their_object_s_paints() {
|
||
let mut s = session();
|
||
let id = rect(&mut s, 10.0, "#ff0000");
|
||
run(&mut s, "paint.setStroke", json!({"ids": [id.0], "color": "#000000"}));
|
||
run(&mut s, "stroke.set", json!({"ids": [id.0], "weight": 5}));
|
||
// The red fill as the host writes paints: a JSON value, its keys sorted.
|
||
let red = serde_json::to_value(&node(&s, id).appearance.fill().unwrap().paint).unwrap().to_string();
|
||
let tri = json!({"objects": [{"path": triangle()}]}).to_string();
|
||
// Each draws the triangle while it is given what it looks for, else hands the geometry back.
|
||
let effect = |id: &str, given, needle: &str| tpl::when_given(&tpl::manifest(id, "effect", "{}"), given, needle, &tri);
|
||
install_wat(&mut s, &effect("test.engine.wide", tpl::Given::Input, r#""width":5.0"#));
|
||
install_wat(&mut s, &effect("test.engine.red", tpl::Given::Input, &format!(r#""fills":[{red}]"#)));
|
||
install_wat(&mut s, &effect("test.engine.second", tpl::Given::Params, r#""item":{"stroke":1}"#));
|
||
let effected = |s: &Session, item: Option<usize>| {
|
||
let n = node(s, id);
|
||
let path = n.path_data().unwrap().clone();
|
||
let ctx = vectorcraft_render::effects::GeomContext::of(&n);
|
||
let (effects, ctx) = match item {
|
||
Some(i) => match &n.appearance.items[i] {
|
||
vectorcraft_doc::AppearanceItem::Stroke(st) => (st.effects.clone(), ctx.item(&n.appearance.items[i])),
|
||
other => panic!("not a stroke: {other:?}"),
|
||
},
|
||
None => (n.appearance.effects.clone(), ctx),
|
||
};
|
||
vectorcraft_render::effects::apply_geometry_with(&effects, &path, path.bounds().unwrap(), &ctx)
|
||
};
|
||
let own = node(&s, id).path_data().unwrap().clone();
|
||
let remove = |s: &mut Session| run(s, "effect.remove", json!({"ids": [id.0], "index": 0, "item": null}));
|
||
for effect in ["plugin.test.engine.wide", "plugin.test.engine.red"] {
|
||
run(&mut s, "effect.apply", json!({"ids": [id.0], "effect": effect, "item": null}));
|
||
assert_eq!(effected(&s, None), triangle(), "{effect}: given the 5 pt stroke and the red fill");
|
||
remove(&mut s);
|
||
}
|
||
// Run again once the stroke is thinner.
|
||
run(&mut s, "effect.apply", json!({"ids": [id.0], "effect": "plugin.test.engine.wide", "item": null}));
|
||
run(&mut s, "stroke.set", json!({"ids": [id.0], "weight": 3, "item": 1}));
|
||
assert_eq!(effected(&s, None), own, "a 3 pt stroke");
|
||
remove(&mut s);
|
||
// On the second of two strokes, and not on the first.
|
||
run(&mut s, "appearance.addStroke", json!({"ids": [id.0]}));
|
||
let top = node(&s, id).appearance.items.len() - 1;
|
||
for item in [1, top] {
|
||
run(&mut s, "effect.apply", json!({"ids": [id.0], "effect": "plugin.test.engine.second", "item": item}));
|
||
}
|
||
assert_eq!(effected(&s, Some(1)), own, "the first stroke");
|
||
assert_eq!(effected(&s, Some(top)), triangle(), "the second stroke");
|
||
}
|
||
|
||
/// Expand gives a plug-in effect on a fill the context drawing gives it (`_context.item`), so a
|
||
/// gradient fill is expanded into the shape the canvas fills.
|
||
#[test]
|
||
fn expanding_a_fill_gives_its_plug_in_effect_the_fill_it_sits_on() {
|
||
let mut s = session();
|
||
let id = rect(&mut s, 10.0, "#ff0000");
|
||
run(&mut s, "paint.setFill", json!({"ids": [id.0], "gradient": {"kind": "linear"}}));
|
||
let tri = json!({"objects": [{"path": triangle()}]}).to_string();
|
||
let on_fill = tpl::when_given(&tpl::manifest("test.engine.onfill", "effect", "{}"), tpl::Given::Params, r#""item":{"fill":0}"#, &tri);
|
||
install_wat(&mut s, &on_fill);
|
||
let fill = node(&s, id).appearance.items.iter().position(|i| i.is_fill()).unwrap();
|
||
run(&mut s, "effect.apply", json!({"ids": [id.0], "effect": "plugin.test.engine.onfill", "item": fill}));
|
||
// Drawn, the gradient fills the triangle: a point of it outside the rectangle is painted.
|
||
let opts = vectorcraft_render::RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() };
|
||
let drawn = vectorcraft_render::Renderer::new().render(&s.doc().unwrap().doc, 40, 40, vectorcraft_geom::Affine::IDENTITY, &opts);
|
||
assert_ne!(drawn.pixel(15, 1), [255, 255, 255, 255], "drawn as a triangle");
|
||
run(&mut s, "select.set", json!({"ids": [id.0]}));
|
||
run(&mut s, "object.expand", json!({"steps": 2}));
|
||
// The gradient's strips sit in a clip group whose clipping path is the shape filled.
|
||
let group = s.doc().unwrap().selection.objects[0];
|
||
let clip = node(&s, group).children().unwrap()[0].clone();
|
||
assert_eq!(clip.path_data().unwrap().bounds(), triangle().bounds(), "the triangle the canvas fills");
|
||
}
|
||
|
||
/// `CRAFT_PLUGIN_PAINTS_WASM=<folder>` saves a test live effect to try in the app
|
||
/// (`stroke-width-test-effect.wasm`): it draws a triangle over the area 100,80–300,230 while its
|
||
/// object's stroke is 5 pt wide, and hands the geometry back otherwise.
|
||
#[test]
|
||
fn stroke_width_test_effect() {
|
||
let Some(dir) = std::env::var_os("CRAFT_PLUGIN_PAINTS_WASM") else { return };
|
||
let triangle = PathData::single(SubPath::polyline(&[Point::new(100.0, 230.0), Point::new(300.0, 230.0), Point::new(200.0, 80.0)], true));
|
||
let manifest = r#"{"id":"org.example.strokewidth","name":"Stroke Width (test plug-in)","version":"1","kind":"effect",
|
||
"description":"Draws a triangle while the object's stroke is 5 pt wide.","params":{}}"#;
|
||
let src = tpl::when_given(manifest, tpl::Given::Input, r#""width":5.0"#, &json!({"objects": [{"path": triangle}]}).to_string());
|
||
std::fs::write(std::path::Path::new(&dir).join("stroke-width-test-effect.wasm"), wat::parse_str(src).unwrap()).unwrap();
|
||
}
|
||
|
||
/// A filter is given its objects' effect records and their strokes' join, cap and miter limit, and
|
||
/// may return them. The records are admitted as effect.apply would admit them; one that isn't
|
||
/// refuses the whole output, and nothing changes.
|
||
#[test]
|
||
fn filters_set_effects_and_stroke_styles() {
|
||
let mut s = session();
|
||
let id = rect(&mut s, 10.0, "#ff0000");
|
||
install_wat(
|
||
&mut s,
|
||
&tpl::module(&tpl::manifest("test.engine.fx", "effect", r#"{"n":{"type":"int","min":0,"max":3,"default":1}}"#), tpl::ECHO, "", 1),
|
||
);
|
||
let black = json!({"type": "solid", "color": {"model": "gray", "k": 1.0}});
|
||
let out = |fx: Value| json!({"objects": [{"id": id.0, "strokes": [{"paint": black, "width": 4, "join": "round", "cap": "square", "miterLimit": 4}], "effects": fx}]});
|
||
let filter = |s: &mut Session, name: &str, fx: Value| {
|
||
install_wat(s, &tpl::returning(&tpl::manifest(name, "filter", "{}"), &out(fx).to_string()));
|
||
s.execute("plugin.run", &json!({"id": name, "ids": [id.0]}))
|
||
};
|
||
let fx = json!([{"id": "distort.roughen", "params": {"size": 3}}, {"id": "plugin.test.engine.fx", "params": {"n": 2}, "visible": false}]);
|
||
filter(&mut s, "test.engine.setfx", fx).unwrap();
|
||
let n = node(&s, id);
|
||
let st = n.appearance.stroke().unwrap();
|
||
assert_eq!((st.width, st.join, st.cap, st.miter_limit), (4.0, vectorcraft_doc::LineJoin::Round, vectorcraft_doc::LineCap::Square, 4.0));
|
||
let effects = &n.appearance.effects;
|
||
assert_eq!(effects.iter().map(|e| (e.id.as_str(), e.visible)).collect::<Vec<_>>(), [("distort.roughen", true), ("plugin.test.engine.fx", false)]);
|
||
assert_eq!(effects[0].params, vectorcraft_render::effects::merged_params("distort.roughen", &json!({"size": 3})), "filled in");
|
||
assert_eq!(effects[1].params, json!({"n": 2}));
|
||
// Handed back unchanged (an echo), nothing changes, not even the records' parameters.
|
||
install_wat(&mut s, &tpl::module(&tpl::manifest("test.engine.same", "filter", "{}"), tpl::ECHO, "", 1));
|
||
let before = s.doc().unwrap().doc.clone();
|
||
run(&mut s, "plugin.run", json!({"id": "test.engine.same", "ids": [id.0]}));
|
||
assert_eq!(s.doc().unwrap().doc, before);
|
||
// Refused: what effect.apply refuses, a live effect that isn't installed, parameters its
|
||
// manifest refuses, a texture to read from a file, two 3D effects.
|
||
for (name, fx, why) in [
|
||
("test.engine.unknown", json!([{"id": "distort.nothing"}]), "no such effect"),
|
||
("test.engine.key", json!([{"id": "distort.roughen", "params": {"bogus": 1}}]), "bogus"),
|
||
("test.engine.gone", json!([{"id": "plugin.test.engine.gone"}]), "no such effect"),
|
||
("test.engine.badn", json!([{"id": "plugin.test.engine.fx", "params": {"n": "two"}}]), "`n`"),
|
||
("test.engine.file", json!([{"id": "distort.glass", "params": {"textureImage": {"path": "/etc/hosts"}}}]), "textureImage"),
|
||
("test.engine.file2", json!([{"id": "texture.texturizer", "params": {"textureImage": {"path": "/etc/hosts"}}}]), "textureImage"),
|
||
("test.engine.two3d", json!([{"id": "threeD.extrude"}, {"id": "threeD.extrude"}]), "one 3D effect"),
|
||
] {
|
||
let e = filter(&mut s, name, fx).unwrap_err().to_string();
|
||
assert!(e.contains("objects[0].effects") && e.contains(why), "{name}: {e}");
|
||
assert_eq!(s.doc().unwrap().doc, before, "{name}");
|
||
}
|
||
}
|
||
|
||
/// `CRAFT_PLUGIN_FRAME_WASM=<folder>` saves a test filter to try in the app
|
||
/// (`rough-frame-test-filter.wasm`): it adds a frame around 60,40–340,260 with a 6 pt stroke with
|
||
/// round joins and caps, roughened by the Roughen effect.
|
||
#[test]
|
||
fn rough_frame_test_filter() {
|
||
let Some(dir) = std::env::var_os("CRAFT_PLUGIN_FRAME_WASM") else { return };
|
||
let frame = PathData::single(SubPath::polyline(
|
||
&[Point::new(60.0, 40.0), Point::new(340.0, 40.0), Point::new(340.0, 260.0), Point::new(60.0, 260.0)],
|
||
true,
|
||
));
|
||
let ink = json!({"type": "solid", "color": {"model": "rgb", "r": 0.15, "g": 0.2, "b": 0.45}});
|
||
let out = json!({"objects": [{"name": "Frame", "path": frame, "fills": [],
|
||
"strokes": [{"paint": ink, "width": 6, "join": "round", "cap": "round"}],
|
||
"effects": [{"id": "distort.roughen", "params": {"size": 2, "detail": 8, "points": "smooth"}}]}]});
|
||
let manifest = r#"{"id":"org.example.frame","name":"Rough Frame (test plug-in)","version":"1","kind":"filter",
|
||
"description":"Adds a frame whose stroke is roughened by the Roughen effect.","params":{}}"#;
|
||
let src = tpl::returning(manifest, &out.to_string());
|
||
std::fs::write(std::path::Path::new(&dir).join("rough-frame-test-filter.wasm"), wat::parse_str(src).unwrap()).unwrap();
|
||
}
|
||
|
||
/// A filter keeps data with the document: it writes it with `documentData` in the same undo step as
|
||
/// its objects (`null` removes it) and reads what it last wrote in `_context.documentData`, which
|
||
/// no other filter is given. It is the document's foreign data, so the native file keeps it with
|
||
/// no section of its own. More than the limit is refused.
|
||
#[test]
|
||
fn filters_keep_their_data_with_the_document() {
|
||
let mut s = session();
|
||
let id = rect(&mut s, 10.0, "#ff0000");
|
||
let pid = "test.engine.data";
|
||
let returning = |out: Value| tpl::returning(&tpl::manifest(pid, "filter", "{}"), &out.to_string());
|
||
let keep = |data: Value| json!({"objects": [{"id": id.0}], "documentData": data});
|
||
let data = |s: &Session| s.doc().unwrap().doc.plugin_data(pid).cloned();
|
||
let filter = |s: &mut Session, pid: &str| s.execute("plugin.run", &json!({"id": pid, "ids": [id.0]}));
|
||
install_wat(&mut s, &returning(keep(json!({"pen": 2}))));
|
||
filter(&mut s, pid).unwrap();
|
||
assert_eq!(data(&s), Some(json!({"pen": 2})));
|
||
run(&mut s, "edit.undo", json!({}));
|
||
assert_eq!(data(&s), None, "one undo step with the objects");
|
||
run(&mut s, "edit.redo", json!({}));
|
||
// Read back: these name the object only when they are given what they look for.
|
||
let named = |name: &str| json!({"objects": [{"id": id.0, "name": name}]}).to_string();
|
||
let when_given =
|
||
|pid: &str, needle: &str, name: &str| tpl::when_given(&tpl::manifest(pid, "filter", "{}"), tpl::Given::Params, needle, &named(name));
|
||
install_wat(&mut s, &when_given(pid, r#""documentData":{"pen":2}"#, "Pen 2"));
|
||
filter(&mut s, pid).unwrap();
|
||
assert_eq!(node(&s, id).name.as_deref(), Some("Pen 2"));
|
||
// Another filter is given nothing: what a filter keeps is its own.
|
||
let other = "test.engine.other";
|
||
install_wat(&mut s, &when_given(other, r#""documentData":null"#, "Nothing kept"));
|
||
filter(&mut s, other).unwrap();
|
||
assert_eq!(node(&s, id).name.as_deref(), Some("Nothing kept"));
|
||
// Saved with the document, among its foreign data.
|
||
let saved = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap();
|
||
assert_eq!(saved.plugin_data(pid), Some(&json!({"pen": 2})));
|
||
assert_eq!(saved.unknown.get(vectorcraft_doc::plugin_data::PLUGIN_DATA), Some(&json!({pid: {"pen": 2}})));
|
||
// Too much is refused, and the document stays as it was; null removes the data.
|
||
let before = s.doc().unwrap().doc.clone();
|
||
let big = "x".repeat(vectorcraft_plugins::objects::MAX_DOCUMENT_DATA);
|
||
install_wat(&mut s, &returning(keep(json!(big))));
|
||
let e = filter(&mut s, pid).unwrap_err().to_string();
|
||
assert!(e.contains("documentData"), "{e}");
|
||
assert_eq!(s.doc().unwrap().doc, before);
|
||
install_wat(&mut s, &returning(keep(Value::Null)));
|
||
filter(&mut s, pid).unwrap();
|
||
assert_eq!(data(&s), None);
|
||
}
|
||
|
||
#[test]
|
||
fn plugin_management_commands() {
|
||
let mut s = session();
|
||
install(&mut s, DESATURATE);
|
||
let list = run(&mut s, "plugin.list", json!({}));
|
||
assert!(list["plugins"].as_array().unwrap().iter().any(|p| p["id"] == DESATURATE_ID));
|
||
let info = run(&mut s, "plugin.info", json!({"id": DESATURATE_ID}));
|
||
assert_eq!(info["defaults"], json!({"amount": 100.0}));
|
||
assert_eq!(info["params"], "{amount: 0..100 (100)}");
|
||
assert_eq!(info["manifest"]["kind"], "filter");
|
||
// Refused installs.
|
||
for bad in [
|
||
json!({}),
|
||
json!({"dataBase64": "!!"}),
|
||
json!({"dataBase64": "AGFzbQ=="}),
|
||
json!({"dataBase64": vectorcraft_format::base64_encode(DESATURATE), "replace": false}),
|
||
] {
|
||
assert!(s.execute("plugin.install", &bad).is_err(), "{bad}");
|
||
}
|
||
assert!(s.execute("plugin.info", &json!({"id": "test.engine.nothing"})).is_err());
|
||
assert!(s.execute("plugin.remove", &json!({"id": "test.engine.nothing"})).is_err());
|
||
assert!(s.execute("plugin.reload", &json!({})).is_err(), "no folder set");
|
||
|
||
// The Additional Plug-ins Folder preference installs what's in it.
|
||
let dir = std::env::temp_dir().join(format!("vectorcraft-engine-plugins-{}", std::process::id()));
|
||
std::fs::create_dir_all(&dir).unwrap();
|
||
let src = tpl::returning(&tpl::manifest("test.engine.folder", "filter", "{}"), r#"{"objects":[]}"#);
|
||
std::fs::write(dir.join("folder.wasm"), wat::parse_str(src).unwrap()).unwrap();
|
||
std::fs::write(dir.join("broken.wasm"), b"\0asm?").unwrap();
|
||
run(&mut s, "prefs.set", json!({"key": "pluginsFolder", "value": dir.display().to_string()}));
|
||
assert!(vectorcraft_plugins::registry::get("test.engine.folder").is_some());
|
||
run(&mut s, "plugin.remove", json!({"id": "test.engine.folder"}));
|
||
let r = run(&mut s, "plugin.reload", json!({}));
|
||
assert_eq!(r["loaded"], json!(["test.engine.folder"]));
|
||
assert_eq!(r["failed"].as_array().unwrap().len(), 1);
|
||
assert_eq!(run(&mut s, "plugin.list", json!({}))["folder"]["loaded"], json!(["test.engine.folder"]));
|
||
std::fs::remove_dir_all(&dir).ok();
|
||
}
|
||
|
||
/// #2541: a plug-in installed in a session with a plug-in store is installed again in the next
|
||
/// session that attaches the store, and Remove Plug-in takes it out of the store.
|
||
#[test]
|
||
fn installed_plugins_are_kept_in_the_store() {
|
||
let folder = crate::tests_links::Folder::new("plugin-store");
|
||
let id = "test.engine.stored";
|
||
let src = tpl::returning(&tpl::manifest(id, "filter", "{}"), r#"{"objects":[]}"#);
|
||
let mut s = session();
|
||
s.attach_plugin_store(folder.0.clone());
|
||
install_wat(&mut s, &src);
|
||
assert_eq!(std::fs::read_dir(&folder.0).unwrap().count(), 1, "one file in the store");
|
||
vectorcraft_plugins::registry::remove(id);
|
||
|
||
let mut next = session();
|
||
next.attach_plugin_store(folder.0.clone());
|
||
assert!(vectorcraft_plugins::registry::get(id).is_some(), "installed again from the store");
|
||
run(&mut next, "plugin.remove", json!({"id": id}));
|
||
assert_eq!(std::fs::read_dir(&folder.0).unwrap().count(), 0, "removed from the store");
|
||
|
||
// A file that isn't a plug-in is skipped; the store still attaches.
|
||
std::fs::write(folder.0.join("broken.wasm"), b"\0asm?").unwrap();
|
||
let mut third = session();
|
||
third.attach_plugin_store(folder.0.clone());
|
||
assert!(vectorcraft_plugins::registry::get(id).is_none());
|
||
}
|
||
|
||
/// A heavy test filter `id` that counts `steps` down, then adds a triangle named "Plug-in".
|
||
fn heavy(id: &str, steps: u64) -> Vec<u8> {
|
||
let manifest = format!(r#"{{"id":"{id}","name":"Test {id}","version":"1","kind":"filter","budget":"heavy"}}"#);
|
||
let output = json!({"objects": [{"path": triangle(), "name": "Plug-in"}]});
|
||
wat::parse_str(tpl::spinning(&manifest, steps, &output.to_string())).unwrap()
|
||
}
|
||
|
||
/// A filter that asks for a heavy budget installs only once the user agrees (`allowHeavy`);
|
||
/// `plugin.inspect` tells whoever installs it so beforehand, and installs nothing.
|
||
#[test]
|
||
fn a_heavy_filter_installs_once_the_user_agrees() {
|
||
let mut s = session();
|
||
let id = "test.engine.heavy";
|
||
let data = vectorcraft_format::base64_encode(&heavy(id, 1));
|
||
// With the file's name, as the app passes it.
|
||
let inspected = run(&mut s, "plugin.inspect", json!({"dataBase64": data, "name": "route.wasm"}));
|
||
assert_eq!((inspected["id"].as_str(), inspected["budget"].as_str()), (Some(id), Some("heavy")));
|
||
assert_eq!(inspected["source"], "route.wasm");
|
||
assert!(vectorcraft_plugins::registry::get(id).is_none(), "inspecting installs nothing");
|
||
let e = s.execute("plugin.install", &json!({"dataBase64": data})).unwrap_err().to_string();
|
||
assert!(e.contains("heavy budget") && e.contains("5 minutes") && e.contains("allowHeavy"), "{e}");
|
||
assert!(vectorcraft_plugins::registry::get(id).is_none());
|
||
let info = run(&mut s, "plugin.install", json!({"dataBase64": data, "allowHeavy": true}));
|
||
assert_eq!(info["budget"], "heavy");
|
||
assert_eq!(vectorcraft_plugins::registry::get(id).unwrap().limits().fuel_base, vectorcraft_plugins::HEAVY_FUEL);
|
||
let listed = run(&mut s, "plugin.list", json!({}));
|
||
assert!(listed["plugins"].as_array().unwrap().iter().any(|p| p["id"] == id && p["budget"] == "heavy"));
|
||
// A light plug-in's entry says nothing of budgets.
|
||
assert!(install(&mut s, DESATURATE).get("budget").is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn invalid_install_flags_are_refused() {
|
||
let mut s = session();
|
||
let id = "test.engine.heavy.flags";
|
||
let data = vectorcraft_format::base64_encode(&heavy(id, 1));
|
||
for allow in [json!("true"), json!(1)] {
|
||
let e = s.execute("plugin.install", &json!({"dataBase64": data, "allowHeavy": allow})).unwrap_err().to_string();
|
||
assert!(e.contains("`allowHeavy` must be true or false"), "allowHeavy {allow}: {e}");
|
||
assert!(vectorcraft_plugins::registry::get(id).is_none(), "installed with allowHeavy {allow}");
|
||
}
|
||
// Null, as for every command, is the same as leaving the flag out.
|
||
let e = s.execute("plugin.install", &json!({"dataBase64": data, "allowHeavy": null})).unwrap_err().to_string();
|
||
assert!(e.contains("heavy budget"), "{e}");
|
||
run(&mut s, "plugin.install", json!({"dataBase64": data, "allowHeavy": true}));
|
||
for replace in [json!("false"), json!(0)] {
|
||
let e = s.execute("plugin.install", &json!({"dataBase64": data, "allowHeavy": true, "replace": replace})).unwrap_err().to_string();
|
||
assert!(e.contains("`replace` must be true or false"), "replace {replace}: {e}");
|
||
}
|
||
run(&mut s, "plugin.install", json!({"dataBase64": data, "allowHeavy": true, "replace": null}));
|
||
assert!(vectorcraft_plugins::registry::get(id).is_some());
|
||
// A plug-in with the usual budget is refused too.
|
||
let light = "test.engine.light.flags";
|
||
let manifest = format!(r#"{{"id":"{light}","name":"Test {light}","version":"1","kind":"filter"}}"#);
|
||
let output = json!({"objects": [{"path": triangle(), "name": "Plug-in"}]});
|
||
let data = vectorcraft_format::base64_encode(&wat::parse_str(tpl::spinning(&manifest, 1, &output.to_string())).unwrap());
|
||
let e = s.execute("plugin.install", &json!({"dataBase64": data, "allowHeavy": "false"})).unwrap_err().to_string();
|
||
assert!(e.contains("`allowHeavy` must be true or false"), "{e}");
|
||
assert!(vectorcraft_plugins::registry::get(light).is_none());
|
||
}
|
||
|
||
/// A test filter `id` (named "Test `id`") whose manifest lists `permissions` (a JSON list) and that
|
||
/// returns `output`.
|
||
fn asking(id: &str, permissions: &str, output: &Value) -> Vec<u8> {
|
||
let manifest = format!(r#"{{"id":"{id}","name":"Test {id}","version":"2.1","kind":"filter","permissions":{permissions}}}"#);
|
||
wat::parse_str(tpl::returning(&manifest, &output.to_string())).unwrap()
|
||
}
|
||
|
||
/// A plug-in whose manifest lists permissions installs only once they are all granted (`grant`),
|
||
/// which `plugin.inspect` tells beforehand (`needsApproval`) with the permissions and the module's
|
||
/// hash. The grant is recorded with that hash and covers only that module: one with other bytes
|
||
/// needs a new grant. Removing it forgets the grant. A plug-in without `permissions` installs as
|
||
/// before, with the ones its kind implies.
|
||
#[test]
|
||
fn a_plugin_that_lists_permissions_installs_once_they_are_granted() {
|
||
let mut s = session();
|
||
let id = "test.engine.permissions.install";
|
||
let empty = json!({"objects": []});
|
||
let data = |perms: &str| vectorcraft_format::base64_encode(&asking(id, perms, &empty));
|
||
let reads = data(r#"["doc.read"]"#);
|
||
let inspected = run(&mut s, "plugin.inspect", json!({"dataBase64": reads}));
|
||
assert_eq!((&inspected["needsApproval"], &inspected["permissions"]), (&json!(true), &json!(["doc.read"])));
|
||
let hash = vectorcraft_plugins::permissions::sha256_hex(&asking(id, r#"["doc.read"]"#, &empty));
|
||
assert_eq!(inspected["hash"], hash);
|
||
assert!(vectorcraft_plugins::registry::get(id).is_none(), "inspecting installs nothing");
|
||
// Refused without a grant that holds every permission it lists, naming them.
|
||
for (grant, says) in [
|
||
(Value::Null, "asks for the permissions doc.read"),
|
||
(json!([]), "asks for the permissions doc.read"),
|
||
(json!(["doc.write"]), "asks for the permissions doc.read"),
|
||
(json!(["doc.read", "net.fetch"]), "unknown permission `net.fetch`"),
|
||
(json!("doc.read"), "`grant` must be a list"),
|
||
(json!([1]), "`grant` must be a list"),
|
||
] {
|
||
let e = s.execute("plugin.install", &json!({"dataBase64": reads, "grant": grant})).unwrap_err().to_string();
|
||
assert!(e.contains(says), "grant {grant}: {e}");
|
||
assert!(vectorcraft_plugins::registry::get(id).is_none(), "installed with grant {grant}");
|
||
}
|
||
// `hash` pins the module the user was shown.
|
||
for wrong in [json!("0".repeat(64)), json!(1)] {
|
||
let e = s.execute("plugin.install", &json!({"dataBase64": reads, "grant": ["doc.read"], "hash": wrong})).unwrap_err().to_string();
|
||
assert!(e.contains("SHA-256 `hash`"), "hash {wrong}: {e}");
|
||
assert!(vectorcraft_plugins::registry::get(id).is_none(), "installed with hash {wrong}");
|
||
}
|
||
let installed = run(&mut s, "plugin.install", json!({"dataBase64": reads, "grant": ["doc.read"], "hash": hash}));
|
||
assert_eq!(installed["permissions"], json!(["doc.read"]));
|
||
let g = vectorcraft_plugins::registry::granted(id).unwrap();
|
||
assert_eq!((g.hash.as_str(), g.permissions.as_slice()), (hash.as_str(), &[vectorcraft_plugins::permissions::Permission::DocRead][..]));
|
||
// Replaced with another module that lists the same permissions: granted again, the grant now
|
||
// records the new module's hash.
|
||
let other = asking(id, r#"["doc.read"]"#, &json!({"objects": [], "error": null}));
|
||
run(&mut s, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&other), "grant": ["doc.read"]}));
|
||
assert_eq!(vectorcraft_plugins::registry::granted(id).unwrap().hash, vectorcraft_plugins::permissions::sha256_hex(&other));
|
||
// One that asks for more is refused until it is granted all of them.
|
||
let more = data(r#"["doc.read","doc.write"]"#);
|
||
assert_eq!(run(&mut s, "plugin.inspect", json!({"dataBase64": more}))["needsApproval"], true);
|
||
let e = s.execute("plugin.install", &json!({"dataBase64": more})).unwrap_err().to_string();
|
||
assert!(e.contains("doc.read, doc.write") && e.contains("`grant`"), "{e}");
|
||
assert_eq!(vectorcraft_plugins::registry::get(id).unwrap().manifest().permissions().len(), 1, "the one installed stays");
|
||
run(&mut s, "plugin.install", json!({"dataBase64": more, "grant": ["doc.write", "doc.read", "state"]}));
|
||
assert_eq!(vectorcraft_plugins::registry::granted(id).unwrap().permissions.len(), 2, "only what it lists is recorded");
|
||
// Removed, it is asked again.
|
||
run(&mut s, "plugin.remove", json!({"id": id}));
|
||
assert!(vectorcraft_plugins::registry::granted(id).is_none());
|
||
assert_eq!(run(&mut s, "plugin.inspect", json!({"dataBase64": reads}))["needsApproval"], true);
|
||
// A plug-in without `permissions` installs as before, with its kind's.
|
||
let plain = install(&mut s, DESATURATE);
|
||
assert_eq!(plain["permissions"], json!(["doc.read", "doc.write", "state"]));
|
||
assert_eq!(run(&mut s, "plugin.inspect", json!({"dataBase64": vectorcraft_format::base64_encode(DESATURATE)}))["needsApproval"], false);
|
||
}
|
||
|
||
/// A module with other bytes under an id that was granted is not covered, even when it lists the
|
||
/// same permissions: `plugin.inspect` says so, `plugin.install` refuses it and leaves the installed
|
||
/// module and its grant alone, and a new `grant` installs it and records its hash. The same bytes
|
||
/// install again without asking, and a module that lists no permissions never asks.
|
||
#[test]
|
||
fn a_grant_covers_only_the_module_that_was_approved() {
|
||
use vectorcraft_plugins::permissions::sha256_hex;
|
||
let mut s = session();
|
||
let id = "test.engine.permissions.bytes";
|
||
let first = asking(id, r#"["doc.read"]"#, &json!({"objects": []}));
|
||
let changed = asking(id, r#"["doc.read"]"#, &json!({"objects": [], "error": null}));
|
||
let (first_data, changed_data) = (vectorcraft_format::base64_encode(&first), vectorcraft_format::base64_encode(&changed));
|
||
let (first_hash, changed_hash) = (sha256_hex(&first), sha256_hex(&changed));
|
||
run(&mut s, "plugin.install", json!({"dataBase64": first_data, "grant": ["doc.read"]}));
|
||
assert_eq!(run(&mut s, "plugin.inspect", json!({"dataBase64": changed_data}))["needsApproval"], true);
|
||
let e = s.execute("plugin.install", &json!({"dataBase64": changed_data})).unwrap_err().to_string();
|
||
assert!(e.contains("doc.read") && e.contains("`grant`"), "{e}");
|
||
let e = s.execute("plugin.install", &json!({"dataBase64": changed_data, "hash": changed_hash})).unwrap_err().to_string();
|
||
assert!(e.contains("`grant`"), "`hash` is not an approval: {e}");
|
||
assert_eq!(vectorcraft_plugins::registry::get(id).unwrap().hash(), first_hash, "the installed module stays");
|
||
assert_eq!(vectorcraft_plugins::registry::granted(id).unwrap().hash, first_hash, "its grant stays");
|
||
// The same bytes need no new grant.
|
||
assert_eq!(run(&mut s, "plugin.inspect", json!({"dataBase64": first_data}))["needsApproval"], false);
|
||
run(&mut s, "plugin.install", json!({"dataBase64": first_data}));
|
||
// A new grant installs the changed module, and the old bytes are no longer covered.
|
||
run(&mut s, "plugin.install", json!({"dataBase64": changed_data, "grant": ["doc.read"], "hash": changed_hash}));
|
||
assert_eq!(vectorcraft_plugins::registry::get(id).unwrap().hash(), changed_hash);
|
||
assert_eq!(vectorcraft_plugins::registry::granted(id).unwrap().hash, changed_hash);
|
||
assert_eq!(run(&mut s, "plugin.inspect", json!({"dataBase64": first_data}))["needsApproval"], true);
|
||
run(&mut s, "plugin.remove", json!({"id": id}));
|
||
|
||
// A manifest without a list, or with an empty one, asks for nothing, whatever its bytes.
|
||
let exempt = "test.engine.permissions.exempt";
|
||
for (permissions, out) in [("null", json!({"objects": []})), ("null", json!({"objects": [], "error": null})), ("[]", json!({"objects": []}))] {
|
||
let data = vectorcraft_format::base64_encode(&asking(exempt, permissions, &out));
|
||
assert_eq!(run(&mut s, "plugin.inspect", json!({"dataBase64": data}))["needsApproval"], false, "{permissions}");
|
||
run(&mut s, "plugin.install", json!({"dataBase64": data}));
|
||
}
|
||
run(&mut s, "plugin.remove", json!({"id": exempt}));
|
||
}
|
||
|
||
/// A stored module whose bytes changed is skipped at the next start and stays in the store with its
|
||
/// grant, and the original bytes load again. A stored grant whose hash is malformed covers nothing.
|
||
/// With a store attached, a matching grant held only by the process doesn't count.
|
||
#[test]
|
||
fn a_stored_grant_covers_only_the_stored_module_it_recorded() {
|
||
use vectorcraft_plugins::permissions::{Grant, Permission, sha256_hex};
|
||
let folder = crate::tests_links::Folder::new("plugin-store-grant-hash");
|
||
let id = "test.engine.permissions.storedbytes";
|
||
let first = asking(id, r#"["doc.read"]"#, &json!({"objects": []}));
|
||
let changed = asking(id, r#"["doc.read"]"#, &json!({"objects": [], "error": null}));
|
||
let mut s = session();
|
||
s.attach_plugin_store(folder.0.clone());
|
||
run(&mut s, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&first), "grant": ["doc.read"]}));
|
||
let grants = folder.0.join("grants.json");
|
||
let recorded = std::fs::read(&grants).unwrap();
|
||
let module = std::fs::read_dir(&folder.0)
|
||
.unwrap()
|
||
.map(|e| e.unwrap().path())
|
||
.find(|p| p.extension().is_some_and(|x| x == "wasm"))
|
||
.expect("the stored module");
|
||
let forget = || {
|
||
vectorcraft_plugins::registry::remove(id);
|
||
vectorcraft_plugins::registry::revoke(id);
|
||
};
|
||
let starts = || {
|
||
forget();
|
||
let mut next = session();
|
||
next.attach_plugin_store(folder.0.clone());
|
||
vectorcraft_plugins::registry::get(id).is_some()
|
||
};
|
||
assert!(starts(), "the module that was approved loads");
|
||
std::fs::write(&module, &changed).unwrap();
|
||
assert!(!starts(), "other bytes under the old grant are skipped");
|
||
assert_eq!(std::fs::read(&module).unwrap(), changed, "the module stays in the store");
|
||
assert_eq!(std::fs::read(&grants).unwrap(), recorded, "its grant stays");
|
||
std::fs::write(&module, &first).unwrap();
|
||
assert!(starts(), "the approved bytes load again");
|
||
|
||
// A malformed hash is no wildcard, even for the module on disk.
|
||
let upper = sha256_hex(&first).to_uppercase();
|
||
for hash in ["", "00", upper.as_str()] {
|
||
std::fs::write(&grants, serde_json::to_vec(&json!([{"id": id, "hash": hash, "permissions": ["doc.read"]}])).unwrap()).unwrap();
|
||
assert!(!starts(), "hash {hash:?}");
|
||
}
|
||
|
||
// The store's grants are the ones that count, even when the process holds one for these bytes.
|
||
std::fs::write(&grants, &recorded).unwrap();
|
||
assert!(starts());
|
||
let mut attached = session();
|
||
attached.attach_plugin_store(folder.0.clone());
|
||
vectorcraft_plugins::registry::grant(Grant { id: id.into(), hash: sha256_hex(&changed), permissions: vec![Permission::DocRead] });
|
||
let inspected = run(&mut attached, "plugin.inspect", json!({"dataBase64": vectorcraft_format::base64_encode(&changed)}));
|
||
assert_eq!(inspected["needsApproval"], true, "a grant held only by the process doesn't count with a store attached");
|
||
run(&mut attached, "plugin.remove", json!({"id": id}));
|
||
}
|
||
|
||
/// A write to the plug-in store that fails leaves the store's module beside the grant that approved
|
||
/// it, so the old module loads at the next start: a module that can't be written doesn't update the
|
||
/// grant, and a grant that can't be written puts the old module back. On a first install, it takes
|
||
/// the new module out again.
|
||
#[test]
|
||
fn a_failed_store_write_keeps_the_stored_module_with_its_grant() {
|
||
use vectorcraft_plugins::permissions::sha256_hex;
|
||
let folder = crate::tests_links::Folder::new("plugin-store-failed-write");
|
||
let id = "test.engine.permissions.failedwrite";
|
||
let first = asking(id, r#"["doc.read"]"#, &json!({"objects": []}));
|
||
let changed = asking(id, r#"["doc.read","doc.write"]"#, &json!({"objects": []}));
|
||
let first_data = vectorcraft_format::base64_encode(&first);
|
||
let update = json!({"dataBase64": vectorcraft_format::base64_encode(&changed), "grant": ["doc.read", "doc.write"]});
|
||
let mut s = session();
|
||
s.attach_plugin_store(folder.0.clone());
|
||
run(&mut s, "plugin.install", json!({"dataBase64": first_data, "grant": ["doc.read"]}));
|
||
let grants = folder.0.join("grants.json");
|
||
let recorded = std::fs::read(&grants).unwrap();
|
||
let module = std::path::PathBuf::from(s.stored_plugin_file(id).unwrap());
|
||
let starts = || {
|
||
vectorcraft_plugins::registry::remove(id);
|
||
vectorcraft_plugins::registry::revoke(id);
|
||
let mut next = session();
|
||
next.attach_plugin_store(folder.0.clone());
|
||
vectorcraft_plugins::registry::get(id).map(|p| p.hash().to_string())
|
||
};
|
||
let block = |path: &std::path::Path| {
|
||
std::fs::remove_file(path).unwrap();
|
||
std::fs::create_dir_all(path.join("block")).unwrap();
|
||
};
|
||
|
||
// The module can't be written: the grant stays the old module's.
|
||
block(&module);
|
||
run(&mut s, "plugin.install", update.clone());
|
||
assert_eq!(std::fs::read(&grants).unwrap(), recorded, "the grant isn't updated for a module that wasn't stored");
|
||
std::fs::remove_dir_all(&module).unwrap();
|
||
std::fs::write(&module, &first).unwrap();
|
||
assert_eq!(starts(), Some(sha256_hex(&first)), "the old module loads after a failed module write");
|
||
|
||
// The grants file is full: the new grant, which is longer, doesn't fit.
|
||
let cap = 1 << 20;
|
||
let mut entries: Vec<Value> = serde_json::from_slice(&recorded).unwrap();
|
||
entries.push(json!({"id": "", "pad": ""}));
|
||
let room = cap - serde_json::to_vec(&entries).unwrap().len();
|
||
*entries.last_mut().unwrap() = json!({"id": "", "pad": "x".repeat(room)});
|
||
let full = serde_json::to_vec(&entries).unwrap();
|
||
assert_eq!(full.len(), cap);
|
||
std::fs::write(&grants, &full).unwrap();
|
||
run(&mut s, "plugin.install", update.clone());
|
||
assert_eq!(std::fs::read(&grants).unwrap(), full, "not written past the cap");
|
||
assert_eq!(std::fs::read(&module).unwrap(), first, "the old module is put back");
|
||
assert_eq!(starts(), Some(sha256_hex(&first)), "the old module loads after a failed grant write");
|
||
|
||
// The grants file can't be read, so nothing can be recorded in it.
|
||
block(&grants);
|
||
run(&mut s, "plugin.install", update.clone());
|
||
assert_eq!(std::fs::read(&module).unwrap(), first, "the old module is put back");
|
||
std::fs::remove_dir_all(&grants).unwrap();
|
||
std::fs::write(&grants, &recorded).unwrap();
|
||
assert_eq!(starts(), Some(sha256_hex(&first)), "the old module loads once the grants are readable");
|
||
|
||
// A stored module that can't be read back isn't replaced, so a failed grant write can't lose it.
|
||
let oversized = vectorcraft_plugins::Limits::default().max_module_bytes as u64 + 1;
|
||
std::fs::File::options().write(true).open(&module).unwrap().set_len(oversized).unwrap();
|
||
block(&grants);
|
||
run(&mut s, "plugin.install", update);
|
||
assert_eq!(std::fs::metadata(&module).unwrap().len(), oversized, "the stored module stays");
|
||
std::fs::remove_dir_all(&grants).unwrap();
|
||
std::fs::write(&grants, &recorded).unwrap();
|
||
std::fs::write(&module, &first).unwrap();
|
||
|
||
// A first install whose grant can't be written leaves no module without a grant in the store.
|
||
run(&mut s, "plugin.remove", json!({"id": id}));
|
||
assert!(!module.exists() && !grants.exists());
|
||
std::fs::create_dir_all(grants.join("block")).unwrap();
|
||
run(&mut s, "plugin.install", json!({"dataBase64": first_data, "grant": ["doc.read"]}));
|
||
assert!(!module.exists(), "the store holds no module its grants don't cover");
|
||
vectorcraft_plugins::registry::remove(id);
|
||
vectorcraft_plugins::registry::revoke(id);
|
||
}
|
||
|
||
/// A plug-in folder reports a changed module as needing approval and still loads the other files;
|
||
/// granted on its own, the changed module loads from the folder. A session without a store uses the
|
||
/// grants held by the process.
|
||
#[test]
|
||
fn a_plugin_folder_skips_a_changed_module_until_it_is_approved() {
|
||
let mut s = session();
|
||
let id = "test.engine.permissions.folderbytes";
|
||
let plain = "test.engine.permissions.folderplain";
|
||
let first = asking(id, r#"["doc.read"]"#, &json!({"objects": []}));
|
||
let changed = asking(id, r#"["doc.read"]"#, &json!({"objects": [], "error": null}));
|
||
let dir = crate::tests_links::Folder::new("plugin-folder-grant-hash");
|
||
let file = dir.0.join("asks.wasm");
|
||
std::fs::write(&file, &first).unwrap();
|
||
std::fs::write(dir.0.join("plain.wasm"), asking(plain, "null", &json!({"objects": []}))).unwrap();
|
||
run(&mut s, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&first), "grant": ["doc.read"]}));
|
||
let path = json!({"path": dir.0.display().to_string()});
|
||
let r = run(&mut s, "plugin.reload", path.clone());
|
||
assert_eq!((r["loaded"].as_array().unwrap().len(), r["failed"].as_array().unwrap().len()), (2, 0), "{r}");
|
||
|
||
std::fs::write(&file, &changed).unwrap();
|
||
let r = run(&mut s, "plugin.reload", path.clone());
|
||
assert_eq!(r["loaded"], json!([plain]), "the other file still loads");
|
||
let failed = r["failed"].as_array().unwrap();
|
||
assert_eq!(failed.len(), 1, "{r}");
|
||
assert!(failed[0]["file"].as_str().unwrap().ends_with("asks.wasm"), "{r}");
|
||
let why = failed[0]["error"].as_str().unwrap();
|
||
assert!(why.contains("needs approval") && why.contains("doc.read"), "{why}");
|
||
let hash = vectorcraft_plugins::permissions::sha256_hex(&first);
|
||
assert_eq!(vectorcraft_plugins::registry::get(id).unwrap().hash(), hash, "the module installed earlier stays");
|
||
|
||
run(&mut s, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&changed), "grant": ["doc.read"]}));
|
||
let r = run(&mut s, "plugin.reload", path);
|
||
assert_eq!(r["failed"], json!([]), "approved on its own, it loads: {r}");
|
||
run(&mut s, "plugin.remove", json!({"id": id}));
|
||
run(&mut s, "plugin.remove", json!({"id": plain}));
|
||
}
|
||
|
||
/// The host enforces what a filter was granted: without `doc.write` an output that changes the
|
||
/// document is refused and nothing changes, without `doc.read` it is given no objects, and without
|
||
/// `state` it is given no data kept with the document and may not write any.
|
||
#[test]
|
||
fn a_filter_does_only_what_its_permissions_allow() {
|
||
let mut s = session();
|
||
let id = rect(&mut s, 10.0, "#ff0000");
|
||
let filter = |s: &mut Session, pid: &str| s.execute("plugin.run", &json!({"id": pid, "ids": [id.0]}));
|
||
let grant = |s: &mut Session, wasm: &[u8], perms: Value| {
|
||
run(s, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(wasm), "grant": perms}));
|
||
};
|
||
// Denied `doc.write`: renaming the object is refused, and the document and history stay.
|
||
let reader = "test.engine.permissions.reader";
|
||
grant(&mut s, &asking(reader, r#"["doc.read"]"#, &json!({"objects": [{"id": id.0, "name": "Changed"}]})), json!(["doc.read"]));
|
||
let (before, undo) = (s.doc().unwrap().doc.clone(), s.doc().unwrap().history.undo.len());
|
||
let e = filter(&mut s, reader).unwrap_err().to_string();
|
||
assert!(e.contains("wasn't granted `doc.write`"), "{e}");
|
||
assert_eq!(s.doc().unwrap().doc, before);
|
||
assert_eq!(s.doc().unwrap().history.undo.len(), undo);
|
||
// Deleting it (leaving it out of the output) and adding objects are changes too.
|
||
for out in [json!({"objects": []}), json!({"objects": [{"id": id.0}, {"path": triangle()}]})] {
|
||
grant(&mut s, &asking(reader, r#"["doc.read"]"#, &out), json!(["doc.read"]));
|
||
assert!(filter(&mut s, reader).is_err(), "{out}");
|
||
assert_eq!(s.doc().unwrap().doc, before, "{out}");
|
||
}
|
||
// Returning the object unchanged changes nothing, so it may.
|
||
grant(&mut s, &asking(reader, r#"["doc.read"]"#, &json!({"objects": [{"id": id.0}]})), json!(["doc.read"]));
|
||
filter(&mut s, reader).unwrap();
|
||
assert_eq!(s.doc().unwrap().doc.layers, before.layers);
|
||
// Granted `doc.write` alone: it is given no objects, so its triangle is added beside the
|
||
// rectangle rather than replacing it.
|
||
let writer = "test.engine.permissions.writer";
|
||
let manifest = format!(r#"{{"id":"{writer}","name":"W","kind":"filter","permissions":["doc.write"]}}"#);
|
||
let added = json!({"objects": [{"path": triangle(), "name": "Given nothing"}]});
|
||
let src = tpl::when_given(&manifest, tpl::Given::Input, r#"{"objects":[]}"#, &added.to_string());
|
||
grant(&mut s, &wat::parse_str(src).unwrap(), json!(["doc.write"]));
|
||
let made = filter(&mut s, writer).unwrap();
|
||
let new = NodeId(made["ids"][0].as_u64().unwrap());
|
||
assert_eq!(node(&s, new).name.as_deref(), Some("Given nothing"));
|
||
assert!(s.doc().unwrap().doc.node(id).is_some(), "the rectangle it wasn't given stays");
|
||
// Without `state`: kept data is not handed over, and writing some is refused.
|
||
run(
|
||
&mut s,
|
||
"plugin.install",
|
||
json!({"dataBase64": vectorcraft_format::base64_encode(&asking("test.engine.permissions.keeper", "null", &json!({"objects": [{"id": id.0}], "documentData": {"pen": 1}})))}),
|
||
);
|
||
filter(&mut s, "test.engine.permissions.keeper").unwrap();
|
||
let stateless = "test.engine.permissions.keeper";
|
||
let manifest = format!(r#"{{"id":"{stateless}","name":"K","kind":"filter","permissions":["doc.read","doc.write"]}}"#);
|
||
let src =
|
||
tpl::when_given(&manifest, tpl::Given::Params, r#""documentData":null"#, &json!({"objects": [{"id": id.0, "name": "No data"}]}).to_string());
|
||
grant(&mut s, &wat::parse_str(src).unwrap(), json!(["doc.read", "doc.write"]));
|
||
filter(&mut s, stateless).unwrap();
|
||
assert_eq!(node(&s, id).name.as_deref(), Some("No data"), "given no kept data");
|
||
let before = s.doc().unwrap().doc.clone();
|
||
grant(
|
||
&mut s,
|
||
&asking(stateless, r#"["doc.read","doc.write"]"#, &json!({"objects": [{"id": id.0}], "documentData": {"pen": 2}})),
|
||
json!(["doc.read", "doc.write"]),
|
||
);
|
||
let e = filter(&mut s, stateless).unwrap_err().to_string();
|
||
assert!(e.contains("wasn't granted `state`"), "{e}");
|
||
assert_eq!(s.doc().unwrap().doc, before);
|
||
assert_eq!(s.doc().unwrap().doc.plugin_data(stateless), Some(&json!({"pen": 1})));
|
||
}
|
||
|
||
/// What the user granted is kept in the plug-in store with the module: the next session installs
|
||
/// the plug-in again without asking. A stored plug-in that lists permissions nobody granted it is
|
||
/// skipped, even when another store granted it, and so is one in the Additional Plug-ins Folder
|
||
/// until it is granted on its own.
|
||
#[test]
|
||
fn granted_permissions_are_kept_and_folders_skip_the_rest() {
|
||
let folder = crate::tests_links::Folder::new("plugin-store-grants");
|
||
let id = "test.engine.permissions.stored";
|
||
let wasm = asking(id, r#"["doc.read"]"#, &json!({"objects": []}));
|
||
let mut s = session();
|
||
s.attach_plugin_store(folder.0.clone());
|
||
run(&mut s, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&wasm), "grant": ["doc.read"]}));
|
||
assert!(folder.0.join("grants.json").is_file());
|
||
vectorcraft_plugins::registry::remove(id);
|
||
vectorcraft_plugins::registry::revoke(id);
|
||
let mut next = session();
|
||
next.attach_plugin_store(folder.0.clone());
|
||
assert!(vectorcraft_plugins::registry::get(id).is_some(), "installed again from the store, as granted");
|
||
// Only a store's own grants count: the same module in another store without a grant is
|
||
// skipped, and a grant given elsewhere is not written to that store.
|
||
let other = crate::tests_links::Folder::new("plugin-store-grants-other");
|
||
std::fs::write(other.0.join("copy.wasm"), &wasm).unwrap();
|
||
vectorcraft_plugins::registry::remove(id);
|
||
let mut elsewhere = session();
|
||
elsewhere.attach_plugin_store(other.0.clone());
|
||
assert!(vectorcraft_plugins::registry::get(id).is_none(), "granted in another store only");
|
||
let e = elsewhere.execute("plugin.install", &json!({"dataBase64": vectorcraft_format::base64_encode(&wasm)})).unwrap_err().to_string();
|
||
assert!(e.contains("`grant`"), "a grant kept in another store doesn't count here: {e}");
|
||
let lone = "test.engine.permissions.lone";
|
||
let lone_wasm = asking(lone, r#"["doc.read"]"#, &json!({"objects": []}));
|
||
run(&mut elsewhere, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&lone_wasm), "grant": ["doc.read"]}));
|
||
let kept = std::fs::read_to_string(other.0.join("grants.json")).unwrap();
|
||
assert!(kept.contains(lone) && !kept.contains(id), "{kept}");
|
||
run(&mut elsewhere, "plugin.remove", json!({"id": lone}));
|
||
assert!(!other.0.join("grants.json").exists(), "the last grant removed takes the file with it");
|
||
// Without the grant it is skipped, and the file stays.
|
||
vectorcraft_plugins::registry::remove(id);
|
||
vectorcraft_plugins::registry::revoke(id);
|
||
std::fs::remove_file(folder.0.join("grants.json")).unwrap();
|
||
let mut third = session();
|
||
third.attach_plugin_store(folder.0.clone());
|
||
assert!(vectorcraft_plugins::registry::get(id).is_none(), "nobody granted it");
|
||
assert_eq!(std::fs::read_dir(&folder.0).unwrap().count(), 1, "the module stays in the store");
|
||
|
||
// The Additional Plug-ins Folder skips it with a note until it is granted.
|
||
let dir = crate::tests_links::Folder::new("plugin-folder-grants");
|
||
std::fs::write(dir.0.join("asks.wasm"), &wasm).unwrap();
|
||
let r = run(&mut third, "plugin.reload", json!({"path": dir.0.display().to_string()}));
|
||
assert_eq!(r["loaded"], json!([]));
|
||
let why = r["failed"][0]["error"].as_str().unwrap();
|
||
assert!(why.contains("needs approval") && why.contains("doc.read"), "{why}");
|
||
run(&mut third, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&wasm), "grant": ["doc.read"]}));
|
||
let r = run(&mut third, "plugin.reload", json!({"path": dir.0.display().to_string()}));
|
||
assert_eq!(r["loaded"], json!([id]));
|
||
run(&mut third, "plugin.remove", json!({"id": id}));
|
||
// With a store attached, only that store's grants load it from the folder: a grant given in a
|
||
// session without a store doesn't count.
|
||
let mut storeless = session();
|
||
run(&mut storeless, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&wasm), "grant": ["doc.read"]}));
|
||
vectorcraft_plugins::registry::remove(id);
|
||
assert!(vectorcraft_plugins::registry::granted(id).is_some(), "granted in the process");
|
||
let empty = crate::tests_links::Folder::new("plugin-store-grants-empty");
|
||
let mut scoped = session();
|
||
scoped.attach_plugin_store(empty.0.clone());
|
||
let r = run(&mut scoped, "plugin.reload", json!({"path": dir.0.display().to_string()}));
|
||
vectorcraft_plugins::registry::revoke(id);
|
||
assert_eq!(r["loaded"], json!([]), "a grant outside this store doesn't load it");
|
||
let why = r["failed"][0]["error"].as_str().unwrap();
|
||
assert!(why.contains("needs approval"), "{why}");
|
||
assert!(vectorcraft_plugins::registry::get(id).is_none(), "not installed");
|
||
// The preference's folder is loaded again for a session whose store holds other grants.
|
||
let pref_dir = crate::tests_links::Folder::new("plugin-folder-pref-grants");
|
||
std::fs::write(pref_dir.0.join("asks.wasm"), &wasm).unwrap();
|
||
let pref = json!({"key": "pluginsFolder", "value": pref_dir.0.display().to_string()});
|
||
run(&mut scoped, "prefs.set", pref.clone());
|
||
assert!(vectorcraft_plugins::registry::get(id).is_none(), "nothing granted in this store");
|
||
let granting = crate::tests_links::Folder::new("plugin-store-grants-pref");
|
||
let mut granted = session();
|
||
granted.attach_plugin_store(granting.0.clone());
|
||
run(&mut granted, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&wasm), "grant": ["doc.read"]}));
|
||
vectorcraft_plugins::registry::remove(id);
|
||
vectorcraft_plugins::registry::revoke(id);
|
||
run(&mut granted, "prefs.set", pref);
|
||
assert!(vectorcraft_plugins::registry::get(id).is_some(), "loaded under this store's grant");
|
||
run(&mut granted, "plugin.remove", json!({"id": id}));
|
||
}
|
||
|
||
/// A grants file entry this version can't use (a permission it doesn't know, an invalid id) is
|
||
/// skipped without dropping the other grants, and recording a grant writes it back as it was,
|
||
/// except an entry for the plug-in being granted or removed. A grants file that doesn't parse as a
|
||
/// list is never overwritten.
|
||
#[test]
|
||
fn grants_this_version_cant_read_are_kept() {
|
||
let folder = crate::tests_links::Folder::new("plugin-store-grants-unknown");
|
||
let kept = "test.engine.permissions.kept";
|
||
let added = "test.engine.permissions.added";
|
||
let later = "test.engine.permissions.later";
|
||
let empty = json!({"objects": []});
|
||
let kept_wasm = asking(kept, r#"["doc.read"]"#, &empty);
|
||
let added_wasm = asking(added, r#"["doc.read"]"#, &empty);
|
||
let later_wasm = asking(later, r#"["doc.read"]"#, &empty);
|
||
let future = json!({"id": later, "hash": "00", "permissions": ["doc.read", "ui.panel"]});
|
||
let invalid = json!({"id": "bad id/", "hash": "00", "permissions": ["doc.read"]});
|
||
let grant = json!({"id": kept, "hash": vectorcraft_plugins::permissions::sha256_hex(&kept_wasm), "permissions": ["doc.read"]});
|
||
let grants = folder.0.join("grants.json");
|
||
std::fs::write(&grants, serde_json::to_vec(&json!([future, grant, invalid])).unwrap()).unwrap();
|
||
std::fs::write(folder.0.join("kept.wasm"), &kept_wasm).unwrap();
|
||
let entries = || serde_json::from_slice::<Vec<Value>>(&std::fs::read(&grants).unwrap()).unwrap();
|
||
let mut s = session();
|
||
s.attach_plugin_store(folder.0.clone());
|
||
assert!(vectorcraft_plugins::registry::get(kept).is_some(), "the entries this version can't read don't drop the grant next to them");
|
||
run(&mut s, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&added_wasm), "grant": ["doc.read"]}));
|
||
let now = entries();
|
||
assert_eq!(now.len(), 4, "{now:?}");
|
||
assert!(now.contains(&future) && now.contains(&invalid), "written back as they were: {now:?}");
|
||
assert!(now.iter().any(|e| e["id"] == added), "{now:?}");
|
||
run(&mut s, "plugin.remove", json!({"id": added}));
|
||
assert_eq!(entries().len(), 3);
|
||
// Granting the plug-in an entry names replaces that entry.
|
||
run(&mut s, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&later_wasm), "grant": ["doc.read"]}));
|
||
let now = entries();
|
||
assert!(!now.contains(&future), "{now:?}");
|
||
assert_eq!(now.iter().filter(|e| e["id"] == later).count(), 1, "{now:?}");
|
||
run(&mut s, "plugin.remove", json!({"id": later}));
|
||
run(&mut s, "plugin.remove", json!({"id": kept}));
|
||
assert_eq!(entries(), vec![invalid.clone()], "the last usable grant removed leaves the entry it can't read");
|
||
|
||
// A file that doesn't hold a list is left as it is: installing still works in this session.
|
||
for broken in [&b"[{\"id\": "[..], &b"{\"id\": \"x\"}"[..]] {
|
||
std::fs::write(&grants, broken).unwrap();
|
||
run(&mut s, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&added_wasm), "grant": ["doc.read"]}));
|
||
assert!(vectorcraft_plugins::registry::get(added).is_some());
|
||
assert_eq!(std::fs::read(&grants).unwrap(), broken, "not overwritten");
|
||
run(&mut s, "plugin.remove", json!({"id": added}));
|
||
assert_eq!(std::fs::read(&grants).unwrap(), broken, "not overwritten by a removal either");
|
||
}
|
||
|
||
// The file stays within the 1 MiB it is read up to: compact when pretty-printing would pass it,
|
||
// and left as it is when even compact would.
|
||
let cap = 1 << 20;
|
||
let many: Vec<Value> = (0..14_000).map(|i| json!({"id": format!("x.{i}"), "hash": "00", "permissions": ["ui.panel"]})).collect();
|
||
let compact = serde_json::to_vec(&many).unwrap();
|
||
assert!(compact.len() < cap && serde_json::to_vec_pretty(&many).unwrap().len() > cap);
|
||
std::fs::write(&grants, &compact).unwrap();
|
||
run(&mut s, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&added_wasm), "grant": ["doc.read"]}));
|
||
let written = std::fs::read(&grants).unwrap();
|
||
assert!(written.len() <= cap, "{}", written.len());
|
||
assert_eq!(entries().len(), many.len() + 1);
|
||
let inspected = run(&mut s, "plugin.inspect", json!({"dataBase64": vectorcraft_format::base64_encode(&added_wasm)}));
|
||
assert_eq!(inspected["needsApproval"], json!(false), "the compact file is read back");
|
||
run(&mut s, "plugin.remove", json!({"id": added}));
|
||
let full = serde_json::to_vec(&json!([{"id": "x.full", "hash": "0".repeat(cap - 100), "permissions": ["ui.panel"]}])).unwrap();
|
||
std::fs::write(&grants, &full).unwrap();
|
||
run(&mut s, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&added_wasm), "grant": ["doc.read"]}));
|
||
assert_eq!(std::fs::read(&grants).unwrap(), full, "not written past the cap");
|
||
run(&mut s, "plugin.remove", json!({"id": added}));
|
||
}
|
||
|
||
/// The user agreed once: a heavy filter kept in the plug-in store is installed again at the next
|
||
/// start without asking, with its budget.
|
||
#[test]
|
||
fn a_stored_heavy_filter_keeps_its_budget() {
|
||
let folder = crate::tests_links::Folder::new("plugin-store-heavy");
|
||
let id = "test.engine.storedheavy";
|
||
let mut s = session();
|
||
s.attach_plugin_store(folder.0.clone());
|
||
run(&mut s, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&heavy(id, 1)), "allowHeavy": true}));
|
||
vectorcraft_plugins::registry::remove(id);
|
||
let mut next = session();
|
||
next.attach_plugin_store(folder.0.clone());
|
||
let plugin = vectorcraft_plugins::registry::get(id).expect("installed again from the store");
|
||
assert_eq!(plugin.limits().fuel_base, vectorcraft_plugins::HEAVY_FUEL);
|
||
run(&mut next, "plugin.remove", json!({"id": id}));
|
||
}
|
||
|
||
/// The app runs a heavy filter away from the session ([`cmd::plugin::FilterRun`]) and hands its
|
||
/// output in: `plugin.run` with the same params applies it, as one undo step, without running the
|
||
/// plug-in again. Only once, and only for the same run: objects that changed since, or the plug-in
|
||
/// installed again, run the plug-in instead.
|
||
#[test]
|
||
fn a_filter_run_made_elsewhere_is_applied_by_plugin_run() {
|
||
use cmd::plugin::FilterRun;
|
||
let mut s = session();
|
||
let id = "test.engine.handin";
|
||
run(&mut s, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&heavy(id, 1)), "allowHeavy": true}));
|
||
let p = json!({"id": id, "ids": []});
|
||
let named = |s: &Session, ids: &Value| s.doc().unwrap().doc.node(NodeId(ids[0].as_u64().unwrap())).unwrap().name.clone();
|
||
// What it computed, as the app hands it in; here marked, so the test sees which output applied.
|
||
let r = FilterRun::prepare(&s, &p).unwrap();
|
||
assert!(r.current(&s));
|
||
let output = String::from_utf8(r.run().unwrap()).unwrap().replace("Plug-in", "Handed in");
|
||
r.hand_in(&mut s, output.clone().into_bytes());
|
||
let undo = s.doc().unwrap().history.undo.len();
|
||
let made = run(&mut s, "plugin.run", p.clone())["ids"].clone();
|
||
assert_eq!(named(&s, &made).as_deref(), Some("Handed in"));
|
||
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1);
|
||
// Once only: the next run runs the plug-in.
|
||
let made = run(&mut s, "plugin.run", p.clone())["ids"].clone();
|
||
assert_eq!(named(&s, &made).as_deref(), Some("Plug-in"));
|
||
// Made for objects that changed since, it isn't theirs.
|
||
let obj = rect(&mut s, 10.0, "#ff0000");
|
||
let on = json!({"id": id, "ids": [obj.0]});
|
||
let stale = FilterRun::prepare(&s, &on).unwrap();
|
||
run(&mut s, "paint.setFill", json!({"ids": [obj.0], "color": "#00ff00"}));
|
||
assert!(!stale.current(&s), "the document changed");
|
||
stale.hand_in(&mut s, output.clone().into_bytes());
|
||
let made = run(&mut s, "plugin.run", on)["ids"].clone();
|
||
assert_eq!(named(&s, &made).as_deref(), Some("Plug-in"), "made for objects that changed since");
|
||
// A plug-in installed again is another one.
|
||
let again = FilterRun::prepare(&s, &p).unwrap();
|
||
run(&mut s, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&heavy(id, 1)), "allowHeavy": true}));
|
||
assert!(!again.current(&s));
|
||
again.hand_in(&mut s, output.into_bytes());
|
||
let made = run(&mut s, "plugin.run", p)["ids"].clone();
|
||
assert_eq!(named(&s, &made).as_deref(), Some("Plug-in"), "made by the plug-in installed before");
|
||
}
|
||
|
||
/// A heavy filter run away from the session reads what it keeps with the document
|
||
/// (`_context.documentData`) and hands its `documentData` back with its output: `plugin.run`
|
||
/// applies both as one undo step.
|
||
#[test]
|
||
fn a_filter_run_made_elsewhere_keeps_its_document_data() {
|
||
use cmd::plugin::FilterRun;
|
||
let mut s = session();
|
||
let id = "test.engine.handindata";
|
||
let manifest = format!(r#"{{"id":"{id}","name":"Test {id}","version":"1","kind":"filter","budget":"heavy"}}"#);
|
||
let install = |s: &mut Session, src: String| {
|
||
let data = vectorcraft_format::base64_encode(&wat::parse_str(src).unwrap());
|
||
run(s, "plugin.install", json!({"dataBase64": data, "allowHeavy": true}));
|
||
};
|
||
// It first keeps the pens it found with the document.
|
||
install(&mut s, tpl::returning(&manifest, &json!({"objects": [], "documentData": {"pens": 2}}).to_string()));
|
||
run(&mut s, "plugin.run", json!({"id": id, "ids": []}));
|
||
// Given those pens, it adds one, in the background; the output handed in is marked, so the
|
||
// test sees it is the one that applied.
|
||
let next = json!({"objects": [{"path": triangle(), "name": "Pen 3"}], "documentData": {"pens": 3}}).to_string();
|
||
install(&mut s, tpl::when_given(&manifest, tpl::Given::Params, r#""documentData":{"pens":2}"#, &next));
|
||
let p = json!({"id": id, "ids": []});
|
||
let r = FilterRun::prepare(&s, &p).unwrap();
|
||
let output = String::from_utf8(r.run().unwrap()).unwrap().replace("Pen 3", "Handed in");
|
||
r.hand_in(&mut s, output.into_bytes());
|
||
let undo = s.doc().unwrap().history.undo.len();
|
||
let made = run(&mut s, "plugin.run", p)["ids"].clone();
|
||
let st = s.doc().unwrap();
|
||
assert_eq!(st.doc.node(NodeId(made[0].as_u64().unwrap())).unwrap().name.as_deref(), Some("Handed in"));
|
||
assert_eq!(st.doc.plugin_data(id), Some(&json!({"pens": 3})));
|
||
assert_eq!(st.history.undo.len(), undo + 1, "one undo step");
|
||
run(&mut s, "edit.undo", json!({}));
|
||
assert_eq!(s.doc().unwrap().doc.plugin_data(id), Some(&json!({"pens": 2})), "undone with its objects");
|
||
}
|
||
|
||
/// A plug-in of API level 2 that uses the host imports lists `progress`, which the user grants like
|
||
/// any permission; a filter run made elsewhere then reports its progress, as a share, to the
|
||
/// progress the caller shows, and sees the caller's Cancel.
|
||
#[test]
|
||
fn a_level_2_filter_installs_with_progress_granted_and_reports_to_the_caller() {
|
||
use crate::cmd::fileio::progress::{self, Progress};
|
||
use crate::cmd::plugin::FilterRun;
|
||
let mut s = session();
|
||
let id = "test.engine.level2";
|
||
let manifest = format!(r#"{{"id":"{id}","name":"Test {id}","kind":"filter","api":2,"permissions":["doc.read","doc.write","progress"]}}"#);
|
||
let wasm = wat::parse_str(tpl::cooperating(&manifest, 1000, r#"{"objects":[]}"#, r#"{"objects":[]}"#)).unwrap();
|
||
let data = vectorcraft_format::base64_encode(&wasm);
|
||
let inspected = run(&mut s, "plugin.inspect", json!({"dataBase64": data}));
|
||
assert_eq!((&inspected["needsApproval"], &inspected["permissions"]), (&json!(true), &json!(["doc.read", "doc.write", "progress"])));
|
||
let e = s.execute("plugin.install", &json!({"dataBase64": data, "grant": ["doc.read", "doc.write"]})).unwrap_err().to_string();
|
||
assert!(e.contains("asks for the permissions doc.read, doc.write, progress"), "{e}");
|
||
run(&mut s, "plugin.install", json!({"dataBase64": data, "grant": ["doc.read", "doc.write", "progress"]}));
|
||
let shown = Progress::new();
|
||
let r = FilterRun::prepare(&s, &json!({"id": id})).unwrap();
|
||
assert!(progress::scope(&shown, || r.run()).is_ok());
|
||
assert_eq!((shown.counts(), shown.counts_share()), ((999, 1000), true));
|
||
// Cancelled, it stops on its first step, and what it returns then is discarded.
|
||
shown.cancel();
|
||
let cancelled = progress::scope(&shown, || r.run());
|
||
assert!(matches!(cancelled, Err(EngineError::Cancelled)), "{cancelled:?}");
|
||
assert_eq!(shown.counts(), (0, 1000));
|
||
run(&mut s, "plugin.remove", json!({"id": id}));
|
||
}
|
||
|
||
/// A filter run the caller cancels (MCP's `notifications/cancelled`, the app's Cancel) stops between
|
||
/// two slices of its budget and changes nothing.
|
||
#[test]
|
||
fn a_cancelled_filter_run_changes_nothing() {
|
||
let mut s = session();
|
||
let id = "test.engine.cancelled";
|
||
run(&mut s, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&heavy(id, 1 << 40)), "allowHeavy": true}));
|
||
let before = s.doc().unwrap().doc.clone();
|
||
let token = vectorcraft_doc::cancel::CancelToken::new();
|
||
token.cancel();
|
||
let r = vectorcraft_doc::cancel::scope(&token, None, || s.execute("plugin.run", &json!({"id": id})));
|
||
assert!(matches!(r, Err(EngineError::Cancelled)), "{r:?}");
|
||
assert_eq!(s.doc().unwrap().doc, before);
|
||
}
|