Files
Marcos Paulo cac6b0ed39
ci / ci (macos-latest) (push) Has been cancelled
ci / ci (ubuntu-latest) (push) Has been cancelled
ci / ci (windows-latest) (push) Has been cancelled
feat: registry consumers (grammar, theme, pack), seed reader, wasm plugin host with approval, wanted scan, STIX and Lottie store lookups, plugin auto-disable persistence
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-21 18:35:47 -03:00

181 lines
7.8 KiB
Rust

//! Plugins installed from the registry: loaded for the vaults that enabled them, commands
//! `plugin.<id>.<command>` registered, run only with the permissions the user approved.
mod addons_support;
mod common;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use addons_support::{Shared, entry, index_json, manifest, serve, wasm};
use common::{TempVault, temp_config, temp_vault};
use notescraft_engine::addons::{FakeTransport, FakeVerifier, INDEX_URL};
use notescraft_engine::{Error, Registry, Session, register_core};
use serde_json::{Value, json};
struct Fixture {
reg: Registry<Session>,
session: Session,
_guards: (TempVault, TempVault),
}
/// A session whose transport serves the plugin `id` made of `manifest_text` and `module`.
fn fixture(name: &str, id: &str, manifest_text: &str, module: &[u8], permissions: &[&str]) -> Fixture {
let files: &[(&str, &[u8])] = &[("manifest.json", manifest_text.as_bytes()), ("plugin.wasm", module)];
let doc = index_json(&[entry("plugin", id, "1.0.0", files, permissions)], &[]);
let net = serve(FakeTransport::new().with(INDEX_URL, &doc), "plugin", id, "1.0.0", files);
let (vault, config) = (temp_vault(name), temp_config(name));
let mut session = Session::open(vault.path()).unwrap();
session.use_settings_dir(config.path());
session.set_addons_transport(Box::new(Shared(Arc::new(net))));
session.set_addons_verifier(Box::new(FakeVerifier { accept: true }));
let mut reg = Registry::new();
register_core(&mut reg).unwrap();
Fixture { reg, session, _guards: (vault, config) }
}
impl Fixture {
fn run(&mut self, id: &str, params: &Value) -> Result<Value, Error> {
self.reg.run(id, &mut self.session, params)
}
fn ok(&mut self, id: &str, params: &Value) -> Value {
self.run(id, params).unwrap_or_else(|e| panic!("{id}: {e}"))
}
fn install(&mut self, id: &str) {
self.ok("registry.check", &json!({}));
self.ok("registry.install", &json!({ "kind": "plugin", "id": id }));
}
fn commands(&mut self) -> usize {
self.session.reload_plugin_commands(&mut self.reg).unwrap()
}
}
fn dice_dir() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join("../../plugins/dice")
}
#[test]
fn an_enabled_installed_plugin_registers_and_runs() {
let mut f = fixture("on", "demo", &manifest("demo", "1.0.0", &[]), wasm(), &[]);
f.install("demo");
assert_eq!(f.commands(), 0, "installed but not enabled in this vault");
assert!(f.reg.get("plugin.demo.roll").is_none());
f.ok("registry.enable", &json!({ "id": "demo" }));
let n = f.commands();
assert_eq!(n, 1, "{:?}", f.session.diagnostics());
let answer = f.ok("plugin.demo.roll", &json!({}));
assert_eq!(answer["command"], "plugin.demo.roll");
assert_eq!(f.session.plugin_infos().first().map(|p| p.id.as_str()), Some("demo"));
}
#[test]
fn a_disabled_plugin_is_not_registered() {
let mut f = fixture("off", "demo", &manifest("demo", "1.0.0", &[]), wasm(), &[]);
f.install("demo");
f.ok("registry.enable", &json!({ "id": "demo" }));
assert_eq!(f.commands(), 1);
f.ok("registry.disable", &json!({ "id": "demo" }));
assert_eq!(f.commands(), 0);
assert!(f.reg.get("plugin.demo.roll").is_none());
assert_eq!(f.session.plugin_infos().len(), 0);
}
#[test]
fn a_plugin_removed_while_enabled_unloads() {
let mut f = fixture("gone", "demo", &manifest("demo", "1.0.0", &[]), wasm(), &[]);
f.install("demo");
f.ok("registry.enable", &json!({ "id": "demo" }));
assert_eq!(f.commands(), 1);
f.ok("registry.remove", &json!({ "kind": "plugin", "id": "demo" }));
assert_eq!(f.commands(), 0);
assert_eq!(f.session.plugin_infos().len(), 0);
assert_eq!(f.session.diagnostics().len(), 0, "{:?}", f.session.diagnostics());
}
#[test]
fn the_registry_owns_the_switch_of_its_plugins() {
let mut f = fixture("switch", "demo", &manifest("demo", "1.0.0", &[]), wasm(), &[]);
f.install("demo");
f.ok("registry.enable", &json!({ "id": "demo" }));
assert!(f.run("plugins.disable", &json!({ "id": "demo" })).is_err());
assert!(f.run("plugins.enable", &json!({ "id": "demo" })).is_err());
assert!(f.session.is_plugin_enabled("demo"));
}
#[test]
fn approval_never_goes_beyond_the_manifest() {
let mut f = fixture("approve", "demo", &manifest("demo", "1.0.0", &["editor"]), wasm(), &["editor"]);
f.install("demo");
assert!(f.run("plugins.approve", &json!({ "id": "demo", "permissions": ["vault.write"] })).is_err());
assert!(f.run("plugins.approve", &json!({ "id": "demo", "permissions": "editor" })).is_err());
assert!(f.run("plugins.approve", &json!({ "id": "ghost" })).is_err());
assert!(f.session.plugin_settings().approved.is_empty());
let answer = f.ok("plugins.approve", &json!({ "id": "demo" }));
assert_eq!(answer["approved"], json!(["editor"]));
let answer = f.ok("plugins.approve", &json!({ "id": "demo", "permissions": [] }));
assert_eq!(answer["approved"], json!([]));
}
#[test]
fn dice_installs_from_a_fake_registry_and_needs_approval_to_roll() {
let (Ok(module), Ok(text)) = (std::fs::read(dice_dir().join("dice.wasm")), std::fs::read_to_string(dice_dir().join("manifest.json"))) else {
println!("skipped: build plugins/dice/build.sh first");
return;
};
let mut f = fixture("dice", "dice", &text, &module, &["editor"]);
f.install("dice");
f.ok("registry.enable", &json!({ "id": "dice" }));
assert_eq!(f.commands(), 1);
let refused = f.run("plugin.dice.roll", &json!({ "args": { "expr": "1d1" } }));
assert!(refused.is_err(), "the editor permission was not approved: {refused:?}");
f.ok("plugins.approve", &json!({ "id": "dice" }));
let answer = f.ok("plugin.dice.roll", &json!({ "args": { "expr": "2d6+3" } }));
let total = answer["result"]["total"].as_i64().unwrap();
assert!((5..=15).contains(&total), "{answer}");
}
/// Like `wasm()`, but `ns_run_command` traps.
fn trapping_wasm() -> Vec<u8> {
let text = r#"(module
(memory (export "memory") 2)
(global $bump (mut i32) (i32.const 1024))
(func (export "ns_alloc") (param $len i32) (result i32)
(local $ptr i32)
(local.set $ptr (global.get $bump))
(global.set $bump (i32.add (local.get $ptr) (local.get $len)))
(local.get $ptr))
(func (export "ns_free") (param i32 i32))
(func (export "ns_abi_version") (result i32) (i32.const 1))
(func (export "ns_on_load") (result i32) (i32.const 0))
(func (export "ns_on_unload") (result i32) (i32.const 0))
(func (export "ns_on_event") (param i32 i32) (result i32) (i32.const 0))
(func (export "ns_run_command") (param i32 i32 i32 i32) (result i64) (unreachable)))"#;
wat::parse_str(text).unwrap_or_default()
}
#[test]
fn a_registry_plugin_disabled_after_failures_stays_off_after_a_restart() {
let mut f = fixture("autooff", "demo", &manifest("demo", "1.0.0", &[]), &trapping_wasm(), &[]);
f.install("demo");
f.ok("registry.enable", &json!({ "id": "demo" }));
assert_eq!(f.commands(), 1);
for _ in 0..3 {
assert!(f.run("plugin.demo.roll", &json!({})).is_err());
}
assert!(!f.session.is_plugin_enabled("demo"));
let file = f.session.vault().root().join(".notescraft/registry.json");
let saved: Value = serde_json::from_slice(&std::fs::read(&file).unwrap()).unwrap();
assert_eq!(saved["disabled_after_failures"], json!(["plugin:demo"]));
assert_eq!(saved["enabled"], json!(["plugin:demo"]), "the user's choice is kept");
// A restart: load again from the files.
assert_eq!(f.commands(), 0, "{:?}", f.session.diagnostics());
assert!(!f.session.is_plugin_enabled("demo"));
f.ok("registry.enable", &json!({ "id": "demo" }));
assert_eq!(f.commands(), 1);
let saved: Value = serde_json::from_slice(&std::fs::read(&file).unwrap()).unwrap();
assert_eq!(saved["disabled_after_failures"], json!([]));
}