mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-09 11:54:03 +00:00
Merge pull request #6286 from dotCooCoo/fix/plugin-stale-grant
Plug-ins: replacing a granted plug-in with one that lists no permissions removes the grant
This commit is contained in:
@@ -568,7 +568,8 @@ fn takes_over(s: &Session, plugin: &Plugin) -> bool {
|
||||
/// `allowHeavy` says the user agreed, one that lists permissions the user hasn't granted this 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.
|
||||
/// the module's hash, keeps it for the next start and installs it → its `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";
|
||||
let replace = match p.get("replace") {
|
||||
@@ -633,6 +634,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());
|
||||
@@ -843,19 +860,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())));
|
||||
@@ -916,7 +939,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 (true once the user agreed to a heavy budget: a filter whose manifest asks for one, budget \"heavy\" in plugin.inspect, installs only with it), grant?: [permission] (the permissions the user granted: a plug-in whose manifest lists permissions, needsApproval in plugin.inspect, installs only when grant holds every one of them; the grant is recorded with the module's hash and covers only that module, so a module with other bytes needs a new grant even when it lists the same permissions; with a plug-in store attached, only the grants kept there count), hash?: string (the module's SHA-256 as plugin.inspect gave it: a module with other bytes is refused)} validate and install a WebAssembly plug-in for this session → its plugin.list entry",
|
||||
"{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 (true once the user agreed to a heavy budget: a filter whose manifest asks for one, budget \"heavy\" in plugin.inspect, installs only with it), grant?: [permission] (the permissions the user granted: a plug-in whose manifest lists permissions, needsApproval in plugin.inspect, installs only when grant holds every one of them; the grant is recorded with the module's hash and covers only that module, so a module with other bytes needs a new grant even when it lists the same permissions; 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), 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
|
||||
),
|
||||
@@ -934,7 +957,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
|
||||
),
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -82,6 +82,7 @@ impl Session {
|
||||
pdf_preset_dir: None,
|
||||
profile_dir: None,
|
||||
plugin_dir: None,
|
||||
ignored_plugin_grants: Default::default(),
|
||||
plugin_output: None,
|
||||
recent_urls: vec![],
|
||||
trace_preset_changes: Default::default(),
|
||||
|
||||
+145
-37
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1338,6 +1338,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.
|
||||
@@ -2504,7 +3004,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
|
||||
@@ -2687,7 +3187,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
|
||||
|
||||
@@ -217,6 +217,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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
+5
-1
@@ -6709,7 +6709,11 @@ the entry's `hash` as `hash` so that a module that changed since is refused. Wit
|
||||
an error naming the permissions needed. The grant is kept with the module's hash and covers only that module: the
|
||||
same bytes install again without `grant`, and a module with other bytes has `needsApproval: true` and needs a new
|
||||
`grant` even when it lists the same permissions. `plugin.install` does not check that a person approved `grant`: it
|
||||
trusts the client that sends it. `plugin.remove` forgets the grant. A
|
||||
trusts the client that sends it. `plugin.remove` forgets the grant, and so does installing a module that lists no
|
||||
permissions under that id: the earlier bytes then need a new `grant`. 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
@@ -212,9 +212,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
|
||||
|
||||
@@ -1585,6 +1585,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
|
||||
- **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.
|
||||
- **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.
|
||||
- **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.
|
||||
|
||||
Reference in New Issue
Block a user