mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-12 13:33:19 +00:00
Merge pull request #6746 from dotCooCoo/fix/plugin-ignored-grants-shared
Plug-ins: a grant one session stops counting stops counting for every session in the process on its folder
This commit is contained in:
@@ -760,14 +760,16 @@ fn install(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// 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.
|
||||
// was none, no module goes with the grant, and the grant stops counting for every session in
|
||||
// the process attached to the folder. 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.
|
||||
let writes = s.grant_writes();
|
||||
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 && holds_new(s);
|
||||
if !back || matches!(previous, Ok(None)) {
|
||||
s.ignore_stored_grant(&m.id);
|
||||
s.ignore_stored_grant(&m.id, writes);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -171,10 +171,6 @@ pub struct Session {
|
||||
/// The Scripts folder, whose scripts File › Scripts lists; `None`: no folder
|
||||
/// ([`Session::attach_scripts_dir`]).
|
||||
scripts_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>,
|
||||
|
||||
@@ -87,7 +87,6 @@ impl Session {
|
||||
color_settings_file: None,
|
||||
plugin_dir: None,
|
||||
scripts_dir: None,
|
||||
ignored_plugin_grants: Default::default(),
|
||||
plugin_output: None,
|
||||
plugin_install_approval: None,
|
||||
plugin_grants_from_requests: false,
|
||||
|
||||
+105
-18
@@ -89,6 +89,24 @@ static PLUGIN_DATA_LOCK: Mutex<()> = Mutex::new(());
|
||||
/// The state of each plug-in folder's storage, shared by every storage made for the folder until
|
||||
/// the plug-in is removed, which closes it and drops it.
|
||||
static PLUGIN_DATA_OPEN: Mutex<BTreeMap<PathBuf, Arc<PluginData>>> = Mutex::new(BTreeMap::new());
|
||||
/// The grants of plug-in stores that the sessions of the process don't count, and when each was
|
||||
/// last written ([`IgnoredGrants`]). Another process reads `grants.json` as it is.
|
||||
static IGNORED_PLUGIN_GRANTS: Mutex<IgnoredGrants> = Mutex::new(IgnoredGrants { ignored: BTreeSet::new(), written: BTreeMap::new(), writes: 0 });
|
||||
/// How many of the latest grant writes [`IgnoredGrants`] keeps.
|
||||
const MAX_GRANT_WRITES_KEPT: usize = 256;
|
||||
|
||||
/// Grants of plug-in stores, by the store's folder ([`plugin_store_key`]) and the plug-in id.
|
||||
struct IgnoredGrants {
|
||||
/// The grants that should be gone but could not be removed: no session in the process counts
|
||||
/// them ([`Session::ignore_stored_grant`]) until one writes the store's grants for the id
|
||||
/// ([`Session::store_grant`]).
|
||||
ignored: BTreeSet<(PathBuf, String)>,
|
||||
/// The latest grant writes of the process, at most [`MAX_GRANT_WRITES_KEPT`], each with the
|
||||
/// count of writes it made ([`IgnoredGrants::writes`]).
|
||||
written: BTreeMap<(PathBuf, String), u64>,
|
||||
/// How many grant writes the sessions of the process have made ([`Session::grant_writes`]).
|
||||
writes: u64,
|
||||
}
|
||||
|
||||
/// The file name (without extension) a stored item called `name` gets: its name made safe for any
|
||||
/// file system, then a hash of the name, so that names that differ only in characters a file name
|
||||
@@ -100,6 +118,37 @@ pub(crate) fn file_stem(name: &str) -> String {
|
||||
format!("{}-{hash:016x}", safe.trim())
|
||||
}
|
||||
|
||||
/// The folder `dir` spelled one way for every path to it, so that sessions attached to it by
|
||||
/// different paths share [`IGNORED_PLUGIN_GRANTS`]: the canonical path when the folder exists, else
|
||||
/// the canonical path of its nearest folder that exists followed by the rest of the path, with `.`
|
||||
/// and `..` taken out by name. The path as given when it has no absolute form (the current folder
|
||||
/// can't be read).
|
||||
pub(crate) fn plugin_store_key(dir: &Path) -> PathBuf {
|
||||
if let Ok(path) = std::fs::canonicalize(dir) {
|
||||
return path;
|
||||
}
|
||||
let Ok(absolute) = std::path::absolute(dir) else { return dir.to_path_buf() };
|
||||
let mut parts: Vec<std::path::Component> = Vec::new();
|
||||
for part in absolute.components() {
|
||||
match part {
|
||||
std::path::Component::CurDir => {}
|
||||
std::path::Component::ParentDir => {
|
||||
if matches!(parts.last(), Some(std::path::Component::Normal(_))) {
|
||||
parts.pop();
|
||||
}
|
||||
}
|
||||
_ => parts.push(part),
|
||||
}
|
||||
}
|
||||
for kept in (1..=parts.len()).rev() {
|
||||
let base: PathBuf = parts[..kept].iter().collect();
|
||||
if let Ok(found) = std::fs::canonicalize(&base) {
|
||||
return parts[kept..].iter().fold(found, |path, part| path.join(part));
|
||||
}
|
||||
}
|
||||
parts.iter().collect()
|
||||
}
|
||||
|
||||
/// The regular files of `dir` with the extension `ext` (any case), by file name, at most
|
||||
/// [`MAX_STORE_FILES`]; none when the folder can't be read.
|
||||
fn list_files(dir: &Path, ext: &str) -> Vec<PathBuf> {
|
||||
@@ -355,9 +404,6 @@ 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();
|
||||
}
|
||||
@@ -368,7 +414,8 @@ impl Session {
|
||||
/// there don't grant that very module ([`grant_covers`]), is skipped with a log record, and
|
||||
/// 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`]).
|
||||
/// any session in the process attached to the folder until the file can be written for the id
|
||||
/// ([`Session::ignore_stored_grant`]).
|
||||
pub fn load_stored_plugins(&mut self) {
|
||||
let Some(dir) = self.plugin_dir.clone() else { return };
|
||||
if safe_mode::active() {
|
||||
@@ -432,8 +479,11 @@ impl Session {
|
||||
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);
|
||||
if named {
|
||||
let writes = self.grant_writes();
|
||||
if !self.store_grant(id, None) && self.holds_plugin_grant(id) {
|
||||
self.ignore_stored_grant(id, writes);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -515,12 +565,16 @@ impl Session {
|
||||
}
|
||||
}
|
||||
|
||||
/// The grants kept in the plug-in store `dir` ([`stored_grants`]) without those this session
|
||||
/// doesn't count ([`Session::ignore_stored_grant`]).
|
||||
/// The grants kept in the plug-in store `dir` ([`stored_grants`]) without those the process
|
||||
/// doesn't count for that folder ([`Session::ignore_stored_grant`]). The folder's key is worked
|
||||
/// out on each call, so a path whose link now leads elsewhere is matched with where it leads.
|
||||
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 key = plugin_store_key(dir);
|
||||
let mut grants = stored_grants(dir);
|
||||
grants.retain(|id, _| !ignored.contains(id));
|
||||
let shared = IGNORED_PLUGIN_GRANTS.lock().unwrap_or_else(|e| e.into_inner());
|
||||
for (_, id) in shared.ignored.range((key.clone(), String::new())..).take_while(|(folder, _)| *folder == key) {
|
||||
grants.remove(id);
|
||||
}
|
||||
grants
|
||||
}
|
||||
|
||||
@@ -573,20 +627,53 @@ impl Session {
|
||||
/// 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). When it does, the grant kept for `id` counts
|
||||
/// again ([`Session::ignore_stored_grant`]).
|
||||
/// again, for every session in the process attached to the store ([`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);
|
||||
if done && let Some(key) = self.plugin_dir.as_deref().map(plugin_store_key) {
|
||||
let mut shared = IGNORED_PLUGIN_GRANTS.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let entry = (key, id.to_string());
|
||||
shared.ignored.remove(&entry);
|
||||
shared.writes = shared.writes.saturating_add(1);
|
||||
let writes = shared.writes;
|
||||
shared.written.insert(entry, writes);
|
||||
if shared.written.len() > MAX_GRANT_WRITES_KEPT
|
||||
&& let Some(oldest) = shared.written.iter().min_by_key(|(_, at)| **at).map(|(entry, _)| entry.clone())
|
||||
{
|
||||
shared.written.remove(&oldest);
|
||||
}
|
||||
}
|
||||
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());
|
||||
/// How many grant writes the sessions of the process have made ([`Session::store_grant`]).
|
||||
/// Taken before a write that may fail, for [`Session::ignore_stored_grant`].
|
||||
pub(crate) fn grant_writes(&self) -> u64 {
|
||||
IGNORED_PLUGIN_GRANTS.lock().unwrap_or_else(|e| e.into_inner()).writes
|
||||
}
|
||||
|
||||
/// How many grant writes the process keeps ([`IgnoredGrants::written`]).
|
||||
#[cfg(test)]
|
||||
pub(crate) fn grant_writes_kept() -> usize {
|
||||
IGNORED_PLUGIN_GRANTS.lock().unwrap_or_else(|e| e.into_inner()).written.len()
|
||||
}
|
||||
|
||||
/// Stop counting the attached store's grant for the plug-in `id`, in every session in the
|
||||
/// process attached to the same folder, until [`Session::store_grant`] next succeeds for it in
|
||||
/// one of them: for a grant that should be gone and could not be removed, which would otherwise
|
||||
/// approve the bytes it covers. `writes` is [`Session::grant_writes`] from before the failed
|
||||
/// write: when a session has written the store's grants for `id` since, the file holds what that
|
||||
/// write asked for, and the grant keeps counting. A write no longer among the latest
|
||||
/// [`MAX_GRANT_WRITES_KEPT`] is not seen, and the grant stops counting. Another process on the
|
||||
/// folder reads `grants.json` as it is.
|
||||
pub(crate) fn ignore_stored_grant(&self, id: &str, writes: u64) {
|
||||
if let Some(key) = self.plugin_dir.as_deref().map(plugin_store_key) {
|
||||
let mut shared = IGNORED_PLUGIN_GRANTS.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let entry = (key, id.to_string());
|
||||
if shared.written.get(&entry).is_none_or(|at| *at <= writes) {
|
||||
shared.ignored.insert(entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn write_grant(&self, id: &str, grant: Option<vectorcraft_plugins::permissions::Grant>) -> bool {
|
||||
|
||||
@@ -1820,11 +1820,11 @@ fn a_grant_that_cannot_be_removed_and_has_no_stored_module_stops_counting() {
|
||||
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.
|
||||
/// A grant that a load of the plug-in store should forget and can't stays out of the approvals in
|
||||
/// the process: 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's grant for the id counts.
|
||||
#[test]
|
||||
fn a_grant_the_load_cannot_remove_stops_counting_for_the_session() {
|
||||
fn a_grant_the_load_cannot_remove_stops_counting_in_the_process() {
|
||||
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");
|
||||
@@ -1856,8 +1856,12 @@ fn a_grant_the_load_cannot_remove_stops_counting_for_the_session() {
|
||||
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 load that can rewrite the file forgets the grant, and the store has nothing ignored again.
|
||||
std::fs::write(&grants, &recorded).unwrap();
|
||||
next.attach_plugin_store(folder.0.clone());
|
||||
assert!(!grants.exists(), "the load removes the grant");
|
||||
|
||||
// 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();
|
||||
@@ -1883,6 +1887,180 @@ fn a_grant_the_load_cannot_remove_stops_counting_for_the_session() {
|
||||
run(&mut third, "plugin.remove", json!({"id": id}));
|
||||
}
|
||||
|
||||
/// A grant one session stops counting stops counting for every session in the process attached to
|
||||
/// the same folder, by any path to it: one attached before, one attached after with no stored module
|
||||
/// to try the removal for, and one that comes back from another folder. A successful write of the
|
||||
/// grant in any of them makes it count in all of them again.
|
||||
#[test]
|
||||
fn an_ignored_stored_grant_is_shared_by_sessions_on_the_same_folder() {
|
||||
use vectorcraft_plugins::permissions::sha256_hex;
|
||||
let folder = crate::tests_links::Folder::new("plugin-store-shared-ignore");
|
||||
let elsewhere = crate::tests_links::Folder::new("plugin-store-shared-ignore-other");
|
||||
let id = "test.engine.permissions.sharedignore";
|
||||
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();
|
||||
std::fs::write(&grants, &recorded).unwrap();
|
||||
let mut first = session();
|
||||
first.attach_plugin_store(folder.0.clone());
|
||||
let inside = folder.0.join("inside");
|
||||
std::fs::create_dir(&inside).unwrap();
|
||||
let mut other = session();
|
||||
other.attach_plugin_store(inside.join(".."));
|
||||
assert_eq!(run(&mut other, "plugin.inspect", needs_approval.clone())["needsApproval"], false, "the recorded grant counts");
|
||||
|
||||
// `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();
|
||||
run(&mut first, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&plain)}));
|
||||
let file = first.stored_plugin_file(id).unwrap();
|
||||
assert!(!std::path::Path::new(&file).exists(), "the store keeps no module");
|
||||
assert_eq!(std::fs::read(&grants).unwrap(), full);
|
||||
|
||||
assert_eq!(run(&mut other, "plugin.inspect", needs_approval.clone())["needsApproval"], true, "the other session doesn't count it");
|
||||
let refused = other.execute("plugin.install", &json!({"dataBase64": approved_data, "replace": true})).unwrap_err().to_string();
|
||||
assert!(refused.contains("doc.read"), "{refused}");
|
||||
std::fs::write(&file, &approved).unwrap();
|
||||
vectorcraft_plugins::registry::remove(id);
|
||||
other.load_stored_plugins();
|
||||
assert!(vectorcraft_plugins::registry::get(id).is_none(), "the formerly approved module isn't loaded");
|
||||
std::fs::remove_file(&file).unwrap();
|
||||
|
||||
let mut later = session();
|
||||
later.attach_plugin_store(folder.0.clone());
|
||||
assert_eq!(run(&mut later, "plugin.inspect", needs_approval.clone())["needsApproval"], true, "a session attached later doesn't count it");
|
||||
|
||||
std::fs::write(elsewhere.0.join("grants.json"), &recorded).unwrap();
|
||||
other.attach_plugin_store(elsewhere.0.clone());
|
||||
assert_eq!(run(&mut other, "plugin.inspect", needs_approval.clone())["needsApproval"], false, "that store's grant counts");
|
||||
other.attach_plugin_store(folder.0.clone());
|
||||
assert_eq!(run(&mut other, "plugin.inspect", needs_approval.clone())["needsApproval"], true, "back on the folder, it doesn't");
|
||||
|
||||
std::fs::write(&grants, &recorded).unwrap();
|
||||
assert!(later.store_grant(id, Some(serde_json::from_value(entry).unwrap())), "the file can be written");
|
||||
for s in [&mut first, &mut other, &mut later] {
|
||||
assert_eq!(run(s, "plugin.inspect", needs_approval.clone())["needsApproval"], false, "it counts again");
|
||||
}
|
||||
std::fs::remove_file(&grants).unwrap();
|
||||
}
|
||||
|
||||
/// A plug-in folder that doesn't exist yet gets the key it gets once it is made, and every path to a
|
||||
/// folder gets the same key.
|
||||
#[test]
|
||||
fn a_plugin_folder_has_one_key_for_every_path_to_it() {
|
||||
use crate::store::plugin_store_key;
|
||||
let folder = crate::tests_links::Folder::new("plugin-store-key");
|
||||
let dir = folder.0.join("Plug-ins");
|
||||
let before = plugin_store_key(&dir);
|
||||
assert_eq!(plugin_store_key(&dir.join("sub").join("..")), before);
|
||||
assert_eq!(plugin_store_key(&folder.0.join(".").join("Plug-ins")), before);
|
||||
std::fs::create_dir(&dir).unwrap();
|
||||
assert_eq!(plugin_store_key(&dir), before, "the key stays when the folder is made");
|
||||
assert_eq!(plugin_store_key(&dir.join(".")), before);
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let plain = std::path::absolute(&dir).unwrap();
|
||||
let verbatim = std::path::PathBuf::from(format!(r"\\?\{}", plain.display()));
|
||||
assert_eq!(plugin_store_key(&verbatim), before, "a verbatim path names the same folder");
|
||||
}
|
||||
}
|
||||
|
||||
/// Sessions attached to a plug-in folder before it exists, by paths that only name the same folder
|
||||
/// once it is made, share the grants the process doesn't count for it. A grant that one session
|
||||
/// writes while another fails to remove it keeps counting.
|
||||
#[test]
|
||||
fn sessions_attached_before_the_folder_exists_share_its_ignored_grants() {
|
||||
use vectorcraft_plugins::permissions::sha256_hex;
|
||||
let folder = crate::tests_links::Folder::new("plugin-store-late-folder");
|
||||
let id = "test.engine.permissions.latefolder";
|
||||
let approved = asking(id, r#"["doc.read"]"#, &json!({"objects": []}));
|
||||
let needs_approval = json!({"dataBase64": vectorcraft_format::base64_encode(&approved)});
|
||||
let entry = json!({"id": id, "hash": sha256_hex(&approved), "permissions": ["doc.read"]});
|
||||
std::fs::create_dir_all(folder.0.join("a").join("b")).unwrap();
|
||||
let dir = folder.0.join("a").join("Plug-ins");
|
||||
// On disk `link/..` is `a`, so `link/../Plug-ins` is the folder; by name it is a `Plug-ins` beside `a`.
|
||||
// On Windows the folder is named in other letter case.
|
||||
#[cfg(unix)]
|
||||
let alias = {
|
||||
std::os::unix::fs::symlink(folder.0.join("a").join("b"), folder.0.join("link")).unwrap();
|
||||
folder.0.join("link").join("..").join("Plug-ins")
|
||||
};
|
||||
#[cfg(windows)]
|
||||
let alias = folder.0.join("A").join("PLUG-INS");
|
||||
#[cfg(not(any(unix, windows)))]
|
||||
let alias = dir.clone();
|
||||
let mut first = session();
|
||||
first.attach_plugin_store(dir.clone());
|
||||
let mut other = session();
|
||||
other.attach_plugin_store(alias);
|
||||
std::fs::create_dir(&dir).unwrap();
|
||||
std::fs::write(dir.join("grants.json"), serde_json::to_vec(&json!([entry])).unwrap()).unwrap();
|
||||
assert_eq!(run(&mut other, "plugin.inspect", needs_approval.clone())["needsApproval"], false, "the recorded grant counts");
|
||||
|
||||
first.ignore_stored_grant(id, first.grant_writes());
|
||||
assert_eq!(run(&mut other, "plugin.inspect", needs_approval.clone())["needsApproval"], true, "the other session doesn't count it");
|
||||
assert!(other.store_grant(id, Some(serde_json::from_value(entry.clone()).unwrap())), "the file can be written");
|
||||
assert_eq!(run(&mut first, "plugin.inspect", needs_approval.clone())["needsApproval"], false, "it counts again");
|
||||
|
||||
// A write that fails in one session while another writes the grant for the id leaves the grant
|
||||
// counting.
|
||||
let before = first.grant_writes();
|
||||
assert!(other.store_grant(id, Some(serde_json::from_value(entry).unwrap())), "the other session writes the grant");
|
||||
first.ignore_stored_grant(id, before);
|
||||
assert_eq!(run(&mut first, "plugin.inspect", needs_approval)["needsApproval"], false, "the written grant counts");
|
||||
std::fs::remove_file(dir.join("grants.json")).unwrap();
|
||||
}
|
||||
|
||||
/// Grant writes that change nothing, each for another id, don't grow what the process keeps past its
|
||||
/// limit.
|
||||
#[test]
|
||||
fn the_process_keeps_a_bounded_number_of_grant_writes() {
|
||||
let folder = crate::tests_links::Folder::new("plugin-store-writes-kept");
|
||||
let mut s = session();
|
||||
s.attach_plugin_store(folder.0.clone());
|
||||
for n in 0..300 {
|
||||
assert!(s.store_grant(&format!("test.engine.permissions.unknown{n}"), None), "nothing to remove is a success");
|
||||
}
|
||||
assert!(Session::grant_writes_kept() <= 256, "{}", Session::grant_writes_kept());
|
||||
}
|
||||
|
||||
/// A session attached by a link to a plug-in folder matches the grants the process doesn't count
|
||||
/// with the folder the link leads to now, not the one it led to when the session was attached.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn a_linked_plugin_folder_uses_the_ignored_grants_of_where_the_link_leads() {
|
||||
use vectorcraft_plugins::permissions::sha256_hex;
|
||||
let folder = crate::tests_links::Folder::new("plugin-store-relinked");
|
||||
let id = "test.engine.permissions.relinked";
|
||||
let approved = asking(id, r#"["doc.read"]"#, &json!({"objects": []}));
|
||||
let needs_approval = json!({"dataBase64": vectorcraft_format::base64_encode(&approved)});
|
||||
let entry = json!({"id": id, "hash": sha256_hex(&approved), "permissions": ["doc.read"]});
|
||||
let recorded = serde_json::to_vec(&json!([entry])).unwrap();
|
||||
let (old, new, link) = (folder.0.join("old"), folder.0.join("new"), folder.0.join("Plug-ins"));
|
||||
for dir in [&old, &new] {
|
||||
std::fs::create_dir(dir).unwrap();
|
||||
std::fs::write(dir.join("grants.json"), &recorded).unwrap();
|
||||
}
|
||||
std::os::unix::fs::symlink(&old, &link).unwrap();
|
||||
let mut linked = session();
|
||||
linked.attach_plugin_store(link.clone());
|
||||
assert_eq!(run(&mut linked, "plugin.inspect", needs_approval.clone())["needsApproval"], false, "the recorded grant counts");
|
||||
|
||||
std::fs::remove_file(&link).unwrap();
|
||||
std::os::unix::fs::symlink(&new, &link).unwrap();
|
||||
let mut direct = session();
|
||||
direct.attach_plugin_store(new.clone());
|
||||
direct.ignore_stored_grant(id, direct.grant_writes());
|
||||
assert_eq!(run(&mut linked, "plugin.inspect", needs_approval.clone())["needsApproval"], true, "the link leads to the ignored grant");
|
||||
assert!(linked.store_grant(id, Some(serde_json::from_value(entry).unwrap())), "the file can be written");
|
||||
assert_eq!(run(&mut direct, "plugin.inspect", needs_approval)["needsApproval"], false, "it counts again");
|
||||
}
|
||||
|
||||
/// 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.
|
||||
|
||||
+7
-5
@@ -262,14 +262,16 @@ capability model proposed for PhotoCraft plug-ins
|
||||
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
|
||||
the grant stops counting. The grant also stops counting 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
|
||||
permissions. When it can't remove that grant from `grants.json`, or can't read the file, the grant stops counting,
|
||||
including for the next load, which tries the removal again. A grant that stops counting in either of these cases
|
||||
does not count for any session in the same process that uses the same `Plug-ins` folder, by any path to it, until
|
||||
`grants.json` can be written for that plug-in, as when the plug-in is granted again. Another process on the same
|
||||
folder, for example `vectorcraft-cli` beside the app, still reads `grants.json` as it is written. A grant for the
|
||||
same id kept in another `Plug-ins` folder counts. 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
|
||||
|
||||
@@ -1702,6 +1702,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
|
||||
- **Only an approval records a plug-in grant (G17):** `vectorcraft_plugins::registry::grant` takes a `GrantToken`, which the engine makes after an approval and when it loads `grants.json`, and `registry::install`, `install_bytes` and folder loads refuse a module that lists permissions no recorded grant gives this very module (`Error::NeedsApproval`), leaving the plug-in under its id and the revision as they were. A module without `permissions`, or with an empty list, installs as before. `docs/plugins.md` says what the approval does not protect against (native code linked into the app, another program running as the same user, the unsigned `grants.json`). Regressions: `crates/plugins/src/registry.rs`, `crates/plugins/tests/grant_sites.rs`, `crates/engine/src/tests/plugins.rs`. Scores and hours unchanged.
|
||||
- **Plug-in approval waits before OK (G17):** the approval dialog's OK stays disabled until its question has been on screen in the focused window for half a second, VectorCraft's own value; a click, Enter, Space, or a press begun before then installs nothing, and the dialog stays open. The wait starts over for every new question, even one that reads the same, and when the window gets the focus back, so a question swapped in just before a click is not approved by it. Cancel works at once. Regressions: `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.
|
||||
- **A grant that can't be forgotten stops counting in the whole process (G17):** when such a grant can't be removed from `grants.json`, no session in the process that uses the same `Plug-ins` folder, by any path to it, counts it for `plugin.inspect`, `plugin.install` or loading plug-ins, until `grants.json` can be written for that plug-in; another process on the folder reads `grants.json` as it is. Regressions: `crates/engine/src/tests/plugins.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.
|
||||
- **Filter plug-ins call commands (G17, #2186):** a filter at plug-in API level 2 with the `commands` permission names the commands it may run in its manifest's `commandAllowlist` and runs them with `env.vc_call`, reading each result with `env.vc_call_result`. Each call sees what the calls before it changed; the calls and the filter's output are one undo step, and a run that fails, is canceled or makes a call that fails leaves every document, its selection and its history as they were. Commands that read or write files, use the clipboard, change preferences or the view, run scripts, actions or plug-ins, switch documents or undo are never called, and a manifest that lists one is refused when the module is installed. The install dialog lists the allowed commands. A run makes at most 1,000 calls, and each call costs 10,000 instructions of its budget. Regressions: `crates/plugins/tests/call.rs`, `crates/engine/tests/plugin_call.rs`, `crates/engine/src/cmd/plugin_call.rs`. Scores and hours unchanged.
|
||||
- **A drag ended by a command with Real-time Drawing off:** when Undo, Redo, a filter that calls commands or a plug-in command that calls commands ends a drag whose preview Real-time Drawing off has not applied yet, the preview is applied first and kept as an undo step of its own, as releasing the drag keeps it; a preview that fails cancels the drag. The filter or plug-in command is then given the objects where the drag left them. Regressions: `crates/engine/src/tests/plugins.rs`, `crates/engine/src/tests/prefs.rs`. Scores and hours unchanged.
|
||||
|
||||
Reference in New Issue
Block a user