Merge main into fix/plugin-grant-app-approval

This commit is contained in:
dotCooCoo
2026-11-08 23:53:57 -08:00
14 changed files with 813 additions and 61 deletions
+33 -11
View File
@@ -612,7 +612,7 @@ fn takes_over(s: &Session, plugin: &Plugin) -> bool {
/// very module unless `grant` lists them all, one that would take another module's place
/// ([`takes_over`]) unless `replace` is true, 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.
/// `plugin.list` entry. A module that lists no permissions ends any grant held for its id.
fn install(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "plugin.install";
// One use: spent whatever this install does.
@@ -692,6 +692,22 @@ fn install(s: &mut Session, p: &Value) -> Result<Value> {
s.put_back_plugin(plugin.id(), previous.ok().flatten().as_deref());
stored = false;
}
} else {
// This module takes the id over, so nothing the user approved for the id applies any more.
registry::revoke(&m.id);
// When the write failed, the old stored module stays in the folder and needs its grant at
// the next launch. A grant that can't be removed still approves the old bytes, so the store
// gets the module it held back. When that module can't be read or written back, or there
// was none, no module goes with the grant, and the grant stops counting for this session. A
// grants file that is too large or doesn't hold a list approves nothing, and the new module
// stays; one that can't be opened or read may approve the old bytes once it reads again.
if stored && !s.store_grant(&m.id, None) && s.holds_plugin_grant(&m.id) {
let back = previous.as_ref().is_ok_and(|held| s.put_back_plugin(plugin.id(), held.as_deref()));
stored = !back;
if !back || matches!(previous, Ok(None)) {
s.ignore_stored_grant(&m.id);
}
}
}
// It gets the settings and cache kept for its id.
s.attach_plugin_storage(plugin.id());
@@ -902,19 +918,25 @@ fn remove(s: &mut Session, p: &Value) -> Result<Value> {
let installed = registry::get(id).is_some();
// Settings and cache the store keeps for an id that isn't installed are deleted too.
let stored = !installed && vectorcraft_plugins::manifest::valid_id(id) && s.has_plugin_storage(id);
// Its settings and cache go first: when they can't be moved aside, the plug-in stays installed,
// as it was, and a second `plugin.remove` tries again.
if (installed || stored)
&& let Err(e) = s.remove_plugin_storage(id)
{
// Installed again, it asks again. A grant that can't be removed still approves the module, and
// so may a grants file that can't be opened or read. One that is too large or doesn't hold a
// list approves nothing and doesn't stop the removal.
let forget = || s.store_grant(id, None) || !s.holds_plugin_grant(id);
// Its settings and cache are moved aside and its grant removed before anything is deleted: when
// either can't be done, the plug-in stays installed, as it was, and a second `plugin.remove`
// tries again.
let failed = if installed || stored {
s.remove_plugin_storage(id, forget).err().map(|e| e.to_string())
} else {
(!forget()).then(|| crate::store::GRANT_NOT_REMOVED.to_string())
};
if let Some(e) = failed {
if installed {
s.attach_plugin_storage(id);
}
return Err(EngineError::Other(e.to_string()));
return Err(EngineError::Other(e));
}
// Installed again, it asks again.
registry::revoke(id);
s.store_grant(id, None);
s.unstore_plugin(id);
if !stored && (!installed || !registry::remove(id)) {
return Err(err(vectorcraft_plugins::Error::NotFound(id.into())));
@@ -975,7 +997,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Install Plug-in",
[],
None,
"{path: a .wasm file (desktop) | dataBase64: the module (+ name?: shown as its source), replace?: bool (true once the user agreed: a module with other bytes than the plug-in installed with its id, or with none installed, one whose id has settings or a cache in the plug-in store, installs only with it, takes that plug-in's place and gets its settings and cache; false: one whose id is installed is refused; left out, the same module installs again), name?: file name (with dataBase64), allowHeavy?: bool (a filter whose manifest asks for a heavy budget, budget \"heavy\" in plugin.inspect, installs only with it), grant?: [permission] (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; a request's allowHeavy and grant are refused unless the session was started with vectorcraft-cli --allow-plugin-grants: in the desktop app a person approves the install, see ui.installPlugin, and a module whose hash is already recorded, by a person's approval or by a grant accepted under that flag, needs no grant), 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 once the user agreed: a module with other bytes than the plug-in installed with its id, or with none installed, one whose id has settings or a cache in the plug-in store, installs only with it, takes that plug-in's place and gets its settings and cache; false: one whose id is installed is refused; left out, the same module installs again), name?: file name (with dataBase64), allowHeavy?: bool (a filter whose manifest asks for a heavy budget, budget \"heavy\" in plugin.inspect, installs only with it), grant?: [permission] (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; installing a module that lists no permissions forgets the grant held for its id; a refused install keeps the grant, a failed write of the module to the plug-in store leaves the grant in the store, and a stored module that is disabled or not read in Safe Mode keeps the grant for its id; with a plug-in store attached, only the grants kept there count; a request's allowHeavy and grant are refused unless the session was started with vectorcraft-cli --allow-plugin-grants: in the desktop app a person approves the install, see ui.installPlugin, and a module whose hash is already recorded, by a person's approval or by a grant accepted under that flag, needs no grant), 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
),
@@ -993,7 +1015,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Remove Plug-in",
[],
None,
"{id} uninstall a plug-in (its live effects then leave the geometry as it is) and delete the settings and cache it kept (when their folder can't be moved aside, the command fails and the plug-in stays installed); for an id that isn't installed, deletes the settings and cache the plug-in store still keeps for it → {removed}",
"{id} uninstall a plug-in (its live effects then leave the geometry as it is) and delete the settings and cache it kept (when their folder can't be moved aside, or its grant can't be removed from the plug-in store, the command fails and the plug-in stays installed); for an id that isn't installed, deletes the settings and cache the plug-in store still keeps for it → {removed}",
always,
remove
),
+4
View File
@@ -154,6 +154,10 @@ pub struct Session {
/// Where the plug-ins `plugin.install` installs are kept; `None`: only for the process
/// ([`Session::attach_plugin_store`]).
plugin_dir: Option<std::path::PathBuf>,
/// The plug-in ids whose grant in the plug-in store should be gone but could not be removed:
/// [`Session::plugin_approver`] and a load of the store do not count those grants, and a successful
/// [`Session::store_grant`] for the id takes it out.
ignored_plugin_grants: std::sync::Mutex<std::collections::BTreeSet<String>>,
/// A filter's output that a run made in the background handed in for `plugin.run` to apply
/// ([`cmd::plugin::FilterRun::hand_in`]); the next `plugin.run` takes it.
plugin_output: Option<cmd::plugin::HandedIn>,
+1
View File
@@ -82,6 +82,7 @@ impl Session {
pdf_preset_dir: None,
profile_dir: None,
plugin_dir: None,
ignored_plugin_grants: Default::default(),
plugin_output: None,
plugin_install_approval: None,
plugin_grants_from_requests: false,
+145 -37
View File
@@ -28,7 +28,7 @@
//! The files are untrusted input like any other: sizes and counts are capped, a file that does not
//! parse is skipped with a log record, and every write is atomic.
use std::collections::BTreeMap;
use std::collections::{BTreeMap, BTreeSet};
use std::io::Read;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
@@ -62,6 +62,8 @@ pub const DISABLED_FILE: &str = "disabled.json";
pub const DISABLED_LOCK: &str = "disabled.lock";
/// The file in the plug-in store that holds the permissions the user granted.
const GRANTS_FILE: &str = "grants.json";
/// The error of a `plugin.remove` that can't remove the plug-in's grant from [`GRANTS_FILE`].
pub(crate) const GRANT_NOT_REMOVED: &str = "the plug-in's grant can't be removed from the plug-in store";
/// The largest grants file read.
const MAX_GRANTS_FILE: u64 = 1 << 20;
/// The folder in the plug-in store that holds each plug-in's settings and cache.
@@ -110,15 +112,21 @@ fn list_files(dir: &Path, ext: &str) -> Vec<PathBuf> {
/// The content of `path` when it is at most `max` bytes; the file is never read past `max`.
pub(crate) fn read_capped(path: &Path, max: u64) -> Result<Vec<u8>, String> {
let file = std::fs::File::open(path).map_err(|e| e.to_string())?;
let mut bytes = vec![];
file.take(max + 1).read_to_end(&mut bytes).map_err(|e| e.to_string())?;
let bytes = read_past_cap(path, max).map_err(|e| e.to_string())?;
if bytes.len() as u64 > max {
return Err(too_large(max));
}
Ok(bytes)
}
/// The first `max + 1` bytes of `path` at most, so that a result longer than `max` shows the file is
/// over its cap.
fn read_past_cap(path: &Path, max: u64) -> std::io::Result<Vec<u8>> {
let mut bytes = vec![];
std::fs::File::open(path)?.take(max + 1).read_to_end(&mut bytes)?;
Ok(bytes)
}
/// What a file over its cap is told (a detail after the file's name, in the log or an error).
fn too_large(max: u64) -> String {
format!("larger than {max} bytes")
@@ -168,13 +176,21 @@ fn lock_disabled(_: &Path) -> Option<std::fs::File> {
None
}
/// Remove `dir/file`; a file that isn't there is what was wanted.
fn take(dir: &Path, file: &str) {
#[cfg(test)]
thread_local! {
/// Tests: [`Session::put_back_plugin`] fails on this thread, as on a full volume.
pub(crate) static PUT_BACK_FAILS: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
}
/// Remove `dir/file` → whether it is gone; a file that isn't there is what was wanted.
fn take(dir: &Path, file: &str) -> bool {
let path = dir.join(file);
if let Err(e) = std::fs::remove_file(&path)
&& e.kind() != std::io::ErrorKind::NotFound
{
log::warn!("can't remove {}: {e}", path.display());
match std::fs::remove_file(&path) {
Err(e) if e.kind() != std::io::ErrorKind::NotFound => {
log::warn!("can't remove {}: {e}", path.display());
false
}
_ => true,
}
}
@@ -283,6 +299,9 @@ impl Session {
let disabled =
read_capped(&dir.join(DISABLED_FILE), safe_mode::MAX_DISABLED_FILE_BYTES).map(|b| safe_mode::parse_disabled_file(&b)).unwrap_or_default();
safe_mode::set_disabled(disabled);
if self.plugin_dir.as_ref() != Some(&dir) {
self.ignored_plugin_grants.lock().unwrap_or_else(|e| e.into_inner()).clear();
}
self.plugin_dir = Some(dir);
self.load_stored_plugins();
}
@@ -291,15 +310,18 @@ impl Session {
/// disabled (matched by file name, which is made from the id). The launch marker names each
/// file while it loads. A module that doesn't load, and one that lists permissions the grants
/// there don't grant that very module ([`grant_covers`]), is skipped with a log record, and
/// stays in the folder with its grant.
/// stays in the folder with its grant. A grant for an id whose stored module lists no
/// permissions is forgotten; one that can't be removed from the grants file doesn't count for
/// the rest of the session ([`Session::plugin_approver`]).
pub fn load_stored_plugins(&mut self) {
let Some(dir) = self.plugin_dir.clone() else { return };
if safe_mode::active() {
return;
}
let grants = stored_grants(&dir);
let grants = self.counted_grants(&dir);
let limits = vectorcraft_plugins::Limits::default();
let mut left = MAX_PLUGIN_STORE;
let mut asks_nothing = BTreeSet::new();
for path in list_files(&dir, PLUGIN_EXT) {
if left == 0 {
log::warn!("skipping the other stored plug-ins: they hold more than {MAX_PLUGIN_STORE} bytes together");
@@ -325,6 +347,7 @@ impl Session {
let m = plugin.manifest();
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::set_storage(&m.id, plugin_storage(Some(&dir), &m.id));
vectorcraft_plugins::registry::install(plugin.with_source(path.display().to_string()));
@@ -335,12 +358,24 @@ 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()));
}
Err(e) => log::warn!("skipping the stored plug-in {}: {e}", path.display()),
}
}
let kept = read_grants(&dir);
for id in &asks_nothing {
vectorcraft_plugins::registry::revoke(id);
let named = match &kept {
Ok(file) => file.names(id),
Err(e) => e.unread,
};
if named && !self.store_grant(id, None) && self.holds_plugin_grant(id) {
self.ignore_stored_grant(id);
}
}
}
/// Write the disabled plug-ins saved for the next launch to the plug-in folder → whether they
@@ -379,7 +414,8 @@ impl Session {
/// 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 ([`grant_covers`]), else in this process's
/// plug-in store when there is one ([`grant_covers`]), not counting a grant that should be gone
/// and could not be removed ([`Session::ignore_stored_grant`]), else in this process's
/// ([`vectorcraft_plugins::registry::approved`]).
pub(crate) fn plugin_approved(&self, plugin: &vectorcraft_plugins::Plugin) -> bool {
self.plugin_approver()(plugin)
@@ -388,7 +424,7 @@ impl Session {
/// [`Session::plugin_approved`] for many plug-ins: reads the store's grants once, when it is
/// made, and checks each plug-in against them.
pub(crate) fn plugin_approver(&self) -> impl Fn(&vectorcraft_plugins::Plugin) -> bool + use<> {
let grants = self.plugin_dir.as_deref().map(stored_grants);
let grants = self.plugin_dir.as_deref().map(|dir| self.counted_grants(dir));
move |plugin| {
let m = plugin.manifest();
match &grants {
@@ -398,6 +434,15 @@ impl Session {
}
}
/// 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> {
let ignored = self.ignored_plugin_grants.lock().unwrap_or_else(|e| e.into_inner());
let mut grants = stored_grants(dir);
grants.retain(|id, _| !ignored.contains(id));
grants
}
/// Write the module `bytes` of the plug-in `id` to the store → whether they went there (`false`
/// without a folder).
pub(crate) fn store_plugin(&self, id: &str, bytes: &[u8]) -> bool {
@@ -417,12 +462,15 @@ impl Session {
}
/// 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]>) {
/// write ([`Session::stored_plugin_bytes`]): with `None`, the file is removed. → whether the
/// store now holds `previous`.
pub(crate) fn put_back_plugin(&self, id: &str, previous: Option<&[u8]>) -> bool {
#[cfg(test)]
if PUT_BACK_FAILS.get() {
return false;
}
match previous {
Some(bytes) => {
self.store_plugin(id, bytes);
}
Some(bytes) => self.store_plugin(id, bytes),
None => self.unstore_plugin(id),
}
}
@@ -438,13 +486,29 @@ impl Session {
/// 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`. → whether the store now
/// holds what was asked (`false` without a folder).
/// holds what was asked (`false` without a folder). When it does, the grant kept for `id` counts
/// again ([`Session::ignore_stored_grant`]).
pub(crate) fn store_grant(&self, id: &str, grant: Option<vectorcraft_plugins::permissions::Grant>) -> bool {
let done = self.write_grant(id, grant);
if done {
self.ignored_plugin_grants.lock().unwrap_or_else(|e| e.into_inner()).remove(id);
}
done
}
/// Stop counting the store's grant for the plug-in `id` until [`Session::store_grant`] next
/// succeeds for it or another store is attached: for a grant that should be gone and could not
/// be removed, which would otherwise approve the bytes it covers.
pub(crate) fn ignore_stored_grant(&self, id: &str) {
self.ignored_plugin_grants.lock().unwrap_or_else(|e| e.into_inner()).insert(id.to_string());
}
fn write_grant(&self, id: &str, grant: Option<vectorcraft_plugins::permissions::Grant>) -> 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());
log::warn!("not updating the stored plug-in grants {}: {}", dir.join(GRANTS_FILE).display(), e.why);
return false;
}
};
@@ -460,8 +524,7 @@ impl Session {
let mut entries: Vec<serde_json::Value> = 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 true;
return take(dir, GRANTS_FILE);
}
// 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.
@@ -475,11 +538,20 @@ impl Session {
}
}
/// Remove the plug-in `id` from the store.
pub(crate) fn unstore_plugin(&self, id: &str) {
if let Some(dir) = &self.plugin_dir {
take(dir, &format!("{}.{PLUGIN_EXT}", file_stem(id)));
}
/// Whether the store holds, or may hold, an entry for the plug-in `id`, including one this
/// version can't use ([`GrantsFile::names`]). `true` when the grants file can't be opened or
/// read, because it may name `id` once it reads again; `false` without a folder, and when the
/// file is too large or doesn't hold a list.
pub(crate) fn holds_plugin_grant(&self, id: &str) -> bool {
self.plugin_dir.as_deref().is_some_and(|dir| match read_grants(dir) {
Ok(file) => file.names(id),
Err(e) => e.unread,
})
}
/// Remove the plug-in `id` from the store → whether its file is gone (`false` without a folder).
pub(crate) fn unstore_plugin(&self, id: &str) -> bool {
self.plugin_dir.as_deref().is_some_and(|dir| take(dir, &format!("{}.{PLUGIN_EXT}", file_stem(id))))
}
/// Keep the settings and cache of plug-in `id` in the attached plug-in store from now on; without
@@ -500,10 +572,15 @@ impl Session {
/// Delete the settings and cache of plug-in `id` from the store, and stop keeping any for it.
/// The folder is first renamed out of the way (`<folder>.removed`), and that fails, leaving it
/// as it was, when the rename does; what the deletion then leaves stays under that name, with a
/// log record. A later call for the id tries to delete it again.
pub(crate) fn remove_plugin_storage(&self, id: &str) -> Result<(), StorageError> {
/// log record. A later call for the id tries to delete it again. `forget` runs once the folder is
/// out of the way, before anything is deleted: when it returns `false`, the folder is renamed
/// back and this fails.
pub(crate) fn remove_plugin_storage(&self, id: &str, forget: impl FnOnce() -> bool) -> Result<(), StorageError> {
let not_forgotten = || StorageError::Io(GRANT_NOT_REMOVED.to_string());
vectorcraft_plugins::registry::set_storage(id, None);
let Some(dir) = &self.plugin_dir else { return Ok(()) };
let Some(dir) = &self.plugin_dir else {
return if forget() { Ok(()) } else { Err(not_forgotten()) };
};
let _held = PLUGIN_DATA_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let path = plugin_data_dir(dir, id);
// Every storage handed out for the folder, including those runs still hold, is closed once
@@ -518,6 +595,9 @@ impl Session {
let data = dir.join(PLUGIN_DATA_DIR);
if is_link(&data) {
log::warn!("{} is a link: the plug-in settings and cache it leads to are left", data.display());
if !forget() {
return Err(not_forgotten());
}
close();
return Ok(());
}
@@ -530,6 +610,14 @@ impl Session {
Err(e) => return Err(StorageError::Io(format!("{}: {e}", path.display()))),
Ok(()) => {}
}
if !forget() {
if std::fs::symlink_metadata(&aside).is_ok()
&& let Err(e) = std::fs::rename(&aside, &path)
{
log::warn!("can't move {} back: {e}", aside.display());
}
return Err(not_forgotten());
}
close();
if std::fs::symlink_metadata(&aside).is_ok()
&& let Err(e) = std::fs::remove_dir_all(&aside)
@@ -960,16 +1048,36 @@ struct GrantsFile {
other: Vec<serde_json::Value>,
}
impl GrantsFile {
/// Whether the file has an entry for the plug-in `id`, usable by this version or not.
fn names(&self, id: &str) -> bool {
self.grants.contains_key(id) || self.other.iter().any(|e| e.get("id").and_then(serde_json::Value::as_str) == Some(id))
}
}
/// Why the grants file of a plug-in store gave no grants.
struct GrantsError {
/// Opening or reading the file failed, so what it holds is unknown. When `false`, the file is
/// too large or doesn't hold a list, and approves nothing.
unread: bool,
/// The detail for a log record.
why: String,
}
/// The grants file of the plug-in store `dir`: empty when there is none, an error when it is too
/// large, can't be read or doesn't hold a list. An entry that doesn't parse as a grant, or names an
/// invalid id, is kept in [`GrantsFile::other`] with a log record.
fn read_grants(dir: &Path) -> Result<GrantsFile, String> {
fn read_grants(dir: &Path) -> Result<GrantsFile, GrantsError> {
let path = dir.join(GRANTS_FILE);
if !path.exists() {
return Ok(GrantsFile::default());
let bytes = match read_past_cap(&path, MAX_GRANTS_FILE) {
Ok(bytes) => bytes,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(GrantsFile::default()),
Err(e) => return Err(GrantsError { unread: true, why: e.to_string() }),
};
if bytes.len() as u64 > MAX_GRANTS_FILE {
return Err(GrantsError { unread: false, why: too_large(MAX_GRANTS_FILE) });
}
let entries =
read_capped(&path, MAX_GRANTS_FILE).and_then(|bytes| serde_json::from_slice::<Vec<serde_json::Value>>(&bytes).map_err(|e| e.to_string()))?;
let entries = serde_json::from_slice::<Vec<serde_json::Value>>(&bytes).map_err(|e| GrantsError { unread: false, why: e.to_string() })?;
let mut file = GrantsFile::default();
let mut first_error = None;
for entry in entries {
@@ -997,7 +1105,7 @@ fn stored_grants(dir: &Path) -> BTreeMap<String, vectorcraft_plugins::permission
match read_grants(dir) {
Ok(file) => file.grants,
Err(e) => {
log::warn!("skipping the stored plug-in grants {}: {e}", dir.join(GRANTS_FILE).display());
log::warn!("skipping the stored plug-in grants {}: {}", dir.join(GRANTS_FILE).display(), e.why);
BTreeMap::new()
}
}
+502 -2
View File
@@ -1346,6 +1346,506 @@ fn a_grant_covers_only_the_module_that_was_approved() {
run(&mut s, "plugin.remove", json!({"id": exempt}));
}
/// Installing a module that lists no permissions under a granted id forgets the grant, so the
/// formerly approved bytes need a new approval. A refused install leaves the grant, and a module
/// that lists permissions keeps replacing it as before.
#[test]
fn a_module_without_permissions_ends_the_grant_of_its_id() {
use vectorcraft_plugins::permissions::sha256_hex;
let mut s = session();
let id = "test.engine.permissions.replaced";
let empty = json!({"objects": []});
let approved = asking(id, r#"["doc.read"]"#, &empty);
let approved_data = vectorcraft_format::base64_encode(&approved);
let granted = || vectorcraft_plugins::registry::granted(id).map(|g| g.hash);
for permissions in ["null", "[]"] {
let plain = vectorcraft_format::base64_encode(&asking(id, permissions, &empty));
run(&mut s, "plugin.install", json!({"dataBase64": approved_data, "grant": ["doc.read"], "replace": true}));
// Refused installs change nothing.
let e = s.execute("plugin.install", &json!({"dataBase64": plain, "hash": "0".repeat(64), "replace": true})).unwrap_err().to_string();
assert!(e.contains("SHA-256 `hash`"), "{e}");
let e = s.execute("plugin.install", &json!({"dataBase64": plain, "replace": false})).unwrap_err().to_string();
assert!(e.contains("already installed"), "{e}");
let e = s.execute("plugin.install", &json!({"dataBase64": plain})).unwrap_err().to_string();
assert!(e.contains("`replace: true`"), "{e}");
assert_eq!(granted(), Some(sha256_hex(&approved)), "{permissions}: a refused install leaves the grant");
run(&mut s, "plugin.install", json!({"dataBase64": plain, "replace": true}));
assert_eq!(granted(), None, "{permissions}");
assert_eq!(run(&mut s, "plugin.inspect", json!({"dataBase64": approved_data}))["needsApproval"], true, "{permissions}");
let e = s.execute("plugin.install", &json!({"dataBase64": approved_data, "replace": true})).unwrap_err().to_string();
assert!(e.contains("`grant`"), "{permissions}: {e}");
assert!(!vectorcraft_plugins::registry::get(id).unwrap().manifest().asks_permissions(), "{permissions}: the module installed stays");
}
// With no module installed under the id, the grant ends the same way.
run(&mut s, "plugin.install", json!({"dataBase64": approved_data, "grant": ["doc.read"], "replace": true}));
vectorcraft_plugins::registry::remove(id);
run(&mut s, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&asking(id, "null", &empty))}));
assert_eq!(granted(), None);
// A module that lists permissions records its own grant.
run(&mut s, "plugin.install", json!({"dataBase64": approved_data, "grant": ["doc.read"], "replace": true}));
assert_eq!(granted(), Some(sha256_hex(&approved)));
run(&mut s, "plugin.remove", json!({"id": id}));
}
/// In a plug-in store, a module that lists no permissions removes only its own id's entry from
/// `grants.json`, and the formerly approved bytes need a new approval after a restart. A store
/// 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};
let folder = crate::tests_links::Folder::new("plugin-store-stale-grant");
let id = "test.engine.permissions.storedstale";
let other = "test.engine.permissions.storedother";
let empty = json!({"objects": []});
let approved = asking(id, r#"["doc.read"]"#, &empty);
let approved_data = vectorcraft_format::base64_encode(&approved);
let plain = asking(id, "null", &empty);
let other_wasm = asking(other, r#"["doc.read"]"#, &empty);
let grants = folder.0.join("grants.json");
let ids = || -> Vec<String> {
serde_json::from_slice::<Vec<Value>>(&std::fs::read(&grants).unwrap())
.unwrap()
.iter()
.map(|e| e["id"].as_str().unwrap().to_string())
.collect()
};
let mut s = session();
s.attach_plugin_store(folder.0.clone());
run(&mut s, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&other_wasm), "grant": ["doc.read"]}));
run(&mut s, "plugin.install", json!({"dataBase64": approved_data, "grant": ["doc.read"]}));
assert_eq!(ids().len(), 2);
run(&mut s, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&plain), "replace": true}));
assert_eq!(ids(), vec![other.to_string()], "only this id's entry goes");
assert!(vectorcraft_plugins::registry::granted(id).is_none());
assert!(vectorcraft_plugins::registry::granted(other).is_some(), "the other plug-in's grant stays");
vectorcraft_plugins::registry::remove(id);
let mut next = session();
next.attach_plugin_store(folder.0.clone());
assert_eq!(run(&mut next, "plugin.inspect", json!({"dataBase64": approved_data}))["needsApproval"], true);
// A grant the store already holds for an id whose stored module lists no permissions.
let entry = json!({"id": id, "hash": sha256_hex(&approved), "permissions": ["doc.read"]});
let other_entry = json!({"id": other, "hash": sha256_hex(&other_wasm), "permissions": ["doc.read"]});
std::fs::write(&grants, serde_json::to_vec(&json!([entry, other_entry])).unwrap()).unwrap();
vectorcraft_plugins::registry::remove(id);
let mut third = session();
third.attach_plugin_store(folder.0.clone());
assert!(vectorcraft_plugins::registry::get(id).is_some(), "the module that lists nothing still loads");
assert_eq!(ids(), vec![other.to_string()]);
assert!(vectorcraft_plugins::registry::get(other).is_some());
// So does an entry this version can't read, and a grant held only by the process.
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] });
let mut fourth = session();
fourth.attach_plugin_store(folder.0.clone());
assert_eq!(ids(), vec![other.to_string()]);
assert!(vectorcraft_plugins::registry::granted(id).is_none());
run(&mut fourth, "plugin.remove", json!({"id": id}));
run(&mut fourth, "plugin.remove", json!({"id": other}));
}
/// A module listing no permissions that can't be written to the plug-in store leaves the stored
/// module in place, so `grants.json` keeps the grant that module needs at the next launch.
#[test]
fn a_failed_store_write_of_a_module_without_permissions_keeps_the_stored_grant() {
let folder = crate::tests_links::Folder::new("plugin-store-failed-write-plain");
let id = "test.engine.permissions.storefail";
let empty = json!({"objects": []});
let approved = asking(id, r#"["doc.read"]"#, &empty);
let plain = asking(id, "null", &empty);
let grants = folder.0.join("grants.json");
let mut s = session();
s.attach_plugin_store(folder.0.clone());
run(&mut s, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&approved), "grant": ["doc.read"]}));
let before = std::fs::read(&grants).unwrap();
let file = s.stored_plugin_file(id).unwrap();
std::fs::remove_file(&file).unwrap();
std::fs::create_dir(&file).unwrap();
run(&mut s, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&plain), "replace": true}));
assert_eq!(std::fs::read(&grants).unwrap(), before, "the grant stays with the module left in the store");
assert!(vectorcraft_plugins::registry::granted(id).is_none(), "the process runs the module that lists nothing");
std::fs::remove_dir(&file).unwrap();
run(&mut s, "plugin.remove", json!({"id": id}));
}
/// A module listing no permissions whose id's grant can't be removed from `grants.json` doesn't stay
/// in the store, so the grant and the module it covers stay together. It does stay when the module
/// before it can't be read back, and when the grants file is too large or doesn't hold a list and so
/// approves nothing.
#[test]
fn a_grant_that_cannot_be_removed_keeps_the_stored_module() {
let folder = crate::tests_links::Folder::new("plugin-store-failed-forget");
let id = "test.engine.permissions.forgetfail";
let empty = json!({"objects": []});
let approved = asking(id, r#"["doc.read"]"#, &empty);
let plain = asking(id, "null", &empty);
let replace = json!({"dataBase64": vectorcraft_format::base64_encode(&plain), "replace": true});
let grants = folder.0.join("grants.json");
let mut s = session();
s.attach_plugin_store(folder.0.clone());
run(&mut s, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&approved), "grant": ["doc.read"]}));
let file = s.stored_plugin_file(id).unwrap();
// An entry this version can't use is written back as it was read: `1e9` as `1000000000.0`, which
// makes the rewritten file larger than the 1 MiB that is read.
let entry = serde_json::from_slice::<Vec<Value>>(&std::fs::read(&grants).unwrap()).unwrap().remove(0);
let full = format!(r#"[{entry},{{"n":[{}]}}]"#, vec!["1e9"; 200_000].join(",")).into_bytes();
std::fs::write(&grants, &full).unwrap();
run(&mut s, "plugin.install", replace.clone());
assert_eq!(std::fs::read(&file).unwrap(), approved, "the store gets the module the grant covers back");
assert_eq!(std::fs::read(&grants).unwrap(), full);
assert!(vectorcraft_plugins::registry::granted(id).is_none(), "the process runs the module that lists nothing");
let oversized = vectorcraft_plugins::Limits::default().max_module_bytes as u64 + 1;
std::fs::File::options().write(true).open(&file).unwrap().set_len(oversized).unwrap();
run(&mut s, "plugin.install", replace.clone());
assert_eq!(std::fs::read(&file).unwrap(), plain, "a module that can't be read back can't be put back");
assert_eq!(std::fs::read(&grants).unwrap(), full);
let inspect = json!({"dataBase64": vectorcraft_format::base64_encode(&approved)});
assert_eq!(run(&mut s, "plugin.inspect", inspect)["needsApproval"], true, "the grant left behind stops counting");
std::fs::write(&file, &approved).unwrap();
std::fs::write(&grants, b"not a list").unwrap();
run(&mut s, "plugin.install", replace.clone());
assert_eq!(std::fs::read(&file).unwrap(), plain, "a grants file that isn't a list approves nothing");
std::fs::write(&file, &approved).unwrap();
std::fs::write(&grants, vec![b' '; (1 << 20) + 1]).unwrap();
run(&mut s, "plugin.install", replace);
assert_eq!(std::fs::read(&file).unwrap(), plain, "a grants file over its size limit approves nothing");
std::fs::remove_file(&grants).unwrap();
run(&mut s, "plugin.remove", json!({"id": id}));
}
/// A grant that can't be removed stops counting for the session when the module it covers can't be
/// written back either: the store holds the new module, and the old bytes would otherwise install
/// without approval.
#[test]
fn a_grant_left_behind_when_its_module_cannot_go_back_stops_counting() {
let folder = crate::tests_links::Folder::new("plugin-store-failed-put-back");
let id = "test.engine.permissions.failedputback";
let empty = json!({"objects": []});
let approved = asking(id, r#"["doc.read"]"#, &empty);
let approved_data = vectorcraft_format::base64_encode(&approved);
let plain = asking(id, "null", &empty);
let grants = folder.0.join("grants.json");
let mut s = session();
s.attach_plugin_store(folder.0.clone());
run(&mut s, "plugin.install", json!({"dataBase64": approved_data, "grant": ["doc.read"]}));
let file = s.stored_plugin_file(id).unwrap();
// `1e9` is written back as `1000000000.0`, which makes the rewritten file larger than the 1 MiB that is read.
let entry = serde_json::from_slice::<Vec<Value>>(&std::fs::read(&grants).unwrap()).unwrap().remove(0);
let full = format!(r#"[{entry},{{"n":[{}]}}]"#, vec!["1e9"; 200_000].join(",")).into_bytes();
std::fs::write(&grants, &full).unwrap();
crate::store::PUT_BACK_FAILS.set(true);
run(&mut s, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&plain), "replace": true}));
crate::store::PUT_BACK_FAILS.set(false);
assert_eq!(std::fs::read(&file).unwrap(), plain, "the store keeps the new module");
assert_eq!(std::fs::read(&grants).unwrap(), full);
assert_eq!(run(&mut s, "plugin.inspect", json!({"dataBase64": approved_data}))["needsApproval"], true, "the grant left behind stops counting");
let refused = s.execute("plugin.install", &json!({"dataBase64": approved_data, "replace": true})).unwrap_err().to_string();
assert!(refused.contains("doc.read"), "{refused}");
std::fs::remove_file(&grants).unwrap();
run(&mut s, "plugin.remove", json!({"id": id}));
}
/// An entry of `grants.json` that this version can't use is kept for a version that can, so when it
/// can't be removed, neither `plugin.remove` nor a replacing module gets past it.
#[test]
fn an_unusable_entry_that_cannot_be_removed_stops_plugin_remove() {
let folder = crate::tests_links::Folder::new("plugin-store-failed-remove-other");
let id = "test.engine.permissions.removeother";
let empty = json!({"objects": []});
let first = asking(id, "null", &empty);
let second = asking(id, "[]", &empty);
let grants = folder.0.join("grants.json");
let mut s = session();
s.attach_plugin_store(folder.0.clone());
run(&mut s, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&first)}));
let file = s.stored_plugin_file(id).unwrap();
let entry = json!({"id": id, "hash": "0".repeat(64), "permissions": ["no.such.permission"]});
// `1e9` is written back as `1000000000.0`, which makes the rewritten file larger than the 1 MiB that is read.
let full = format!(r#"[{entry},{{"n":[{}]}}]"#, vec!["1e9"; 200_000].join(",")).into_bytes();
std::fs::write(&grants, &full).unwrap();
let refused = s.execute("plugin.remove", &json!({"id": id})).unwrap_err().to_string();
assert!(refused.contains(crate::store::GRANT_NOT_REMOVED), "{refused}");
assert!(vectorcraft_plugins::registry::get(id).is_some(), "the plug-in stays installed");
assert_eq!(std::fs::read(&file).unwrap(), first, "the store keeps the module");
assert_eq!(std::fs::read(&grants).unwrap(), full);
run(&mut s, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&second), "replace": true}));
assert_eq!(std::fs::read(&file).unwrap(), first, "the store gets its module back");
std::fs::write(&grants, serde_json::to_vec(&json!([entry])).unwrap()).unwrap();
run(&mut s, "plugin.remove", json!({"id": id}));
assert!(vectorcraft_plugins::registry::get(id).is_none());
assert!(!grants.exists(), "the removal takes the entry out of the file");
}
/// A grant that can't be removed when no module is stored for its id has no module to go with, so
/// it stops counting for the session.
#[test]
fn a_grant_that_cannot_be_removed_and_has_no_stored_module_stops_counting() {
let folder = crate::tests_links::Folder::new("plugin-store-grant-no-module");
let id = "test.engine.permissions.forgetnomodule";
let empty = json!({"objects": []});
let approved = asking(id, r#"["doc.read"]"#, &empty);
let approved_data = vectorcraft_format::base64_encode(&approved);
let plain = asking(id, "null", &empty);
let grants = folder.0.join("grants.json");
let mut s = session();
s.attach_plugin_store(folder.0.clone());
run(&mut s, "plugin.install", json!({"dataBase64": approved_data, "grant": ["doc.read"]}));
let file = s.stored_plugin_file(id).unwrap();
let entry = serde_json::from_slice::<Vec<Value>>(&std::fs::read(&grants).unwrap()).unwrap().remove(0);
let full = format!(r#"[{entry},{{"n":[{}]}}]"#, vec!["1e9"; 200_000].join(",")).into_bytes();
std::fs::write(&grants, &full).unwrap();
std::fs::remove_file(&file).unwrap();
run(&mut s, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&plain), "replace": true}));
assert!(!std::path::Path::new(&file).exists(), "the store keeps no module");
assert_eq!(std::fs::read(&grants).unwrap(), full);
assert_eq!(run(&mut s, "plugin.inspect", json!({"dataBase64": approved_data}))["needsApproval"], true);
std::fs::remove_file(&grants).unwrap();
run(&mut s, "plugin.remove", json!({"id": id}));
}
/// A grant that a load of the plug-in store should forget and can't stays out of the approvals for
/// the session: both when `grants.json` can't be rewritten and when it can't be read at the load.
/// A grant recorded later counts again, and another store starts with nothing ignored.
#[test]
fn a_grant_the_load_cannot_remove_stops_counting_for_the_session() {
use vectorcraft_plugins::permissions::sha256_hex;
let folder = crate::tests_links::Folder::new("plugin-store-load-forget");
let elsewhere = crate::tests_links::Folder::new("plugin-store-load-forget-other");
let id = "test.engine.permissions.loadforget";
let empty = json!({"objects": []});
let approved = asking(id, r#"["doc.read"]"#, &empty);
let approved_data = vectorcraft_format::base64_encode(&approved);
let plain = asking(id, "null", &empty);
let needs_approval = json!({"dataBase64": approved_data});
let grants = folder.0.join("grants.json");
let entry = json!({"id": id, "hash": sha256_hex(&approved), "permissions": ["doc.read"]});
let recorded = serde_json::to_vec(&json!([entry])).unwrap();
let mut s = session();
s.attach_plugin_store(folder.0.clone());
run(&mut s, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&plain)}));
// `1e9` is written back as `1000000000.0`, which makes the rewritten file larger than the 1 MiB that is read.
let full = format!(r#"[{entry},{{"n":[{}]}}]"#, vec!["1e9"; 200_000].join(",")).into_bytes();
std::fs::write(&grants, &full).unwrap();
vectorcraft_plugins::registry::remove(id);
let mut next = session();
next.attach_plugin_store(folder.0.clone());
assert_eq!(std::fs::read(&grants).unwrap(), full, "the load can't rewrite the file");
assert_eq!(run(&mut next, "plugin.inspect", needs_approval.clone())["needsApproval"], true);
let refused = next.execute("plugin.install", &json!({"dataBase64": approved_data, "replace": true})).unwrap_err().to_string();
assert!(refused.contains("doc.read"), "{refused}");
std::fs::write(elsewhere.0.join("grants.json"), &recorded).unwrap();
next.attach_plugin_store(elsewhere.0.clone());
assert_eq!(run(&mut next, "plugin.inspect", needs_approval.clone())["needsApproval"], false, "that store's grant counts");
// A directory at the file's path gives a read error on every platform.
std::fs::remove_file(&grants).unwrap();
std::fs::create_dir(&grants).unwrap();
vectorcraft_plugins::registry::remove(id);
let mut third = session();
third.attach_plugin_store(folder.0.clone());
std::fs::remove_dir(&grants).unwrap();
std::fs::write(&grants, &recorded).unwrap();
assert_eq!(run(&mut third, "plugin.inspect", needs_approval.clone())["needsApproval"], true, "the file reads again, and the grant doesn't count");
// Attaching the same store again, with no stored module to try the removal for, keeps it out.
let file = third.stored_plugin_file(id).unwrap();
std::fs::remove_file(&file).unwrap();
third.attach_plugin_store(folder.0.clone());
assert_eq!(run(&mut third, "plugin.inspect", needs_approval.clone())["needsApproval"], true, "the same store's grant stays out");
// A later load in the session doesn't count it either, for the bytes it covers.
std::fs::write(&file, &approved).unwrap();
vectorcraft_plugins::registry::remove(id);
third.load_stored_plugins();
assert!(vectorcraft_plugins::registry::get(id).is_none(), "the formerly approved module isn't loaded");
run(&mut third, "plugin.install", json!({"dataBase64": approved_data, "grant": ["doc.read"], "replace": true}));
assert_eq!(run(&mut third, "plugin.inspect", needs_approval)["needsApproval"], false, "the user granted it again");
run(&mut third, "plugin.remove", json!({"id": id}));
}
/// A grants file that can't be opened or read may name the old module once it reads again, so
/// neither a module listing no permissions nor `plugin.remove` gets past it. A directory at the
/// file's path gives a read error on every platform.
#[test]
fn a_grants_file_that_cannot_be_read_keeps_the_grant_and_the_module_it_covers() {
let folder = crate::tests_links::Folder::new("plugin-store-unread-grants");
let id = "test.engine.permissions.unreadgrants";
let empty = json!({"objects": []});
let approved = asking(id, r#"["doc.read"]"#, &empty);
let plain = asking(id, "null", &empty);
let grants = folder.0.join("grants.json");
let mut s = session();
s.attach_plugin_store(folder.0.clone());
run(&mut s, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&approved), "grant": ["doc.read"]}));
let file = s.stored_plugin_file(id).unwrap();
let recorded = std::fs::read(&grants).unwrap();
std::fs::remove_file(&grants).unwrap();
std::fs::create_dir(&grants).unwrap();
run(&mut s, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&plain), "replace": true}));
assert_eq!(std::fs::read(&file).unwrap(), approved, "the store gets the module the grant covers back");
run(&mut s, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&approved), "grant": ["doc.read"], "replace": true}));
let refused = s.execute("plugin.remove", &json!({"id": id})).unwrap_err().to_string();
assert!(refused.contains(crate::store::GRANT_NOT_REMOVED), "{refused}");
assert!(vectorcraft_plugins::registry::get(id).is_some(), "the plug-in stays installed");
assert_eq!(std::fs::read(&file).unwrap(), approved, "the store keeps the module");
std::fs::remove_dir(&grants).unwrap();
std::fs::write(&grants, &recorded).unwrap();
run(&mut s, "plugin.remove", json!({"id": id}));
assert!(vectorcraft_plugins::registry::get(id).is_none());
assert!(!grants.exists(), "the file reads again and the grant goes");
}
/// `plugin.remove` returns an error and keeps the plug-in when its grant can't be removed from
/// `grants.json`; a second one tries again. A grants file that doesn't hold a list doesn't stop it.
#[test]
fn a_grant_that_cannot_be_removed_stops_plugin_remove() {
let folder = crate::tests_links::Folder::new("plugin-store-failed-remove");
let id = "test.engine.permissions.removefail";
let approved = asking(id, r#"["doc.read"]"#, &json!({"objects": []}));
let approved_data = vectorcraft_format::base64_encode(&approved);
let grants = folder.0.join("grants.json");
let mut s = session();
s.attach_plugin_store(folder.0.clone());
run(&mut s, "plugin.install", json!({"dataBase64": approved_data, "grant": ["doc.read"]}));
let file = s.stored_plugin_file(id).unwrap();
let recorded = std::fs::read(&grants).unwrap();
// `1e9` is written back as `1000000000.0`, which makes the rewritten file larger than the 1 MiB that is read.
let entry = serde_json::from_slice::<Vec<Value>>(&recorded).unwrap().remove(0);
let full = format!(r#"[{entry},{{"n":[{}]}}]"#, vec!["1e9"; 200_000].join(",")).into_bytes();
std::fs::write(&grants, &full).unwrap();
let data = crate::store::plugin_data_dir(&folder.0, id);
std::fs::create_dir_all(&data).unwrap();
std::fs::write(data.join("settings.json"), b"{}").unwrap();
assert!(s.execute("plugin.remove", &json!({"id": id})).is_err());
assert!(vectorcraft_plugins::registry::get(id).is_some(), "the plug-in stays installed");
assert!(vectorcraft_plugins::registry::granted(id).is_some(), "it keeps its grant");
assert_eq!(std::fs::read(&file).unwrap(), approved, "the store keeps the module the grant covers");
assert_eq!(std::fs::read(&grants).unwrap(), full);
assert!(data.join("settings.json").is_file(), "it keeps its settings");
// Settings that can't be moved aside leave the grant recorded. A file at the aside path stops
// the move on Unix, and a file held open in the folder stops it on Windows.
std::fs::write(&grants, &recorded).unwrap();
let mut aside = data.clone().into_os_string();
aside.push(".removed");
let aside = std::path::PathBuf::from(aside);
std::fs::write(&aside, b"in the way").unwrap();
#[cfg(windows)]
let held = {
use std::os::windows::fs::OpenOptionsExt;
std::fs::File::options().read(true).share_mode(1).open(data.join("settings.json")).unwrap()
};
assert!(s.execute("plugin.remove", &json!({"id": id})).is_err());
#[cfg(windows)]
drop(held);
assert_eq!(std::fs::read(&grants).unwrap(), recorded, "the grant stays while the settings do");
assert!(vectorcraft_plugins::registry::get(id).is_some());
let _ = std::fs::remove_file(&aside);
run(&mut s, "plugin.remove", json!({"id": id}));
assert!(vectorcraft_plugins::registry::get(id).is_none());
assert!(!grants.exists(), "the second removal forgets the grant");
run(&mut s, "plugin.install", json!({"dataBase64": approved_data, "grant": ["doc.read"]}));
std::fs::write(&grants, b"not a list").unwrap();
run(&mut s, "plugin.remove", json!({"id": id}));
assert!(vectorcraft_plugins::registry::get(id).is_none(), "a grants file that isn't a list approves nothing");
assert!(!std::path::Path::new(&file).exists());
}
/// Windows keeps a file another program holds open without delete sharing, so the last grant can't
/// be removed by deleting `grants.json`.
#[cfg(windows)]
#[test]
fn a_grants_file_that_cannot_be_deleted_keeps_the_stored_module() {
use std::os::windows::fs::OpenOptionsExt;
const FILE_SHARE_READ: u32 = 1;
let folder = crate::tests_links::Folder::new("plugin-store-held-grants");
let id = "test.engine.permissions.heldgrants";
let empty = json!({"objects": []});
let approved = asking(id, r#"["doc.read"]"#, &empty);
let plain = asking(id, "null", &empty);
let grants = folder.0.join("grants.json");
let mut s = session();
s.attach_plugin_store(folder.0.clone());
run(&mut s, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&approved), "grant": ["doc.read"]}));
let file = s.stored_plugin_file(id).unwrap();
let held = std::fs::File::options().read(true).share_mode(FILE_SHARE_READ).open(&grants).unwrap();
run(&mut s, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&plain), "replace": true}));
drop(held);
assert_eq!(std::fs::read(&file).unwrap(), approved, "the store gets the module the grant covers back");
assert!(grants.exists());
run(&mut s, "plugin.remove", json!({"id": id}));
}
/// A plug-in folder that leaves a module listing no permissions under an id forgets the process's
/// grant for it, unless a later file in the folder leaves a granted module there. A grant kept in
/// the plug-in store stays, with the stored module it covers.
#[test]
fn a_plugin_folder_ends_the_process_grant_of_an_id_it_replaces() {
let mut s = session();
let id = "test.engine.permissions.folderstale";
let empty = json!({"objects": []});
let approved = asking(id, r#"["doc.read"]"#, &empty);
let approved_data = vectorcraft_format::base64_encode(&approved);
let plain = asking(id, "null", &empty);
let dir = crate::tests_links::Folder::new("plugin-folder-stale-grant");
std::fs::write(dir.0.join("a-plain.wasm"), &plain).unwrap();
std::fs::write(dir.0.join("b-approved.wasm"), &approved).unwrap();
let path = json!({"path": dir.0.display().to_string()});
run(&mut s, "plugin.install", json!({"dataBase64": approved_data, "grant": ["doc.read"]}));
let r = run(&mut s, "plugin.reload", path.clone());
assert_eq!(r["failed"], json!([]), "{r}");
assert!(vectorcraft_plugins::registry::granted(id).is_some(), "the granted module is the one left");
std::fs::remove_file(dir.0.join("b-approved.wasm")).unwrap();
let r = run(&mut s, "plugin.reload", path.clone());
assert_eq!(r["loaded"], json!([id]), "{r}");
assert!(vectorcraft_plugins::registry::granted(id).is_none());
assert_eq!(run(&mut s, "plugin.inspect", json!({"dataBase64": approved_data}))["needsApproval"], true);
run(&mut s, "plugin.remove", json!({"id": id}));
let store = crate::tests_links::Folder::new("plugin-folder-stale-grant-store");
let mut stored = session();
stored.attach_plugin_store(store.0.clone());
run(&mut stored, "plugin.install", json!({"dataBase64": approved_data, "grant": ["doc.read"]}));
let grants = store.0.join("grants.json");
let recorded = std::fs::read(&grants).unwrap();
run(&mut stored, "plugin.reload", path);
assert_eq!(std::fs::read(&grants).unwrap(), recorded, "the store still holds the module its grant covers");
assert_eq!(run(&mut stored, "plugin.inspect", json!({"dataBase64": approved_data}))["needsApproval"], false);
run(&mut stored, "plugin.remove", json!({"id": id}));
}
/// 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.
@@ -2638,7 +3138,7 @@ fn plugin_storage_is_capped_and_survives_a_damaged_settings_file() {
assert_eq!(st.set_cache_entry("tile", b"again", m), Err(StorageError::Unavailable));
assert!(!dir.exists());
assert_eq!(upper.setting("k0", m).unwrap(), Some(b"upper".to_vec()));
s.remove_plugin_storage(upper_id).unwrap();
s.remove_plugin_storage(upper_id, || true).unwrap();
}
/// Files larger than the storage reads are not read: a settings file larger than 1 MiB, although
@@ -2821,7 +3321,7 @@ fn removing_a_plugin_drops_its_settings_from_memory() {
let mut m = Meter::unlimited();
assert_eq!(st.setting("zoom", &mut m).unwrap(), Some(b"hello".to_vec()));
assert_eq!(m.used(), bytes.len() as u64, "read from the file");
s.remove_plugin_storage(id).unwrap();
s.remove_plugin_storage(id, || true).unwrap();
}
/// The cache's total stays in memory while the cache folder keeps its time; a write and a clear
+31
View File
@@ -218,6 +218,37 @@ fn a_disabled_plug_in_whose_new_grant_cant_be_stored_stays_disabled() {
let _ = std::fs::remove_dir_all(&dir);
}
/// A stored module that lists no permissions and isn't read, because it is disabled or Safe Mode is
/// on, leaves the grant for its id in the store. The load that reads it forgets the grant.
#[test]
fn a_stored_module_that_is_not_read_keeps_the_grant_for_its_id() {
let _f = fresh();
let dir = tmp_dir("unread-grant");
let id = "test.sm.unread";
let mut s = launch(&dir);
s.execute("plugin.install", &json!({"dataBase64": vectorcraft_format::base64_encode(&filter(id))})).unwrap();
let grants = dir.join("grants.json");
let entry = serde_json::to_vec(&json!([{"id": id, "hash": "0".repeat(64), "permissions": ["doc.read"]}])).unwrap();
std::fs::write(&grants, &entry).unwrap();
s.execute("plugin.setEnabled", &json!({"id": id, "enabled": false})).unwrap();
let mut s = launch(&dir);
assert!(!installed(id), "the disabled module is skipped");
assert_eq!(std::fs::read(&grants).unwrap(), entry, "a disabled module keeps the grant");
s.execute("plugin.setEnabled", &json!({"id": id, "enabled": true})).unwrap();
vectorcraft_engine::cmd::plugin::start_in_safe_mode();
let mut s = launch(&dir);
assert!(!installed(id), "Safe Mode loads nothing");
assert_eq!(std::fs::read(&grants).unwrap(), entry, "Safe Mode keeps the grant");
safe_mode::set_active(None);
vectorcraft_engine::cmd::plugin::load_prefs_folder(&mut s);
assert!(installed(id));
assert!(!grants.exists(), "the load that reads the module forgets the grant");
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 {
+28 -5
View File
@@ -451,6 +451,10 @@ impl Interp<'_> {
continue;
}
let want = step.min(n - o.len());
// The bytes of this read's own copy of a procedure's string: counted while it
// lives, released once it is appended (the string itself is the program's, and
// stays counted as long as the program can hold it).
let mut copied = 0;
let chunk = match src {
Source::File(f) => self.read(f, want)?,
// A string gives its bytes over and over.
@@ -462,11 +466,14 @@ impl Interp<'_> {
self.call(p.clone())?;
let s = self.pop_str()?.to_vec();
self.alloc(s.len())?;
copied = s.len();
s
}
};
*e = chunk.is_empty();
o.extend_from_slice(chunk.get(..chunk.len().min(n - o.len())).unwrap_or_default());
drop(chunk);
self.unalloc(copied);
}
}
Ok(out)
@@ -549,12 +556,16 @@ impl Interp<'_> {
}
return Ok(None);
}
// Interleaved data is read into this decoder's own buffers, split into the two images'
// samples: those stop counting once both are decoded (their pixels still count).
let mut charged = 0;
if interleave != 3.0 {
let src = image.sources.first().cloned().ok_or(PsError::Ps("undefined", "DataSource".into()))?;
let (pixels, samples) = if interleave == 2.0 {
let (mrow, drow) = (stencil.row_bytes(1), image.row_bytes(n));
let total = mrow.saturating_mul(stencil.h).saturating_add(drow.saturating_mul(image.h));
self.alloc(total)?;
charged = total;
let raw = self.source_bytes(&src, total.min(MAX_DATA))?;
by_rows(&raw, (mrow, stencil.h), (drow, image.h))
} else {
@@ -562,15 +573,21 @@ impl Interp<'_> {
stencil.bpc = image.bpc;
(stencil.w, stencil.h) = (image.w, image.h);
let row = (image.w * (n + 1) * usize::from(image.bpc)).div_ceil(8);
self.alloc(row.saturating_mul(image.h))?;
let raw = self.source_bytes(&src, row.saturating_mul(image.h).min(MAX_DATA))?;
charged = row.saturating_mul(image.h);
self.alloc(charged)?;
let raw = self.source_bytes(&src, charged.min(MAX_DATA))?;
by_samples(&raw, image.w, image.h, n, image.bpc)
};
stencil.sources = vec![Source::Str(pixels)];
image.sources = vec![Source::Str(samples)];
}
let Some(mut img) = self.decode_image(image)? else { return Ok(None) };
let Some(m) = self.decode_image(stencil)? else { return Ok(Some(img)) };
let decoded = match self.decode_image(image) {
Ok(Some(img)) => self.decode_image(stencil).map(|m| Some((img, m))),
other => other.map(|_| None),
};
self.unalloc(charged);
let Some((mut img, m)) = decoded? else { return Ok(None) };
let Some(m) = m else { return Ok(Some(img)) };
let (w, h, mw, mh) = (img.w as usize, img.h as usize, m.w as usize, m.h as usize);
for (i, px) in img.rgba.as_chunks_mut::<4>().0.iter_mut().enumerate() {
let (x, y) = (i % w, i / w);
@@ -689,7 +706,11 @@ impl Interp<'_> {
return ps_err("rangecheck", "DataSource");
}
let plane = row.checked_mul(s.h).filter(|p| *p <= MAX_DATA).ok_or(PsError::Limit("decoded data is too large"))?;
self.alloc(plane.saturating_mul(sources.len()))?;
// The sample planes are this decoder's own and live only while the image is decoded: they
// stop counting once it is (the pixels made from them still count). What the data source
// procedures allocate is the program's and stays counted.
let plane_bytes = plane.saturating_mul(sources.len());
self.alloc(plane_bytes)?;
let mut planes = self.read_sources(sources, row * s.h, if multi { row } else { row * s.h })?;
for p in &mut planes {
p.resize(row * s.h, 0);
@@ -766,6 +787,8 @@ impl Interp<'_> {
rgba.extend([rgb[0], rgb[1], rgb[2], if masked { 0 } else { 255 }]);
}
}
drop(planes);
self.unalloc(plane_bytes);
Ok(Some(Pixels { w: s.w as u32, h: s.h as u32, rgba, inks, matrix: s.matrix }))
}
+6
View File
@@ -367,6 +367,12 @@ impl<'a> Interp<'a> {
Ok(())
}
/// Stop counting `bytes` the program no longer holds: buffers an operator made for itself and
/// freed before it returned. Never for what the program's own procedures allocated.
pub(crate) fn unalloc(&mut self, bytes: usize) {
self.allocated = self.allocated.saturating_sub(bytes);
}
fn tick(&mut self) -> Res {
self.spend(1)
}
+25
View File
@@ -1436,6 +1436,31 @@ fn the_planes_of_multi_source_images_are_charged() {
assert!(r.warnings.iter().any(|w| w.contains("too much memory")), "{:?}", r.warnings);
}
/// The planes an image is decoded from, and the decoder's copies of the strings a procedure gives
/// to fill them, stop counting against the memory budget once the image is decoded: they are freed
/// then. A procedure that hands back one large buffer for every image (1 MiB here, for a one-pixel
/// image) charged the whole buffer each time, and a file of many images came in as its preview.
#[test]
fn an_images_transient_data_is_not_charged_after_it() {
let body = "/buf 1048576 string def\n1100 { 1 1 8 [1 0 0 1 0 0] { buf } image } repeat\n";
let r = import(&eps(body)).unwrap();
assert!(!r.warnings.iter().any(|w| w.contains("too much memory")), "{:?}", r.warnings);
let mut images = 0;
r.document.walk(|n| images += usize::from(matches!(n.kind, NodeKind::Image(_))));
assert_eq!(images, 1100);
}
/// What a data source procedure allocates stays counted, whatever image it fills: a procedure
/// that keeps each new string (in an array here) still runs into the memory budget, however small
/// the images it fills are.
#[test]
fn strings_a_data_source_keeps_stay_charged() {
let body = "/keep 1100 array def /i 0 def\n\
1100 { 1 1 8 [1 0 0 1 0 0] { keep i 1048576 string put keep i get /i i 1 add def } image } repeat\n";
let r = import(&eps(body)).unwrap();
assert!(r.warnings.iter().any(|w| w.contains("too much memory")), "{:?}", r.warnings);
}
/// #3568: a single sample plane over the data limit is refused.
#[test]
fn an_oversized_image_plane_is_refused() {
+8 -1
View File
@@ -165,7 +165,9 @@ pub fn load_folder(dir: &std::path::Path) -> Result<FolderReport> {
/// permissions only those `approve` accepts; 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`]).
/// ([`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,
@@ -213,6 +215,11 @@ pub fn load_folder_where(
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)
}
+2 -1
View File
@@ -1,6 +1,6 @@
# File format parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-07 · **Change:** minor (EPS import: sampled functions interpolate between their samples; `.ai` and EPS type from the text document keeps its tab stops; it keeps auto leading and hyphenation options, and may show more of the file's last lines in an area frame; `.ai` and EPS import: Art brushes in the older form of their objects come into the Brushes panel; EPS import: gradients drawn as abutting strips come in as one gradient, without seams; EPS import: composite fonts draw their Type 42 glyphs; SVG import: filter and mask regions clip, #5876, #5875; SVG import: Gaussian blurs keep `color-interpolation-filters`, #5877; PDF and `.ai` export: objects' URLs as links, #5785; SVG import: `color-interpolation="linearRGB"` gradients, #5878; `.ai` import: paths with a Calligraphic brush open brushed, #372) · **Target:** Adobe Illustrator 2026 (30.x)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-07 · **Change:** minor (EPS import: an image decoder's own buffers stop counting against the memory budget once it is decoded; EPS import: sampled functions interpolate between their samples; `.ai` and EPS type from the text document keeps its tab stops; it keeps auto leading and hyphenation options, and may show more of the file's last lines in an area frame; `.ai` and EPS import: Art brushes in the older form of their objects come into the Brushes panel; EPS import: gradients drawn as abutting strips come in as one gradient, without seams; EPS import: composite fonts draw their Type 42 glyphs; SVG import: filter and mask regions clip, #5876, #5875; SVG import: Gaussian blurs keep `color-interpolation-filters`, #5877; PDF and `.ai` export: objects' URLs as links, #5785; SVG import: `color-interpolation="linearRGB"` gradients, #5878; `.ai` import: paths with a Calligraphic brush open brushed, #372) · **Target:** Adobe Illustrator 2026 (30.x)
Every format Illustrator opens, places, saves or exports, against VectorCraft's format table (`FORMATS`,
`OPEN_EXTS`, `PLACE_EXTS` in `crates/engine/src/cmd/fileio/formats.rs`). Illustrator's list comes from its public
@@ -320,6 +320,7 @@ Names in the editing data that aren't UTF-8 (layers, objects, artboards, colors,
| Date | Change | Summary |
|---|---|---|
| 2026-11-06 | minor | EPS import: the buffers an image decoder makes for itself (its sample planes, its copies of the strings a procedure data source hands back, and the split buffers of an interleaved masked image) stop counting against the PostScript reader's 1 GiB memory budget once the image is decoded, as they are freed then; the pixels kept, and anything the program's own procedures allocate, still count. A user's Illustrator 25.2 EPS with 71 CMYK images (used locally, not committed) used to stop with "the program uses too much memory" and come in as its screen preview; it now opens with its art. The budget itself is unchanged. Scores and estimates unchanged. |
| 2026-11-07 | minor | EPS import: a sampled function (type 0) of two or more inputs took the nearest sample, so a function-based shading (type 1) over one came in as flat blocks; it is now interpolated between its samples (multilinearly), and with `Order 3` by cubics through them (Catmull-Rom, kept in the function's range), as the PostScript Language Reference (3rd ed., §3.10.1, type 0 functions) defines. An axial or radial shading whose function is a linearly interpolated sampled function of one input has a stop at each sample, not 33 evenly spaced ones. Tests `sampled_functions_interpolate_linearly_or_by_cubics`, `mesh_and_function_shadings_become_gradient_meshes`; a 2 × 2 function-based shading is 1.2% from Ghostscript's render (it was 45.9%); scores unchanged |
| 2026-11-07 | minor | EPS and `.ai` type from the text document keeps its paragraphs' tab stops (positions and alignments; leaders aren't read), at most 1,024 a paragraph and 2^20 a story, past which the default stops apply with a warning. A user's `.ai` (used locally, not committed) whose price lists came in on the default stops now lays them out as its page has them (its type 0.8% from its page, from 1.5%; what differs is hyphenation in Hungarian and type in a font that isn't installed). Scores and estimates unchanged. |
| 2026-11-07 | minor | EPS and `.ai` type from the text document: auto leading stays auto at the paragraph's share (it was the first two lines' distance for every line, so 10 pt lines under a 15 pt heading came in 18 pt apart), the paragraph's hyphenation options are read, and area type of one frame whose last lines the file sets in too short a frame for them here may get a frame taller by its lines' deepest descent plus 0.05 pt, when that shows more of them. A user's `.ai` (used locally, not committed) whose type differed from its page by 6.6%, so that it opened from its PDF part, now opens from its layers (4.9%; type with tab stops in right-aligned paragraphs still differs, a separate issue) (#6137). Scores and estimates unchanged. |
+4 -1
View File
@@ -6717,7 +6717,10 @@ flag lasts for that session only. In other sessions the approval, or a `grant` a
hash, counts as approved in later sessions and in the desktop app, and covers only that module: the
same bytes install again without one, also from a headless session that reads the same `grants.json`, and a module
with other bytes has `needsApproval: true` and needs a new one even when it lists the same permissions. `plugin.remove`
forgets the approval. A
forgets the approval, and so does installing a module that lists no permissions under that id: the earlier bytes then
need a new approval. A refused install keeps the grant. A failed write of the module to the plug-in store leaves the
grant in the store. A stored module that is disabled, or not read in Safe Mode, keeps the grant for its id. When
`plugin.remove` can't remove the grant from the plug-in store, it returns an error and the plug-in stays installed. 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
+23 -3
View File
@@ -226,9 +226,29 @@ capability model proposed for PhotoCraft plug-ins
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
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
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`
tries again. A refused install leaves the grant as it is. When an install of a module that lists no permissions can't
write the module to the `Plug-ins` folder, or can't remove the grant from `grants.json`, the folder keeps the module
it held and `grants.json` keeps the grant. The new module runs until the session ends. When the grant can't be
removed and the module already stored can't be read back or written back, the new module stays in the folder, and
the grant does not count for the rest of the session. The grant also does not count when the folder held no module
for that id. A
`grants.json` larger than 1 MiB, or one that is not a JSON list, grants nothing: the new module stays in the folder,
and `plugin.remove` removes the plug-in. A `grants.json` that can't be opened or read counts as a grant that can't be
removed. When VectorCraft loads the `Plug-ins` folder, it forgets any grant for an id whose stored module lists no
permissions. When it can't remove that grant from `grants.json`, or can't read the file, the grant does not count for
the rest of the session, or until the plug-in is granted again, including for the next load, which tries the removal
again. In Safe Mode, and for
a stored module that the user disabled,
VectorCraft does not read the module, and `grants.json` keeps the grant for its id. The Additional Plug-ins Folder
and `plugin.reload` do not change the `Plug-ins` folder: when they leave a module that lists no permissions under an
id, the process forgets its own grant for that id, and a grant kept in `grants.json` stays with the stored module it
covers. 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, replaced by a module that lists no permissions, or
removed. When such an entry can't be removed from the file, `plugin.install` and `plugin.remove` treat it as a grant
that can't be 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 is skipped when no grant covers its bytes and the permissions it lists. The Additional Plug-ins
+1
View File
@@ -1586,6 +1586,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
- **Filter plug-ins read type, images and symbol instances (G17, #4333):** a filter at plug-in API level 2 lists `inputs` (`type`, `image`, `symbolInstance`) with `doc.read` in its `permissions`, so installing it asks for approval, and is also given the selected objects of those kinds, read only: type with its runs, character and paragraph attributes (no text past 256 KiB or 4,096 runs or paragraphs, or 16 MiB of runs over the input), images with their size, color mode and, when embedded and at most 1 MiB in base64 (16 MiB over the input), their RGBA pixels (a CMYK image's through the working CMYK profile; never a linked file's), and symbol instances with their symbol and transform. The objects come in paint order from the bottom, and an image that reaches the decoder counts toward the 16 MiB of pixels even when decoding fails. Every object counts toward the input's half of the memory cap; one whose fields besides its pixels and runs don't fit fails the run naming it and its size, a path or compound path that doesn't fit fails it too, and so do more than 100,000 of these objects. Its output returns each as `{id}` or exactly as given; any change or omission is refused before the document changes. Filters without `inputs` get the same input as before, except that compound paths that clip a clip group and paths inside a group that clips one are left out for every filter. Regressions: `crates/plugins/src/objects/tests.rs`, `crates/plugins/src/manifest.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_fuzz.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 that lists permissions and has 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 module with a missing or empty permissions list needs no grant; without the list, it gets its kind's implied permissions. 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 approval needs a person (G17):** a plug-in's permissions and heavy budget are approved in a dialog that only a person can confirm: the control channel, MCP and injected input are refused, and `plugin.install` takes `grant` or `allowHeavy` only with that approval. `vectorcraft-cli mcp` and `run` refuse them unless started with `--allow-plugin-grants`. Regressions: `crates/engine/src/tests/plugins.rs`, `crates/ui-egui/src/tests/automation.rs`, `apps/vectorcraft-cli/tests/it/cli.rs`. Scores and hours unchanged.
- **A plug-in with no permissions ends its id's grant (G17):** installing a module that lists no permissions under a granted id forgets the grant, in the process and in `grants.json`, so the formerly approved bytes need a new approval. Attaching a `Plug-ins` folder whose module for that id lists no permissions forgets it too, and plug-in folders forget the process's grant. A refused install leaves the grant. Regressions: `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_safe_mode.rs`. Scores and hours unchanged.
- **Plug-in commands (G17):** a filter or live effect may declare up to 32 `commands` (`{id, label, params?}`); each is the command `plugin.<id>.<command>` while the plug-in is installed, in a submenu named after the plug-in under Object › Plug-ins (Effect › Plug-ins for a live effect) whatever menu the manifest names, with its label escaped. Commands are recorded in actions, listed in Keyboard Shortcuts for the user to give a shortcut, and run through `run` and MCP with their parameters checked first (a command with parameters opens a dialog from its menu item or shortcut). A run uses the plug-in's permissions (a live effect with commands may also hold `doc.write` and `state`, and gets them without a `permissions` list), is one undo step named after the command, and on failure (a trap or an exhausted instruction budget) leaves the document and the undo history as they were with an error naming the plug-in and the command. For a command and for `plugin.run`, an `ids` that is not a list of object ids is an error rather than the selection. A malformed entry is refused by name when the module is installed. Regressions: `crates/plugins/src/manifest.rs`, `crates/plugins/src/permissions.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 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.
- **Live-effect plug-ins paint their output (G17, #121):** a live effect whose manifest has `"api": 2` and `"paintOutput": true` returns pieces, each drawn by itself in order with fills and strokes of its own in the filter's paint forms (a piece without them keeps the object's; an empty list paints none; an effect on a fill returns only fills, one on a stroke only strokes). The whole result is checked first: limits, numbers in range with nothing clamped, known keys only, and the swatches and patterns it names in the document; one fault draws that step of the stack unchanged, keeps the effect record and puts the plug-in's name and the field in `plugin.info`'s `lastError`. The pieces are drawn, measured and exported to PDF and SVG from the appearance stack without changing the document, and Expand Appearance writes them as ordinary paths in one undo step. Effects without the key behave as before. Regressions: `crates/plugins/src/painted/tests.rs`, `crates/plugins/src/manifest.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_fuzz.rs`. Scores and hours unchanged.