Plug-ins: an update left in the store by a failed put-back turns a disabled plug-in on

When plugin.install writes a module that lists permissions, its grant
can't be written, and the old module can't be put back (or, on a first
install, the new module can't be removed), the store holds the new
module. install now takes put_back_plugin's result, so the plug-in's
disabled entries (by id and by stored file) are cleared as for any
install the store holds. The old grant, or none, still doesn't cover the
new module, so the next start skips it until it is approved.

Docs: docs/plugins.md, docs/mcp.md, docs/target-app-parity.md.
This commit is contained in:
dotCooCoo
2026-11-11 13:03:38 -08:00
parent b08e421445
commit fd7ff70941
5 changed files with 114 additions and 7 deletions
+4 -5
View File
@@ -741,17 +741,16 @@ fn install(s: &mut Session, p: &Value) -> Result<Value> {
}
// 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. 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.
// held back. A failed write of that module is only logged: the store then holds the new module,
// which the grant doesn't cover. 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());
if stored && !s.store_grant(&m.id, Some(g)) {
s.put_back_plugin(plugin.id(), previous.ok().flatten().as_deref());
stored = false;
stored = !s.put_back_plugin(plugin.id(), previous.ok().flatten().as_deref());
}
} else {
// This module takes the id over, so nothing the user approved for the id applies any more.
+103
View File
@@ -2178,6 +2178,109 @@ fn a_failed_store_write_keeps_the_stored_module_with_its_grant() {
vectorcraft_plugins::registry::revoke(id);
}
/// Runs the test `name` of this module again in a process of its own → whether this is that
/// process. The list of disabled plug-ins is kept for the whole process, and the other tests here
/// read it again each time they attach a plug-in store.
fn in_own_process(name: &str) -> bool {
const CHILD: &str = "VECTORCRAFT_TEST_PLUGIN_DISABLED_LIST";
if std::env::var_os(CHILD).is_some() {
return true;
}
let output = std::process::Command::new(std::env::current_exe().unwrap())
.args(["--exact", &format!("tests::plugins::{name}"), "--nocapture"])
.env(CHILD, "1")
.output()
.unwrap();
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
output.status.success() && stdout.contains("test result: ok. 1 passed; 0 failed;"),
"{name} failed in its own process:\n{stdout}\n{}",
String::from_utf8_lossy(&output.stderr),
);
false
}
/// An update whose grant can't be written, and whose old module can't be put back either, stays in
/// the store: a disabled plug-in is turned on, as for any update the store holds. The old grant
/// doesn't cover the new module, so the next start skips it until it is approved.
#[test]
fn an_update_left_in_the_store_by_a_failed_put_back_turns_a_disabled_plugin_on() {
if !in_own_process("an_update_left_in_the_store_by_a_failed_put_back_turns_a_disabled_plugin_on") {
return;
}
use vectorcraft_plugins::{permissions::sha256_hex, registry, safe_mode};
let folder = crate::tests_links::Folder::new("plugin-store-failed-put-back-update");
let id = "test.engine.permissions.putbackupdate";
let first = asking(id, r#"["doc.read"]"#, &json!({"objects": []}));
let changed = asking(id, r#"["doc.read","doc.write"]"#, &json!({"objects": []}));
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"]}));
run(&mut s, "plugin.setEnabled", json!({"id": id, "enabled": false}));
let grants = folder.0.join("grants.json");
let recorded = std::fs::read(&grants).unwrap();
std::fs::remove_file(&grants).unwrap();
std::fs::create_dir_all(grants.join("block")).unwrap();
crate::store::PUT_BACK_FAILS.set(true);
let update = json!({"dataBase64": vectorcraft_format::base64_encode(&changed), "grant": ["doc.read", "doc.write"], "replace": true});
let installed = s.execute("plugin.install", &update);
crate::store::PUT_BACK_FAILS.set(false);
assert_eq!(installed.unwrap()["hash"], sha256_hex(&changed), "the new module runs in this session");
assert_eq!(registry::get(id).map(|p| p.hash().to_string()), Some(sha256_hex(&changed)));
assert_eq!(std::fs::read(s.stored_plugin_file(id).unwrap()).unwrap(), changed, "the store holds the new module");
assert!(safe_mode::disabled().is_empty(), "the module the store holds is turned on");
let saved = std::fs::read(folder.0.join(crate::store::DISABLED_FILE)).unwrap();
assert!(safe_mode::parse_disabled_file(&saved).is_empty(), "nothing disabled is saved");
std::fs::remove_dir_all(&grants).unwrap();
std::fs::write(&grants, &recorded).unwrap();
registry::remove(id);
registry::revoke(id);
let mut next = session();
next.attach_plugin_store(folder.0.clone());
assert!(registry::get(id).is_none(), "the old grant doesn't cover the new module");
safe_mode::set_disabled(vec![]);
}
/// A first install whose grant can't be written, and whose module can't be taken out of the store
/// again, stays in the store: the plug-in's file is turned on. No grant covers the module, so the
/// next start skips it until it is approved.
#[test]
fn a_first_install_left_in_the_store_by_a_failed_put_back_turns_its_file_on() {
if !in_own_process("a_first_install_left_in_the_store_by_a_failed_put_back_turns_its_file_on") {
return;
}
use vectorcraft_plugins::{registry, safe_mode};
let folder = crate::tests_links::Folder::new("plugin-store-failed-put-back-first");
let id = "test.engine.permissions.putbackfirst";
let first = asking(id, r#"["doc.read"]"#, &json!({"objects": []}));
let mut s = session();
s.attach_plugin_store(folder.0.clone());
let module = s.stored_plugin_file(id).unwrap();
run(&mut s, "plugin.setEnabled", json!({"file": module, "enabled": false}));
let grants = folder.0.join("grants.json");
std::fs::create_dir_all(grants.join("block")).unwrap();
crate::store::PUT_BACK_FAILS.set(true);
let installed = s.execute("plugin.install", &json!({"dataBase64": vectorcraft_format::base64_encode(&first), "grant": ["doc.read"]}));
crate::store::PUT_BACK_FAILS.set(false);
installed.unwrap();
assert!(registry::get(id).is_some(), "the module runs in this session");
assert_eq!(std::fs::read(&module).unwrap(), first, "the store holds the module");
assert!(safe_mode::disabled().is_empty(), "the file the store holds is turned on");
let saved = std::fs::read(folder.0.join(crate::store::DISABLED_FILE)).unwrap();
assert!(safe_mode::parse_disabled_file(&saved).is_empty(), "nothing disabled is saved");
std::fs::remove_dir_all(&grants).unwrap();
registry::remove(id);
registry::revoke(id);
let mut next = session();
next.attach_plugin_store(folder.0.clone());
assert!(registry::get(id).is_none(), "no grant covers the module");
safe_mode::set_disabled(vec![]);
}
/// 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.
+3 -1
View File
@@ -7593,7 +7593,9 @@ pendingItem}`; a command does not turn Safe Mode on or off. `plugin.setEnabled {
off for the next launch, as Help › Safe Mode's Enable on Re-launch does; it does not load, unload or uninstall anything
now, and `plugin.install` of a disabled plug-in turns it on. Turning a plug-in on by `id` or by installing it also turns
on the file it is stored in, which names it when it stopped a launch while loading; when the new version can't be saved
over that file, the plug-in stays disabled. `plugin.list` reports `safeMode` and `disabled`, and
over that file, the plug-in stays disabled. When the new version is saved but its grant can't be, and the file can't be
returned to what it held before (the old module, or no file), the plug-in is turned on, and the next launch skips it
until it is approved. `plugin.list` reports `safeMode` and `disabled`, and
`plugin.reload` skips disabled plug-ins. A change to the disabled list is saved on top of the list the file holds at
that moment, so a change another window saved to the same preferences meanwhile is kept. When the list file can't be
read (it is too large) or holds another format or version, it is left as it is; a file that isn't JSON is replaced.
+3 -1
View File
@@ -240,7 +240,9 @@ capability model proposed for PhotoCraft plug-ins
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. If the write that puts the old module back
fails as well, the folder holds the new module with the old grant, the grant does not cover that module, and the
plug-in is skipped at the next start until it is approved again. A grant whose hash is not 64 lower-case
plug-in is skipped at the next start until it is approved again. When the folder held no module for that id, that
write removes the new module; when the removal fails, the folder holds the new module and no grant covers it. In
both cases a disabled plug-in is turned on, as for any install the folder holds. A grant whose hash is not 64 lower-case
hexadecimal digits covers nothing. `plugin.remove` forgets the grant, and so does a successful `plugin.install` of a
module that lists no permissions under that id, so the earlier bytes need a new approval. When `plugin.remove` can't
remove the grant from `grants.json`, it returns an error, the plug-in stays installed, and a second `plugin.remove`
+1
View File
@@ -1652,6 +1652,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
- **Plug-in data kept with the document (G17):** a filter writes JSON of at most 1 MiB with `documentData` (one undo step with its objects; `null` removes it) and reads it back in `_context.documentData`, which no other filter is given. It is the document's foreign data (`unknown.pluginData`, by plug-in id), as the perspective grid is, so the native file keeps it with no section of its own; an exporter is given the whole document, so it can read every plug-in's. Regressions: `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_export.rs` (the exporter), `crates/doc/src/plugin_data.rs`, `crates/plugins/src/objects/tests.rs`. Scores and hours unchanged.
- **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.
- **Safe Mode for plug-ins (G17):** `vectorcraft --safe-mode` and `vectorcraft-cli --safe-mode <subcommand>` start without plug-ins. When a launch stopped while a plug-in was loading or drawing its first live effect, the next launch names that plug-in, without saying it caused the stop, and offers Disable and Continue, Start Normally or Start in Safe Mode. Help › Safe Mode lists the disabled plug-ins with Enable on Re-launch, which takes effect at the next launch, and the app bar and the window title show Safe Mode. The help page (https://web.archive.org/web/20210601162814/https://helpx.adobe.com/pdf/illustrator_reference.pdf, pages 65 to 69) describes the same choice between diagnosing and starting normally, a Safe Mode with the problem item off and the rest of the app working, and turning the item back on at the next launch. In Safe Mode no plug-in loads or runs, and a live-effect plug-in draws its object's geometry unchanged while the object keeps the effect, which the Appearance panel marks "(Safe Mode)" with the plug-in's id in its tooltip and `effect.list` marks `skipped`; Expand Appearance refuses such an object. The web build keeps no plug-ins between page loads and has no marker. Each desktop launch with preferences keeps a marker in the `Launch` folder next to them; `vectorcraft-cli` and a desktop launch without preferences keep none, so a stop there leads to no choice at the next launch. Disabled plug-ins are listed in `Plug-ins/disabled.json`. `plugin.safeMode` reports Safe Mode and the disabled plug-ins, and `plugin.setEnabled` changes the list. Regressions: `crates/plugins/src/safe_mode.rs`, `crates/plugins/tests/safe_mode.rs`, `crates/engine/tests/plugin_safe_mode.rs`, `crates/ui-egui/tests/safe_mode.rs`. Scores and hours unchanged.
- **A disabled plug-in whose update stays in the store is turned on:** when `plugin.install` writes a module that lists permissions to the `Plug-ins` folder, its grant can't be written, and the folder can't be returned to the old module (or, on a first install, the new module can't be removed), the folder holds the new module, so a disabled plug-in is turned on, by id and by stored file, as for any install the folder holds. The old grant, or none, doesn't cover the new module, so the next start skips it until it is approved. Regressions: `crates/engine/src/tests/plugins.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.
- **Bulk memory fuzz cases use the host's fuel constants:** `crates/engine/tests/plugin_fuzz.rs` samples runs around one slice of the instruction budget and lengths around the bytes one unit of fuel pays for with `vectorcraft_plugins`' own `FUEL_SLICE` and `BYTES_COPIED_PER_FUEL`, so the sampled cases move when either value changes. Regressions: `crates/engine/tests/plugin_fuzz.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; for a plug-in that lists permissions, `plugin.install` needs `grant` and folders install it only when it is granted. 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.