//! A filter plug-in whose manifest's `commandAllowlist` names commands calls them with `vc_call`: //! each call sees what the calls before it left, the calls and the filter's output are one undo //! step, and a run that fails leaves the document as it was. // Integration tests: unwrapping and panicking on failure is fine here, unlike in shipped code (AGENTS.md › Robustness). #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] use serde_json::{Value, json}; use vectorcraft_engine::Session; use vectorcraft_plugins::wat as tpl; /// The command imports and the strings the plug-ins pass: the ids "select.all" (0x2000, 10 bytes), /// "object.move" (0x2010, 11), "no.such.cmd" (0x2020, 11), "effect.apply" (0x2030, 12) and /// "select.globalEditOptions" (0x2040, 24), and the arguments `{"dx":5,"dy":0}` (0x2100, 15), /// `{"dx":"x"}` (0x2200, 10), `{"effect":"plugin.test.call.fx"}` (0x2300, 32) and `{"size":true}` /// (0x2400, 13). const CALLS: &str = r#"(import "env" "vc_call" (func $vc_call (param i32 i32 i32 i32) (result i32))) (import "env" "vc_call_result" (func $vc_call_result (param i32 i32) (result i32))) (data (i32.const 0x2000) "select.all") (data (i32.const 0x2010) "object.move") (data (i32.const 0x2020) "no.such.cmd") (data (i32.const 0x2030) "effect.apply") (data (i32.const 0x2040) "select.globalEditOptions") (data (i32.const 0x2100) "{\22dx\22:5,\22dy\22:0}") (data (i32.const 0x2200) "{\22dx\22:\22x\22}") (data (i32.const 0x2300) "{\22effect\22:\22plugin.test.call.fx\22}") (data (i32.const 0x2400) "{\22size\22:true}")"#; /// Selects everything. const SELECT_ALL: &str = "(drop (call $vc_call (i32.const 0x2000) (i32.const 10) (i32.const 0) (i32.const 0)))"; /// Moves the selection 5 to the right. const MOVE: &str = "(drop (call $vc_call (i32.const 0x2010) (i32.const 11) (i32.const 0x2100) (i32.const 15)))"; const EMPTY: &str = r#"{"objects":[]}"#; fn run(s: &mut Session, cmd: &str, p: Value) -> Value { s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}")) } /// Installs filter `id` with `permissions` (a JSON list) and `allowlist` (a JSON list), whose /// `vc_run` runs `first`, then returns `output` → what `plugin.install` returned, or why it refused. fn install(s: &mut Session, id: &str, permissions: &str, allowlist: &str, first: &str, output: &str) -> Result { let manifest = format!(r#"{{"id":"{id}","name":"Test {id}","kind":"filter","api":2,"permissions":{permissions},"commandAllowlist":{allowlist}}}"#); let wasm = wat::parse_str(tpl::with_imports(&manifest, CALLS, first, output)).unwrap(); let grant: Value = serde_json::from_str(permissions).unwrap(); let data = vectorcraft_format::base64_encode(&wasm); s.execute("plugin.install", &json!({"dataBase64": data, "grant": grant, "replace": true})).map_err(|e| e.to_string()) } /// A filter `id` that may read and change the document and call `select.all` and `object.move`. fn install_mover(s: &mut Session, id: &str, first: &str, output: &str) { install(s, id, r#"["doc.read","doc.write","commands"]"#, r#"["select.all","object.move"]"#, first, output).unwrap(); } /// A document with two rectangles and nothing selected. fn document() -> Session { let mut s = Session::new(); s.allow_plugin_grants_from_requests(); run(&mut s, "file.new", json!({"width": 400, "height": 200})); run(&mut s, "shape.rectangle", json!({"x": 10, "y": 10, "width": 50, "height": 50})); run(&mut s, "shape.rectangle", json!({"x": 100, "y": 10, "width": 50, "height": 50})); run(&mut s, "select.none", json!({})); s } /// The active document's layers, its undo and redo steps and how many objects are selected. fn state(s: &mut Session) -> (Value, usize, usize, usize) { let content = run(s, "document.inspect", json!({}))["layers"].take(); let d = s.doc().unwrap(); (content, d.history.undo.len(), d.history.redo.len(), d.selection.len()) } /// Runs filter `id` on nothing, so only its calls change the document. fn run_plugin(s: &mut Session, id: &str) -> Result { s.execute("plugin.run", &json!({"id": id, "ids": []})).map_err(|e| e.to_string()) } #[test] fn calls_see_each_others_changes_and_make_one_undo_step() { let mut s = document(); let before = state(&mut s); install_mover(&mut s, "test.call.mover", &[SELECT_ALL, MOVE].concat(), EMPTY); run_plugin(&mut s, "test.call.mover").unwrap(); let after = state(&mut s); // `object.move` moved what `select.all` selected: both rectangles. let xs: Vec<&Value> = after.0[0]["children"].as_array().unwrap().iter().map(|c| &c["bounds"]["x"]).collect(); assert_eq!(xs, [&json!(105.0), &json!(15.0)]); assert_eq!(after.1, before.1 + 1, "one undo step"); assert_eq!(s.doc().unwrap().history.undo.last().map(|u| u.label.as_str()), Some("Test test.call.mover")); run(&mut s, "edit.undo", json!({})); assert_eq!(state(&mut s).0, before.0); run(&mut s, "edit.redo", json!({})); assert_eq!(state(&mut s).0, after.0); // Without `select.all` nothing is selected, so `object.move` has nothing to move. run(&mut s, "select.none", json!({})); install_mover(&mut s, "test.call.mover", MOVE, EMPTY); assert!(run_plugin(&mut s, "test.call.mover").is_err()); run(&mut s, "plugin.remove", json!({"id": "test.call.mover"})); } #[test] fn a_run_that_fails_after_its_calls_changes_nothing() { let mut s = document(); let before = state(&mut s); // A trap, an output that isn't JSON, and a command that fails, each after the move. let bad_move = "(drop (call $vc_call (i32.const 0x2010) (i32.const 11) (i32.const 0x2200) (i32.const 10)))"; for (first, output) in [([SELECT_ALL, MOVE, "unreachable"].concat(), EMPTY), ([SELECT_ALL, MOVE].concat(), "nope"), ([SELECT_ALL, MOVE, bad_move].concat(), EMPTY)] { install_mover(&mut s, "test.call.fails", &first, output); assert!(run_plugin(&mut s, "test.call.fails").is_err(), "{first} {output}"); assert_eq!(state(&mut s), before, "{first} {output}"); } run(&mut s, "plugin.remove", json!({"id": "test.call.fails"})); // Session state a call changed outside the document goes back too: Global Edit's options. let options = "(drop (call $vc_call (i32.const 0x2040) (i32.const 24) (i32.const 0x2400) (i32.const 13))) unreachable"; let allowlist = r#"["select.globalEditOptions"]"#; install(&mut s, "test.call.options", r#"["doc.read","commands"]"#, allowlist, options, EMPTY).unwrap(); assert!(run_plugin(&mut s, "test.call.options").is_err()); assert_eq!(run(&mut s, "select.globalEditOptions", json!({}))["size"], json!(false)); run(&mut s, "plugin.remove", json!({"id": "test.call.options"})); } /// A command that changes state a failed run can't put back (the preferences, a tool's options, a /// drag's state) is refused at install, by name. #[test] fn install_refuses_reviewed_denials() { let mut s = document(); for refused in ["object.liquify", "object.pathfinder.options", "addon.pen.nextSegment"] { let allowlist = format!(r#"["select.all","{refused}"]"#); let e = install(&mut s, "test.call.reviewed", r#"["doc.read","commands"]"#, &allowlist, "", EMPTY).unwrap_err(); assert!(e.contains(refused) && e.contains("may not call"), "{e}"); } } #[test] fn install_refuses_a_command_a_plugin_may_not_call_and_reports_unknown_ones() { let mut s = document(); let first = "(drop (call $vc_call (i32.const 0x2020) (i32.const 11) (i32.const 0) (i32.const 0)))"; for refused in ["document.save", "edit.undo", "plugin.run", "script.run"] { let allowlist = format!(r#"["select.all","{refused}"]"#); let e = install(&mut s, "test.call.refused", r#"["doc.read","commands"]"#, &allowlist, "", EMPTY).unwrap_err(); assert!(e.contains(refused) && e.contains("may not call"), "{e}"); } let r = install(&mut s, "test.call.unknown", r#"["doc.read","commands"]"#, r#"["select.all","no.such.cmd"]"#, first, EMPTY).unwrap(); assert_eq!(r["unknownCommands"], json!(["no.such.cmd"])); let e = run_plugin(&mut s, "test.call.unknown").unwrap_err(); assert!(e.contains("no.such.cmd"), "{e}"); run(&mut s, "plugin.remove", json!({"id": "test.call.unknown"})); } #[test] fn a_call_that_names_a_plugin_is_refused() { let mut s = document(); let before = state(&mut s); let first = format!("{SELECT_ALL} (drop (call $vc_call (i32.const 0x2030) (i32.const 12) (i32.const 0x2300) (i32.const 32)))"); install(&mut s, "test.call.fx", r#"["doc.read","doc.write","commands"]"#, r#"["select.all","effect.apply"]"#, &first, EMPTY).unwrap(); let e = run_plugin(&mut s, "test.call.fx").unwrap_err(); assert!(e.contains("may not call"), "{e}"); assert_eq!(state(&mut s), before); run(&mut s, "plugin.remove", json!({"id": "test.call.fx"})); } #[test] fn without_doc_write_a_call_that_edits_is_refused() { let mut s = document(); let before = state(&mut s); let allowlist = r#"["select.all","object.move"]"#; install(&mut s, "test.call.reader", r#"["doc.read","commands"]"#, allowlist, &[SELECT_ALL, MOVE].concat(), EMPTY).unwrap(); let e = run_plugin(&mut s, "test.call.reader").unwrap_err(); assert!(e.contains("may not call"), "{e}"); assert_eq!(state(&mut s), before); // Selecting changes no art, so it runs. install(&mut s, "test.call.reader", r#"["doc.read","commands"]"#, allowlist, SELECT_ALL, EMPTY).unwrap(); run_plugin(&mut s, "test.call.reader").unwrap(); run(&mut s, "plugin.remove", json!({"id": "test.call.reader"})); } /// Installs filter `id` that may read and change the document and call `allowlist`, whose `vc_run` /// makes `calls` (command id and arguments) in order, dropping their codes, then returns nothing. fn install_calls(s: &mut Session, id: &str, allowlist: &str, calls: &[(&str, &str)]) { let mut imports = r#"(import "env" "vc_call" (func $vc_call (param i32 i32 i32 i32) (result i32)))"#.to_string(); let mut first = String::new(); for (i, (cmd, args)) in calls.iter().enumerate() { let at = 0x3000 + i * 0x400; imports += &format!(r#" (data (i32.const {at}) "{}") (data (i32.const {}) "{}")"#, tpl::string(cmd), at + 0x100, tpl::string(args)); first += &format!("(drop (call $vc_call (i32.const {at}) (i32.const {}) (i32.const {}) (i32.const {})))", cmd.len(), at + 0x100, args.len()); } let manifest = format!( r#"{{"id":"{id}","name":"Test {id}","kind":"filter","api":2,"permissions":["doc.read","doc.write","commands"],"commandAllowlist":{allowlist}}}"# ); let wasm = wat::parse_str(tpl::with_imports(&manifest, &imports, &first, EMPTY)).unwrap(); let data = vectorcraft_format::base64_encode(&wasm); let grant = json!(["doc.read", "doc.write", "commands"]); run(s, "plugin.install", json!({"dataBase64": data, "grant": grant, "replace": true})); } /// Arguments that carry a file or folder key at any depth, such as an effect's /// `textureImage.path`, are refused before the command runs, so no file is read; arguments nested /// without one run. #[test] fn a_call_whose_arguments_name_a_file_is_refused() { let mut s = document(); let before = state(&mut s); let allowlist = r#"["select.all","effect.apply","effect.setParams"]"#; let select = ("select.all", ""); for (cmd, args) in [ ("effect.apply", r#"{"effect":"distort.glass","params":{"textureImage":{"path":"missing-texture.png"}}}"#), ("effect.setParams", r#"{"index":0,"params":{"textureImage":{"path":"missing-texture.png"}}}"#), ("effect.apply", r#"{"effect":"distort.glass","params":{"layers":[{"name":"a"},{"file":"missing-texture.png"}]}}"#), ] { install_calls(&mut s, "test.call.files", allowlist, &[select, (cmd, args)]); let e = run_plugin(&mut s, "test.call.files").unwrap_err(); assert!(e.contains("may not call") && e.contains(cmd), "{args}: {e}"); assert_eq!(state(&mut s), before, "{args}"); } // The same effect with ordinary nested params runs. install_calls(&mut s, "test.call.files", allowlist, &[select, ("effect.apply", r#"{"effect":"distort.glass","params":{"distortion":5}}"#)]); run_plugin(&mut s, "test.call.files").unwrap(); assert_eq!(state(&mut s).1, before.1 + 1, "one undo step"); run(&mut s, "plugin.remove", json!({"id": "test.call.files"})); } /// A command's result describes the document, so a plug-in without `doc.read` may call nothing. #[test] fn without_doc_read_every_call_is_refused() { let mut s = document(); let before = state(&mut s); install(&mut s, "test.call.blind", r#"["commands"]"#, r#"["select.all"]"#, SELECT_ALL, EMPTY).unwrap(); let e = run_plugin(&mut s, "test.call.blind").unwrap_err(); assert!(e.contains("may not call") && e.contains("select.all"), "{e}"); assert_eq!(state(&mut s), before); // The same for an id no command has: without `doc.read` the answer never depends on the command. let unknown = "(drop (call $vc_call (i32.const 0x2020) (i32.const 11) (i32.const 0) (i32.const 0)))"; install(&mut s, "test.call.blind", r#"["commands"]"#, r#"["no.such.cmd"]"#, unknown, EMPTY).unwrap(); let e = run_plugin(&mut s, "test.call.blind").unwrap_err(); assert!(e.contains("may not call") && e.contains("no.such.cmd"), "{e}"); run(&mut s, "plugin.remove", json!({"id": "test.call.blind"})); }