Files
vectorcraft/crates/plugins/src/registry.rs
T
dotCooCoo 6f3ffeb393 Plug-ins: plugin.install writes nothing to the store until the module is checked
plugin.install wrote the module and its grant to the Plug-ins folder,
attached the plug-in's settings and cache and turned a disabled plug-in
on before the registry's last approval check. When another session
revoked the grant, or recorded one for other bytes, after the command
recorded its own grant and before that check, the install was refused
but left the new module and grant in the folder for the next launch.
A plug-in another session installed under the same id between the
command's checks and the install took no part in the decision, so the
install replaced it without `replace: true`.

- registry::install_checked records the grant (or forgets it) and
  installs the plug-in as one step, after a caller check that sees the
  plug-in installed under the id and its grant; a refusal changes
  nothing.
- plugin.install commits through it before any store write, refusing
  with the existing messages when another plug-in now needs `replace`
  or the grant its approval came from changed. The store writes, the
  put-back on a failed write and the disabled-list update now run after
  the commit; their logic is unchanged.
- The grant call-site audit also counts registry::install_checked.

Docs: docs/plugins.md, docs/target-app-parity.md.
2026-11-13 05:25:16 -08:00

457 lines
22 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)
}
/// What [`install_checked`] does to the grant recorded for the plug-in's id.
pub enum GrantUpdate {
/// Records this grant, replacing an earlier one for the id ([`grant`]).
Record(GrantToken),
/// Forgets the grant for the id ([`revoke`]).
Revoke,
}
/// Installs `plugin` and updates the grant for its id as one step. While it holds the plug-ins and
/// then the grants, `check` is given the plug-in installed under the id and the grant recorded for
/// it. When `check` returns an error, nothing changes and that error is returned. A plug-in that
/// lists permissions the grant `update` records doesn't give this very module is refused the same
/// way, with [`Error::NeedsApproval`] passed through `refused`. Otherwise `attach` runs, the grant
/// is updated and the plug-in is installed, replacing one with the same id. `check` and `attach`
/// must not call the other functions of this module, which wait for the locks held here.
pub fn install_checked<E>(
mut plugin: Plugin,
update: GrantUpdate,
check: impl FnOnce(Option<&Plugin>, Option<&Grant>) -> std::result::Result<(), E>,
attach: impl FnOnce(),
refused: impl FnOnce(Error) -> E,
) -> std::result::Result<Arc<Plugin>, E> {
// The same order as `install`, which reads the grants while it holds the plug-ins.
let mut plugins = PLUGINS.write().unwrap_or_else(|e| e.into_inner());
let mut grants = GRANTS.write().unwrap_or_else(|e| e.into_inner());
let id = plugin.id().to_string();
check(plugins.get(&id).map(|p| &**p), grants.get(&id))?;
let m = plugin.manifest();
let granted = match &update {
GrantUpdate::Record(token) => Some(token.grant()),
GrantUpdate::Revoke => None,
};
if m.asks_permissions() && !granted.is_some_and(|g| g.covers(&m.id, plugin.hash(), &m.permissions())) {
return Err(refused(Error::NeedsApproval(crate::permissions::names(&m.permissions()).join(", "))));
}
attach();
match update {
GrantUpdate::Record(token) => {
let g = token.grant().clone();
grants.insert(g.id.clone(), g);
}
GrantUpdate::Revoke => {
grants.remove(&id);
}
}
plugin.set_serial(REVISION.fetch_add(1, Ordering::Relaxed) + 1);
let p = Arc::new(plugin);
plugins.insert(id, 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:?}");
}
}
/// `install_checked` gives `check` the plug-in and grant held for the id. When `check` refuses,
/// or the grant it would record doesn't approve the module, the plug-in, the grant and the
/// revision stay as they were and `attach` doesn't run; otherwise it records the grant (or
/// forgets it) and installs the module.
#[test]
fn install_checked_changes_nothing_when_refused() {
use crate::permissions::Permission::DocRead;
let _lock = crate::safe_mode::TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let id = "test.registry.checked";
let old = module(id, Some(r#"["doc.read"]"#), 8);
let new = module(id, Some(r#"["doc.read"]"#), 9);
grant(GrantToken::after_engine_approval(grant_for(id, &old, vec![DocRead])));
install_bytes(&old).unwrap();
let load = |bytes: &[u8]| Plugin::load(bytes, Limits::default()).unwrap();
let record = |bytes: &[u8]| GrantUpdate::Record(GrantToken::after_engine_approval(grant_for(id, bytes, vec![DocRead])));
let unchanged = |why: &str| {
assert_eq!(get(id).map(|p| p.hash().to_string()), Some(crate::permissions::sha256_hex(&old)), "{why}");
assert_eq!(granted(id), Some(grant_for(id, &old, vec![DocRead])), "{why}");
};
let attached = std::cell::Cell::new(false);
let before = revision();
let seen = std::cell::RefCell::new(None);
let e = install_checked(
load(&new),
record(&new),
|p, g| {
*seen.borrow_mut() = Some((p.map(|p| p.hash().to_string()), g.cloned()));
Err("refused")
},
|| attached.set(true),
|_| "not approved",
)
.unwrap_err();
assert_eq!(e, "refused");
assert_eq!(seen.take(), Some((Some(crate::permissions::sha256_hex(&old)), Some(grant_for(id, &old, vec![DocRead])))));
unchanged("check refused");
let same = std::convert::identity::<Error>;
let e = install_checked(load(&new), record(&old), |_, _| Ok(()), || attached.set(true), same).unwrap_err();
assert_eq!(e, Error::NeedsApproval("doc.read".into()));
unchanged("a grant for other bytes");
let e = install_checked(load(&new), GrantUpdate::Revoke, |_, _| Ok(()), || attached.set(true), same).unwrap_err();
assert_eq!(e, Error::NeedsApproval("doc.read".into()));
unchanged("no grant");
assert!(!attached.get() && revision() == before, "nothing ran or moved");
let p = install_checked(load(&new), record(&new), |_, _| Ok(()), || attached.set(true), same).unwrap();
assert!(attached.get() && revision() > before);
assert_eq!(get(id).map(|p| p.hash().to_string()), Some(p.hash().to_string()));
assert_eq!(granted(id), Some(grant_for(id, &new, vec![DocRead])));
let plain = module(id, Some("[]"), 10);
install_checked(load(&plain), GrantUpdate::Revoke, |_, _| Ok(()), || {}, same).unwrap();
assert!(granted(id).is_none(), "a module without permissions forgets the grant");
assert!(remove(id));
}
}