Merge pull request #6747 from dotCooCoo/fix/plugin-grant-sealed-api

Plug-ins: only an approval records a grant, and installs need one
This commit is contained in:
BFlat
2026-11-12 12:41:15 +07:00
committed by GitHub
12 changed files with 339 additions and 42 deletions
+5 -4
View File
@@ -52,7 +52,7 @@ use vectorcraft_plugins::{Budget, Command, Kind, Plugin, effect, export, objects
use super::{CommandSpec, EngineError, NodeId, Result, Session, always, bad, cmd, has_doc, ids_param, str_param, switch_or};
use vectorcraft_plugins::permissions::{self, Grant, Permission};
use vectorcraft_plugins::permissions::{self, Grant, GrantToken, Permission};
/// What a plug-in error looks like to the user. A run the caller cancelled changed nothing.
fn err(e: vectorcraft_plugins::Error) -> EngineError {
@@ -750,7 +750,7 @@ fn install(s: &mut Session, p: &Value) -> Result<Value> {
let holds_new = |s: &Session| s.stored_plugin_bytes(plugin.id()).is_ok_and(|held| held.as_deref() == Some(&bytes[..]));
if m.asks_permissions() {
let g = Grant { id: m.id.clone(), hash: plugin.hash().to_string(), permissions: wanted };
registry::grant(g.clone());
registry::grant(GrantToken::after_engine_approval(g.clone()));
if stored && !s.store_grant(&m.id, Some(g)) {
stored = !s.put_back_plugin(plugin.id(), previous.ok().flatten().as_deref()) && holds_new(s);
}
@@ -787,7 +787,8 @@ fn install(s: &mut Session, p: &Value) -> Result<Value> {
if changed {
s.store_disabled_plugins();
}
let mut v = describe_installed(&registry::install(plugin));
let installed = registry::install(plugin).map_err(err)?;
let mut v = describe_installed(&installed);
if !unknown.is_empty() {
v["unknownCommands"] = json!(unknown);
}
@@ -886,7 +887,7 @@ fn load_folder(s: &Session, folder: &str) -> Value {
None if crate::file_access::current().is_none() => Ok(()),
None => Err(format!("automation path rejected: {} isn't valid text", f.display())),
};
match registry::load_folder_where(std::path::Path::new(folder), allow, s.plugin_approver()) {
match registry::load_folder_where(std::path::Path::new(folder), allow, s.plugin_folder_approver()) {
Ok(r) => {
for id in &r.loaded {
s.attach_plugin_storage(id);
+29 -3
View File
@@ -39,6 +39,7 @@ use std::sync::{Arc, Mutex, MutexGuard};
use std::time::SystemTime;
use vectorcraft_pdf::{PdfPreset, builtin_preset};
use vectorcraft_plugins::permissions::GrantToken;
use vectorcraft_plugins::safe_mode;
use vectorcraft_plugins::storage::{self, Meter, Storage, StorageError};
@@ -392,9 +393,11 @@ impl Session {
match grants.get(&m.id).filter(|g| grant_covers(g, &plugin)) {
Some(g) => {
asks_nothing.remove(&m.id);
vectorcraft_plugins::registry::grant(g.clone());
vectorcraft_plugins::registry::grant(GrantToken::from_engine_grants_file(g.clone()));
vectorcraft_plugins::registry::set_storage(&m.id, plugin_storage(Some(&dir), &m.id));
vectorcraft_plugins::registry::install(plugin.with_source(path.display().to_string()));
if let Err(e) = vectorcraft_plugins::registry::install(plugin.with_source(path.display().to_string())) {
log::warn!("skipping the stored plug-in {}: {e}", path.display());
}
}
None => {
log::warn!("skipping the stored plug-in {}: the user hasn't granted this module the permissions it lists", path.display())
@@ -404,7 +407,9 @@ impl Session {
Ok(plugin) => {
asks_nothing.insert(plugin.id().to_string());
vectorcraft_plugins::registry::set_storage(plugin.id(), plugin_storage(Some(&dir), plugin.id()));
vectorcraft_plugins::registry::install(plugin.with_source(path.display().to_string()));
if let Err(e) = vectorcraft_plugins::registry::install(plugin.with_source(path.display().to_string())) {
log::warn!("skipping the stored plug-in {}: {e}", path.display());
}
}
Err(e) => log::warn!("skipping the stored plug-in {}: {e}", path.display()),
}
@@ -478,6 +483,27 @@ impl Session {
}
}
/// [`Session::plugin_approver`] for a folder load: a module the store's grants approve gets
/// that grant recorded for the process too, since the registry installs only a module that a
/// recorded grant covers ([`vectorcraft_plugins::registry::install`]).
#[cfg(not(target_arch = "wasm32"))]
pub(crate) fn plugin_folder_approver(&self) -> impl Fn(&vectorcraft_plugins::Plugin) -> bool + use<> {
let grants = self.plugin_dir.as_deref().map(|dir| self.counted_grants(dir));
move |plugin| {
let m = plugin.manifest();
match &grants {
Some(grants) if m.asks_permissions() => match grants.get(&m.id).filter(|g| grant_covers(g, plugin)) {
Some(g) => {
vectorcraft_plugins::registry::grant(GrantToken::from_engine_grants_file(g.clone()));
true
}
None => false,
},
_ => vectorcraft_plugins::registry::approved(plugin),
}
}
}
/// The grants kept in the plug-in store `dir` ([`stored_grants`]) without those this session
/// doesn't count ([`Session::ignore_stored_grant`]).
fn counted_grants(&self, dir: &Path) -> BTreeMap<String, vectorcraft_plugins::permissions::Grant> {
+57 -7
View File
@@ -1587,7 +1587,7 @@ fn a_module_without_permissions_ends_the_grant_of_its_id() {
/// that holds such a module and a grant for its id has that grant forgotten when it is attached.
#[test]
fn a_module_without_permissions_ends_the_grant_kept_in_the_store() {
use vectorcraft_plugins::permissions::{Grant, Permission, sha256_hex};
use vectorcraft_plugins::permissions::{Grant, GrantToken, Permission, sha256_hex};
let folder = crate::tests_links::Folder::new("plugin-store-stale-grant");
let id = "test.engine.permissions.storedstale";
let other = "test.engine.permissions.storedother";
@@ -1634,7 +1634,11 @@ fn a_module_without_permissions_ends_the_grant_kept_in_the_store() {
let future = json!({"id": id, "hash": "00", "permissions": ["doc.read", "ui.panel"]});
std::fs::write(&grants, serde_json::to_vec(&json!([future, other_entry])).unwrap()).unwrap();
vectorcraft_plugins::registry::remove(id);
vectorcraft_plugins::registry::grant(Grant { id: id.into(), hash: sha256_hex(&approved), permissions: vec![Permission::DocRead] });
vectorcraft_plugins::registry::grant(GrantToken::after_engine_approval(Grant {
id: id.into(),
hash: sha256_hex(&approved),
permissions: vec![Permission::DocRead],
}));
let mut fourth = session();
fourth.attach_plugin_store(folder.0.clone());
assert_eq!(ids(), vec![other.to_string()]);
@@ -2165,7 +2169,7 @@ fn a_plugin_folder_ends_the_process_grant_of_an_id_it_replaces() {
/// With a store attached, a matching grant held only by the process doesn't count.
#[test]
fn a_stored_grant_covers_only_the_stored_module_it_recorded() {
use vectorcraft_plugins::permissions::{Grant, Permission, sha256_hex};
use vectorcraft_plugins::permissions::{Grant, GrantToken, Permission, sha256_hex};
let folder = crate::tests_links::Folder::new("plugin-store-recorded-module");
let id = "test.engine.permissions.storedbytes";
let first = asking(id, r#"["doc.read"]"#, &json!({"objects": []}));
@@ -2210,7 +2214,11 @@ fn a_stored_grant_covers_only_the_stored_module_it_recorded() {
assert!(starts());
let mut attached = session();
attached.attach_plugin_store(folder.0.clone());
vectorcraft_plugins::registry::grant(Grant { id: id.into(), hash: sha256_hex(&changed), permissions: vec![Permission::DocRead] });
vectorcraft_plugins::registry::grant(GrantToken::after_engine_approval(Grant {
id: id.into(),
hash: sha256_hex(&changed),
permissions: vec![Permission::DocRead],
}));
let inspected = run(&mut attached, "plugin.inspect", json!({"dataBase64": vectorcraft_format::base64_encode(&changed)}));
assert_eq!(inspected["needsApproval"], true, "a grant held only by the process doesn't count with a store attached");
run(&mut attached, "plugin.remove", json!({"id": id}));
@@ -2668,8 +2676,15 @@ fn a_run_past_the_memory_budget_fails_before_the_plugin_starts() {
params.join(",")
);
let module = wat::parse_str(tpl::module(&manifest, "unreachable", "", 1)).unwrap();
// One page of memory: the input and the parameters share 32 KiB.
registry::install(Plugin::load(&module, Limits { max_memory_bytes: 1 << 16, ..Limits::default() }).unwrap());
// One page of memory: the input and the parameters share 32 KiB. The module lists permissions,
// so its grant is recorded first.
let plugin = Plugin::load(&module, Limits { max_memory_bytes: 1 << 16, ..Limits::default() }).unwrap();
registry::grant(vectorcraft_plugins::permissions::GrantToken::after_engine_approval(vectorcraft_plugins::permissions::Grant {
id: id.into(),
hash: plugin.hash().to_string(),
permissions: plugin.manifest().permissions(),
}));
registry::install(plugin).unwrap();
let mut s = session();
let small = rect(&mut s, 10.0, "#ff0000");
let long = rect(&mut s, 30.0, "#00ff00");
@@ -2710,7 +2725,7 @@ fn a_run_past_the_memory_budget_fails_before_the_plugin_starts() {
assert!(e.contains(says), "{says}: {e}");
assert_eq!((s.doc().unwrap().doc.clone(), s.doc().unwrap().history.undo.len()), before, "{says}");
}
assert!(registry::remove(id));
assert!(registry::remove(id) && registry::revoke(id));
}
/// What the user granted is kept in the plug-in store with the module: the next session installs
@@ -2801,6 +2816,41 @@ fn granted_permissions_are_kept_and_folders_skip_the_rest() {
run(&mut granted, "plugin.remove", json!({"id": id}));
}
/// The registry installs a module that lists permissions only under a grant the process holds, so
/// a start and a `plugin.reload` record the store's grant for the module they load. A module in the
/// folder that nobody granted is reported and skipped, and records nothing.
#[test]
fn stored_and_reload_grants_still_install() {
let _folder = PREFS_FOLDER.lock().unwrap_or_else(|e| e.into_inner());
let store = crate::tests_links::Folder::new("plugin-store-grants-reload");
let id = "test.engine.permissions.reload";
let lone = "test.engine.permissions.reload.lone";
let wasm = asking(id, r#"["doc.read"]"#, &json!({"objects": []}));
let mut s = session();
s.attach_plugin_store(store.0.clone());
run(&mut s, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&wasm), "grant": ["doc.read"]}));
let forget = || {
vectorcraft_plugins::registry::remove(id);
vectorcraft_plugins::registry::revoke(id);
};
forget();
let mut next = session();
next.attach_plugin_store(store.0.clone());
assert!(vectorcraft_plugins::registry::get(id).is_some(), "installed again from the store");
assert_eq!(vectorcraft_plugins::registry::granted(id).map(|g| g.hash), Some(vectorcraft_plugins::permissions::sha256_hex(&wasm)));
forget();
let dir = crate::tests_links::Folder::new("plugin-folder-grants-reload");
std::fs::write(dir.0.join("granted.wasm"), &wasm).unwrap();
std::fs::write(dir.0.join("lone.wasm"), asking(lone, r#"["doc.read"]"#, &json!({"objects": []}))).unwrap();
let r = run(&mut next, "plugin.reload", json!({"path": dir.0.display().to_string()}));
assert_eq!(r["loaded"], json!([id]), "{r}");
assert!(vectorcraft_plugins::registry::granted(id).is_some(), "the store's grant is the process's too");
let why = r["failed"][0]["error"].as_str().unwrap();
assert!(why.contains("needs approval") && r["failed"][0]["file"].as_str().unwrap().ends_with("lone.wasm"), "{r}");
assert!(vectorcraft_plugins::registry::get(lone).is_none() && vectorcraft_plugins::registry::granted(lone).is_none());
run(&mut next, "plugin.remove", json!({"id": id}));
}
/// A grant covers the module it was given to: another module under the id, listing the same
/// permissions, isn't installed from the store at the next start, a folder load reports it as
/// needing approval, and `plugin.install` asks for `grant` again, even with `replace: true`. It
+1 -1
View File
@@ -326,7 +326,7 @@ fn an_importer_past_its_budget_opens_nothing() {
];
for (id, limits, _) in &cases {
let wasm = wat::parse_str(tpl::module(&tpl::importer(id, r#"["tbg"]"#, "{}"), SPINS, "", 1)).unwrap();
vectorcraft_plugins::registry::install(Plugin::load(&wasm, limits.clone()).unwrap());
vectorcraft_plugins::registry::install(Plugin::load(&wasm, limits.clone()).unwrap()).unwrap();
}
let mut s = Session::new();
for (id, _, why) in &cases {