//! Safe Mode, disabled plug-ins and the launch marker. //! //! **Safe Mode**: while it is on, no plug-in code runs in this process. [`crate::Plugin::load`] and //! every run of an installed plug-in fail with [`Error::SafeMode`], so a plug-in installed before //! Safe Mode was turned on can't run either, and live effects draw their object's geometry //! unchanged. The apps turn it on at launch (`--safe-mode`, or the choice after a launch that //! stopped while a plug-in was loading) and off only when the user starts normally from that //! choice. //! //! **Disabled plug-ins**: the items the user turned off. Loading skips them by file or by id //! ([`skipped`]); nothing is uninstalled. [`set_disabled`] sets both the list this launch skips and //! the list saved for the next one; [`set_enabled`] changes only the saved list, so turning an item //! back on takes effect at the next launch. //! //! **Launch marker** (native builds): a small file in a folder the app picks, written before the //! stored plug-ins load, updated before and after each module load and each live effect's first //! evaluation (and each evaluation that starts while one of those is running) to name the oldest //! item still loading (several threads can load at once), and removed //! when the app quits ([`begin_launch`], [`loading`], [`loaded`], [`end_launch`]). A launch that //! ends while an item is still loading (a panic the app caught while a plug-in loaded) keeps its //! marker naming that item. A marker left behind by a //! launch that is no longer running names the item that was loading when it stopped, which may or //! may not be what stopped it. Until the user //! chooses what to do about that item ([`forget_previous`]), the next launch's marker names it too //! and stays when that launch quits, so a launch that asks nothing (`--safe-mode`) or one closed in //! Safe Mode leaves the choice to the launch after it. Each launch has //! its own marker and holds a lock on a file beside it, so a launch that is still running (a second //! window) is never taken for one that stopped. A marker that is missing, empty, unreadable, //! malformed, larger than [`MAX_MARKER_BYTES`] or of another version names nothing. use std::sync::atomic::{AtomicU8, Ordering}; use std::sync::{Mutex, RwLock}; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; use crate::{Error, Result}; /// The longest id, file or name a stored item keeps, in characters. const MAX_FIELD_CHARS: usize = 1024; /// The most bytes one stored item takes in JSON: three fields of [`MAX_FIELD_CHARS`] characters, /// each written in at most 6 bytes (`\u001f`), plus keys, quotes and indentation. const MAX_ITEM_JSON_BYTES: usize = 3 * MAX_FIELD_CHARS * 6 + 256; /// The largest launch marker read, in bytes: room for one stored item. pub const MAX_MARKER_BYTES: u64 = (MAX_ITEM_JSON_BYTES + 256) as u64; /// The longest name an item is shown with, in characters (longer ones end with "…"). pub const MAX_NAME_CHARS: usize = 64; /// The most disabled items kept. pub const MAX_DISABLED: usize = 1000; /// The largest disabled-items file read, in bytes: room for [`MAX_DISABLED`] stored items. pub const MAX_DISABLED_FILE_BYTES: u64 = (MAX_DISABLED * MAX_ITEM_JSON_BYTES + 256) as u64; /// The `format` of a launch marker, and its version. const MARKER_FORMAT: &str = "vectorcraft-launch"; const MARKER_VERSION: u64 = 1; /// Why Safe Mode is on. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Source { /// The `--safe-mode` option. Flag, /// The launch after one that stopped while a plug-in was loading, until the user chooses. Startup, } impl Source { /// Its name in `plugin.safeMode`. pub fn name(self) -> &'static str { match self { Source::Flag => "flag", Source::Startup => "startup", } } } /// 0: off; otherwise a [`Source`]. static ACTIVE: AtomicU8 = AtomicU8::new(0); /// Turns Safe Mode on for `source`, or off with `None`. pub fn set_active(source: Option) { ACTIVE.store( match source { None => 0, Some(Source::Flag) => 1, Some(Source::Startup) => 2, }, Ordering::SeqCst, ); } /// Why Safe Mode is on; `None` while it is off. pub fn source() -> Option { match ACTIVE.load(Ordering::SeqCst) { 1 => Some(Source::Flag), 2 => Some(Source::Startup), _ => None, } } /// Is Safe Mode on? pub fn active() -> bool { source().is_some() } /// [`Error::SafeMode`] while Safe Mode is on. pub fn check() -> Result<()> { if active() { Err(Error::SafeMode) } else { Ok(()) } } /// A plug-in as the launch marker and the disabled list name it: its id when known, the file it /// was loaded from, and its name. Two items are the same plug-in when their ids match, or, when /// either has no id, their files match. An id or file longer than [`MAX_FIELD_CHARS`] characters /// is kept, and compared, as its start followed by a hash of the whole ([`bound`]). #[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] pub struct Item { #[serde(default, skip_serializing_if = "Option::is_none")] pub id: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub file: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub name: Option, } impl Item { /// The item of a module loaded from `file`, before its id is known. pub fn file(file: impl Into) -> Self { Item { file: Some(file.into()), ..Item::default() } } /// The item of an installed plug-in. pub fn of(plugin: &crate::Plugin) -> Self { Item { id: Some(plugin.id().to_string()), file: plugin.source().map(str::to_string), name: Some(plugin.manifest().name.clone()) } } /// Is `other` the same plug-in? pub fn same(&self, other: &Item) -> bool { match (&self.id, &other.id) { (Some(a), Some(b)) => bound(a) == bound(b), _ => match (&self.file, &other.file) { (Some(a), Some(b)) => bound(a) == bound(b), _ => false, }, } } /// The name it is shown with: its name, else its id, else its file's name, through /// [`display_name`]. pub fn label(&self) -> String { let file_name = || self.file.as_deref().map(|f| f.rsplit(['/', '\\']).next().unwrap_or(f)); display_name(self.name.as_deref().filter(|n| !n.trim().is_empty()).or(self.id.as_deref()).or_else(file_name).unwrap_or("")) } /// Its fields, each at most [`MAX_FIELD_CHARS`] characters (the id and the file through /// [`bound`], the name cut); `None` when it has neither an id nor a file. fn bounded(self) -> Option { let keep = |s: Option| s.filter(|s| !s.is_empty()).map(|s| bound(&s).into_owned()); let name = self.name.filter(|s| !s.is_empty()).map(|s| s.chars().take(MAX_FIELD_CHARS).collect::()); let item = Item { id: keep(self.id), file: keep(self.file), name }; (item.id.is_some() || item.file.is_some()).then_some(item) } /// As `plugin.safeMode` lists it: `{id, file, name, label}`. pub fn to_json(&self) -> Value { json!({"id": self.id, "file": self.file, "name": self.name, "label": self.label()}) } } /// `s` when it has at most [`MAX_FIELD_CHARS`] characters; else its start, `#` and a hash of the /// whole of it, [`MAX_FIELD_CHARS`] characters in all, so two long values that share a start stay /// apart. A value made this way is its own bound. fn bound(s: &str) -> std::borrow::Cow<'_, str> { if s.chars().nth(MAX_FIELD_CHARS).is_none() { return std::borrow::Cow::Borrowed(s); } // FNV-1a: stable across versions and platforms. let hash = s.bytes().fold(0xcbf2_9ce4_8422_2325_u64, |h, b| (h ^ u64::from(b)).wrapping_mul(0x0100_0000_01b3)); let start: String = s.chars().take(MAX_FIELD_CHARS - 17).collect(); std::borrow::Cow::Owned(format!("{start}#{hash:016x}")) } /// `s` safe to show in a sentence: control characters and the characters that reorder text /// (U+061C, U+200E, U+200F, U+202A to U+202E, U+2066 to U+2069) removed, at most /// [`MAX_NAME_CHARS`] characters (a longer name ends with "…"). pub fn display_name(s: &str) -> String { let reorders = |c: char| matches!(c, '\u{061C}' | '\u{200E}' | '\u{200F}' | '\u{202A}'..='\u{202E}' | '\u{2066}'..='\u{2069}'); let clean: Vec = s.trim().chars().filter(|&c| !c.is_control() && !reorders(c)).collect(); if clean.len() > MAX_NAME_CHARS { clean[..MAX_NAME_CHARS - 1].iter().chain(std::iter::once(&'…')).collect() } else { clean.into_iter().collect() } } /// The disabled items this launch skips, the ones saved for the next launch, and the changes made /// to the saved list since this launch last read or wrote the file the apps keep it in. struct Disabled { skipped: Vec, saved: Vec, changes: Vec, /// Counts the [`set_disabled`] calls. read: u64, } /// One change to the saved list, made again to the file's list when it is saved ([`merge_saved`]). #[derive(Clone)] enum Change { /// [`set_enabled`] (or [`disable`], turning it off). Enabled(Item, bool), /// [`disable_file_of`]. FileOf(Item), } impl Change { /// Makes the change to `list` → whether it changed. fn apply(&self, list: &mut Vec) -> bool { match self { Change::Enabled(item, enabled) => enable_in(list, item, *enabled), Change::FileOf(plugin) => file_of_in(list, plugin), } } } static DISABLED: RwLock = RwLock::new(Disabled { skipped: Vec::new(), saved: Vec::new(), changes: Vec::new(), read: 0 }); /// Sets the disabled items, for this launch and the next, as read from the file the apps keep them /// in (at most [`MAX_DISABLED`]; one without an id or a file is dropped). pub fn set_disabled(items: Vec) { let items: Vec = items.into_iter().filter_map(Item::bounded).take(MAX_DISABLED).collect(); let mut d = DISABLED.write().unwrap_or_else(|e| e.into_inner()); d.skipped = items.clone(); d.saved = items; d.changes.clear(); d.read += 1; } /// What [`merge_saved`] made: the content to write to the file, and which changes it holds. #[derive(Debug)] pub struct Merged { /// The file's new content. pub bytes: Vec, /// The changes it holds: the first `made` of them. made: usize, /// The [`set_disabled`] they follow. read: u64, /// Of those, the ones turning a plug-in off that a full list (another window's) had no room /// for: they stay to be made at the next save. full: Vec, } /// Before the saved list is written: takes in `theirs`, the list the file holds now (another /// window sharing the preferences may have saved it since this launch read it; `None` when it /// can't be read, and the saved list stays as it is). The changes this launch made to the saved /// list since it last read or wrote the file ([`saved_written`]) are made again, in order, to /// `theirs`, and the result becomes the saved list. This launch's skipped list stays as it is. pub fn merge_saved(theirs: Option>) -> Merged { let mut d = DISABLED.write().unwrap_or_else(|e| e.into_inner()); let made = d.changes.len(); let mut full = Vec::new(); if let Some(theirs) = theirs { let mut merged: Vec = theirs.into_iter().filter_map(Item::bounded).take(MAX_DISABLED).collect(); for (i, change) in d.changes.iter().enumerate() { if !change.apply(&mut merged) && let Change::Enabled(item, false) = change && !merged.iter().any(|m| m.same(item)) { full.push(i); } } d.saved = merged; } Merged { bytes: file_bytes(&d.saved), made, read: d.read, full } } impl Merged { /// Does the content hold every change? A plug-in turned off that a full list had no room for /// is not in it (it waits for the next save). pub fn complete(&self) -> bool { self.full.is_empty() } } /// `merged`'s content went to the file: later saves make again only the changes made after /// [`merge_saved`], and the ones it had no room for ([`merge_saved`]). pub fn saved_written(merged: &Merged) { let mut d = DISABLED.write().unwrap_or_else(|e| e.into_inner()); if d.read != merged.read { return; } let made = merged.made.min(d.changes.len()); let kept: Vec = merged.full.iter().filter_map(|&i| d.changes.get(i).filter(|_| i < made).cloned()).collect(); d.changes.drain(..made); d.changes.splice(0..0, kept); } /// Adds `item` to the disabled items, for this launch and the next → whether the list saved for /// the next launch has it (`false` when that list is full). This launch skips it either way. pub fn disable(item: Item) -> bool { let Some(item) = item.bounded() else { return false }; let mut d = DISABLED.write().unwrap_or_else(|e| e.into_inner()); let d = &mut *d; if !d.skipped.iter().any(|s| s.same(&item)) { d.skipped.push(item.clone()); } if enable_in(&mut d.saved, &item, false) { d.changes.push(Change::Enabled(item, false)); return true; } d.saved.iter().any(|s| s.same(&item)) } /// Turns `item` on or off for the next launch, leaving this launch as it is → whether the saved /// list changed. pub fn set_enabled(item: &Item, enabled: bool) -> bool { let mut d = DISABLED.write().unwrap_or_else(|e| e.into_inner()); let changed = enable_in(&mut d.saved, item, enabled); if changed { d.changes.push(Change::Enabled(item.clone(), enabled)); } changed } /// Turns `item` on (removes every entry for it) or off (adds it unless an entry has it, within /// [`MAX_DISABLED`]) in `list` → whether it changed. fn enable_in(list: &mut Vec, item: &Item, enabled: bool) -> bool { let before = list.len(); if enabled { list.retain(|i| !i.same(item)); list.len() != before } else if let Some(item) = item.clone().bounded().filter(|i| !list.iter().any(|s| s.same(i)) && before < MAX_DISABLED) { list.push(item); true } else { false } } /// `plugin`, loaded from its file and then skipped by its id: each list that disables it gets its /// file too (the entry naming its id without a file takes it, or a new entry is added), so a later /// load skips that file before running any of its code → whether the saved list changed. pub fn disable_file_of(plugin: &Item) -> bool { let mut d = DISABLED.write().unwrap_or_else(|e| e.into_inner()); let d = &mut *d; file_of_in(&mut d.skipped, plugin); let changed = file_of_in(&mut d.saved, plugin); if changed { d.changes.push(Change::FileOf(plugin.clone())); } changed } /// [`disable_file_of`] for one list → whether it changed. fn file_of_in(list: &mut Vec, plugin: &Item) -> bool { let Some(file) = plugin.file.as_deref().filter(|f| !f.is_empty()) else { return false }; let by_file = Item::file(file); if list.iter().any(|i| i.same(&by_file)) || !list.iter().any(|i| i.same(plugin)) { return false; } if let Some(entry) = list.iter_mut().find(|i| i.same(plugin) && i.file.is_none()) { entry.file = Some(bound(file).into_owned()); } else if let Some(item) = plugin.clone().bounded().filter(|_| list.len() < MAX_DISABLED) { list.push(item); } else { return false; } true } /// The disabled items saved for the next launch. pub fn disabled() -> Vec { DISABLED.read().unwrap_or_else(|e| e.into_inner()).saved.clone() } /// Does this launch skip `item` (a module file before it loads, or a plug-in once its id is known)? pub fn skipped(item: &Item) -> bool { DISABLED.read().unwrap_or_else(|e| e.into_inner()).skipped.iter().any(|i| i.same(item)) } /// The ids of the disabled items this launch skips. pub fn skipped_ids() -> Vec { DISABLED.read().unwrap_or_else(|e| e.into_inner()).skipped.iter().filter_map(|i| i.id.clone()).collect() } /// The disabled items saved for the next launch, as the file the apps keep them in. pub fn disabled_file() -> Vec { file_bytes(&disabled()) } /// `items` as the file the apps keep the disabled items in. fn file_bytes(items: &[Item]) -> Vec { serde_json::to_vec_pretty(&json!({"format": "vectorcraft-disabled-plugins", "version": 1, "items": items})).unwrap_or_default() } /// The items of a file [`disabled_file`] wrote; none when it isn't one. pub fn parse_disabled_file(bytes: &[u8]) -> Vec { read_disabled_file(bytes).unwrap_or_default() } /// The items of a file [`disabled_file`] wrote, `None` when the file is JSON of another format or /// version (a later version's list, which a save must not replace). Bytes that aren't JSON hold no /// items any launch can read: none. pub fn read_disabled_file(bytes: &[u8]) -> Option> { let Ok(v) = serde_json::from_slice::(bytes) else { return Some(vec![]) }; if v["format"] != "vectorcraft-disabled-plugins" || v["version"] != 1 { return None; } Some( v["items"] .as_array() .into_iter() .flatten() .filter_map(|i| serde_json::from_value::(i.clone()).ok()?.bounded()) .take(MAX_DISABLED) .collect(), ) } /// The item the launch before this one was loading when it stopped, once [`begin_launch`] read it. static PREVIOUS: Mutex> = Mutex::new(None); /// The item the launch before this one was loading when it stopped ([`begin_launch`]); `None` once /// the user chose what to do about it ([`forget_previous`]). pub fn previous() -> Option { PREVIOUS.lock().unwrap_or_else(|e| e.into_inner()).clone() } /// The user chose what to do about [`previous`]: this launch's marker stops naming it. pub fn forget_previous() { *PREVIOUS.lock().unwrap_or_else(|e| e.into_inner()) = None; refresh(); } /// The items still loading, oldest first: one entry for each [`loading`] call, until the [`loaded`] /// given its [`Loading`]. struct Running { next: u64, items: Vec<(u64, Item)>, } #[cfg(not(target_arch = "wasm32"))] impl Running { /// The item this launch's marker names: the oldest item still loading, else [`previous`]. fn pending(&self) -> Option { self.items.first().map(|(_, i)| i.clone()).or_else(previous) } } static LOADING: Mutex = Mutex::new(Running { next: 0, items: Vec::new() }); /// One item [`loading`] named, until it is given to [`loaded`]. #[must_use] #[derive(Debug)] pub struct Loading(u64); /// Writes this launch's marker naming the item [`Running::pending`] gives, while holding the lock /// on the items still loading, so two threads' writes land in the order of their changes. fn refresh() { #[cfg(not(target_arch = "wasm32"))] marker::write(LOADING.lock().unwrap_or_else(|e| e.into_inner()).pending().as_ref()); } /// The marker's content naming `item`, cut as a stored item is (none: nothing is loading). #[cfg(not(target_arch = "wasm32"))] fn marker_bytes(item: Option<&Item>) -> Vec { let item = item.cloned().and_then(Item::bounded); serde_json::to_vec(&json!({"format": MARKER_FORMAT, "version": MARKER_VERSION, "item": item})).unwrap_or_default() } /// The item a marker's content names; `None` when it names none or isn't a marker of this version. pub fn parse_marker(bytes: &[u8]) -> Option { if bytes.len() as u64 > MAX_MARKER_BYTES { return None; } let v: Value = serde_json::from_slice(bytes).ok()?; if v["format"] != MARKER_FORMAT || v["version"] != MARKER_VERSION { return None; } serde_json::from_value::(v.get("item").filter(|i| i.is_object())?.clone()).ok()?.bounded() } #[cfg(not(target_arch = "wasm32"))] pub use marker::{begin_launch, end_launch}; /// Adds `item` to the items still loading and writes this launch's marker naming the oldest of /// them (no marker: only the list changes) → the [`Loading`] to give to [`loaded`] when it is done. pub fn loading(item: &Item) -> Loading { let mut running = LOADING.lock().unwrap_or_else(|e| e.into_inner()); let token = running.next; running.next = running.next.wrapping_add(1); running.items.push((token, item.clone())); #[cfg(not(target_arch = "wasm32"))] marker::write(running.pending().as_ref()); Loading(token) } /// Removes the item `token` names from the items still loading and writes this launch's marker /// naming the oldest one left; with none left, the item of [`previous`] or nothing. pub fn loaded(token: Loading) { let mut running = LOADING.lock().unwrap_or_else(|e| e.into_inner()); running.items.retain(|(t, _)| *t != token.0); #[cfg(not(target_arch = "wasm32"))] marker::write(running.pending().as_ref()); } #[cfg(not(target_arch = "wasm32"))] mod marker { use std::fs::File; use std::io::{Read, Write}; use std::path::{Path, PathBuf}; use std::sync::Mutex; use super::{Item, LOADING, MAX_MARKER_BYTES, PREVIOUS, marker_bytes, parse_marker}; /// The most marker files looked at in the folder. const MAX_MARKERS: usize = 100; const PREFIX: &str = "launch-"; /// This launch's marker: its folder, its name and the open lock file. struct Current { dir: PathBuf, token: String, _lock: File, } static CURRENT: Mutex> = Mutex::new(None); fn json_path(dir: &Path, token: &str) -> PathBuf { dir.join(format!("{PREFIX}{token}.json")) } fn lock_path(dir: &Path, token: &str) -> PathBuf { dir.join(format!("{PREFIX}{token}.lock")) } fn tmp_path(dir: &Path, token: &str) -> PathBuf { dir.join(format!("{PREFIX}{token}.json.tmp")) } /// Is the launch `token` still running (does it hold its lock)? fn running(dir: &Path, token: &str) -> bool { let Ok(f) = File::options().read(true).write(true).open(lock_path(dir, token)) else { return false }; match f.try_lock() { Ok(()) => { let _ = f.unlock(); false } Err(_) => true, } } /// The content of `path` up to [`MAX_MARKER_BYTES`]; `None` when it can't be read or is larger. fn read(path: &Path) -> Option> { let mut bytes = Vec::new(); File::open(path).ok()?.take(MAX_MARKER_BYTES + 1).read_to_end(&mut bytes).ok()?; (bytes.len() as u64 <= MAX_MARKER_BYTES).then_some(bytes) } /// Starts this launch's marker in `dir` → the item a launch that is no longer running was /// loading when it stopped, if one was (its marker is then removed, and this launch's marker /// names the item until [`super::forget_previous`]). A folder that can't be written leaves this /// launch without a marker; it still starts. pub fn begin_launch(dir: &Path) -> Option { let mut previous = None; let mut tokens: Vec = std::fs::read_dir(dir) .into_iter() .flatten() .filter_map(|e| e.ok()?.file_name().into_string().ok()) .filter_map(|n| Some(n.strip_prefix(PREFIX)?.strip_suffix(".json")?.to_string())) .collect(); tokens.sort(); tokens.truncate(MAX_MARKERS); for token in tokens { if running(dir, &token) { continue; } if previous.is_none() { previous = read(&json_path(dir, &token)).as_deref().and_then(parse_marker); } for path in [json_path(dir, &token), tmp_path(dir, &token), lock_path(dir, &token)] { let _ = std::fs::remove_file(path); } } *PREVIOUS.lock().unwrap_or_else(|e| e.into_inner()) = previous.clone(); let nanos = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.as_nanos()); let token = format!("{}-{nanos:x}", std::process::id()); let lock = std::fs::create_dir_all(dir) .and_then(|()| File::options().create(true).truncate(false).read(true).write(true).open(lock_path(dir, &token))); if let Ok(lock) = lock.and_then(|f| f.try_lock().map(|()| f).map_err(std::io::Error::other)) { *CURRENT.lock().unwrap_or_else(|e| e.into_inner()) = Some(Current { dir: dir.to_path_buf(), token, _lock: lock }); super::refresh(); } previous } /// Replaces this launch's marker with one naming `item`: written to a temporary file first, /// then renamed over the marker, so a reader finds the old marker or the new one. A write that /// fails leaves the marker as it was. pub(super) fn write(item: Option<&Item>) { let current = CURRENT.lock().unwrap_or_else(|e| e.into_inner()); let Some(c) = current.as_ref() else { return }; let tmp = tmp_path(&c.dir, &c.token); let _ = File::create(&tmp) .and_then(|mut f| f.write_all(&marker_bytes(item)).and_then(|()| f.sync_all())) .and_then(|()| std::fs::rename(&tmp, json_path(&c.dir, &c.token))); } /// Removes this launch's marker: the app is quitting normally. While an item is still loading /// ([`super::loading`] without [`super::loaded`]), the marker stays, naming the oldest one; /// otherwise, while the user has not chosen what to do about [`super::previous`], the marker /// stays, naming that, so the next launch asks. pub fn end_launch() { let running = LOADING.lock().unwrap_or_else(|e| e.into_inner()); let pending = running.pending(); if pending.is_some() { write(pending.as_ref()); } let Some(c) = CURRENT.lock().unwrap_or_else(|e| e.into_inner()).take() else { return }; let Current { dir, token, _lock } = c; drop(_lock); let marker = pending.is_none().then(|| json_path(&dir, &token)); for path in marker.into_iter().chain([tmp_path(&dir, &token), lock_path(&dir, &token)]) { let _ = std::fs::remove_file(path); } } #[cfg(test)] pub(super) fn path_of_current() -> Option { CURRENT.lock().unwrap_or_else(|e| e.into_inner()).as_ref().map(|c| json_path(&c.dir, &c.token)) } } /// Serializes the tests that change this module's process-wide state. #[cfg(test)] pub(crate) static TEST_LOCK: Mutex<()> = Mutex::new(()); #[cfg(test)] mod tests { use super::*; /// Safe Mode off, no launch marker, nothing loading, pending or disabled. fn reset() { LOADING.lock().unwrap_or_else(|e| e.into_inner()).items.clear(); end_launch(); set_active(None); forget_previous(); set_disabled(vec![]); } /// Holds [`TEST_LOCK`] for one test and resets the module's 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 = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); reset(); Fresh { _guard: guard } } impl Drop for Fresh { fn drop(&mut self) { reset(); } } fn temp_dir(name: &str) -> std::path::PathBuf { let d = std::env::temp_dir().join(format!("vc-safe-mode-{name}-{}", std::process::id())); let _ = std::fs::remove_dir_all(&d); std::fs::create_dir_all(&d).unwrap(); d } #[test] fn names_are_bounded_and_cannot_reorder_or_hide_text() { assert_eq!(display_name("Good\u{202E}gnp.exe"), "Goodgnp.exe"); assert_eq!(display_name("a\u{0007}b\nc\u{2067}d\u{200F}"), "abcd"); let long = "x".repeat(200); let shown = display_name(&long); assert_eq!(shown.chars().count(), MAX_NAME_CHARS); assert!(shown.ends_with('…')); // The label shows the name, but the id still picks the plug-in. let item = Item { id: Some("org.example.a".into()), file: None, name: Some("\u{202E}evil".into()) }; assert_eq!(item.label(), "evil"); assert!(item.same(&Item { id: Some("org.example.a".into()), file: None, name: Some("other".into()) })); assert!(!item.same(&Item { id: Some("org.example.b".into()), file: None, name: Some("\u{202E}evil".into()) })); assert_eq!(Item::file("C:\\Plug-ins\\x.wasm").label(), "x.wasm"); } #[test] fn only_a_marker_of_this_version_names_an_item() { let item = Item { id: Some("org.example.x".into()), file: Some("/p/x.wasm".into()), name: None }; assert_eq!(parse_marker(&marker_bytes(Some(&item))), Some(item)); assert_eq!(parse_marker(&marker_bytes(None)), None, "nothing was loading"); for bad in [ &b""[..], b"{", b"[]", br#"{"format":"vectorcraft-launch","version":2,"item":{"id":"x"}}"#, br#"{"format":"other","version":1,"item":{"id":"x"}}"#, br#"{"format":"vectorcraft-launch","version":1,"item":"x"}"#, br#"{"format":"vectorcraft-launch","version":1,"item":{}}"#, br#"{"format":"vectorcraft-launch","version":1,"item":{"id":"x""#, ] { assert_eq!(parse_marker(bad), None, "{}", String::from_utf8_lossy(bad)); } let mut big = format!(r#"{{"format":"vectorcraft-launch","version":1,"item":{{"id":"{}"}}}}"#, "y".repeat(MAX_MARKER_BYTES as usize)).into_bytes(); assert_eq!(parse_marker(&big), None, "larger than the limit"); big.truncate(10); assert_eq!(parse_marker(&big), None, "cut short"); } #[test] fn a_launch_that_stopped_names_its_item_and_a_running_one_does_not() { let _f = fresh(); let dir = temp_dir("marker"); // A launch that stopped while loading x: its marker without a held lock, and a stray // temporary file from a write it didn't finish. let x = Item { id: Some("org.example.x".into()), file: None, name: Some("X".into()) }; std::fs::write(dir.join("launch-1-a.json"), marker_bytes(Some(&x))).unwrap(); std::fs::write(dir.join("launch-1-a.json.tmp"), b"{\"format\":\"vectorcr").unwrap(); assert_eq!(begin_launch(&dir), Some(x.clone())); assert_eq!(previous(), Some(x.clone())); assert!(!dir.join("launch-1-a.json").exists() && !dir.join("launch-1-a.json.tmp").exists(), "read once"); let mine = marker::path_of_current().unwrap(); assert_eq!(std::fs::read(&mine).ok().as_deref().and_then(parse_marker), Some(x.clone()), "named until the user chooses"); forget_previous(); assert_eq!(std::fs::read(&mine).ok().as_deref().and_then(parse_marker), None); let token = loading(&x); assert_eq!(std::fs::read(&mine).ok().as_deref().and_then(parse_marker), Some(x.clone())); assert!(!mine.with_extension("json.tmp").exists(), "the temporary file is renamed over the marker"); loaded(token); assert_eq!(std::fs::read(&mine).ok().as_deref().and_then(parse_marker), None); end_launch(); assert!(!mine.exists(), "a normal quit removes the marker"); assert_eq!(begin_launch(&dir), None, "nothing left behind"); end_launch(); forget_previous(); let _ = std::fs::remove_dir_all(&dir); } #[test] fn a_launch_that_quits_before_the_user_chooses_leaves_the_choice_to_the_next() { let _f = fresh(); let dir = temp_dir("pending"); let x = Item { id: Some("org.example.x".into()), file: None, name: Some("X".into()) }; std::fs::write(dir.join("launch-1-a.json"), marker_bytes(Some(&x))).unwrap(); assert_eq!(begin_launch(&dir), Some(x.clone())); end_launch(); assert_eq!(begin_launch(&dir), Some(x.clone()), "asked again"); forget_previous(); end_launch(); assert_eq!(begin_launch(&dir), None, "once the user chose"); end_launch(); let _ = std::fs::remove_dir_all(&dir); } #[test] fn a_launch_that_ends_while_a_plug_in_loads_names_it_at_the_next_launch() { let _f = fresh(); let dir = temp_dir("ends-loading"); let x = Item { id: Some("org.example.x".into()), file: None, name: Some("X".into()) }; assert_eq!(begin_launch(&dir), None); let token = loading(&x); // A panic the app caught while x loaded: the app quits without `loaded`. end_launch(); loaded(token); assert_eq!(begin_launch(&dir), Some(x.clone()), "the next launch asks"); forget_previous(); end_launch(); assert_eq!(begin_launch(&dir), None); end_launch(); let _ = std::fs::remove_dir_all(&dir); } #[test] fn the_marker_names_a_plug_in_still_loading_when_another_one_is_done() { let _f = fresh(); let dir = temp_dir("two-loading"); let a = Item { id: Some("org.example.a".into()), file: None, name: Some("A".into()) }; let b = Item { id: Some("org.example.b".into()), file: None, name: Some("B".into()) }; assert_eq!(begin_launch(&dir), None); let mine = marker::path_of_current().unwrap(); let marker = || std::fs::read(&mine).ok().as_deref().and_then(parse_marker); // Two threads run their plug-ins' first evaluations at once. let ta = loading(&a); let tb = loading(&b); assert_eq!(marker(), Some(a.clone()), "the oldest item still loading"); loaded(ta); assert_eq!(marker(), Some(b.clone()), "b is still loading"); // The app stops while b loads. end_launch(); loaded(tb); assert_eq!(begin_launch(&dir), Some(b.clone()), "the next launch offers b"); forget_previous(); end_launch(); assert_eq!(begin_launch(&dir), None); end_launch(); let _ = std::fs::remove_dir_all(&dir); } #[test] fn a_long_file_path_names_and_matches_its_plug_in() { let _f = fresh(); let dir = temp_dir("long-path"); // Each character written as 6 or 2 bytes, so the item is far larger in JSON than in characters. let long = format!("C:\\{}\\x.wasm", "\u{1}é".repeat(700)); assert_eq!(begin_launch(&dir), None); let token = loading(&Item::file(long.clone())); end_launch(); loaded(token); let stopped = begin_launch(&dir).expect("the marker names the module"); assert!(stopped.same(&Item::file(long.clone())), "the stored item matches the full path"); disable(stopped); assert!(skipped(&Item::file(long.clone())), "Disable and Continue skips it"); assert!(!skipped(&Item::file(format!("D{}", &long[1..]))), "a path that differs within the kept part is another plug-in"); let other = long.replace("x.wasm", "y.wasm"); assert!(!skipped(&Item::file(other.clone())), "a path that differs after the kept part is another plug-in"); assert!(!Item::file(long.clone()).same(&Item::file(other))); forget_previous(); end_launch(); set_disabled(vec![]); let _ = std::fs::remove_dir_all(&dir); } #[test] fn a_full_list_of_long_items_fits_the_file_the_apps_read() { let _f = fresh(); let field = |i: usize, tag: &str| format!("{tag}{i:04}{}", "\u{1}".repeat(MAX_FIELD_CHARS)); set_disabled((0..MAX_DISABLED).map(|i| Item { id: Some(field(i, "i")), file: Some(field(i, "f")), name: Some(field(i, "n")) }).collect()); let file = disabled_file(); assert!(file.len() as u64 <= MAX_DISABLED_FILE_BYTES, "{} bytes", file.len()); assert_eq!(parse_disabled_file(&file).len(), MAX_DISABLED); // A full list has no room for one more: this launch skips it, and the caller is told. let late = Item::file("late.wasm"); assert!(!disable(late.clone())); assert!(skipped(&late) && !disabled().contains(&late)); assert!(disable(Item { id: Some(field(0, "i")), ..Item::default() }), "one the list has"); set_disabled(vec![]); } /// A plug-in turned on here is removed from the file's list even when another window saved its /// entry with more details. A change whose save failed, one made while the file was written and /// one that a full list had no room for are made again at the next save. #[test] fn merging_the_saved_list_matches_plug_ins_and_keeps_unsaved_changes() { let _f = fresh(); let id = |s: &str| Item { id: Some(s.into()), ..Item::default() }; set_disabled(vec![id("x")]); assert!(set_enabled(&id("x"), true)); let theirs_x = Item { id: Some("x".into()), file: Some("/p/x.wasm".into()), name: Some("X".into()) }; let merged = merge_saved(Some(vec![theirs_x, id("y")])); assert_eq!(disabled(), [id("y")], "x is turned on, whatever details the other window saved"); assert_eq!(parse_disabled_file(&merged.bytes), [id("y")]); saved_written(&merged); assert!(set_enabled(&id("a"), false)); // Another window has turned c off. let _ = merge_saved(Some(vec![id("y"), id("c")])); // The write failed: no saved_written. The other window turns c on again. assert!(set_enabled(&id("b"), false)); let merged = merge_saved(Some(vec![id("y")])); assert_eq!(disabled(), [id("y"), id("a"), id("b")], "a is still saved, and c stays on"); // A change made while the file is written is made again at the next save. assert!(set_enabled(&id("d"), false)); saved_written(&merged); let _ = merge_saved(Some(vec![id("y"), id("a"), id("b")])); assert_eq!(disabled(), [id("y"), id("a"), id("b"), id("d")]); set_disabled(vec![]); // Another window filled the list: the plug-in turned off here waits for room. let full: Vec = (0..MAX_DISABLED).map(|i| id(&format!("full{i}"))).collect(); assert!(set_enabled(&id("e"), false)); let merged = merge_saved(Some(full.clone())); assert_eq!(disabled(), full); saved_written(&merged); let _ = merge_saved(Some(full[1..].to_vec())); assert_eq!(disabled().last(), Some(&id("e")), "made once there is room"); // This launch adds x's file to its entry: the file's entry for x takes it, unless another // window turned x on meanwhile. let x_file = Item { id: Some("x".into()), file: Some("/p/x.wasm".into()), name: Some("X".into()) }; let x_entry = Item { name: None, ..x_file.clone() }; for (theirs, expected) in [(vec![id("x"), id("y")], vec![x_entry, id("y")]), (vec![id("y")], vec![id("y")])] { set_disabled(vec![id("x"), id("y")]); assert!(disable_file_of(&x_file)); let _ = merge_saved(Some(theirs)); assert_eq!(disabled(), expected); } } #[test] fn a_marker_whose_launch_holds_its_lock_is_left_alone() { let _f = fresh(); let dir = temp_dir("running"); let x = Item { id: Some("org.example.x".into()), file: None, name: None }; std::fs::write(dir.join("launch-9-b.json"), marker_bytes(Some(&x))).unwrap(); let lock = std::fs::File::create(dir.join("launch-9-b.lock")).unwrap(); lock.try_lock().unwrap(); assert_eq!(begin_launch(&dir), None, "that launch is still running"); assert!(dir.join("launch-9-b.json").exists()); end_launch(); drop(lock); assert_eq!(begin_launch(&dir), Some(x), "once it stopped"); end_launch(); forget_previous(); let _ = std::fs::remove_dir_all(&dir); } #[test] fn enabling_takes_effect_at_the_next_launch() { let _f = fresh(); let x = Item { id: Some("org.example.x".into()), file: Some("/p/x.wasm".into()), name: None }; let y = Item::file("/p/y.wasm"); set_disabled(vec![x.clone(), Item::default()]); assert_eq!(disabled(), std::slice::from_ref(&x), "an item that names nothing is dropped"); assert!(skipped(&Item { id: Some("org.example.x".into()), ..Item::default() })); assert!(!skipped(&y)); assert!(set_enabled(&x, true)); assert!(disabled().is_empty()); assert!(skipped(&x), "still skipped until the next launch"); assert!(set_enabled(&y, false) && !set_enabled(&y, false)); assert!(!skipped(&y), "turning one off waits for the next launch too"); disable(y.clone()); assert!(skipped(&y)); assert_eq!(parse_disabled_file(&disabled_file()), disabled()); assert!(parse_disabled_file(b"{\"format\":\"other\",\"version\":1,\"items\":[{\"id\":\"x\"}]}").is_empty()); set_disabled(vec![]); } }