From 91d9639e6a2aeba4bf4bbe81ebceebcb5e31bcf2 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 8 Nov 2026 08:40:00 -0800 Subject: [PATCH 1/4] Plug-ins: a permission grant covers only the module that was approved A grant records the module's SHA-256, but only its permissions were compared, so a module with other bytes under a granted id inherited the grant when it listed no new permission. A grant now covers a module only when the id, the SHA-256 and every listed permission match. plugin.inspect reports needsApproval for a changed module, plugin.install refuses it without a new grant and leaves the installed module and its grant as they are, Install Plug-in and File > Open ask again, and the plug-in store and plug-in folders skip it. The same bytes install again without asking, and a module with no permissions list or an empty one needs no grant. A grant whose hash is not 64 lower-case hexadecimal digits covers nothing. plugin.install still trusts the client that sends grant; the docs say so. --- crates/engine/src/cmd/plugin.rs | 15 +-- crates/engine/src/store.rs | 17 ++-- crates/engine/src/tests/plugins.rs | 147 ++++++++++++++++++++++++++-- crates/plugins/src/permissions.rs | 49 ++++++++-- crates/plugins/src/registry.rs | 8 +- crates/ui-egui/src/tests/plugins.rs | 20 +++- docs/mcp.md | 6 +- docs/plugins.md | 16 +-- docs/target-app-parity.md | 1 + 9 files changed, 232 insertions(+), 47 deletions(-) diff --git a/crates/engine/src/cmd/plugin.rs b/crates/engine/src/cmd/plugin.rs index 6593f7a79..6457e2cc9 100644 --- a/crates/engine/src/cmd/plugin.rs +++ b/crates/engine/src/cmd/plugin.rs @@ -333,9 +333,10 @@ fn grant_param(c: &str, p: &Value) -> Result>> { } /// `plugin.install`: loads and checks the module, refuses one that asks for a heavy budget unless -/// `allowHeavy` says the user agreed, and one that lists permissions the user hasn't granted its id -/// unless `grant` lists them all, and one whose SHA-256 isn't the `hash` given; records the grant -/// with the module's hash, keeps it for the next start and installs it → its `plugin.list` entry. +/// `allowHeavy` says the user agreed, and one that lists permissions the user hasn't granted this +/// very module unless `grant` lists them all, and one whose SHA-256 isn't the `hash` given; records +/// the grant with the module's hash, keeps it for the next start and installs it → its +/// `plugin.list` entry. fn install(s: &mut Session, p: &Value) -> Result { const C: &str = "plugin.install"; let (replace, allow_heavy) = (switch_or(p, "replace", true, C)?, switch_or(p, "allowHeavy", false, C)?); @@ -362,7 +363,7 @@ fn install(s: &mut Session, p: &Value) -> Result { )); } // A plug-in that lists permissions installs once the user grants them: whoever installs it - // asks them first, unless they already granted its id all of them. + // asks them first, unless they already granted this very module all of them. let m = plugin.manifest(); let wanted = m.permissions(); if !s.plugin_approved(&plugin) && !grant.as_ref().is_some_and(|g| wanted.iter().all(|w| g.contains(w))) { @@ -526,7 +527,7 @@ pub fn specs() -> Vec { "Install Plug-in", [], None, - "{path: a .wasm file (desktop) | dataBase64: the module (+ name?: shown as its source), replace?: bool (true: a plug-in with the same id is replaced), name?: file name (with dataBase64), allowHeavy?: bool (true once the user agreed to a heavy budget: a filter whose manifest asks for one, budget \"heavy\" in plugin.inspect, installs only with it), grant?: [permission] (the permissions the user granted: a plug-in whose manifest lists permissions, needsApproval in plugin.inspect, installs only when grant holds every one of them; the grant is recorded with the module's hash, and replacing the plug-in with one that lists no new permission needs none; with a plug-in store attached, only the grants kept there count), hash?: string (the module's SHA-256 as plugin.inspect gave it: a module with other bytes is refused)} validate and install a WebAssembly plug-in for this session → its plugin.list entry", + "{path: a .wasm file (desktop) | dataBase64: the module (+ name?: shown as its source), replace?: bool (true: a plug-in with the same id is replaced), name?: file name (with dataBase64), allowHeavy?: bool (true once the user agreed to a heavy budget: a filter whose manifest asks for one, budget \"heavy\" in plugin.inspect, installs only with it), grant?: [permission] (the permissions the user granted: a plug-in whose manifest lists permissions, needsApproval in plugin.inspect, installs only when grant holds every one of them; the grant is recorded with the module's hash and covers only that module, so a module with other bytes needs a new grant even when it lists the same permissions; with a plug-in store attached, only the grants kept there count), hash?: string (the module's SHA-256 as plugin.inspect gave it: a module with other bytes is refused)} validate and install a WebAssembly plug-in for this session → its plugin.list entry", always, install ), @@ -535,7 +536,7 @@ pub fn specs() -> Vec { "Inspect Plug-in", [], None, - "{path: a .wasm file (desktop) | dataBase64: the module, name?: the file name shown as its source (with dataBase64)} validate a WebAssembly plug-in without installing it → the plugin.list entry it would have, plus needsApproval: bool (true when it lists permissions the user hasn't granted its id: plugin.install then needs grant)", + "{path: a .wasm file (desktop) | dataBase64: the module, name?: the file name shown as its source (with dataBase64)} validate a WebAssembly plug-in without installing it → the plugin.list entry it would have, plus needsApproval: bool (true when it lists permissions the user hasn't granted this very module, its bytes included: plugin.install then needs grant)", always, inspect ), @@ -545,7 +546,7 @@ pub fn specs() -> Vec { "Reload Plug-ins", ["Object", "Plug-ins"], None, - "{path?: a folder (default: the Additional Plug-ins Folder preference)} install every *.wasm in it (desktop; a bad module is reported and skipped, and so are a heavy filter and a plug-in that lists permissions the user hasn't granted its id) → {folder, loaded: [id], failed: [{file, error}]}", + "{path?: a folder (default: the Additional Plug-ins Folder preference)} install every *.wasm in it (desktop; a bad module is reported and skipped, and so are a heavy filter and a plug-in that lists permissions the user hasn't granted this very module, its bytes included) → {folder, loaded: [id], failed: [{file, error}]}", always, reload ), diff --git a/crates/engine/src/store.rs b/crates/engine/src/store.rs index 02f2bb532..3d8259361 100644 --- a/crates/engine/src/store.rs +++ b/crates/engine/src/store.rs @@ -224,8 +224,8 @@ impl Session { } /// Keep the plug-ins this session installs in `dir`, and install the ones already there (a - /// module that doesn't load, and one that lists permissions the grants there don't grant it, - /// is skipped with a log record). + /// module that doesn't load, and one that lists permissions the grants there don't grant that + /// very module, is skipped with a log record, and stays in `dir` with its grant). pub fn attach_plugin_store(&mut self, dir: PathBuf) { let grants = stored_grants(&dir); let limits = vectorcraft_plugins::Limits::default(); @@ -244,12 +244,12 @@ impl Session { // module stored here load. Ok(plugin) if plugin.manifest().asks_permissions() => { let m = plugin.manifest(); - match grants.get(&m.id).filter(|g| g.covers(&m.permissions())) { + match grants.get(&m.id).filter(|g| g.covers(&m.id, plugin.hash(), &m.permissions())) { Some(g) => { vectorcraft_plugins::registry::grant(g.clone()); vectorcraft_plugins::registry::install(plugin.with_source(path.display().to_string())); } - None => log::warn!("skipping the stored plug-in {}: the user hasn't granted the permissions it lists", path.display()), + None => log::warn!("skipping the stored plug-in {}: the user hasn't approved this module's permissions", path.display()), } } Ok(plugin) => { @@ -261,9 +261,10 @@ impl Session { self.plugin_dir = Some(dir); } - /// Whether `plugin` installs without asking the user: it lists no permissions, or its id was - /// granted every one it lists, in the grants of the attached plug-in store when there is one, - /// else in this process's ([`vectorcraft_plugins::registry::approved`]). + /// Whether `plugin` installs without asking the user: it lists no permissions, or this very + /// module (its id and SHA-256) was granted every one it lists, in the grants of the attached + /// plug-in store when there is one, else in this process's + /// ([`vectorcraft_plugins::registry::approved`]). pub(crate) fn plugin_approved(&self, plugin: &vectorcraft_plugins::Plugin) -> bool { self.plugin_approver()(plugin) } @@ -275,7 +276,7 @@ impl Session { move |plugin| { let m = plugin.manifest(); match &grants { - Some(grants) if m.asks_permissions() => grants.get(&m.id).is_some_and(|g| g.covers(&m.permissions())), + Some(grants) if m.asks_permissions() => grants.get(&m.id).is_some_and(|g| g.covers(&m.id, plugin.hash(), &m.permissions())), _ => vectorcraft_plugins::registry::approved(plugin), } } diff --git a/crates/engine/src/tests/plugins.rs b/crates/engine/src/tests/plugins.rs index fdfe60067..7d0983ef2 100644 --- a/crates/engine/src/tests/plugins.rs +++ b/crates/engine/src/tests/plugins.rs @@ -532,9 +532,9 @@ fn asking(id: &str, permissions: &str, output: &Value) -> Vec { /// A plug-in whose manifest lists permissions installs only once they are all granted (`grant`), /// which `plugin.inspect` tells beforehand (`needsApproval`) with the permissions and the module's -/// hash. The grant is recorded with that hash: replacing the plug-in with one that lists the same -/// permissions needs no new grant, and one that lists another needs one. Removing it forgets the -/// grant. A plug-in without `permissions` installs as before, with the ones its kind implies. +/// hash. The grant is recorded with that hash and covers only that module: one with other bytes +/// needs a new grant. Removing it forgets the grant. A plug-in without `permissions` installs as +/// before, with the ones its kind implies. #[test] fn a_plugin_that_lists_permissions_installs_once_they_are_granted() { let mut s = session(); @@ -570,11 +570,10 @@ fn a_plugin_that_lists_permissions_installs_once_they_are_granted() { assert_eq!(installed["permissions"], json!(["doc.read"])); let g = vectorcraft_plugins::registry::granted(id).unwrap(); assert_eq!((g.hash.as_str(), g.permissions.as_slice()), (hash.as_str(), &[vectorcraft_plugins::permissions::Permission::DocRead][..])); - // Replaced with another module that lists the same permissions: no new grant needed, and the - // grant now records the new module's hash. + // Replaced with another module that lists the same permissions: granted again, the grant now + // records the new module's hash. let other = asking(id, r#"["doc.read"]"#, &json!({"objects": [], "error": null})); - assert_eq!(run(&mut s, "plugin.inspect", json!({"dataBase64": vectorcraft_format::base64_encode(&other)}))["needsApproval"], false); - run(&mut s, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&other)})); + run(&mut s, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&other), "grant": ["doc.read"]})); assert_eq!(vectorcraft_plugins::registry::granted(id).unwrap().hash, vectorcraft_plugins::permissions::sha256_hex(&other)); // One that asks for more is refused until it is granted all of them. let more = data(r#"["doc.read","doc.write"]"#); @@ -594,6 +593,140 @@ fn a_plugin_that_lists_permissions_installs_once_they_are_granted() { assert_eq!(run(&mut s, "plugin.inspect", json!({"dataBase64": vectorcraft_format::base64_encode(DESATURATE)}))["needsApproval"], false); } +/// A module with other bytes under an id that was granted is not covered, even when it lists the +/// same permissions: `plugin.inspect` says so, `plugin.install` refuses it and leaves the installed +/// module and its grant alone, and a new `grant` installs it and records its hash. The same bytes +/// install again without asking, and a module that lists no permissions never asks. +#[test] +fn a_grant_covers_only_the_module_that_was_approved() { + use vectorcraft_plugins::permissions::sha256_hex; + let mut s = session(); + let id = "test.engine.permissions.bytes"; + let first = asking(id, r#"["doc.read"]"#, &json!({"objects": []})); + let changed = asking(id, r#"["doc.read"]"#, &json!({"objects": [], "error": null})); + let (first_data, changed_data) = (vectorcraft_format::base64_encode(&first), vectorcraft_format::base64_encode(&changed)); + let (first_hash, changed_hash) = (sha256_hex(&first), sha256_hex(&changed)); + run(&mut s, "plugin.install", json!({"dataBase64": first_data, "grant": ["doc.read"]})); + assert_eq!(run(&mut s, "plugin.inspect", json!({"dataBase64": changed_data}))["needsApproval"], true); + let e = s.execute("plugin.install", &json!({"dataBase64": changed_data})).unwrap_err().to_string(); + assert!(e.contains("doc.read") && e.contains("`grant`"), "{e}"); + let e = s.execute("plugin.install", &json!({"dataBase64": changed_data, "hash": changed_hash})).unwrap_err().to_string(); + assert!(e.contains("`grant`"), "`hash` is not an approval: {e}"); + assert_eq!(vectorcraft_plugins::registry::get(id).unwrap().hash(), first_hash, "the installed module stays"); + assert_eq!(vectorcraft_plugins::registry::granted(id).unwrap().hash, first_hash, "its grant stays"); + // The same bytes need no new grant. + assert_eq!(run(&mut s, "plugin.inspect", json!({"dataBase64": first_data}))["needsApproval"], false); + run(&mut s, "plugin.install", json!({"dataBase64": first_data})); + // A new grant installs the changed module, and the old bytes are no longer covered. + run(&mut s, "plugin.install", json!({"dataBase64": changed_data, "grant": ["doc.read"], "hash": changed_hash})); + assert_eq!(vectorcraft_plugins::registry::get(id).unwrap().hash(), changed_hash); + assert_eq!(vectorcraft_plugins::registry::granted(id).unwrap().hash, changed_hash); + assert_eq!(run(&mut s, "plugin.inspect", json!({"dataBase64": first_data}))["needsApproval"], true); + run(&mut s, "plugin.remove", json!({"id": id})); + + // A manifest without a list, or with an empty one, asks for nothing, whatever its bytes. + let exempt = "test.engine.permissions.exempt"; + for (permissions, out) in [("null", json!({"objects": []})), ("null", json!({"objects": [], "error": null})), ("[]", json!({"objects": []}))] { + let data = vectorcraft_format::base64_encode(&asking(exempt, permissions, &out)); + assert_eq!(run(&mut s, "plugin.inspect", json!({"dataBase64": data}))["needsApproval"], false, "{permissions}"); + run(&mut s, "plugin.install", json!({"dataBase64": data})); + } + run(&mut s, "plugin.remove", json!({"id": exempt})); +} + +/// A stored module whose bytes changed is skipped at the next start and stays in the store with its +/// grant, and the original bytes load again. A stored grant whose hash is malformed covers nothing. +/// 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}; + let folder = crate::tests_links::Folder::new("plugin-store-grant-hash"); + let id = "test.engine.permissions.storedbytes"; + let first = asking(id, r#"["doc.read"]"#, &json!({"objects": []})); + let changed = asking(id, r#"["doc.read"]"#, &json!({"objects": [], "error": null})); + let mut s = session(); + s.attach_plugin_store(folder.0.clone()); + run(&mut s, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&first), "grant": ["doc.read"]})); + let grants = folder.0.join("grants.json"); + let recorded = std::fs::read(&grants).unwrap(); + let module = std::fs::read_dir(&folder.0) + .unwrap() + .map(|e| e.unwrap().path()) + .find(|p| p.extension().is_some_and(|x| x == "wasm")) + .expect("the stored module"); + let forget = || { + vectorcraft_plugins::registry::remove(id); + vectorcraft_plugins::registry::revoke(id); + }; + let starts = || { + forget(); + let mut next = session(); + next.attach_plugin_store(folder.0.clone()); + vectorcraft_plugins::registry::get(id).is_some() + }; + assert!(starts(), "the module that was approved loads"); + std::fs::write(&module, &changed).unwrap(); + assert!(!starts(), "other bytes under the old grant are skipped"); + assert_eq!(std::fs::read(&module).unwrap(), changed, "the module stays in the store"); + assert_eq!(std::fs::read(&grants).unwrap(), recorded, "its grant stays"); + std::fs::write(&module, &first).unwrap(); + assert!(starts(), "the approved bytes load again"); + + // A malformed hash is no wildcard, even for the module on disk. + let upper = sha256_hex(&first).to_uppercase(); + for hash in ["", "00", upper.as_str()] { + std::fs::write(&grants, serde_json::to_vec(&json!([{"id": id, "hash": hash, "permissions": ["doc.read"]}])).unwrap()).unwrap(); + assert!(!starts(), "hash {hash:?}"); + } + + // The store's grants are the ones that count, even when the process holds one for these bytes. + std::fs::write(&grants, &recorded).unwrap(); + 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] }); + 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})); +} + +/// A plug-in folder reports a changed module as needing approval and still loads the other files; +/// granted on its own, the changed module loads from the folder. A session without a store uses the +/// grants held by the process. +#[test] +fn a_plugin_folder_skips_a_changed_module_until_it_is_approved() { + let mut s = session(); + let id = "test.engine.permissions.folderbytes"; + let plain = "test.engine.permissions.folderplain"; + let first = asking(id, r#"["doc.read"]"#, &json!({"objects": []})); + let changed = asking(id, r#"["doc.read"]"#, &json!({"objects": [], "error": null})); + let dir = crate::tests_links::Folder::new("plugin-folder-grant-hash"); + let file = dir.0.join("asks.wasm"); + std::fs::write(&file, &first).unwrap(); + std::fs::write(dir.0.join("plain.wasm"), asking(plain, "null", &json!({"objects": []}))).unwrap(); + run(&mut s, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&first), "grant": ["doc.read"]})); + let path = json!({"path": dir.0.display().to_string()}); + let r = run(&mut s, "plugin.reload", path.clone()); + assert_eq!((r["loaded"].as_array().unwrap().len(), r["failed"].as_array().unwrap().len()), (2, 0), "{r}"); + + std::fs::write(&file, &changed).unwrap(); + let r = run(&mut s, "plugin.reload", path.clone()); + assert_eq!(r["loaded"], json!([plain]), "the other file still loads"); + let failed = r["failed"].as_array().unwrap(); + assert_eq!(failed.len(), 1, "{r}"); + assert!(failed[0]["file"].as_str().unwrap().ends_with("asks.wasm"), "{r}"); + let why = failed[0]["error"].as_str().unwrap(); + assert!(why.contains("needs approval") && why.contains("doc.read"), "{why}"); + let hash = vectorcraft_plugins::permissions::sha256_hex(&first); + assert_eq!(vectorcraft_plugins::registry::get(id).unwrap().hash(), hash, "the module installed earlier stays"); + + run(&mut s, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&changed), "grant": ["doc.read"]})); + let r = run(&mut s, "plugin.reload", path); + assert_eq!(r["failed"], json!([]), "approved on its own, it loads: {r}"); + run(&mut s, "plugin.remove", json!({"id": id})); + run(&mut s, "plugin.remove", json!({"id": plain})); +} + /// The host enforces what a filter was granted: without `doc.write` an output that changes the /// document is refused and nothing changes, without `doc.read` it is given no objects, and without /// `state` it is given no data kept with the document and may not write any. diff --git a/crates/plugins/src/permissions.rs b/crates/plugins/src/permissions.rs index 083c78a04..41ab1569a 100644 --- a/crates/plugins/src/permissions.rs +++ b/crates/plugins/src/permissions.rs @@ -199,6 +199,11 @@ pub fn sha256_hex(bytes: &[u8]) -> String { Sha256::digest(bytes).iter().map(|b| format!("{b:02x}")).collect() } +/// Is `s` a SHA-256 as [`sha256_hex`] writes it: 64 lower-case hexadecimal digits? +fn is_sha256_hex(s: &str) -> bool { + s.len() == 64 && s.bytes().all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f')) +} + /// What the user approved for a plug-in: its id, the SHA-256 of the module they approved /// ([`sha256_hex`]) and the permissions it was granted. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] @@ -209,9 +214,10 @@ pub struct Grant { } impl Grant { - /// Does it grant every one of `wanted`? - pub fn covers(&self, wanted: &[Permission]) -> bool { - wanted.iter().all(|w| self.permissions.contains(w)) + /// Does it grant every one of `wanted` to the module with this `id` and SHA-256 `hash`? A grant + /// whose own hash is not a valid SHA-256 ([`sha256_hex`]) covers nothing. + pub fn covers(&self, id: &str, hash: &str, wanted: &[Permission]) -> bool { + self.id == id && is_sha256_hex(&self.hash) && self.hash == hash && wanted.iter().all(|w| self.permissions.contains(w)) } } @@ -281,10 +287,37 @@ mod tests { #[test] fn a_grant_covers_its_permissions_and_no_more() { - let g = Grant { id: "a".into(), hash: String::new(), permissions: vec![Permission::DocRead, Permission::DocWrite] }; - assert!(g.covers(&[Permission::DocRead])); - assert!(g.covers(&[])); - assert!(!g.covers(&[Permission::State])); - assert!(!g.covers(&[Permission::DocRead, Permission::State])); + let hash = sha256_hex(b"module"); + let g = Grant { id: "a".into(), hash: hash.clone(), permissions: vec![Permission::DocRead, Permission::DocWrite] }; + assert!(g.covers("a", &hash, &[Permission::DocRead])); + assert!(g.covers("a", &hash, &[])); + assert!(!g.covers("a", &hash, &[Permission::State])); + assert!(!g.covers("a", &hash, &[Permission::DocRead, Permission::State])); + } + + #[test] + fn a_grant_covers_only_the_module_that_was_approved() { + let hash = sha256_hex(b"module"); + let wanted = [Permission::DocRead]; + let g = Grant { id: "a".into(), hash: hash.clone(), permissions: vec![Permission::DocRead] }; + assert!(g.covers("a", &hash, &wanted)); + assert!(!g.covers("b", &hash, &wanted), "another id"); + assert!(!g.covers("a", &sha256_hex(b"other"), &wanted), "other bytes"); + assert!(!g.covers("a", &sha256_hex(b"other"), &[]), "other bytes, even when nothing is wanted"); + } + + /// Damaged data can't stand for every module, even when the hash asked about is just as damaged. + #[test] + fn a_grant_with_an_invalid_hash_covers_nothing() { + let upper = sha256_hex(b"module").to_uppercase(); + let short = "ab".repeat(31); + let long = "ab".repeat(33); + let not_hex = "g".repeat(64); + let wide = "é".repeat(32); + for hash in ["", "00", upper.as_str(), short.as_str(), long.as_str(), not_hex.as_str(), wide.as_str()] { + let g = Grant { id: "a".into(), hash: hash.into(), permissions: vec![Permission::DocRead] }; + assert!(!g.covers("a", hash, &[Permission::DocRead]), "{hash:?}"); + assert!(!g.covers("a", hash, &[]), "{hash:?}"); + } } } diff --git a/crates/plugins/src/registry.rs b/crates/plugins/src/registry.rs index 0302ab6be..8e4635c3f 100644 --- a/crates/plugins/src/registry.rs +++ b/crates/plugins/src/registry.rs @@ -38,11 +38,11 @@ pub fn revoke(id: &str) -> bool { } /// May `plugin` be installed without asking the user: does it list no permissions, or did the user -/// already grant its id every permission it lists? A module with other bytes but no new permission -/// needs no new approval. +/// 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.permissions())) + !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. @@ -125,7 +125,7 @@ pub struct FolderReport { /// 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 its id ([`approved`]). +/// granted this very module ([`approved`]). #[cfg(not(target_arch = "wasm32"))] pub fn load_folder(dir: &std::path::Path) -> Result { load_folder_where(dir, |_| Ok(()), approved) diff --git a/crates/ui-egui/src/tests/plugins.rs b/crates/ui-egui/src/tests/plugins.rs index d5e10d8d1..59da85c22 100644 --- a/crates/ui-egui/src/tests/plugins.rs +++ b/crates/ui-egui/src/tests/plugins.rs @@ -130,8 +130,8 @@ fn asking(id: &str, name: &str, permissions: &str) -> Vec { /// Installing a plug-in that lists permissions asks first, showing its name, id, version, file hash /// and each permission in plain words, with hidden characters from the manifest escaped. Cancel -/// installs nothing, OK installs it; installing it again with the same permissions doesn't ask, and -/// one that asks for another does. +/// installs nothing, OK installs it; installing the same module again doesn't ask, and one with +/// other bytes does, even when it lists the same permissions, as does one that asks for another. #[test] fn installing_a_plugin_that_lists_permissions_asks_first() { let mut app = app(); @@ -165,11 +165,21 @@ fn installing_a_plugin_that_lists_permissions_asks_first() { assert!(app.ui.dialog.is_none()); assert!(vectorcraft_plugins::registry::get(id).is_some()); assert_eq!(vectorcraft_plugins::registry::granted(id).map(|g| g.hash), Some(hash)); - // Again with the same permissions: installed without asking. + // The same module again: installed without asking. + app.run("ui.installPlugin", json!({"dataBase64": data})).unwrap(); + assert!(app.ui.dialog.is_none(), "the same bytes don't ask again"); + // Other bytes with the same permissions ask, from both ways in; Cancel keeps the one installed. let again = asking(id, "Tidy 2", r#"["doc.write","doc.read"]"#); - app.run("ui.installPlugin", json!({"dataBase64": vectorcraft_format::base64_encode(&again)})).unwrap(); - assert!(app.ui.dialog.is_none(), "the same permissions don't ask again"); + let r = app.run("ui.installPlugin", json!({"dataBase64": vectorcraft_format::base64_encode(&again)})).unwrap(); + assert_eq!(r["pending"], dialogs::confirm::KIND, "other bytes ask again"); + dialogs::cancel(&mut app); + assert_eq!(vectorcraft_plugins::registry::get(id).map(|p| p.manifest().name.clone()), Some(name.into()), "Cancel keeps the one installed"); + io::open_bytes(&mut app, "tidy2.wasm", &again, None).unwrap(); + assert_eq!(app.ui.dialog.as_ref().map(|d| d.kind.as_str()), Some(dialogs::confirm::KIND), "File › Open asks too"); + dialogs::confirm(&mut app).unwrap(); assert_eq!(vectorcraft_plugins::registry::get(id).map(|p| p.manifest().name.clone()), Some("Tidy 2".into())); + let new_hash = vectorcraft_plugins::permissions::sha256_hex(&again); + assert_eq!(vectorcraft_plugins::registry::granted(id).map(|g| g.hash), Some(new_hash), "OK records the new module's hash"); // One that asks for another permission asks again. let more = asking(id, "Tidy 3", r#"["doc.read","doc.write","state"]"#); let r = app.run("ui.installPlugin", json!({"dataBase64": vectorcraft_format::base64_encode(&more)})).unwrap(); diff --git a/docs/mcp.md b/docs/mcp.md index 9220b1c62..037a159d4 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -6097,8 +6097,10 @@ runs it where it is called, so the call may take minutes; `notifications/cancell A plug-in whose manifest lists permissions (`permissions` in its `plugin.inspect` entry, with `needsApproval: true`) installs only with `grant: [permission]` listing every one of them: show the user the list and ask first, and pass the entry's `hash` as `hash` so that a module that changed since is refused. Without `grant`, `plugin.install` returns -an error naming the permissions needed. The grant is kept with the module's hash; a module -with the same id that lists no new permission installs without `grant`, and `plugin.remove` forgets the grant. A +an error naming the permissions needed. The grant is kept with the module's hash and covers only that module: the +same bytes install again without `grant`, and a module with other bytes has `needsApproval: true` and needs a new +`grant` even when it lists the same permissions. `plugin.install` does not check that a person approved `grant`: it +trusts the client that sends it. `plugin.remove` forgets the grant. A filter without `doc.write` or `state` that returns output needing them gets an error and the document is unchanged. A plug-in of API level 2 (`api: 2` in its entry) that lists `progress` may report its progress, see Cancel and write log lines: `notifications/cancelled` makes its `vc_cancelled` return 1, and its log lines go to the server's log diff --git a/docs/plugins.md b/docs/plugins.md index 27a7b0f91..80544ad2b 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -26,7 +26,7 @@ This page is the contract for plug-in authors and the reference for the host (`c | `plugin.list` | `{}` | `{"plugins": [{id, name, version, kind, author, description, params, paramsSchema, effect?, format?, extensions?, budget?, size, source, api, permissions, hash}], "folder": report}` (`api` is the manifest's [plug-in API level](#permissions); `budget: "heavy"` for a [heavy filter](#heavy-filters); `permissions` lists what it [may do](#permissions), `hash` is the module's SHA-256) | | `plugin.info` | `{"id"}` | One plug-in: the `plugin.list` entry plus `manifest`, `defaults` and `lastError` (a live effect's last failure). | | `plugin.run` | `{"id", "params"?: {…}, "ids"?: [...]}` (plug-in params may also be top-level keys) | Runs an object filter on the selection (or `ids`). One undo step; the result is selected, except the objects it locked or made guides. | -| `plugin.install` | `{"path": "/x/y.wasm"}` (desktop) or `{"dataBase64": "…", "name"?}`, `"replace": bool = true`, `"allowHeavy": bool = false`, `"grant"?: [permission]`, `"hash"?: "sha256 hex"` | Validates and installs a module; returns its `plugin.list` entry. The desktop app and `vectorcraft-cli` keep it in the `Plug-ins` folder next to the preferences, so it is installed again after a restart (#2541). A [heavy filter](#heavy-filters) installs only with `allowHeavy: true`, once the user agreed. A plug-in whose manifest lists [permissions](#permissions) installs only when `grant` lists every one of them, once the user agreed. With `hash`, a module whose SHA-256 differs is refused. | +| `plugin.install` | `{"path": "/x/y.wasm"}` (desktop) or `{"dataBase64": "…", "name"?}`, `"replace": bool = true`, `"allowHeavy": bool = false`, `"grant"?: [permission]`, `"hash"?: "sha256 hex"` | Validates and installs a module; returns its `plugin.list` entry. The desktop app and `vectorcraft-cli` keep it in the `Plug-ins` folder next to the preferences, so it is installed again after a restart (#2541). A [heavy filter](#heavy-filters) installs only with `allowHeavy: true`, once the user agreed. A plug-in whose manifest lists [permissions](#permissions) installs only when `grant` lists every one of them, once the user agreed, unless the user already granted a module with the same SHA-256. With `hash`, a module whose SHA-256 differs is refused. | | `plugin.inspect` | as `plugin.install`: `{"path"}` or `{"dataBase64", "name"?}` | Validates a module without installing it; returns the `plugin.list` entry it would have, plus `needsApproval` (true when `plugin.install` needs `grant`). | | `plugin.remove` | `{"id"}` | Uninstalls, and deletes it from the `Plug-ins` folder. | | `plugin.reload` | `{"path"?: "folder"}` (default: the preference folder) | Installs every `*.wasm` in a folder; a bad module is reported and skipped, and so are a heavy filter and a plug-in whose permissions the user hasn't granted (nobody was asked). | @@ -131,16 +131,20 @@ capability model proposed for PhotoCraft plug-ins names shown have control and bidirectional characters escaped (`\u{202E}`) and are cut to 64 characters. `plugin.install` refuses the module, naming the permissions it needs, unless `grant` lists them all, which an agent passes once the user agreed (`plugin.inspect`'s `needsApproval` tells it beforehand), with `hash` set to the - `plugin.inspect` entry's `hash` so that the module installed is the one the user saw. + `plugin.inspect` entry's `hash` so that the module installed is the one the user saw. `plugin.install` does not + check that a person approved `grant`: it trusts the client that sends it. - The grant is recorded with the plug-in's id, the module's hash and the permissions, in `grants.json` in the - `Plug-ins` folder. Installing a module with the same id that lists no new permission needs no new approval; one that - lists more asks again. `plugin.remove` forgets the grant. An entry of `grants.json` that this version can't use (a + `Plug-ins` folder, and covers only that module. Installing the same bytes again needs no new approval. A module with + other bytes asks again, even when it lists the same permissions. Until it is approved, the installed module and its + grant stay as they are; once it is, the grant records the new hash. A grant whose hash is not 64 lower-case + hexadecimal digits covers nothing. `plugin.remove` forgets the grant. An entry of `grants.json` that this version can't use (a permission it doesn't know, an invalid id) is skipped and stays in the file until its plug-in is granted again or removed. A `grants.json` that is not a JSON list is never overwritten, and the file is never written larger than 1 MiB, the most that is read. When a `Plug-ins` folder is in use, only the grants kept in it count for `plugin.install`, `plugin.inspect`, `plugin.reload` and the Additional Plug-ins Folder. At startup, - a stored plug-in whose permissions are not granted is skipped. The Additional Plug-ins Folder and `plugin.reload` - install only plug-ins whose permissions were granted, and report the others. + a stored plug-in is skipped when no grant covers its bytes and the permissions it lists. The Additional Plug-ins + Folder and `plugin.reload` install only plug-ins that such a grant covers, and report the others. A module without a + `permissions` list, or with an empty one, needs no grant. - The host enforces the grant on a filter run: without `doc.read` the filter is given no objects; without `state` its `documentData` is `null` and an output that sets it is refused; without `doc.write` an output that changes the document is refused. A refused output leaves the document as it was. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 31dc47a26..4eb68b804 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1487,6 +1487,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Heavy filter plug-ins (G17):** a filter's manifest may ask for `"budget": "heavy"`: 1 G instructions (20 times a light run's) and 300 s a run, for routing, packing or simulation. Install Plug-in… and File › Open ask the user first, `plugin.install` needs `allowHeavy: true` (`plugin.inspect` tells beforehand), the Additional Plug-ins Folder skips such filters, and the plug-in store keeps the answer. Its dialog has no Preview; the app runs it on a thread of its own with a progress window (Cancel, Continue in Background) and applies the result as `plugin.run` with the same parameters, one undo step, unless the document or the plug-in changed meanwhile. Regressions: `crates/plugins/tests/abi.rs`, `crates/plugins/src/manifest.rs`, `crates/engine/src/tests/plugins.rs`, `crates/ui-egui/src/tests/plugin_runs.rs`. Scores and hours unchanged. - **Plug-in instruction budget at a bulk memory instruction:** a run that pauses where one slice of its instruction budget ends keeps the instructions the slice still held. A `memory.fill` or `memory.copy` that the slice can't pay for in full runs on the next slice, so a run whose budget covers its work finishes; a run ends with `Limit("instruction budget")` only when the instructions it has left can't pay for the next instruction. Regressions: `crates/plugins/tests/abi.rs`. Scores and hours unchanged. - **Plug-in permissions (G17):** a manifest may list `permissions` (`doc.read`, `doc.write`, `state`, `effect.render`, `export.write`; the first three follow storytold/photocraft#662) and an `api` level; without the list a plug-in gets what its kind implies and installs as before. Install Plug-in… and File › Open show the name, id, version, file SHA-256 and each permission in plain words, with control and bidirectional characters escaped, and install nothing on Cancel; `plugin.install` needs `grant`, and folders install only granted plug-ins. The grant is kept with the module's hash, a module asking for more asks again, and a filter run refuses output its permissions don't allow. Regressions: `crates/plugins/src/permissions.rs`, `crates/plugins/src/manifest.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_fuzz.rs`, `crates/ui-egui/src/tests/plugins.rs`. Scores and hours unchanged. +- **Plug-in grants cover one module (G17):** a grant counts only for the module with the id and SHA-256 it records. A module with other bytes under the same id needs a new approval, even with the same permissions: `plugin.inspect` reports `needsApproval`, `plugin.install` refuses it without `grant`, Install Plug-in… and File › Open ask again, and the plug-in store and plug-in folders skip it. A grant with a malformed hash covers nothing. Regressions: `crates/plugins/src/permissions.rs`, `crates/engine/src/tests/plugins.rs`, `crates/ui-egui/src/tests/plugins.rs`. Scores and hours unchanged. - **Plug-in progress, Cancel and log lines (G17):** at plug-in API level 2 a plug-in with the `progress` permission imports `env.vc_progress`, `env.vc_cancelled` and `env.vc_log`. A heavy filter's and an export's progress windows show its progress as a percentage bar, their Cancel reaches it through `vc_cancelled`, and its log lines go to the app's log with its id, capped per line and per run. Installing checks every import and names the one it refuses. Regressions: `crates/plugins/src/host.rs`, `crates/plugins/tests/abi.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_fuzz.rs`, `crates/ui-egui/src/tests/plugin_runs.rs`. Scores and hours unchanged. - **Plug-in effects after a plug-in changes:** when a plug-in is installed for the first time, installed again or removed (`plugin.install`, `plugin.reload`, `plugin.remove`, File › Open of a `.wasm` file, the Additional Plug-ins Folder preference), the next frame the canvas draws uses the plug-ins then installed for objects with a plug-in effect and their raster effects, for the blends, envelopes and repeats that show such objects, and for pattern tiles and symbol instances whose art holds one. From a224331554129c126e0c80a18f67d802aebbc992 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 8 Nov 2026 12:04:03 -0800 Subject: [PATCH 2/4] Plug-ins: a failed store write keeps the stored module and its grant together plugin.install wrote the grant and the module as two separate writes, and either could fail with only a log record. With a grant tied to the module's hash, the stored pair then no longer matched and the plug-in was skipped at the next start. The module is now written first and its grant only once it is there. When the grant can't be written, the store gets its old module back, or loses the new one on a first install, and a disabled plug-in stays disabled. A stored module that can't be read back is not replaced by one that needs a grant. --- crates/engine/src/cmd/plugin.rs | 13 +++- crates/engine/src/store.rs | 43 ++++++++++--- crates/engine/src/tests/plugins.rs | 81 +++++++++++++++++++++++++ crates/engine/tests/plugin_safe_mode.rs | 19 ++++++ docs/plugins.md | 4 +- 5 files changed, 147 insertions(+), 13 deletions(-) diff --git a/crates/engine/src/cmd/plugin.rs b/crates/engine/src/cmd/plugin.rs index 90a8cc827..8e7fc717e 100644 --- a/crates/engine/src/cmd/plugin.rs +++ b/crates/engine/src/cmd/plugin.rs @@ -379,13 +379,20 @@ fn install(s: &mut Session, p: &Value) -> Result { let asked = permissions::names(&wanted).join(", "); return Err(bad(C, format!("“{name}” asks for the permissions {asked}: install it with `grant` listing them once the user agrees"))); } + // Installed plug-ins stay installed after a restart (#2541). The module is written first and its + // grant only once it is there; when the grant can't be written, the store gets the module it + // held back, so the module and grant kept there always belong together. A stored module that + // can't be read back isn't replaced by one that needs a grant. + let previous = s.stored_plugin_bytes(plugin.id()); + let mut stored = (previous.is_ok() || !m.asks_permissions()) && s.store_plugin(plugin.id(), &bytes); if m.asks_permissions() { let g = Grant { id: m.id.clone(), hash: plugin.hash().to_string(), permissions: wanted }; registry::grant(g.clone()); - s.store_grant(&m.id, Some(g)); + if stored && !s.store_grant(&m.id, Some(g)) { + s.put_back_plugin(plugin.id(), previous.ok().flatten().as_deref()); + stored = false; + } } - // Installed plug-ins stay installed after a restart (#2541). - let stored = s.store_plugin(plugin.id(), &bytes); // Installing a disabled plug-in again (an updated version) turns it on for the next launch too, // also when it is disabled by its stored file alone (it stopped a launch while loading). When // the new version could not replace the stored file, the old one stays disabled. diff --git a/crates/engine/src/store.rs b/crates/engine/src/store.rs index 375ca4d02..e54f8deb9 100644 --- a/crates/engine/src/store.rs +++ b/crates/engine/src/store.rs @@ -372,6 +372,29 @@ impl Session { self.plugin_dir.as_deref().is_some_and(|dir| put(dir, &format!("{}.{PLUGIN_EXT}", file_stem(id)), bytes)) } + /// The module the store holds for the plug-in `id`: `None` without a folder or such a file, and + /// an error, with a log record, when the file can't be read or is larger than the module limit. + pub(crate) fn stored_plugin_bytes(&self, id: &str) -> Result>, String> { + let Some(dir) = self.plugin_dir.as_deref() else { return Ok(None) }; + let path = dir.join(format!("{}.{PLUGIN_EXT}", file_stem(id))); + if !path.exists() { + return Ok(None); + } + let max = vectorcraft_plugins::Limits::default().max_module_bytes as u64; + read_capped(&path, max).map(Some).inspect_err(|e| log::warn!("can't read the stored plug-in {}: {e}", path.display())) + } + + /// Return the store's module for the plug-in `id` to `previous`, the bytes it held before a + /// write ([`Session::stored_plugin_bytes`]): with `None`, the file is removed. + pub(crate) fn put_back_plugin(&self, id: &str, previous: Option<&[u8]>) { + match previous { + Some(bytes) => { + self.store_plugin(id, bytes); + } + None => self.unstore_plugin(id), + } + } + /// The file the plug-in `id` is stored in, spelled as [`Session::load_stored_plugins`] names it /// (`None` without a plug-in folder). pub(crate) fn stored_plugin_file(&self, id: &str) -> Option { @@ -382,14 +405,15 @@ impl Session { /// other grants as they are; with none left, remove the file. Entries this version can't use /// are written back as they were, except one for `id`. A grants file that is too large, can't /// be read or doesn't hold a list is left as it is, with a log record, and nothing is recorded; - /// so is one whose new content would be larger than `MAX_GRANTS_FILE`. - pub(crate) fn store_grant(&self, id: &str, grant: Option) { - let Some(dir) = &self.plugin_dir else { return }; + /// so is one whose new content would be larger than `MAX_GRANTS_FILE`. → whether the store now + /// holds what was asked (`false` without a folder). + pub(crate) fn store_grant(&self, id: &str, grant: Option) -> bool { + let Some(dir) = &self.plugin_dir else { return false }; let mut file = match read_grants(dir) { Ok(file) => file, Err(e) => { log::warn!("not updating the stored plug-in grants {}: {e}", dir.join(GRANTS_FILE).display()); - return; + return false; } }; let others = file.other.len(); @@ -399,22 +423,23 @@ impl Session { None => file.grants.remove(id).is_some(), } || file.other.len() != others; if !changed { - return; + return true; } let mut entries: Vec = file.grants.into_values().filter_map(|g| serde_json::to_value(g).ok()).collect(); entries.extend(file.other); if entries.is_empty() { take(dir, GRANTS_FILE); - return; + return true; } // The file is read back only up to its cap: written compactly when pretty-printed is larger, // and not at all when that is too. let fits = |bytes: &Vec| bytes.len() as u64 <= MAX_GRANTS_FILE; match serde_json::to_vec_pretty(&entries).ok().filter(fits).or_else(|| serde_json::to_vec(&entries).ok().filter(fits)) { - Some(bytes) => { - put(dir, GRANTS_FILE, &bytes); + Some(bytes) => put(dir, GRANTS_FILE, &bytes), + None => { + log::warn!("not updating the stored plug-in grants {}: {}", dir.join(GRANTS_FILE).display(), too_large(MAX_GRANTS_FILE)); + false } - None => log::warn!("not updating the stored plug-in grants {}: {}", dir.join(GRANTS_FILE).display(), too_large(MAX_GRANTS_FILE)), } } diff --git a/crates/engine/src/tests/plugins.rs b/crates/engine/src/tests/plugins.rs index 7d0983ef2..2dbcccb2b 100644 --- a/crates/engine/src/tests/plugins.rs +++ b/crates/engine/src/tests/plugins.rs @@ -690,6 +690,87 @@ fn a_stored_grant_covers_only_the_stored_module_it_recorded() { run(&mut attached, "plugin.remove", json!({"id": id})); } +/// A write to the plug-in store that fails leaves the store's module beside the grant that approved +/// it, so the old module loads at the next start: a module that can't be written doesn't update the +/// grant, and a grant that can't be written puts the old module back. On a first install, it takes +/// the new module out again. +#[test] +fn a_failed_store_write_keeps_the_stored_module_with_its_grant() { + use vectorcraft_plugins::permissions::sha256_hex; + let folder = crate::tests_links::Folder::new("plugin-store-failed-write"); + let id = "test.engine.permissions.failedwrite"; + let first = asking(id, r#"["doc.read"]"#, &json!({"objects": []})); + let changed = asking(id, r#"["doc.read","doc.write"]"#, &json!({"objects": []})); + let first_data = vectorcraft_format::base64_encode(&first); + let update = json!({"dataBase64": vectorcraft_format::base64_encode(&changed), "grant": ["doc.read", "doc.write"]}); + let mut s = session(); + s.attach_plugin_store(folder.0.clone()); + run(&mut s, "plugin.install", json!({"dataBase64": first_data, "grant": ["doc.read"]})); + let grants = folder.0.join("grants.json"); + let recorded = std::fs::read(&grants).unwrap(); + let module = std::path::PathBuf::from(s.stored_plugin_file(id).unwrap()); + let starts = || { + vectorcraft_plugins::registry::remove(id); + vectorcraft_plugins::registry::revoke(id); + let mut next = session(); + next.attach_plugin_store(folder.0.clone()); + vectorcraft_plugins::registry::get(id).map(|p| p.hash().to_string()) + }; + let block = |path: &std::path::Path| { + std::fs::remove_file(path).unwrap(); + std::fs::create_dir_all(path.join("block")).unwrap(); + }; + + // The module can't be written: the grant stays the old module's. + block(&module); + run(&mut s, "plugin.install", update.clone()); + assert_eq!(std::fs::read(&grants).unwrap(), recorded, "the grant isn't updated for a module that wasn't stored"); + std::fs::remove_dir_all(&module).unwrap(); + std::fs::write(&module, &first).unwrap(); + assert_eq!(starts(), Some(sha256_hex(&first)), "the old module loads after a failed module write"); + + // The grants file is full: the new grant, which is longer, doesn't fit. + let cap = 1 << 20; + let mut entries: Vec = serde_json::from_slice(&recorded).unwrap(); + entries.push(json!({"id": "", "pad": ""})); + let room = cap - serde_json::to_vec(&entries).unwrap().len(); + *entries.last_mut().unwrap() = json!({"id": "", "pad": "x".repeat(room)}); + let full = serde_json::to_vec(&entries).unwrap(); + assert_eq!(full.len(), cap); + std::fs::write(&grants, &full).unwrap(); + run(&mut s, "plugin.install", update.clone()); + assert_eq!(std::fs::read(&grants).unwrap(), full, "not written past the cap"); + assert_eq!(std::fs::read(&module).unwrap(), first, "the old module is put back"); + assert_eq!(starts(), Some(sha256_hex(&first)), "the old module loads after a failed grant write"); + + // The grants file can't be read, so nothing can be recorded in it. + block(&grants); + run(&mut s, "plugin.install", update.clone()); + assert_eq!(std::fs::read(&module).unwrap(), first, "the old module is put back"); + std::fs::remove_dir_all(&grants).unwrap(); + std::fs::write(&grants, &recorded).unwrap(); + assert_eq!(starts(), Some(sha256_hex(&first)), "the old module loads once the grants are readable"); + + // A stored module that can't be read back isn't replaced, so a failed grant write can't lose it. + let oversized = vectorcraft_plugins::Limits::default().max_module_bytes as u64 + 1; + std::fs::File::options().write(true).open(&module).unwrap().set_len(oversized).unwrap(); + block(&grants); + run(&mut s, "plugin.install", update); + assert_eq!(std::fs::metadata(&module).unwrap().len(), oversized, "the stored module stays"); + std::fs::remove_dir_all(&grants).unwrap(); + std::fs::write(&grants, &recorded).unwrap(); + std::fs::write(&module, &first).unwrap(); + + // A first install whose grant can't be written leaves no module without a grant in the store. + run(&mut s, "plugin.remove", json!({"id": id})); + assert!(!module.exists() && !grants.exists()); + std::fs::create_dir_all(grants.join("block")).unwrap(); + run(&mut s, "plugin.install", json!({"dataBase64": first_data, "grant": ["doc.read"]})); + assert!(!module.exists(), "the store holds no module its grants don't cover"); + vectorcraft_plugins::registry::remove(id); + vectorcraft_plugins::registry::revoke(id); +} + /// A plug-in folder reports a changed module as needing approval and still loads the other files; /// granted on its own, the changed module loads from the folder. A session without a store uses the /// grants held by the process. diff --git a/crates/engine/tests/plugin_safe_mode.rs b/crates/engine/tests/plugin_safe_mode.rs index 6923df616..fc9bfed29 100644 --- a/crates/engine/tests/plugin_safe_mode.rs +++ b/crates/engine/tests/plugin_safe_mode.rs @@ -198,6 +198,25 @@ fn installing_a_disabled_plug_in_again_turns_it_on() { let _ = std::fs::remove_dir_all(&dir); } +/// When the grant of an update can't be stored, the store keeps the old module, and it stays disabled. +#[test] +fn a_disabled_plug_in_whose_new_grant_cant_be_stored_stays_disabled() { + let _f = fresh(); + let dir = tmp_dir("reinstall-no-grant"); + let mut s = launch(&dir); + let manifest = r#"{"id":"test.sm.g","name":"Test","version":"1","kind":"filter","permissions":["doc.read"]}"#; + let module = |out: &str| vectorcraft_format::base64_encode(&wat::parse_str(tpl::returning(manifest, out)).unwrap()); + s.execute("plugin.install", &json!({"dataBase64": module(r#"{"objects":[]}"#), "grant": ["doc.read"]})).unwrap(); + s.execute("plugin.setEnabled", &json!({"id": "test.sm.g", "enabled": false})).unwrap(); + let grants = dir.join("grants.json"); + std::fs::remove_file(&grants).unwrap(); + std::fs::create_dir_all(grants.join("block")).unwrap(); + s.execute("plugin.install", &json!({"dataBase64": module(r#"{"objects":[],"error":null}"#), "grant": ["doc.read"]})).unwrap(); + assert_eq!(safe_mode::disabled().len(), 1, "the module the update could not replace stays disabled"); + safe_mode::set_disabled(vec![]); + let _ = std::fs::remove_dir_all(&dir); +} + /// A launch where the stored plug-in `id` stopped while loading: it is installed, then disabled by /// its stored file alone (as Disable and Continue saves it), and the next launch skips it. fn stored_plug_in_disabled_by_file(dir: &std::path::Path, id: &str) -> Session { diff --git a/docs/plugins.md b/docs/plugins.md index 2c581c425..0dc8f88e4 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -136,7 +136,9 @@ capability model proposed for PhotoCraft plug-ins - The grant is recorded with the plug-in's id, the module's hash and the permissions, in `grants.json` in the `Plug-ins` folder, and covers only that module. Installing the same bytes again needs no new approval. A module with other bytes asks again, even when it lists the same permissions. Until it is approved, the installed module and its - grant stay as they are; once it is, the grant records the new hash. A grant whose hash is not 64 lower-case + grant stay as they are; once it is, the grant records the new hash. When the new module or its grant can't be + written to the `Plug-ins` folder, the folder keeps the old module and its grant, and the old module loads at the + next start. A grant whose hash is not 64 lower-case hexadecimal digits covers nothing. `plugin.remove` forgets the grant. An entry of `grants.json` that this version can't use (a permission it doesn't know, an invalid id) is skipped and stays in the file until its plug-in is granted again or removed. A `grants.json` that is not a JSON list is never overwritten, and the file is never written larger than From 4e1f7841151494ec176cbbdb530120a2b750923c Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 8 Nov 2026 13:17:51 -0800 Subject: [PATCH 3/4] Plug-ins: a module without a grant gets no settings or cache kept for its id The test for a changed module under a granted id now checks that the next start, a folder load and a refused install leave the id's settings and cache unattached. --- crates/engine/src/tests/plugins.rs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/crates/engine/src/tests/plugins.rs b/crates/engine/src/tests/plugins.rs index 61d864142..096d6e5d1 100644 --- a/crates/engine/src/tests/plugins.rs +++ b/crates/engine/src/tests/plugins.rs @@ -1030,18 +1030,25 @@ fn a_grant_covers_only_the_module_it_was_given_to() { std::fs::write(&stored[0], &other).unwrap(); vectorcraft_plugins::registry::remove(id); vectorcraft_plugins::registry::revoke(id); + vectorcraft_plugins::registry::set_storage(id, None); + let no_storage = |how: &str| { + assert!(vectorcraft_plugins::registry::storage(id).is_none(), "{how} gives another module the id's settings and cache"); + }; let mut next = session(); next.attach_plugin_store(folder.0.clone()); assert!(vectorcraft_plugins::registry::get(id).is_none(), "another module isn't loaded under the grant"); + no_storage("the next start"); let dir = crate::tests_links::Folder::new("plugin-folder-grant-hash"); std::fs::write(dir.0.join("other.wasm"), &other).unwrap(); let r = run(&mut next, "plugin.reload", json!({"path": dir.0.display().to_string()})); assert_eq!(r["loaded"], json!([])); let why = r["failed"][0]["error"].as_str().unwrap(); assert!(why.contains("needs approval"), "{why}"); + no_storage("a folder load"); let data = vectorcraft_format::base64_encode(&other); let e = next.execute("plugin.install", &json!({"dataBase64": data, "replace": true})).unwrap_err().to_string(); assert!(e.contains("`grant`"), "{e}"); + no_storage("a refused install"); // Granted, it reads the settings the first module set. run(&mut next, "plugin.install", json!({"dataBase64": data, "grant": ["settings", "cache"], "replace": true})); run(&mut next, "plugin.run", json!({"id": id, "ids": []})); From 6326d1c4ffea0c67032fde591779554fa1cc8251 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 8 Nov 2026 15:07:20 -0800 Subject: [PATCH 4/4] Plug-ins: the install comment states what a failed module restore leaves --- crates/engine/src/cmd/plugin.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/plugin.rs b/crates/engine/src/cmd/plugin.rs index 08b56cde7..e9f35aa7c 100644 --- a/crates/engine/src/cmd/plugin.rs +++ b/crates/engine/src/cmd/plugin.rs @@ -409,8 +409,9 @@ fn install(s: &mut Session, p: &Value) -> Result { } // Installed plug-ins stay installed after a restart (#2541). The module is written first and its // grant only once it is there; when the grant can't be written, the store gets the module it - // held back, so the module and grant kept there always belong together. A stored module that - // can't be read back isn't replaced by one that needs a grant. + // held back. A failed write of that module is only logged, and the grant then doesn't cover + // the new module. A stored module that can't be read back isn't replaced by one that needs a + // grant. let previous = s.stored_plugin_bytes(plugin.id()); let mut stored = (previous.is_ok() || !m.asks_permissions()) && s.store_plugin(plugin.id(), &bytes); if m.asks_permissions() {