mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-11 02:19:56 -06:00
249 lines
11 KiB
Rust
249 lines
11 KiB
Rust
//! 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<BTreeMap<String, Arc<Plugin>>> = RwLock::new(BTreeMap::new());
|
||
static REVISION: AtomicU64 = AtomicU64::new(0);
|
||
/// What the user approved, by plug-in id.
|
||
static GRANTS: RwLock<BTreeMap<String, Grant>> = RwLock::new(BTreeMap::new());
|
||
/// Where each plug-in keeps its settings and cache, by plug-in id.
|
||
static STORAGE: RwLock<BTreeMap<String, Arc<dyn Storage>>> = 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<Arc<dyn Storage>>) {
|
||
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<Arc<dyn Storage>> {
|
||
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<Grant> {
|
||
GRANTS.read().unwrap_or_else(|e| e.into_inner()).get(id).cloned()
|
||
}
|
||
|
||
/// Every grant, sorted by id.
|
||
pub fn grants() -> Vec<Grant> {
|
||
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<Plugin> {
|
||
// 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<Arc<Plugin>> {
|
||
Ok(install(Plugin::load(bytes, Limits::default())?))
|
||
}
|
||
|
||
pub fn get(id: &str) -> Option<Arc<Plugin>> {
|
||
PLUGINS.read().unwrap_or_else(|e| e.into_inner()).get(id).cloned()
|
||
}
|
||
|
||
/// Installed plug-ins, sorted by id.
|
||
pub fn list() -> Vec<Arc<Plugin>> {
|
||
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<Option<HashSet<&'static str>>> = 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<Plugin> {
|
||
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<String>,
|
||
/// `(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<FolderReport> {
|
||
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<FolderReport> {
|
||
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<std::path::PathBuf> =
|
||
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));
|
||
}
|
||
}
|