//! Safe Mode and disabled plug-ins through the engine: `plugin.safeMode`, `plugin.setEnabled`, the //! plug-in folder skipping disabled plug-ins at the next launch, and the `plugin.*` commands in Safe //! Mode. //! //! A test binary of its own: Safe Mode and installed plug-ins are process-wide, so these tests run //! one at a time and no other test sees them. // 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 std::path::PathBuf; use std::sync::Mutex; use serde_json::{Value, json}; use vectorcraft_engine::Session; use vectorcraft_plugins::safe_mode; use vectorcraft_plugins::wat as tpl; static LOCK: Mutex<()> = Mutex::new(()); /// Safe Mode off, nothing disabled or installed. fn reset() { safe_mode::set_active(None); safe_mode::set_disabled(vec![]); for p in vectorcraft_plugins::registry::list() { vectorcraft_plugins::registry::remove(p.id()); } } /// Holds the lock for one test and resets the process-wide state when the test starts and when it /// ends, so a test that fails leaves the next one as it would find a new process. struct Fresh { _guard: std::sync::MutexGuard<'static, ()>, } fn fresh() -> Fresh { let guard = LOCK.lock().unwrap_or_else(|e| e.into_inner()); reset(); Fresh { _guard: guard } } impl Drop for Fresh { fn drop(&mut self) { reset(); } } fn filter(id: &str) -> Vec { wat::parse_str(tpl::returning(&tpl::manifest(id, "filter", "{}"), r#"{"objects":[]}"#)).unwrap() } fn tmp_dir(tag: &str) -> PathBuf { let d = std::env::temp_dir().join(format!("vc-plugin-safe-mode-{tag}-{}", std::process::id())); let _ = std::fs::remove_dir_all(&d); std::fs::create_dir_all(&d).unwrap(); d } fn installed(id: &str) -> bool { vectorcraft_plugins::registry::get(id).is_some() } /// A new launch: nothing installed, the plug-in folder `dir` attached. fn launch(dir: &std::path::Path) -> Session { for p in vectorcraft_plugins::registry::list() { vectorcraft_plugins::registry::remove(p.id()); } let mut s = Session::new(); s.attach_plugin_store(dir.to_path_buf()); s } #[test] fn a_disabled_plug_in_stays_installed_and_loads_again_once_enabled_at_the_next_launch() { let _f = fresh(); let dir = tmp_dir("enable"); let mut s = launch(&dir); for id in ["test.sm.x", "test.sm.y"] { s.execute("plugin.install", &json!({"dataBase64": vectorcraft_format::base64_encode(&filter(id))})).unwrap(); } let r = s.execute("plugin.setEnabled", &json!({"id": "test.sm.x", "enabled": false})).unwrap(); assert_eq!(r["changed"], true); assert_eq!(r["disabled"][0]["id"], "test.sm.x"); assert!(installed("test.sm.x"), "disabling waits for the next launch"); let again = s.execute("plugin.setEnabled", &json!({"id": "test.sm.x", "enabled": false})).unwrap(); assert_eq!(again["changed"], false, "already disabled: nothing changes"); let mut s = launch(&dir); assert!(!installed("test.sm.x") && installed("test.sm.y"), "only the disabled one is skipped"); assert!(dir.read_dir().unwrap().flatten().filter(|e| e.path().extension().is_some_and(|x| x == "wasm")).count() == 2, "nothing uninstalled"); let list = s.execute("plugin.list", &json!({})).unwrap(); assert_eq!((list["safeMode"].clone(), list["disabled"][0]["id"].clone()), (json!(false), json!("test.sm.x"))); let state = s.execute("plugin.safeMode", &json!({})).unwrap(); assert_eq!(state, json!({"active": false, "source": null, "pendingItem": null, "disabled": state["disabled"].clone()})); assert_eq!(state["disabled"].as_array().map(Vec::len), Some(1)); s.execute("plugin.setEnabled", &json!({"id": "test.sm.x", "enabled": true})).unwrap(); assert!(!installed("test.sm.x"), "enabling waits for the next launch"); launch(&dir); assert!(installed("test.sm.x") && installed("test.sm.y")); safe_mode::set_disabled(vec![]); let _ = std::fs::remove_dir_all(&dir); } /// Two windows share a plug-in folder: a plug-in the other one disabled after this one started stays /// disabled when this one saves its own changes. #[test] fn saving_the_disabled_list_keeps_what_another_window_saved_meanwhile() { let _f = fresh(); let dir = tmp_dir("two-windows"); let mut s = launch(&dir); s.execute("plugin.setEnabled", &json!({"id": "test.sm.a", "enabled": false})).unwrap(); // The other window disabled b and saved its list. let other = |ids: &[&str]| { let items: Vec = ids.iter().map(|id| json!({"id": id})).collect(); let file = json!({"format": "vectorcraft-disabled-plugins", "version": 1, "items": items}); std::fs::write(dir.join(vectorcraft_engine::store::DISABLED_FILE), serde_json::to_vec(&file).unwrap()).unwrap(); }; other(&["test.sm.a", "test.sm.b"]); s.execute("plugin.setEnabled", &json!({"id": "test.sm.c", "enabled": false})).unwrap(); s.execute("plugin.setEnabled", &json!({"id": "test.sm.a", "enabled": true})).unwrap(); let saved = |s: &mut Session| -> Vec { let state = s.execute("plugin.safeMode", &json!({})).unwrap(); state["disabled"].as_array().unwrap().iter().map(|i| i["id"].as_str().unwrap().to_string()).collect() }; assert_eq!(saved(&mut s), ["test.sm.b", "test.sm.c"], "b is kept, c added, and a turned on again"); // The other window turns b back on; this window's next change keeps that too. other(&["test.sm.c"]); s.execute("plugin.setEnabled", &json!({"id": "test.sm.d", "enabled": false})).unwrap(); assert_eq!(saved(&mut s), ["test.sm.c", "test.sm.d"]); let mut next = launch(&dir); assert_eq!(saved(&mut next), ["test.sm.c", "test.sm.d"], "the file holds the merged list"); // While the lock file can't be locked (here a folder stands in its place), nothing is written; // the next change that is written carries this one too. let lock = dir.join(vectorcraft_engine::store::DISABLED_LOCK); std::fs::remove_file(&lock).unwrap(); std::fs::create_dir_all(&lock).unwrap(); let in_file = || -> Vec { let bytes = std::fs::read(dir.join(vectorcraft_engine::store::DISABLED_FILE)).unwrap(); safe_mode::parse_disabled_file(&bytes).into_iter().filter_map(|i| i.id).collect() }; next.execute("plugin.setEnabled", &json!({"id": "test.sm.e", "enabled": false})).unwrap(); assert_eq!(in_file(), ["test.sm.c", "test.sm.d"]); std::fs::remove_dir(&lock).unwrap(); next.execute("plugin.setEnabled", &json!({"id": "test.sm.f", "enabled": false})).unwrap(); assert_eq!(in_file(), ["test.sm.c", "test.sm.d", "test.sm.e", "test.sm.f"]); let _ = std::fs::remove_dir_all(&dir); } #[test] fn a_full_list_of_long_disabled_items_is_read_back_at_the_next_launch() { let _f = fresh(); let dir = tmp_dir("full-list"); let field = |i: usize, tag: &str| format!("{tag}{i:04}{}", "\u{1}".repeat(1024)); let items: Vec = (0..safe_mode::MAX_DISABLED) .map(|i| safe_mode::Item { id: Some(field(i, "i")), file: Some(field(i, "f")), name: Some(field(i, "n")) }) .collect(); safe_mode::set_disabled(items); let saved = safe_mode::disabled(); std::fs::write(dir.join(vectorcraft_engine::store::DISABLED_FILE), safe_mode::disabled_file()).unwrap(); safe_mode::set_disabled(vec![]); launch(&dir); assert_eq!(safe_mode::disabled(), saved, "every disabled item stays disabled"); safe_mode::set_disabled(vec![]); let _ = std::fs::remove_dir_all(&dir); } #[test] fn a_folder_plug_in_disabled_by_id_is_saved_with_its_file() { let _f = fresh(); let dir = tmp_dir("by-id"); let extra = tmp_dir("by-id-extra"); let f = extra.join("f.wasm"); std::fs::write(&f, filter("test.sm.f")).unwrap(); let mut s = launch(&dir); s.execute("plugin.setEnabled", &json!({"id": "test.sm.f", "enabled": false})).unwrap(); let mut s = launch(&dir); let r = s.execute("plugin.reload", &json!({"path": extra.to_string_lossy()})).unwrap(); assert_eq!(r["failed"][0]["error"], vectorcraft_plugins::registry::DISABLED); safe_mode::set_disabled(vec![]); launch(&dir); assert_eq!(safe_mode::disabled()[0].file.as_deref(), Some(f.display().to_string().as_str()), "the next launch skips the file before it loads"); safe_mode::set_disabled(vec![]); let _ = std::fs::remove_dir_all(&dir); let _ = std::fs::remove_dir_all(&extra); } #[test] fn installing_a_disabled_plug_in_again_turns_it_on() { let _f = fresh(); let dir = tmp_dir("reinstall"); let mut s = launch(&dir); s.execute("plugin.setEnabled", &json!({"id": "test.sm.r", "enabled": false})).unwrap(); s.execute("plugin.install", &json!({"dataBase64": vectorcraft_format::base64_encode(&filter("test.sm.r"))})).unwrap(); assert!(safe_mode::disabled().is_empty()); launch(&dir); assert!(installed("test.sm.r")); safe_mode::set_disabled(vec![]); let _ = std::fs::remove_dir_all(&dir); } /// A launch where the stored plug-in `id` stopped while loading: it is installed, then disabled by /// its stored file alone (as Disable and Continue saves it), and the next launch skips it. fn stored_plug_in_disabled_by_file(dir: &std::path::Path, id: &str) -> Session { let mut s = launch(dir); s.execute("plugin.install", &json!({"dataBase64": vectorcraft_format::base64_encode(&filter(id))})).unwrap(); let stored = dir.read_dir().unwrap().flatten().map(|e| e.path()).find(|p| p.extension().is_some_and(|x| x == "wasm")).unwrap(); s.execute("plugin.setEnabled", &json!({"file": stored.display().to_string(), "enabled": false})).unwrap(); let s = launch(dir); assert!(!installed(id), "the next launch skips the file"); assert_eq!(safe_mode::disabled()[0].id, None); s } fn nothing_disabled(s: &mut Session, dir: &std::path::Path) { assert_eq!(s.execute("plugin.safeMode", &json!({})).unwrap()["disabled"], json!([])); let saved = std::fs::read(dir.join(vectorcraft_engine::store::DISABLED_FILE)).unwrap(); assert!(safe_mode::parse_disabled_file(&saved).is_empty(), "nothing disabled is saved"); } #[test] fn installing_a_stored_plug_in_disabled_by_its_file_turns_it_on() { let _f = fresh(); let dir = tmp_dir("reinstall-file"); let mut s = stored_plug_in_disabled_by_file(&dir, "test.sm.p"); s.execute("plugin.install", &json!({"dataBase64": vectorcraft_format::base64_encode(&filter("test.sm.p"))})).unwrap(); nothing_disabled(&mut s, &dir); launch(&dir); assert!(installed("test.sm.p")); safe_mode::set_disabled(vec![]); let _ = std::fs::remove_dir_all(&dir); } #[test] fn a_stored_plug_in_disabled_by_its_file_stays_disabled_when_its_update_cannot_be_saved() { let _f = fresh(); let dir = tmp_dir("reinstall-unsaved"); let mut s = stored_plug_in_disabled_by_file(&dir, "test.sm.u"); let stored = PathBuf::from(safe_mode::disabled()[0].file.clone().unwrap()); std::fs::remove_file(&stored).unwrap(); std::fs::create_dir_all(stored.join("block")).unwrap(); s.execute("plugin.install", &json!({"dataBase64": vectorcraft_format::base64_encode(&filter("test.sm.u"))})).unwrap(); assert_eq!(safe_mode::disabled().len(), 1, "the file the update could not replace stays disabled"); safe_mode::set_disabled(vec![]); let _ = std::fs::remove_dir_all(&dir); } #[test] fn enabling_a_stored_plug_in_disabled_by_its_file_by_its_id_turns_it_on() { let _f = fresh(); let dir = tmp_dir("enable-file"); let mut s = stored_plug_in_disabled_by_file(&dir, "test.sm.q"); let r = s.execute("plugin.setEnabled", &json!({"id": "test.sm.q", "enabled": true})).unwrap(); assert_eq!(r["changed"], true); nothing_disabled(&mut s, &dir); launch(&dir); assert!(installed("test.sm.q")); safe_mode::set_disabled(vec![]); let _ = std::fs::remove_dir_all(&dir); } #[test] fn set_enabled_rejects_bad_parameters() { let _f = fresh(); safe_mode::set_disabled(vec![]); let mut s = Session::new(); let err = |s: &mut Session, p: Value| s.execute("plugin.setEnabled", &p).unwrap_err().to_string(); assert!(err(&mut s, json!({"id": "a"})).contains("`enabled` must be true or false")); assert!(err(&mut s, json!({"id": "a", "enabled": "yes"})).contains("`enabled` must be true or false")); assert!(err(&mut s, json!({"id": 3, "enabled": false})).contains("`id` must be a string")); assert!(err(&mut s, json!({"enabled": false})).contains("give `id` or `file`")); assert!(err(&mut s, json!({"id": " ", "enabled": false})).contains("give `id` or `file`")); assert!(err(&mut s, json!({"id": "a", "enabled": true})).contains("isn't disabled")); assert!(safe_mode::disabled().is_empty(), "nothing changed"); let r = s.execute("plugin.setEnabled", &json!({"file": "/p/a.wasm", "enabled": false})).unwrap(); assert_eq!( (r["changed"].clone(), r["disabled"][0]["file"].clone(), r["disabled"][0]["label"].clone()), (json!(true), json!("/p/a.wasm"), json!("a.wasm")) ); assert_eq!(r["saved"], false, "a session without a plug-in folder can't keep the change for the next launch"); safe_mode::set_disabled(vec![]); } /// A disabled list too large to read, or one of a later version, stays as it is: the changes wait /// for a save that can merge them. A file that isn't JSON holds nothing to keep and is replaced. #[test] fn a_disabled_list_this_version_cannot_read_is_kept() { let _f = fresh(); let dir = tmp_dir("kept"); let file = dir.join(vectorcraft_engine::store::DISABLED_FILE); let mut s = launch(&dir); let later = serde_json::to_vec(&json!({"format": "vectorcraft-disabled-plugins", "version": 2, "items": [{"id": "test.sm.k"}]})).unwrap(); let large = vec![b' '; safe_mode::MAX_DISABLED_FILE_BYTES as usize + 1]; for (kept, id) in [(later, "test.sm.a"), (large, "test.sm.b")] { std::fs::write(&file, &kept).unwrap(); let r = s.execute("plugin.setEnabled", &json!({"id": id, "enabled": false})).unwrap(); assert_eq!((r["changed"].clone(), r["saved"].clone()), (json!(true), json!(false)), "{id}"); assert!(std::fs::read(&file).unwrap() == kept, "{id}: the file is kept"); } std::fs::write(&file, b"{\"format\": \"vectorcraft-disab").unwrap(); let r = s.execute("plugin.setEnabled", &json!({"id": "test.sm.c", "enabled": false})).unwrap(); assert_eq!(r["saved"], true); let ids: Vec = safe_mode::parse_disabled_file(&std::fs::read(&file).unwrap()).into_iter().filter_map(|i| i.id).collect(); assert_eq!(ids, ["test.sm.a", "test.sm.b", "test.sm.c"], "the waiting changes are saved too"); let _ = std::fs::remove_dir_all(&dir); } /// Another window filled the list after this one read it: a plug-in turned off here has no room in /// the file, and `saved` says so. #[test] fn a_disable_that_finds_the_saved_list_full_is_not_saved() { let _f = fresh(); let dir = tmp_dir("full-merge"); let mut s = launch(&dir); let items: Vec = (0..safe_mode::MAX_DISABLED).map(|i| json!({"id": format!("test.sm.full{i}")})).collect(); let file = json!({"format": "vectorcraft-disabled-plugins", "version": 1, "items": items}); std::fs::write(dir.join(vectorcraft_engine::store::DISABLED_FILE), serde_json::to_vec(&file).unwrap()).unwrap(); let r = s.execute("plugin.setEnabled", &json!({"id": "test.sm.late", "enabled": false})).unwrap(); assert_eq!((r["changed"].clone(), r["saved"].clone()), (json!(true), json!(false))); let _ = std::fs::remove_dir_all(&dir); } #[test] fn in_safe_mode_the_plug_in_folder_and_commands_load_and_run_nothing() { let _f = fresh(); let dir = tmp_dir("safe"); let mut s = launch(&dir); s.execute("plugin.install", &json!({"dataBase64": vectorcraft_format::base64_encode(&filter("test.sm.s"))})).unwrap(); let extra = tmp_dir("safe-extra"); std::fs::write(extra.join("e.wasm"), filter("test.sm.e")).unwrap(); vectorcraft_engine::cmd::plugin::start_in_safe_mode(); let mut s = launch(&dir); assert!(!installed("test.sm.s"), "stored plug-ins don't load"); let safe = "Safe Mode is on: plug-ins don't load or run until VectorCraft starts normally"; let install = s.execute("plugin.install", &json!({"dataBase64": vectorcraft_format::base64_encode(&filter("test.sm.t"))})); assert!(install.unwrap_err().to_string().contains(safe)); let reload = s.execute("plugin.reload", &json!({"path": extra.to_string_lossy()})); assert!(reload.unwrap_err().to_string().contains(safe)); assert!(!installed("test.sm.e") && !installed("test.sm.t")); let state = s.execute("plugin.safeMode", &json!({})).unwrap(); assert_eq!((state["active"].clone(), state["source"].clone()), (json!(true), json!("flag"))); assert_eq!(s.execute("plugin.list", &json!({})).unwrap()["safeMode"], true); // Leaving the choice after a launch that stopped with Start Normally loads them. safe_mode::set_active(None); vectorcraft_engine::cmd::plugin::load_prefs_folder(&mut s); assert!(installed("test.sm.s")); let _ = std::fs::remove_dir_all(&dir); let _ = std::fs::remove_dir_all(&extra); } #[test] fn a_live_effect_skipped_in_safe_mode_stays_on_its_object_and_says_so() { let _f = fresh(); let mut s = Session::new(); let fx = wat::parse_str(tpl::returning(&tpl::manifest("test.sm.fx", "effect", "{}"), r#"{"objects":[]}"#)).unwrap(); s.execute("plugin.install", &json!({"dataBase64": vectorcraft_format::base64_encode(&fx)})).unwrap(); s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap(); s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 50, "height": 50})).unwrap(); s.execute("effect.apply", &json!({"effect": "plugin.test.sm.fx"})).unwrap(); let effect = |s: &mut Session| s.execute("effect.list", &json!({})).unwrap()["applied"][0]["effects"][0].clone(); assert_eq!((effect(&mut s)["id"].clone(), effect(&mut s).get("skipped").cloned()), (json!("plugin.test.sm.fx"), None)); safe_mode::set_active(Some(safe_mode::Source::Flag)); let skipped = effect(&mut s); assert_eq!((skipped["id"].clone(), skipped["skipped"].clone()), (json!("plugin.test.sm.fx"), json!(true)), "the object keeps it"); safe_mode::set_active(None); assert_eq!(effect(&mut s).get("skipped"), None); } /// In Safe Mode, Expand Appearance refuses an object whose plug-in effect it would drop, its own or /// in a symbol's art it places, and changes nothing. #[test] fn expand_appearance_keeps_a_plug_in_effect_safe_mode_skips() { let _f = fresh(); let mut s = Session::new(); let fx = wat::parse_str(tpl::returning(&tpl::manifest("test.sm.xp", "effect", "{}"), r#"{"objects":[]}"#)).unwrap(); s.execute("plugin.install", &json!({"dataBase64": vectorcraft_format::base64_encode(&fx)})).unwrap(); s.execute("file.new", &json!({"width": 200, "height": 200})).unwrap(); s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 50, "height": 50})).unwrap(); s.execute("effect.apply", &json!({"effect": "plugin.test.sm.xp"})).unwrap(); let doc = |s: &mut Session| s.execute("document.inspect", &json!({})).unwrap(); let refused = |s: &mut Session| { let before = doc(s); let err = s.execute("effect.expandAppearance", &json!({})).unwrap_err().to_string(); assert!(err.contains("a plug-in effect in the selection can't be expanded in Safe Mode"), "{err}"); assert_eq!(doc(s), before, "nothing changes"); }; safe_mode::set_active(Some(safe_mode::Source::Flag)); refused(&mut s); // An instance, with an effect of its own, of a symbol whose art holds the plug-in effect. safe_mode::set_active(None); s.execute("symbol.new", &json!({"name": "fx"})).unwrap(); s.execute("effect.apply", &json!({"effect": "distort.puckerBloat"})).unwrap(); safe_mode::set_active(Some(safe_mode::Source::Flag)); refused(&mut s); safe_mode::set_active(None); s.execute("effect.expandAppearance", &json!({})).unwrap(); // A plain instance of that symbol doesn't expand: selected with an object that does, they expand. let plain = s.execute("symbol.place", &json!({"name": "fx", "x": 150, "y": 150})).unwrap()["id"].clone(); let other = s.execute("shape.rectangle", &json!({"x": 10, "y": 120, "width": 30, "height": 30})).unwrap()["id"].clone(); s.execute("effect.apply", &json!({"effect": "distort.puckerBloat"})).unwrap(); safe_mode::set_active(Some(safe_mode::Source::Flag)); s.execute("effect.expandAppearance", &json!({"ids": [plain, other]})).unwrap(); safe_mode::set_active(None); // A plug-in effect on a hidden fill: Expand Appearance drops that fill, so it expands. s.execute("shape.rectangle", &json!({"x": 100, "y": 10, "width": 50, "height": 50})).unwrap(); s.execute("effect.apply", &json!({"effect": "plugin.test.sm.xp", "item": 0})).unwrap(); s.execute("appearance.setItem", &json!({"index": 0, "visible": false})).unwrap(); safe_mode::set_active(Some(safe_mode::Source::Flag)); s.execute("effect.expandAppearance", &json!({})).unwrap(); }