fix(engine): install a directory plugin only with the permissions that were shown

The confirmation showed the directory entry's access and commands, but the
downloaded bundle's manifest was registered as is, so a directory could
show a read-only plugin and install one that edits the document. A manifest
asking for more than the entry showed is refused before it is saved.
This commit is contained in:
Dylan M. Taylor committed 2026-11-03 15:46:01 +00:00
1 parent 02c38a6d95
commit c673996fe7
3 files changed
+45 -2

No files matched your search

@@ -127,6 +127,16 @@ fn install(s: &mut Session, p: &Value) -> Result<Value> {
if manifest.id != entry.id || manifest.version != entry.version {
return Err(fail("its manifest doesn't match the directory's entry"));
}
// What was shown and approved is the entry's access and commands: a bundle whose manifest
// asks for more is refused, since the manifest is what the plugin then runs under.
if manifest.permissions.access == figcraft_plugin::manifest::Access::Write && !entry.access.trim().eq_ignore_ascii_case("write") {
return Err(fail("its manifest asks to write the document, but the directory showed it as read-only"));
}
if !entry.commands.is_empty()
&& (manifest.permissions.commands.is_empty() || manifest.permissions.commands.iter().any(|c| !entry.commands.contains(c)))
{
return Err(fail("its manifest asks for commands the directory didn't show"));
}
let source = address(&server);
let path = save(s, &mut manifest, &bundle).map_err(|e| fail(&e))?;
let out = json!({
@@ -252,3 +252,29 @@ fn publishing_sends_a_checked_folder() {
assert_eq!(r["id"], ID);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_bundle_asking_for_more_than_the_directory_showed_is_not_installed() {
let (mut s, d) = session();
// Shown as read-only with one command; the bundle's manifest asks to write, any command.
let text =
json!({"id": ID, "name": "Tiny", "version": "1.0.0", "author": "Ada", "main": "tiny.wasm", "permissions": {"access": "write"}}).to_string();
let b = Bundle { manifest: text, wasm: wasm(), assets: vec![] }.encode();
let mut e = entry(ID, "1.0.0", &b);
e.commands = vec!["node.get".into()];
d.lock().unwrap().plugins.push((e, b));
s.execute("plugin.directory.list", &json!({"server": "ws://127.0.0.1:9/"})).unwrap();
let err = s.execute("plugin.directory.install", &json!({"id": ID, "confirm": true})).unwrap_err().to_string();
assert!(err.contains("write"), "{err}");
assert!(s.prefs.plugins.is_empty(), "nothing was installed");
// Read-only as shown, but more commands than shown.
let text = json!({"id": ID, "name": "Tiny", "version": "1.0.1", "author": "Ada", "main": "tiny.wasm", "permissions": {"access": "read", "commands": ["node.get", "node.set"]}}).to_string();
let b = Bundle { manifest: text, wasm: wasm(), assets: vec![] }.encode();
let mut e = entry(ID, "1.0.1", &b);
e.commands = vec!["node.get".into()];
d.lock().unwrap().plugins.clear();
d.lock().unwrap().plugins.push((e, b));
s.execute("plugin.directory.list", &json!({"server": "ws://127.0.0.1:9/"})).unwrap();
let err = s.execute("plugin.directory.install", &json!({"id": ID, "confirm": true})).unwrap_err().to_string();
assert!(err.contains("commands"), "{err}");
}
@@ -73,8 +73,15 @@ fn bundle() -> Vec<u8> {
fn directory(req: PluginReq) -> PluginReply {
let bytes = bundle();
let entry =
PluginEntry { id: ID.into(), name: "Tiny tool".into(), version: "1.0.0".into(), sha256: sha256_hex(&bytes), ..PluginEntry::default() };
let entry = PluginEntry {
id: ID.into(),
name: "Tiny tool".into(),
version: "1.0.0".into(),
access: "write".into(),
commands: vec!["shape.rectangle".into()],
sha256: sha256_hex(&bytes),
..PluginEntry::default()
};
match req {
PluginReq::List { .. } => PluginReply::List(vec![entry]),
PluginReq::Fetch { .. } => PluginReply::Bundle { entry, bundle: bytes },