Files
vectorcraft/crates/engine/tests/plugin_safe_mode.rs
T

414 lines
21 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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<u8> {
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<Value> = 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<String> {
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<String> {
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<safe_mode::Item> = (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<String> = 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<Value> = (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();
}