mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-12 21:56:24 +00:00
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.
349 lines
17 KiB
Rust
349 lines
17 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};
|
||
|
||
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:?}");
|
||
}
|
||
}
|
||
}
|