mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-09 04:54:45 +00:00
Plug-ins: a permission grant covers only the module that was approved
A grant records the module's SHA-256, but only its permissions were compared, so a module with other bytes under a granted id inherited the grant when it listed no new permission. A grant now covers a module only when the id, the SHA-256 and every listed permission match. plugin.inspect reports needsApproval for a changed module, plugin.install refuses it without a new grant and leaves the installed module and its grant as they are, Install Plug-in and File > Open ask again, and the plug-in store and plug-in folders skip it. The same bytes install again without asking, and a module with no permissions list or an empty one needs no grant. A grant whose hash is not 64 lower-case hexadecimal digits covers nothing. plugin.install still trusts the client that sends grant; the docs say so.
This commit is contained in:
@@ -333,9 +333,10 @@ fn grant_param(c: &str, p: &Value) -> Result<Option<Vec<Permission>>> {
|
||||
}
|
||||
|
||||
/// `plugin.install`: loads and checks the module, refuses one that asks for a heavy budget unless
|
||||
/// `allowHeavy` says the user agreed, and one that lists permissions the user hasn't granted its id
|
||||
/// unless `grant` lists them all, and one whose SHA-256 isn't the `hash` given; records the grant
|
||||
/// with the module's hash, keeps it for the next start and installs it → its `plugin.list` entry.
|
||||
/// `allowHeavy` says the user agreed, and one that lists permissions the user hasn't granted this
|
||||
/// very module unless `grant` lists them all, and one whose SHA-256 isn't the `hash` given; records
|
||||
/// the grant with the module's hash, keeps it for the next start and installs it → its
|
||||
/// `plugin.list` entry.
|
||||
fn install(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "plugin.install";
|
||||
let (replace, allow_heavy) = (switch_or(p, "replace", true, C)?, switch_or(p, "allowHeavy", false, C)?);
|
||||
@@ -362,7 +363,7 @@ fn install(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
));
|
||||
}
|
||||
// A plug-in that lists permissions installs once the user grants them: whoever installs it
|
||||
// asks them first, unless they already granted its id all of them.
|
||||
// asks them first, unless they already granted this very module all of them.
|
||||
let m = plugin.manifest();
|
||||
let wanted = m.permissions();
|
||||
if !s.plugin_approved(&plugin) && !grant.as_ref().is_some_and(|g| wanted.iter().all(|w| g.contains(w))) {
|
||||
@@ -526,7 +527,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Install Plug-in",
|
||||
[],
|
||||
None,
|
||||
"{path: a .wasm file (desktop) | dataBase64: the module (+ name?: shown as its source), replace?: bool (true: a plug-in with the same id is replaced), name?: file name (with dataBase64), allowHeavy?: bool (true once the user agreed to a heavy budget: a filter whose manifest asks for one, budget \"heavy\" in plugin.inspect, installs only with it), grant?: [permission] (the permissions the user granted: a plug-in whose manifest lists permissions, needsApproval in plugin.inspect, installs only when grant holds every one of them; the grant is recorded with the module's hash, and replacing the plug-in with one that lists no new permission needs none; with a plug-in store attached, only the grants kept there count), hash?: string (the module's SHA-256 as plugin.inspect gave it: a module with other bytes is refused)} validate and install a WebAssembly plug-in for this session → its plugin.list entry",
|
||||
"{path: a .wasm file (desktop) | dataBase64: the module (+ name?: shown as its source), replace?: bool (true: a plug-in with the same id is replaced), name?: file name (with dataBase64), allowHeavy?: bool (true once the user agreed to a heavy budget: a filter whose manifest asks for one, budget \"heavy\" in plugin.inspect, installs only with it), grant?: [permission] (the permissions the user granted: a plug-in whose manifest lists permissions, needsApproval in plugin.inspect, installs only when grant holds every one of them; the grant is recorded with the module's hash and covers only that module, so a module with other bytes needs a new grant even when it lists the same permissions; with a plug-in store attached, only the grants kept there count), hash?: string (the module's SHA-256 as plugin.inspect gave it: a module with other bytes is refused)} validate and install a WebAssembly plug-in for this session → its plugin.list entry",
|
||||
always,
|
||||
install
|
||||
),
|
||||
@@ -535,7 +536,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Inspect Plug-in",
|
||||
[],
|
||||
None,
|
||||
"{path: a .wasm file (desktop) | dataBase64: the module, name?: the file name shown as its source (with dataBase64)} validate a WebAssembly plug-in without installing it → the plugin.list entry it would have, plus needsApproval: bool (true when it lists permissions the user hasn't granted its id: plugin.install then needs grant)",
|
||||
"{path: a .wasm file (desktop) | dataBase64: the module, name?: the file name shown as its source (with dataBase64)} validate a WebAssembly plug-in without installing it → the plugin.list entry it would have, plus needsApproval: bool (true when it lists permissions the user hasn't granted this very module, its bytes included: plugin.install then needs grant)",
|
||||
always,
|
||||
inspect
|
||||
),
|
||||
@@ -545,7 +546,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Reload Plug-ins",
|
||||
["Object", "Plug-ins"],
|
||||
None,
|
||||
"{path?: a folder (default: the Additional Plug-ins Folder preference)} install every *.wasm in it (desktop; a bad module is reported and skipped, and so are a heavy filter and a plug-in that lists permissions the user hasn't granted its id) → {folder, loaded: [id], failed: [{file, error}]}",
|
||||
"{path?: a folder (default: the Additional Plug-ins Folder preference)} install every *.wasm in it (desktop; a bad module is reported and skipped, and so are a heavy filter and a plug-in that lists permissions the user hasn't granted this very module, its bytes included) → {folder, loaded: [id], failed: [{file, error}]}",
|
||||
always,
|
||||
reload
|
||||
),
|
||||
|
||||
@@ -224,8 +224,8 @@ impl Session {
|
||||
}
|
||||
|
||||
/// Keep the plug-ins this session installs in `dir`, and install the ones already there (a
|
||||
/// module that doesn't load, and one that lists permissions the grants there don't grant it,
|
||||
/// is skipped with a log record).
|
||||
/// module that doesn't load, and one that lists permissions the grants there don't grant that
|
||||
/// very module, is skipped with a log record, and stays in `dir` with its grant).
|
||||
pub fn attach_plugin_store(&mut self, dir: PathBuf) {
|
||||
let grants = stored_grants(&dir);
|
||||
let limits = vectorcraft_plugins::Limits::default();
|
||||
@@ -244,12 +244,12 @@ impl Session {
|
||||
// module stored here load.
|
||||
Ok(plugin) if plugin.manifest().asks_permissions() => {
|
||||
let m = plugin.manifest();
|
||||
match grants.get(&m.id).filter(|g| g.covers(&m.permissions())) {
|
||||
match grants.get(&m.id).filter(|g| g.covers(&m.id, plugin.hash(), &m.permissions())) {
|
||||
Some(g) => {
|
||||
vectorcraft_plugins::registry::grant(g.clone());
|
||||
vectorcraft_plugins::registry::install(plugin.with_source(path.display().to_string()));
|
||||
}
|
||||
None => log::warn!("skipping the stored plug-in {}: the user hasn't granted the permissions it lists", path.display()),
|
||||
None => log::warn!("skipping the stored plug-in {}: the user hasn't approved this module's permissions", path.display()),
|
||||
}
|
||||
}
|
||||
Ok(plugin) => {
|
||||
@@ -261,9 +261,10 @@ impl Session {
|
||||
self.plugin_dir = Some(dir);
|
||||
}
|
||||
|
||||
/// Whether `plugin` installs without asking the user: it lists no permissions, or its id was
|
||||
/// granted every one it lists, in the grants of the attached plug-in store when there is one,
|
||||
/// else in this process's ([`vectorcraft_plugins::registry::approved`]).
|
||||
/// Whether `plugin` installs without asking the user: it lists no permissions, or this very
|
||||
/// module (its id and SHA-256) was granted every one it lists, in the grants of the attached
|
||||
/// plug-in store when there is one, else in this process's
|
||||
/// ([`vectorcraft_plugins::registry::approved`]).
|
||||
pub(crate) fn plugin_approved(&self, plugin: &vectorcraft_plugins::Plugin) -> bool {
|
||||
self.plugin_approver()(plugin)
|
||||
}
|
||||
@@ -275,7 +276,7 @@ impl Session {
|
||||
move |plugin| {
|
||||
let m = plugin.manifest();
|
||||
match &grants {
|
||||
Some(grants) if m.asks_permissions() => grants.get(&m.id).is_some_and(|g| g.covers(&m.permissions())),
|
||||
Some(grants) if m.asks_permissions() => grants.get(&m.id).is_some_and(|g| g.covers(&m.id, plugin.hash(), &m.permissions())),
|
||||
_ => vectorcraft_plugins::registry::approved(plugin),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -532,9 +532,9 @@ fn asking(id: &str, permissions: &str, output: &Value) -> Vec<u8> {
|
||||
|
||||
/// A plug-in whose manifest lists permissions installs only once they are all granted (`grant`),
|
||||
/// which `plugin.inspect` tells beforehand (`needsApproval`) with the permissions and the module's
|
||||
/// hash. The grant is recorded with that hash: replacing the plug-in with one that lists the same
|
||||
/// permissions needs no new grant, and one that lists another needs one. Removing it forgets the
|
||||
/// grant. A plug-in without `permissions` installs as before, with the ones its kind implies.
|
||||
/// hash. The grant is recorded with that hash and covers only that module: one with other bytes
|
||||
/// needs a new grant. Removing it forgets the grant. A plug-in without `permissions` installs as
|
||||
/// before, with the ones its kind implies.
|
||||
#[test]
|
||||
fn a_plugin_that_lists_permissions_installs_once_they_are_granted() {
|
||||
let mut s = session();
|
||||
@@ -570,11 +570,10 @@ fn a_plugin_that_lists_permissions_installs_once_they_are_granted() {
|
||||
assert_eq!(installed["permissions"], json!(["doc.read"]));
|
||||
let g = vectorcraft_plugins::registry::granted(id).unwrap();
|
||||
assert_eq!((g.hash.as_str(), g.permissions.as_slice()), (hash.as_str(), &[vectorcraft_plugins::permissions::Permission::DocRead][..]));
|
||||
// Replaced with another module that lists the same permissions: no new grant needed, and the
|
||||
// grant now records the new module's hash.
|
||||
// Replaced with another module that lists the same permissions: granted again, the grant now
|
||||
// records the new module's hash.
|
||||
let other = asking(id, r#"["doc.read"]"#, &json!({"objects": [], "error": null}));
|
||||
assert_eq!(run(&mut s, "plugin.inspect", json!({"dataBase64": vectorcraft_format::base64_encode(&other)}))["needsApproval"], false);
|
||||
run(&mut s, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&other)}));
|
||||
run(&mut s, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&other), "grant": ["doc.read"]}));
|
||||
assert_eq!(vectorcraft_plugins::registry::granted(id).unwrap().hash, vectorcraft_plugins::permissions::sha256_hex(&other));
|
||||
// One that asks for more is refused until it is granted all of them.
|
||||
let more = data(r#"["doc.read","doc.write"]"#);
|
||||
@@ -594,6 +593,140 @@ fn a_plugin_that_lists_permissions_installs_once_they_are_granted() {
|
||||
assert_eq!(run(&mut s, "plugin.inspect", json!({"dataBase64": vectorcraft_format::base64_encode(DESATURATE)}))["needsApproval"], false);
|
||||
}
|
||||
|
||||
/// A module with other bytes under an id that was granted is not covered, even when it lists the
|
||||
/// same permissions: `plugin.inspect` says so, `plugin.install` refuses it and leaves the installed
|
||||
/// module and its grant alone, and a new `grant` installs it and records its hash. The same bytes
|
||||
/// install again without asking, and a module that lists no permissions never asks.
|
||||
#[test]
|
||||
fn a_grant_covers_only_the_module_that_was_approved() {
|
||||
use vectorcraft_plugins::permissions::sha256_hex;
|
||||
let mut s = session();
|
||||
let id = "test.engine.permissions.bytes";
|
||||
let first = asking(id, r#"["doc.read"]"#, &json!({"objects": []}));
|
||||
let changed = asking(id, r#"["doc.read"]"#, &json!({"objects": [], "error": null}));
|
||||
let (first_data, changed_data) = (vectorcraft_format::base64_encode(&first), vectorcraft_format::base64_encode(&changed));
|
||||
let (first_hash, changed_hash) = (sha256_hex(&first), sha256_hex(&changed));
|
||||
run(&mut s, "plugin.install", json!({"dataBase64": first_data, "grant": ["doc.read"]}));
|
||||
assert_eq!(run(&mut s, "plugin.inspect", json!({"dataBase64": changed_data}))["needsApproval"], true);
|
||||
let e = s.execute("plugin.install", &json!({"dataBase64": changed_data})).unwrap_err().to_string();
|
||||
assert!(e.contains("doc.read") && e.contains("`grant`"), "{e}");
|
||||
let e = s.execute("plugin.install", &json!({"dataBase64": changed_data, "hash": changed_hash})).unwrap_err().to_string();
|
||||
assert!(e.contains("`grant`"), "`hash` is not an approval: {e}");
|
||||
assert_eq!(vectorcraft_plugins::registry::get(id).unwrap().hash(), first_hash, "the installed module stays");
|
||||
assert_eq!(vectorcraft_plugins::registry::granted(id).unwrap().hash, first_hash, "its grant stays");
|
||||
// The same bytes need no new grant.
|
||||
assert_eq!(run(&mut s, "plugin.inspect", json!({"dataBase64": first_data}))["needsApproval"], false);
|
||||
run(&mut s, "plugin.install", json!({"dataBase64": first_data}));
|
||||
// A new grant installs the changed module, and the old bytes are no longer covered.
|
||||
run(&mut s, "plugin.install", json!({"dataBase64": changed_data, "grant": ["doc.read"], "hash": changed_hash}));
|
||||
assert_eq!(vectorcraft_plugins::registry::get(id).unwrap().hash(), changed_hash);
|
||||
assert_eq!(vectorcraft_plugins::registry::granted(id).unwrap().hash, changed_hash);
|
||||
assert_eq!(run(&mut s, "plugin.inspect", json!({"dataBase64": first_data}))["needsApproval"], true);
|
||||
run(&mut s, "plugin.remove", json!({"id": id}));
|
||||
|
||||
// A manifest without a list, or with an empty one, asks for nothing, whatever its bytes.
|
||||
let exempt = "test.engine.permissions.exempt";
|
||||
for (permissions, out) in [("null", json!({"objects": []})), ("null", json!({"objects": [], "error": null})), ("[]", json!({"objects": []}))] {
|
||||
let data = vectorcraft_format::base64_encode(&asking(exempt, permissions, &out));
|
||||
assert_eq!(run(&mut s, "plugin.inspect", json!({"dataBase64": data}))["needsApproval"], false, "{permissions}");
|
||||
run(&mut s, "plugin.install", json!({"dataBase64": data}));
|
||||
}
|
||||
run(&mut s, "plugin.remove", json!({"id": exempt}));
|
||||
}
|
||||
|
||||
/// A stored module whose bytes changed is skipped at the next start and stays in the store with its
|
||||
/// grant, and the original bytes load again. A stored grant whose hash is malformed covers nothing.
|
||||
/// With a store attached, a matching grant held only by the process doesn't count.
|
||||
#[test]
|
||||
fn a_stored_grant_covers_only_the_stored_module_it_recorded() {
|
||||
use vectorcraft_plugins::permissions::{Grant, Permission, sha256_hex};
|
||||
let folder = crate::tests_links::Folder::new("plugin-store-grant-hash");
|
||||
let id = "test.engine.permissions.storedbytes";
|
||||
let first = asking(id, r#"["doc.read"]"#, &json!({"objects": []}));
|
||||
let changed = asking(id, r#"["doc.read"]"#, &json!({"objects": [], "error": null}));
|
||||
let mut s = session();
|
||||
s.attach_plugin_store(folder.0.clone());
|
||||
run(&mut s, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&first), "grant": ["doc.read"]}));
|
||||
let grants = folder.0.join("grants.json");
|
||||
let recorded = std::fs::read(&grants).unwrap();
|
||||
let module = std::fs::read_dir(&folder.0)
|
||||
.unwrap()
|
||||
.map(|e| e.unwrap().path())
|
||||
.find(|p| p.extension().is_some_and(|x| x == "wasm"))
|
||||
.expect("the stored module");
|
||||
let forget = || {
|
||||
vectorcraft_plugins::registry::remove(id);
|
||||
vectorcraft_plugins::registry::revoke(id);
|
||||
};
|
||||
let starts = || {
|
||||
forget();
|
||||
let mut next = session();
|
||||
next.attach_plugin_store(folder.0.clone());
|
||||
vectorcraft_plugins::registry::get(id).is_some()
|
||||
};
|
||||
assert!(starts(), "the module that was approved loads");
|
||||
std::fs::write(&module, &changed).unwrap();
|
||||
assert!(!starts(), "other bytes under the old grant are skipped");
|
||||
assert_eq!(std::fs::read(&module).unwrap(), changed, "the module stays in the store");
|
||||
assert_eq!(std::fs::read(&grants).unwrap(), recorded, "its grant stays");
|
||||
std::fs::write(&module, &first).unwrap();
|
||||
assert!(starts(), "the approved bytes load again");
|
||||
|
||||
// A malformed hash is no wildcard, even for the module on disk.
|
||||
let upper = sha256_hex(&first).to_uppercase();
|
||||
for hash in ["", "00", upper.as_str()] {
|
||||
std::fs::write(&grants, serde_json::to_vec(&json!([{"id": id, "hash": hash, "permissions": ["doc.read"]}])).unwrap()).unwrap();
|
||||
assert!(!starts(), "hash {hash:?}");
|
||||
}
|
||||
|
||||
// The store's grants are the ones that count, even when the process holds one for these bytes.
|
||||
std::fs::write(&grants, &recorded).unwrap();
|
||||
assert!(starts());
|
||||
let mut attached = session();
|
||||
attached.attach_plugin_store(folder.0.clone());
|
||||
vectorcraft_plugins::registry::grant(Grant { id: id.into(), hash: sha256_hex(&changed), permissions: vec![Permission::DocRead] });
|
||||
let inspected = run(&mut attached, "plugin.inspect", json!({"dataBase64": vectorcraft_format::base64_encode(&changed)}));
|
||||
assert_eq!(inspected["needsApproval"], true, "a grant held only by the process doesn't count with a store attached");
|
||||
run(&mut attached, "plugin.remove", json!({"id": id}));
|
||||
}
|
||||
|
||||
/// A plug-in folder reports a changed module as needing approval and still loads the other files;
|
||||
/// granted on its own, the changed module loads from the folder. A session without a store uses the
|
||||
/// grants held by the process.
|
||||
#[test]
|
||||
fn a_plugin_folder_skips_a_changed_module_until_it_is_approved() {
|
||||
let mut s = session();
|
||||
let id = "test.engine.permissions.folderbytes";
|
||||
let plain = "test.engine.permissions.folderplain";
|
||||
let first = asking(id, r#"["doc.read"]"#, &json!({"objects": []}));
|
||||
let changed = asking(id, r#"["doc.read"]"#, &json!({"objects": [], "error": null}));
|
||||
let dir = crate::tests_links::Folder::new("plugin-folder-grant-hash");
|
||||
let file = dir.0.join("asks.wasm");
|
||||
std::fs::write(&file, &first).unwrap();
|
||||
std::fs::write(dir.0.join("plain.wasm"), asking(plain, "null", &json!({"objects": []}))).unwrap();
|
||||
run(&mut s, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&first), "grant": ["doc.read"]}));
|
||||
let path = json!({"path": dir.0.display().to_string()});
|
||||
let r = run(&mut s, "plugin.reload", path.clone());
|
||||
assert_eq!((r["loaded"].as_array().unwrap().len(), r["failed"].as_array().unwrap().len()), (2, 0), "{r}");
|
||||
|
||||
std::fs::write(&file, &changed).unwrap();
|
||||
let r = run(&mut s, "plugin.reload", path.clone());
|
||||
assert_eq!(r["loaded"], json!([plain]), "the other file still loads");
|
||||
let failed = r["failed"].as_array().unwrap();
|
||||
assert_eq!(failed.len(), 1, "{r}");
|
||||
assert!(failed[0]["file"].as_str().unwrap().ends_with("asks.wasm"), "{r}");
|
||||
let why = failed[0]["error"].as_str().unwrap();
|
||||
assert!(why.contains("needs approval") && why.contains("doc.read"), "{why}");
|
||||
let hash = vectorcraft_plugins::permissions::sha256_hex(&first);
|
||||
assert_eq!(vectorcraft_plugins::registry::get(id).unwrap().hash(), hash, "the module installed earlier stays");
|
||||
|
||||
run(&mut s, "plugin.install", json!({"dataBase64": vectorcraft_format::base64_encode(&changed), "grant": ["doc.read"]}));
|
||||
let r = run(&mut s, "plugin.reload", path);
|
||||
assert_eq!(r["failed"], json!([]), "approved on its own, it loads: {r}");
|
||||
run(&mut s, "plugin.remove", json!({"id": id}));
|
||||
run(&mut s, "plugin.remove", json!({"id": plain}));
|
||||
}
|
||||
|
||||
/// The host enforces what a filter was granted: without `doc.write` an output that changes the
|
||||
/// document is refused and nothing changes, without `doc.read` it is given no objects, and without
|
||||
/// `state` it is given no data kept with the document and may not write any.
|
||||
|
||||
@@ -199,6 +199,11 @@ pub fn sha256_hex(bytes: &[u8]) -> String {
|
||||
Sha256::digest(bytes).iter().map(|b| format!("{b:02x}")).collect()
|
||||
}
|
||||
|
||||
/// Is `s` a SHA-256 as [`sha256_hex`] writes it: 64 lower-case hexadecimal digits?
|
||||
fn is_sha256_hex(s: &str) -> bool {
|
||||
s.len() == 64 && s.bytes().all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f'))
|
||||
}
|
||||
|
||||
/// What the user approved for a plug-in: its id, the SHA-256 of the module they approved
|
||||
/// ([`sha256_hex`]) and the permissions it was granted.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
@@ -209,9 +214,10 @@ pub struct Grant {
|
||||
}
|
||||
|
||||
impl Grant {
|
||||
/// Does it grant every one of `wanted`?
|
||||
pub fn covers(&self, wanted: &[Permission]) -> bool {
|
||||
wanted.iter().all(|w| self.permissions.contains(w))
|
||||
/// Does it grant every one of `wanted` to the module with this `id` and SHA-256 `hash`? A grant
|
||||
/// whose own hash is not a valid SHA-256 ([`sha256_hex`]) covers nothing.
|
||||
pub fn covers(&self, id: &str, hash: &str, wanted: &[Permission]) -> bool {
|
||||
self.id == id && is_sha256_hex(&self.hash) && self.hash == hash && wanted.iter().all(|w| self.permissions.contains(w))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -281,10 +287,37 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn a_grant_covers_its_permissions_and_no_more() {
|
||||
let g = Grant { id: "a".into(), hash: String::new(), permissions: vec![Permission::DocRead, Permission::DocWrite] };
|
||||
assert!(g.covers(&[Permission::DocRead]));
|
||||
assert!(g.covers(&[]));
|
||||
assert!(!g.covers(&[Permission::State]));
|
||||
assert!(!g.covers(&[Permission::DocRead, Permission::State]));
|
||||
let hash = sha256_hex(b"module");
|
||||
let g = Grant { id: "a".into(), hash: hash.clone(), permissions: vec![Permission::DocRead, Permission::DocWrite] };
|
||||
assert!(g.covers("a", &hash, &[Permission::DocRead]));
|
||||
assert!(g.covers("a", &hash, &[]));
|
||||
assert!(!g.covers("a", &hash, &[Permission::State]));
|
||||
assert!(!g.covers("a", &hash, &[Permission::DocRead, Permission::State]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_grant_covers_only_the_module_that_was_approved() {
|
||||
let hash = sha256_hex(b"module");
|
||||
let wanted = [Permission::DocRead];
|
||||
let g = Grant { id: "a".into(), hash: hash.clone(), permissions: vec![Permission::DocRead] };
|
||||
assert!(g.covers("a", &hash, &wanted));
|
||||
assert!(!g.covers("b", &hash, &wanted), "another id");
|
||||
assert!(!g.covers("a", &sha256_hex(b"other"), &wanted), "other bytes");
|
||||
assert!(!g.covers("a", &sha256_hex(b"other"), &[]), "other bytes, even when nothing is wanted");
|
||||
}
|
||||
|
||||
/// Damaged data can't stand for every module, even when the hash asked about is just as damaged.
|
||||
#[test]
|
||||
fn a_grant_with_an_invalid_hash_covers_nothing() {
|
||||
let upper = sha256_hex(b"module").to_uppercase();
|
||||
let short = "ab".repeat(31);
|
||||
let long = "ab".repeat(33);
|
||||
let not_hex = "g".repeat(64);
|
||||
let wide = "é".repeat(32);
|
||||
for hash in ["", "00", upper.as_str(), short.as_str(), long.as_str(), not_hex.as_str(), wide.as_str()] {
|
||||
let g = Grant { id: "a".into(), hash: hash.into(), permissions: vec![Permission::DocRead] };
|
||||
assert!(!g.covers("a", hash, &[Permission::DocRead]), "{hash:?}");
|
||||
assert!(!g.covers("a", hash, &[]), "{hash:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,11 +38,11 @@ pub fn revoke(id: &str) -> bool {
|
||||
}
|
||||
|
||||
/// May `plugin` be installed without asking the user: does it list no permissions, or did the user
|
||||
/// already grant its id every permission it lists? A module with other bytes but no new permission
|
||||
/// needs no new approval.
|
||||
/// already grant this very module (its id and SHA-256) every permission it lists? A module with
|
||||
/// other bytes needs a new approval, even when it lists the same permissions.
|
||||
pub fn approved(plugin: &Plugin) -> bool {
|
||||
let m = plugin.manifest();
|
||||
!m.asks_permissions() || granted(&m.id).is_some_and(|g| g.covers(&m.permissions()))
|
||||
!m.asks_permissions() || granted(&m.id).is_some_and(|g| g.covers(&m.id, plugin.hash(), &m.permissions()))
|
||||
}
|
||||
|
||||
/// Installs `plugin`, replacing one with the same id.
|
||||
@@ -125,7 +125,7 @@ pub struct FolderReport {
|
||||
/// Installs every `*.wasm` directly in `dir` (not recursive), in file-name order. A bad module is
|
||||
/// reported and skipped, the others still load; so is a heavy one ([`crate::Budget::Heavy`]),
|
||||
/// which installs only once the user agrees, and one that lists permissions the user has not
|
||||
/// granted its id ([`approved`]).
|
||||
/// granted this very module ([`approved`]).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn load_folder(dir: &std::path::Path) -> Result<FolderReport> {
|
||||
load_folder_where(dir, |_| Ok(()), approved)
|
||||
|
||||
@@ -130,8 +130,8 @@ fn asking(id: &str, name: &str, permissions: &str) -> Vec<u8> {
|
||||
|
||||
/// Installing a plug-in that lists permissions asks first, showing its name, id, version, file hash
|
||||
/// and each permission in plain words, with hidden characters from the manifest escaped. Cancel
|
||||
/// installs nothing, OK installs it; installing it again with the same permissions doesn't ask, and
|
||||
/// one that asks for another does.
|
||||
/// installs nothing, OK installs it; installing the same module again doesn't ask, and one with
|
||||
/// other bytes does, even when it lists the same permissions, as does one that asks for another.
|
||||
#[test]
|
||||
fn installing_a_plugin_that_lists_permissions_asks_first() {
|
||||
let mut app = app();
|
||||
@@ -165,11 +165,21 @@ fn installing_a_plugin_that_lists_permissions_asks_first() {
|
||||
assert!(app.ui.dialog.is_none());
|
||||
assert!(vectorcraft_plugins::registry::get(id).is_some());
|
||||
assert_eq!(vectorcraft_plugins::registry::granted(id).map(|g| g.hash), Some(hash));
|
||||
// Again with the same permissions: installed without asking.
|
||||
// The same module again: installed without asking.
|
||||
app.run("ui.installPlugin", json!({"dataBase64": data})).unwrap();
|
||||
assert!(app.ui.dialog.is_none(), "the same bytes don't ask again");
|
||||
// Other bytes with the same permissions ask, from both ways in; Cancel keeps the one installed.
|
||||
let again = asking(id, "Tidy 2", r#"["doc.write","doc.read"]"#);
|
||||
app.run("ui.installPlugin", json!({"dataBase64": vectorcraft_format::base64_encode(&again)})).unwrap();
|
||||
assert!(app.ui.dialog.is_none(), "the same permissions don't ask again");
|
||||
let r = app.run("ui.installPlugin", json!({"dataBase64": vectorcraft_format::base64_encode(&again)})).unwrap();
|
||||
assert_eq!(r["pending"], dialogs::confirm::KIND, "other bytes ask again");
|
||||
dialogs::cancel(&mut app);
|
||||
assert_eq!(vectorcraft_plugins::registry::get(id).map(|p| p.manifest().name.clone()), Some(name.into()), "Cancel keeps the one installed");
|
||||
io::open_bytes(&mut app, "tidy2.wasm", &again, None).unwrap();
|
||||
assert_eq!(app.ui.dialog.as_ref().map(|d| d.kind.as_str()), Some(dialogs::confirm::KIND), "File › Open asks too");
|
||||
dialogs::confirm(&mut app).unwrap();
|
||||
assert_eq!(vectorcraft_plugins::registry::get(id).map(|p| p.manifest().name.clone()), Some("Tidy 2".into()));
|
||||
let new_hash = vectorcraft_plugins::permissions::sha256_hex(&again);
|
||||
assert_eq!(vectorcraft_plugins::registry::granted(id).map(|g| g.hash), Some(new_hash), "OK records the new module's hash");
|
||||
// One that asks for another permission asks again.
|
||||
let more = asking(id, "Tidy 3", r#"["doc.read","doc.write","state"]"#);
|
||||
let r = app.run("ui.installPlugin", json!({"dataBase64": vectorcraft_format::base64_encode(&more)})).unwrap();
|
||||
|
||||
+4
-2
@@ -6097,8 +6097,10 @@ runs it where it is called, so the call may take minutes; `notifications/cancell
|
||||
A plug-in whose manifest lists permissions (`permissions` in its `plugin.inspect` entry, with `needsApproval: true`)
|
||||
installs only with `grant: [permission]` listing every one of them: show the user the list and ask first, and pass
|
||||
the entry's `hash` as `hash` so that a module that changed since is refused. Without `grant`, `plugin.install` returns
|
||||
an error naming the permissions needed. The grant is kept with the module's hash; a module
|
||||
with the same id that lists no new permission installs without `grant`, and `plugin.remove` forgets the grant. A
|
||||
an error naming the permissions needed. The grant is kept with the module's hash and covers only that module: the
|
||||
same bytes install again without `grant`, and a module with other bytes has `needsApproval: true` and needs a new
|
||||
`grant` even when it lists the same permissions. `plugin.install` does not check that a person approved `grant`: it
|
||||
trusts the client that sends it. `plugin.remove` forgets the grant. A
|
||||
filter without `doc.write` or `state` that returns output needing them gets an error and the document is unchanged.
|
||||
A plug-in of API level 2 (`api: 2` in its entry) that lists `progress` may report its progress, see Cancel and write
|
||||
log lines: `notifications/cancelled` makes its `vc_cancelled` return 1, and its log lines go to the server's log
|
||||
|
||||
+10
-6
@@ -26,7 +26,7 @@ This page is the contract for plug-in authors and the reference for the host (`c
|
||||
| `plugin.list` | `{}` | `{"plugins": [{id, name, version, kind, author, description, params, paramsSchema, effect?, format?, extensions?, budget?, size, source, api, permissions, hash}], "folder": report}` (`api` is the manifest's [plug-in API level](#permissions); `budget: "heavy"` for a [heavy filter](#heavy-filters); `permissions` lists what it [may do](#permissions), `hash` is the module's SHA-256) |
|
||||
| `plugin.info` | `{"id"}` | One plug-in: the `plugin.list` entry plus `manifest`, `defaults` and `lastError` (a live effect's last failure). |
|
||||
| `plugin.run` | `{"id", "params"?: {…}, "ids"?: [...]}` (plug-in params may also be top-level keys) | Runs an object filter on the selection (or `ids`). One undo step; the result is selected, except the objects it locked or made guides. |
|
||||
| `plugin.install` | `{"path": "/x/y.wasm"}` (desktop) or `{"dataBase64": "…", "name"?}`, `"replace": bool = true`, `"allowHeavy": bool = false`, `"grant"?: [permission]`, `"hash"?: "sha256 hex"` | Validates and installs a module; returns its `plugin.list` entry. The desktop app and `vectorcraft-cli` keep it in the `Plug-ins` folder next to the preferences, so it is installed again after a restart (#2541). A [heavy filter](#heavy-filters) installs only with `allowHeavy: true`, once the user agreed. A plug-in whose manifest lists [permissions](#permissions) installs only when `grant` lists every one of them, once the user agreed. With `hash`, a module whose SHA-256 differs is refused. |
|
||||
| `plugin.install` | `{"path": "/x/y.wasm"}` (desktop) or `{"dataBase64": "…", "name"?}`, `"replace": bool = true`, `"allowHeavy": bool = false`, `"grant"?: [permission]`, `"hash"?: "sha256 hex"` | Validates and installs a module; returns its `plugin.list` entry. The desktop app and `vectorcraft-cli` keep it in the `Plug-ins` folder next to the preferences, so it is installed again after a restart (#2541). A [heavy filter](#heavy-filters) installs only with `allowHeavy: true`, once the user agreed. A plug-in whose manifest lists [permissions](#permissions) installs only when `grant` lists every one of them, once the user agreed, unless the user already granted a module with the same SHA-256. With `hash`, a module whose SHA-256 differs is refused. |
|
||||
| `plugin.inspect` | as `plugin.install`: `{"path"}` or `{"dataBase64", "name"?}` | Validates a module without installing it; returns the `plugin.list` entry it would have, plus `needsApproval` (true when `plugin.install` needs `grant`). |
|
||||
| `plugin.remove` | `{"id"}` | Uninstalls, and deletes it from the `Plug-ins` folder. |
|
||||
| `plugin.reload` | `{"path"?: "folder"}` (default: the preference folder) | Installs every `*.wasm` in a folder; a bad module is reported and skipped, and so are a heavy filter and a plug-in whose permissions the user hasn't granted (nobody was asked). |
|
||||
@@ -131,16 +131,20 @@ capability model proposed for PhotoCraft plug-ins
|
||||
names shown have control and bidirectional characters escaped (`\u{202E}`) and are cut to 64 characters.
|
||||
`plugin.install` refuses the module, naming the permissions it needs, unless `grant` lists them all, which an agent
|
||||
passes once the user agreed (`plugin.inspect`'s `needsApproval` tells it beforehand), with `hash` set to the
|
||||
`plugin.inspect` entry's `hash` so that the module installed is the one the user saw.
|
||||
`plugin.inspect` entry's `hash` so that the module installed is the one the user saw. `plugin.install` does not
|
||||
check that a person approved `grant`: it trusts the client that sends it.
|
||||
- The grant is recorded with the plug-in's id, the module's hash and the permissions, in `grants.json` in the
|
||||
`Plug-ins` folder. Installing a module with the same id that lists no new permission needs no new approval; one that
|
||||
lists more asks again. `plugin.remove` forgets the grant. An entry of `grants.json` that this version can't use (a
|
||||
`Plug-ins` folder, and covers only that module. Installing the same bytes again needs no new approval. A module with
|
||||
other bytes asks again, even when it lists the same permissions. Until it is approved, the installed module and its
|
||||
grant stay as they are; once it is, the grant records the new hash. A grant whose hash is not 64 lower-case
|
||||
hexadecimal digits covers nothing. `plugin.remove` forgets the grant. An entry of `grants.json` that this version can't use (a
|
||||
permission it doesn't know, an invalid id) is skipped and stays in the file until its plug-in is granted again or
|
||||
removed. A `grants.json` that is not a JSON list is never overwritten, and the file is never written larger than
|
||||
1 MiB, the most that is read. When a `Plug-ins` folder is in use, only the grants kept
|
||||
in it count for `plugin.install`, `plugin.inspect`, `plugin.reload` and the Additional Plug-ins Folder. At startup,
|
||||
a stored plug-in whose permissions are not granted is skipped. The Additional Plug-ins Folder and `plugin.reload`
|
||||
install only plug-ins whose permissions were granted, and report the others.
|
||||
a stored plug-in is skipped when no grant covers its bytes and the permissions it lists. The Additional Plug-ins
|
||||
Folder and `plugin.reload` install only plug-ins that such a grant covers, and report the others. A module without a
|
||||
`permissions` list, or with an empty one, needs no grant.
|
||||
- The host enforces the grant on a filter run: without `doc.read` the filter is given no objects; without `state` its
|
||||
`documentData` is `null` and an output that sets it is refused; without `doc.write` an output that changes the
|
||||
document is refused. A refused output leaves the document as it was.
|
||||
|
||||
@@ -1487,6 +1487,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
|
||||
- **Heavy filter plug-ins (G17):** a filter's manifest may ask for `"budget": "heavy"`: 1 G instructions (20 times a light run's) and 300 s a run, for routing, packing or simulation. Install Plug-in… and File › Open ask the user first, `plugin.install` needs `allowHeavy: true` (`plugin.inspect` tells beforehand), the Additional Plug-ins Folder skips such filters, and the plug-in store keeps the answer. Its dialog has no Preview; the app runs it on a thread of its own with a progress window (Cancel, Continue in Background) and applies the result as `plugin.run` with the same parameters, one undo step, unless the document or the plug-in changed meanwhile. Regressions: `crates/plugins/tests/abi.rs`, `crates/plugins/src/manifest.rs`, `crates/engine/src/tests/plugins.rs`, `crates/ui-egui/src/tests/plugin_runs.rs`. Scores and hours unchanged.
|
||||
- **Plug-in instruction budget at a bulk memory instruction:** a run that pauses where one slice of its instruction budget ends keeps the instructions the slice still held. A `memory.fill` or `memory.copy` that the slice can't pay for in full runs on the next slice, so a run whose budget covers its work finishes; a run ends with `Limit("instruction budget")` only when the instructions it has left can't pay for the next instruction. Regressions: `crates/plugins/tests/abi.rs`. Scores and hours unchanged.
|
||||
- **Plug-in permissions (G17):** a manifest may list `permissions` (`doc.read`, `doc.write`, `state`, `effect.render`, `export.write`; the first three follow storytold/photocraft#662) and an `api` level; without the list a plug-in gets what its kind implies and installs as before. Install Plug-in… and File › Open show the name, id, version, file SHA-256 and each permission in plain words, with control and bidirectional characters escaped, and install nothing on Cancel; `plugin.install` needs `grant`, and folders install only granted plug-ins. The grant is kept with the module's hash, a module asking for more asks again, and a filter run refuses output its permissions don't allow. Regressions: `crates/plugins/src/permissions.rs`, `crates/plugins/src/manifest.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_fuzz.rs`, `crates/ui-egui/src/tests/plugins.rs`. Scores and hours unchanged.
|
||||
- **Plug-in grants cover one module (G17):** a grant counts only for the module with the id and SHA-256 it records. A module with other bytes under the same id needs a new approval, even with the same permissions: `plugin.inspect` reports `needsApproval`, `plugin.install` refuses it without `grant`, Install Plug-in… and File › Open ask again, and the plug-in store and plug-in folders skip it. A grant with a malformed hash covers nothing. Regressions: `crates/plugins/src/permissions.rs`, `crates/engine/src/tests/plugins.rs`, `crates/ui-egui/src/tests/plugins.rs`. Scores and hours unchanged.
|
||||
- **Plug-in progress, Cancel and log lines (G17):** at plug-in API level 2 a plug-in with the `progress` permission imports `env.vc_progress`, `env.vc_cancelled` and `env.vc_log`. A heavy filter's and an export's progress windows show its progress as a percentage bar, their Cancel reaches it through `vc_cancelled`, and its log lines go to the app's log with its id, capped per line and per run. Installing checks every import and names the one it refuses. Regressions: `crates/plugins/src/host.rs`, `crates/plugins/tests/abi.rs`, `crates/engine/src/tests/plugins.rs`, `crates/engine/tests/plugin_fuzz.rs`, `crates/ui-egui/src/tests/plugin_runs.rs`. Scores and hours unchanged.
|
||||
|
||||
- **Plug-in effects after a plug-in changes:** when a plug-in is installed for the first time, installed again or removed (`plugin.install`, `plugin.reload`, `plugin.remove`, File › Open of a `.wasm` file, the Additional Plug-ins Folder preference), the next frame the canvas draws uses the plug-ins then installed for objects with a plug-in effect and their raster effects, for the blends, envelopes and repeats that show such objects, and for pattern tiles and symbol instances whose art holds one.
|
||||
|
||||
Reference in New Issue
Block a user