Files
Marcos Paulo a31eb59678 feat(plugins): sandboxed wasmi plugin host
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 19:24:35 -03:00

150 lines
7.0 KiB
Rust

//! The good plugin end to end: lifecycle, commands, permissions and the plugin set.
mod common;
use std::sync::Arc;
use common::{StubHost, load, manifest_for};
use notescraft_plugins::{Error, Limits, Manifest, Plugin, PluginSet};
const READ: [&str; 1] = ["vault.read"];
#[test]
fn loads_and_reports_its_manifest() {
let plugin = load("test-plugin", "good", &READ, &["echo"], Arc::new(StubHost::new())).unwrap_or_else(|e| panic!("{e}"));
assert_eq!(plugin.id(), "test-plugin");
assert_eq!(plugin.manifest().name, "Test plugin");
assert_eq!(plugin.limits(), &Limits::default());
assert!(!plugin.is_disabled());
}
#[test]
fn run_command_echoes_args_and_reaches_the_host() {
let host = Arc::new(StubHost::new());
let mut plugin = load("test-plugin", "good", &READ, &["echo"], Arc::clone(&host) as Arc<_>).unwrap_or_else(|e| panic!("{e}"));
let out = plugin.run_command("echo", r#"{"n":1}"#).unwrap_or_else(|e| panic!("{e}"));
assert_eq!(out, r#"{"n":1}"#);
assert_eq!(host.calls(), vec![String::from("log(info,running)"), String::from("vault_read(notes/a.md)")]);
}
#[test]
fn lifecycle_runs_load_unload_and_events() {
let host = Arc::new(StubHost::new());
let mut plugin = load("test-plugin", "good", &READ, &["echo"], Arc::clone(&host) as Arc<_>).unwrap_or_else(|e| panic!("{e}"));
plugin.on_load().unwrap_or_else(|e| panic!("{e}"));
plugin.send_event(r#"{"type":"note.opened"}"#).unwrap_or_else(|e| panic!("{e}"));
plugin.on_unload().unwrap_or_else(|e| panic!("{e}"));
assert_eq!(host.calls(), vec![String::from("log(info,running)"), String::from("notice(goodbye)")]);
}
#[test]
fn a_call_without_its_permission_never_reaches_the_host() {
let host = Arc::new(StubHost::new());
let mut plugin = load("test-plugin", "good", &[], &["echo"], Arc::clone(&host) as Arc<_>).unwrap_or_else(|e| panic!("{e}"));
let err = plugin.run_command("echo", "{}").unwrap_err();
assert!(matches!(err, Error::PermissionDenied { call: "vault_read", .. }), "{err}");
// `ns.log` ran first and is always allowed; the read never did.
assert_eq!(host.calls(), vec![String::from("log(info,running)")]);
}
#[test]
fn empty_arguments_are_accepted() {
let host = Arc::new(StubHost::new());
let mut plugin = load("test-plugin", "good", &READ, &["echo"], host as Arc<_>).unwrap_or_else(|e| panic!("{e}"));
assert_eq!(plugin.run_command("echo", "").unwrap_or_else(|e| panic!("{e}")), "");
}
#[test]
fn a_declared_permission_lets_the_call_through() {
let host = Arc::new(StubHost::new());
let mut plugin = load("test-plugin", "good", &["vault.read"], &["echo"], Arc::clone(&host) as Arc<_>).unwrap_or_else(|e| panic!("{e}"));
plugin.run_command("echo", "{}").unwrap_or_else(|e| panic!("{e}"));
assert!(host.calls().contains(&String::from("vault_read(notes/a.md)")));
}
#[test]
fn a_failing_host_call_is_reported_with_its_message() {
let host = Arc::new(StubHost::failing());
let mut plugin = load("test-plugin", "good", &READ, &["echo"], host as Arc<_>).unwrap_or_else(|e| panic!("{e}"));
let err = plugin.run_command("echo", "{}").unwrap_err();
assert!(matches!(&err, Error::HostCall { call: "log", message, .. } if message == "the vault is locked"), "{err}");
}
#[test]
fn an_oversize_host_answer_hits_the_result_cap() {
let host = Arc::new(StubHost::with_payload(&"x".repeat(5 << 20)));
let mut plugin = load("test-plugin", "huge_host_answer", &[], &["now"], host as Arc<_>).unwrap_or_else(|e| panic!("{e}"));
let err = plugin.run_command("now", "{}").unwrap_err();
assert!(matches!(err, Error::PluginLimit(_)), "{err}");
}
#[test]
fn vault_write_needs_its_own_permission() {
let host = Arc::new(StubHost::new());
let mut plugin = load("test-plugin", "write", &["vault.read"], &["write"], Arc::clone(&host) as Arc<_>).unwrap_or_else(|e| panic!("{e}"));
let err = plugin.run_command("write", "body").unwrap_err();
assert!(matches!(&err, Error::PermissionDenied { call: "vault_write", .. }), "{err}");
assert_eq!(host.calls().len(), 0);
let host = Arc::new(StubHost::new());
let mut plugin = load("test-plugin", "write", &["vault.write"], &["write"], Arc::clone(&host) as Arc<_>).unwrap_or_else(|e| panic!("{e}"));
plugin.run_command("write", "body").unwrap_or_else(|e| panic!("{e}"));
assert_eq!(host.calls(), vec![String::from("vault_write(notes/new.md,body)")]);
}
#[test]
fn back_to_back_calls_stay_inside_the_per_call_host_budget() {
// The budget is 8 host calls per millisecond *of one guest call*. Two calls in a row is
// 4 host calls; a plugin that is busy must not be charged for its neighbour's calls.
let host = Arc::new(StubHost::new());
let mut plugin = load("test-plugin", "good", &READ, &["echo"], host as Arc<_>).unwrap_or_else(|e| panic!("{e}"));
for round in 0..50 {
plugin.run_command("echo", "{}").unwrap_or_else(|e| panic!("round {round}: {e}"));
}
}
#[test]
fn an_unknown_command_is_not_found() {
let host = Arc::new(StubHost::new());
let mut plugin = load("test-plugin", "good", &READ, &["echo"], host as Arc<_>).unwrap_or_else(|e| panic!("{e}"));
assert!(matches!(plugin.run_command("nope", "{}"), Err(Error::NotFound(_))));
}
#[test]
fn an_invalid_manifest_is_rejected_before_the_module_runs() {
let bad = Manifest { id: "Bad Id".into(), ..manifest_for("x", &[], &[]) };
let err = Plugin::load(bad, &common::wasm("good"), Arc::new(StubHost::new()), Limits::default()).unwrap_err();
assert!(matches!(err, Error::Manifest(_)), "{err}");
}
#[test]
fn the_plugin_set_lists_and_dispatches_full_command_ids() {
let mut set = PluginSet::new();
set.insert(load("alpha", "good", &READ, &["echo", "shout"], Arc::new(StubHost::new())).unwrap_or_else(|e| panic!("{e}")));
set.insert(load("beta", "good", &READ, &["only"], Arc::new(StubHost::new())).unwrap_or_else(|e| panic!("{e}")));
assert_eq!(set.commands(), vec![String::from("plugin.alpha.echo"), String::from("plugin.alpha.shout"), String::from("plugin.beta.only")]);
assert_eq!(set.ids(), vec!["alpha", "beta"]);
assert!(set.get("alpha").is_some());
assert!(set.get_mut("alpha").is_some());
let out = set.dispatch("plugin.alpha.echo", r#"{"a":true}"#).unwrap_or_else(|e| panic!("{e}"));
assert_eq!(out, r#"{"a":true}"#);
assert!(matches!(set.dispatch("plugin.alpha.nope", "{}"), Err(Error::NotFound(_))));
assert!(matches!(set.dispatch("plugin.other.echo", "{}"), Err(Error::NotFound(_))));
assert!(matches!(set.dispatch("echo", "{}"), Err(Error::NotFound(_))));
assert!(set.remove("alpha"));
assert!(!set.remove("alpha"));
assert_eq!(set.len(), 1);
assert!(set.remove("beta"));
assert!(set.is_empty());
}
#[test]
fn broadcast_reports_one_error_per_failing_plugin() {
let mut set = PluginSet::new();
set.insert(load("test-plugin", "trap", &[], &["echo"], Arc::new(StubHost::new())).unwrap_or_else(|e| panic!("{e}")));
let errors = set.broadcast_event(r#"{"type":"vault.indexed"}"#);
assert_eq!(errors.len(), 1);
assert_eq!(errors.first().map(|(id, _)| id.as_str()), Some("test-plugin"));
}