Files
vectorcraft/crates/plugins/src/registry.rs
T
dotCooCoo cde367b889 Plug-ins: only an approval records a grant, and installs need one
registry::grant takes a GrantToken, which the engine makes after a
person's approval and when it loads grants.json. registry::install,
install_bytes and folder loads refuse a module that lists permissions no
recorded grant gives this very module (Error::NeedsApproval), and leave
the plug-in under its id and the revision as they were. A folder load's
approve callback only narrows what installs. A module without
permissions, or with an empty list, installs as before.

A folder load in a session with a plug-in store records the store's
grant for each module it approves, so those modules still load.

docs/plugins.md says what the approval does not protect against: native
code linked into the app, and another program running as the same user,
which can edit the Plug-ins folder and its unsigned grants.json.
2026-11-11 17:35:06 -08:00

349 lines
17 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.
//! 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};
use crate::Error;
use crate::permissions::{Grant, GrantToken};
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. Only a
/// [`GrantToken`] is recorded, which the engine makes after an approval or when it loads its grants
/// file.
pub fn grant(token: GrantToken) {
let g = token.grant().clone();
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, when it is [`approved`]: a module that lists
/// permissions no recorded grant gives this very module is refused ([`Error::NeedsApproval`]), and
/// the plug-in installed under its id, the grants and the revision stay as they were.
pub fn install(mut plugin: Plugin) -> Result<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. The approval is checked while it is held
// too.
let mut plugins = PLUGINS.write().unwrap_or_else(|e| e.into_inner());
if !approved(&plugin) {
return Err(Error::NeedsApproval(crate::permissions::names(&plugin.manifest().permissions()).join(", ")));
}
plugin.set_serial(REVISION.fetch_add(1, Ordering::Relaxed) + 1);
let p = Arc::new(plugin);
plugins.insert(p.id().to_string(), p.clone());
Ok(p)
}
/// Loads `bytes` with the default limits and installs the plug-in ([`install`]: one that lists
/// permissions installs only when they were granted to this very module).
pub fn install_bytes(bytes: &[u8]) -> Result<Arc<Plugin>> {
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 and a recorded grant covers ([`install`]); 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(), Error::NeedsApproval(asked).to_string()));
}
// `approve` only narrows what installs: `install` still refuses a module that no
// recorded grant covers.
Ok(p) => match install(p) {
Ok(p) => report.loaded.push(p.id().to_string()),
Err(e) => report.failed.push((f.display().to_string(), e.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 _lock = crate::safe_mode::TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
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().unwrap();
assert!(!moved, "the revision changed while the install waited for the set");
assert!(revision() > before && remove(&id));
}
/// A module of a filter `id` whose manifest lists `permissions` (a JSON list, or nothing), with
/// `marker` in its code so that two markers give two SHA-256s.
fn module(id: &str, permissions: Option<&str>, marker: i64) -> Vec<u8> {
let listed = permissions.map(|p| format!(r#","permissions":{p}"#)).unwrap_or_default();
let manifest = format!(r#"{{"id":"{id}","name":"T","kind":"filter"{listed}}}"#);
wat::parse_str(crate::wat::module(&manifest, &format!("(i64.const {marker}) (drop) (i64.const 0)"), "", 1)).unwrap()
}
fn grant_for(id: &str, bytes: &[u8], permissions: Vec<crate::permissions::Permission>) -> Grant {
Grant { id: id.into(), hash: crate::permissions::sha256_hex(bytes), permissions }
}
/// A module that lists permissions installs only once a recorded grant gives this very module
/// all of them: refused, nothing changes, neither the revision nor the plug-in under its id.
#[test]
fn install_bytes_refuses_unapproved_manifest_permissions() {
// The other tests that install or remove plug-ins hold it, so the revision moves only here.
let _lock = crate::safe_mode::TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let id = "test.registry.unapproved";
let plain = install_bytes(&module(id, None, 1)).unwrap();
let before = revision();
let e = install_bytes(&module(id, Some(r#"["doc.read"]"#), 2)).unwrap_err();
assert_eq!(e, Error::NeedsApproval("doc.read".into()));
assert_eq!(revision(), before, "nothing installed");
assert_eq!(get(id).map(|p| p.hash().to_string()), Some(plain.hash().to_string()), "the plug-in before it stays");
assert!(granted(id).is_none());
assert!(remove(id));
}
/// `install` itself and a folder load whose `approve` accepts everything refuse the module too.
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn install_and_folder_callback_cannot_bypass_approval() {
let _lock = crate::safe_mode::TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
crate::safe_mode::set_active(None);
let id = "test.registry.bypass";
let plain = install_bytes(&module(id, Some("[]"), 3)).unwrap();
let asking = module(id, Some(r#"["doc.read","doc.write"]"#), 4);
let e = install(Plugin::load(&asking, Limits::default()).unwrap()).unwrap_err();
assert_eq!(e, Error::NeedsApproval("doc.read, doc.write".into()));
let dir = std::env::temp_dir().join(format!("vc-registry-bypass-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("asking.wasm"), &asking).unwrap();
let r = load_folder_where(&dir, |_| Ok(()), |_| true).unwrap();
std::fs::remove_dir_all(&dir).unwrap();
assert!(r.loaded.is_empty(), "{r:?}");
assert_eq!(r.failed.len(), 1);
assert!(r.failed[0].1.starts_with("needs approval: it asks for doc.read, doc.write"), "{r:?}");
assert_eq!(get(id).map(|p| p.hash().to_string()), Some(plain.hash().to_string()), "the plug-in before it stays");
assert!(remove(id));
}
/// A recorded grant lets in only the module with its id and SHA-256 that lists nothing it
/// doesn't grant; a module whose manifest lists no permissions, or an empty list, needs none.
#[test]
fn matching_grant_allows_only_exact_bytes() {
use crate::permissions::Permission::{DocRead, DocWrite};
let _lock = crate::safe_mode::TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let id = "test.registry.exact";
let bytes = module(id, Some(r#"["doc.read","doc.write"]"#), 5);
let plugin = Plugin::load(&bytes, Limits::default()).unwrap();
assert_eq!(plugin.hash(), crate::permissions::sha256_hex(&bytes), "a grant's hash is the loaded module's");
let refused = |g: Grant, why: &str| {
let other = g.id.clone();
revoke(id);
grant(GrantToken::after_engine_approval(g));
assert!(matches!(install_bytes(&bytes), Err(Error::NeedsApproval(_))), "{why}");
assert!(get(id).is_none(), "{why}");
revoke(&other);
};
refused(grant_for(id, &module(id, Some(r#"["doc.read","doc.write"]"#), 6), vec![DocRead, DocWrite]), "other bytes");
refused(grant_for("test.registry.other", &bytes, vec![DocRead, DocWrite]), "another id");
refused(Grant { id: id.into(), hash: "not a hash".into(), permissions: vec![DocRead, DocWrite] }, "a malformed hash");
refused(
Grant { id: id.into(), hash: crate::permissions::sha256_hex(&bytes).to_uppercase(), permissions: vec![DocRead, DocWrite] },
"an upper-case hash",
);
refused(grant_for(id, &bytes, vec![DocRead]), "a missing permission");
grant(GrantToken::from_engine_grants_file(grant_for(id, &bytes, vec![DocRead, DocWrite])));
assert_eq!(install_bytes(&bytes).unwrap().hash(), plugin.hash());
assert!(remove(id) && revoke(id));
for listed in [None, Some("[]")] {
assert!(install_bytes(&module(id, listed, 7)).is_ok(), "{listed:?}");
assert!(granted(id).is_none() && remove(id), "{listed:?}");
}
}
}