//! The process-wide set of installed plug-ins. //! //! Plug-ins are installed per process, not per document: live effects are drawn from every open //! document (and from scratch sessions such as previews), and they must find the same plug-ins. use std::collections::{BTreeMap, HashSet}; use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Arc, Mutex, RwLock}; #[cfg(not(target_arch = "wasm32"))] use crate::Error; use crate::permissions::Grant; use crate::storage::Storage; use crate::{Limits, Plugin, Result}; static PLUGINS: RwLock>> = RwLock::new(BTreeMap::new()); static REVISION: AtomicU64 = AtomicU64::new(0); /// What the user approved, by plug-in id. static GRANTS: RwLock> = RwLock::new(BTreeMap::new()); /// Where each plug-in keeps its settings and cache, by plug-in id. static STORAGE: RwLock>> = RwLock::new(BTreeMap::new()); /// Keeps the settings and cache of plug-in `id` in `storage` from now on (`None`: nowhere, so its /// storage calls fail as unavailable). A run of the plug-in reaches only its own id's storage. pub fn set_storage(id: &str, storage: Option>) { let mut all = STORAGE.write().unwrap_or_else(|e| e.into_inner()); match storage { Some(s) => all.insert(id.to_string(), s), None => all.remove(id), }; } /// Where plug-in `id` keeps its settings and cache, if anywhere. pub fn storage(id: &str) -> Option> { STORAGE.read().unwrap_or_else(|e| e.into_inner()).get(id).cloned() } /// Records what the user approved for a plug-in, replacing an earlier grant for its id. pub fn grant(g: Grant) { GRANTS.write().unwrap_or_else(|e| e.into_inner()).insert(g.id.clone(), g); } /// What the user approved for plug-in `id`. pub fn granted(id: &str) -> Option { GRANTS.read().unwrap_or_else(|e| e.into_inner()).get(id).cloned() } /// Every grant, sorted by id. pub fn grants() -> Vec { GRANTS.read().unwrap_or_else(|e| e.into_inner()).values().cloned().collect() } /// Forgets what the user approved for `id`; false if nothing was. pub fn revoke(id: &str) -> bool { GRANTS.write().unwrap_or_else(|e| e.into_inner()).remove(id).is_some() } /// May `plugin` be installed without asking the user: does it list no permissions, or did the user /// already grant this very module (its id and SHA-256) every permission it lists? A module with /// other bytes needs a new approval, even when it lists the same permissions. pub fn approved(plugin: &Plugin) -> bool { let m = plugin.manifest(); !m.asks_permissions() || granted(&m.id).is_some_and(|g| g.covers(&m.id, plugin.hash(), &m.permissions())) } /// Installs `plugin`, replacing one with the same id. pub fn install(mut plugin: Plugin) -> Arc { // The revision moves while the write lock is held, so a cache that reads the new revision and // then the plug-ins finds the new plug-in among them. let mut plugins = PLUGINS.write().unwrap_or_else(|e| e.into_inner()); plugin.set_serial(REVISION.fetch_add(1, Ordering::Relaxed) + 1); let p = Arc::new(plugin); plugins.insert(p.id().to_string(), p.clone()); p } /// Loads `bytes` with the default limits and installs the plug-in. pub fn install_bytes(bytes: &[u8]) -> Result> { Ok(install(Plugin::load(bytes, Limits::default())?)) } pub fn get(id: &str) -> Option> { PLUGINS.read().unwrap_or_else(|e| e.into_inner()).get(id).cloned() } /// Installed plug-ins, sorted by id. pub fn list() -> Vec> { PLUGINS.read().unwrap_or_else(|e| e.into_inner()).values().cloned().collect() } /// Uninstalls `id`; false if it wasn't installed. pub fn remove(id: &str) -> bool { // The revision moves while the write lock is held, as in `install`. let mut plugins = PLUGINS.write().unwrap_or_else(|e| e.into_inner()); let gone = plugins.remove(id).is_some(); if gone { REVISION.fetch_add(1, Ordering::Relaxed); } gone } /// Changes whenever a plug-in is installed or removed (for caches such as menus). pub fn revision() -> u64 { REVISION.load(Ordering::Relaxed) } /// `s` as a `&'static str`, for catalogues that hold static strings (effect and menu labels). Each /// distinct string is stored once, so memory stays bounded by the plug-ins ever installed. pub fn intern(s: &str) -> &'static str { static SET: Mutex>> = Mutex::new(None); let mut set = SET.lock().unwrap_or_else(|e| e.into_inner()); let set = set.get_or_insert_with(HashSet::new); if let Some(v) = set.get(s) { return v; } let v: &'static str = Box::leak(s.to_string().into_boxed_str()); set.insert(v); v } /// Reads a `.wasm` file (refusing anything that isn't a regular file or exceeds the module limit) /// and loads it. #[cfg(not(target_arch = "wasm32"))] pub fn load_file(path: &std::path::Path, limits: Limits) -> Result { use std::io::Read; let meta = std::fs::metadata(path).map_err(|e| Error::Io(format!("{}: {e}", path.display())))?; if !meta.is_file() { return Err(Error::Io(format!("{} is not a file", path.display()))); } if meta.len() > limits.max_module_bytes as u64 { return Err(Error::Module(format!("{} is {} bytes (limit {})", path.display(), meta.len(), limits.max_module_bytes))); } let f = std::fs::File::open(path).map_err(|e| Error::Io(format!("{}: {e}", path.display())))?; let mut bytes = Vec::new(); f.take(limits.max_module_bytes as u64 + 1).read_to_end(&mut bytes).map_err(|e| Error::Io(format!("{}: {e}", path.display())))?; Ok(Plugin::load(&bytes, limits)?.with_source(path.display().to_string())) } /// What [`load_folder`] reports for a plug-in the user disabled. pub const DISABLED: &str = "disabled: turn it on again in Help › Safe Mode, for the next launch"; /// What [`load_folder`] did. #[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct FolderReport { /// Ids of the plug-ins installed. pub loaded: Vec, /// `(file, error)` for each `.wasm` that failed to load. pub failed: Vec<(String, String)>, /// The disabled items saved for the next launch changed: a plug-in disabled by its id alone was /// found in a file, which is now disabled too ([`crate::safe_mode::disable_file_of`]). The app /// saves the list again. pub disabled_changed: bool, } /// Installs every `*.wasm` directly in `dir` (not recursive), in file-name order. A bad module is /// reported and skipped, the others still load; so is a heavy one ([`crate::Budget::Heavy`]), /// which installs only once the user agrees, and one that lists permissions the user has not /// granted this very module ([`approved`]). #[cfg(not(target_arch = "wasm32"))] pub fn load_folder(dir: &std::path::Path) -> Result { load_folder_where(dir, |_| Ok(()), approved) } /// [`load_folder`], loading only the files `allow` accepts, and of the plug-ins that list /// permissions only those `approve` accepts; the others are reported as failed with the reason. A plug-in the user disabled ([`crate::safe_mode::skipped`]) is reported and /// skipped, by its file before it loads or by its id after (its file is then disabled too, so the /// next load skips it before it runs); the launch marker names each file while it loads /// ([`crate::safe_mode::loading`]). In Safe Mode nothing loads ([`Error::SafeMode`]). When the /// module a load leaves under an id lists no permissions, the process's grant for that id is /// forgotten. #[cfg(not(target_arch = "wasm32"))] pub fn load_folder_where( dir: &std::path::Path, allow: impl Fn(&std::path::Path) -> std::result::Result<(), String>, approve: impl Fn(&Plugin) -> bool, ) -> Result { use crate::safe_mode::{self, Item}; safe_mode::check()?; let rd = std::fs::read_dir(dir).map_err(|e| Error::Io(format!("{}: {e}", dir.display())))?; let mut files: Vec = rd.filter_map(|e| e.ok().map(|e| e.path())).filter(|p| p.extension().is_some_and(|x| x.eq_ignore_ascii_case("wasm"))).collect(); files.sort(); files.truncate(1000); let mut report = FolderReport::default(); for f in files { if let Err(why) = allow(&f) { report.failed.push((f.display().to_string(), why)); continue; } let item = Item::file(f.display().to_string()); if safe_mode::skipped(&item) { report.failed.push((f.display().to_string(), DISABLED.into())); continue; } let token = safe_mode::loading(&item); let loaded = load_file(&f, Limits::default()); safe_mode::loaded(token); match loaded { Ok(p) if safe_mode::skipped(&Item::of(&p)) => { report.disabled_changed |= safe_mode::disable_file_of(&Item::of(&p)); report.failed.push((f.display().to_string(), DISABLED.into())); } // A heavy plug-in installs once the user agrees, which loading a folder doesn't ask. Ok(p) if p.manifest().budget == crate::Budget::Heavy => { report.failed.push((f.display().to_string(), "asks for a heavy budget, which the user allows when installing it on its own".into())); } Ok(p) if p.manifest().asks_permissions() && !approve(&p) => { let asked = crate::permissions::names(&p.manifest().permissions()).join(", "); report.failed.push(( f.display().to_string(), format!("needs approval: it asks for {asked}, which the user grants when installing it on its own"), )); } Ok(p) => report.loaded.push(install(p).id().to_string()), Err(e) => report.failed.push((f.display().to_string(), e.to_string())), } } for id in &report.loaded { if get(id).is_some_and(|p| !p.manifest().asks_permissions()) { revoke(id); } } Ok(report) } #[cfg(test)] mod tests { use super::*; /// A cache keyed by the revision can't store the old list under the new revision: an install /// waiting for the set leaves the revision alone until it holds the set. #[test] fn an_install_changes_the_revision_only_while_it_holds_the_set() { let plugin = Plugin::load(include_bytes!("../tests/fixtures/desaturate.wasm"), Limits::default()).unwrap(); let id = plugin.id().to_string(); let held = PLUGINS.read().unwrap_or_else(|e| e.into_inner()); let before = revision(); let installing = std::thread::spawn(move || install(plugin)); let moved = (0..1_000_000).any(|_| { std::thread::yield_now(); revision() != before }); drop(held); installing.join().unwrap(); assert!(!moved, "the revision changed while the install waited for the set"); assert!(revision() > before && remove(&id)); } }