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

131 lines
5.1 KiB
Rust

//! Shared fixtures for the plugin tests: a stub [`HostApi`] and the WAT modules under test.
//!
//! Each test binary uses a different subset of these helpers.
#![allow(dead_code)]
use std::sync::{Arc, Mutex, PoisonError};
use notescraft_plugins::{HostApi, HostError, Limits, Manifest, Plugin, Result};
/// A host that records every call and answers with a canned JSON payload.
#[derive(Debug)]
pub struct StubHost {
calls: Mutex<Vec<String>>,
payload: String,
failing: bool,
}
impl Default for StubHost {
fn default() -> Self {
Self::new()
}
}
impl StubHost {
/// Answers every call with `{"ok":true}`.
#[must_use]
pub fn new() -> Self {
Self { calls: Mutex::new(Vec::new()), payload: String::from("{\"ok\":true}"), failing: false }
}
/// Fails every call.
#[must_use]
pub fn failing() -> Self {
Self { failing: true, ..Self::new() }
}
/// Answers every call with `payload`.
#[must_use]
pub fn with_payload(payload: &str) -> Self {
Self { payload: payload.to_string(), ..Self::new() }
}
/// The calls made so far, in order.
#[must_use]
pub fn calls(&self) -> Vec<String> {
self.calls.lock().unwrap_or_else(PoisonError::into_inner).clone()
}
fn record(&self, call: &str, args: &[&str]) -> std::result::Result<String, HostError> {
self.calls.lock().unwrap_or_else(PoisonError::into_inner).push(format!("{call}({})", args.join(",")));
if self.failing { Err(HostError::new("the vault is locked")) } else { Ok(self.payload.clone()) }
}
}
impl HostApi for StubHost {
fn log(&self, level: &str, message: &str) -> std::result::Result<String, HostError> {
self.record("log", &[level, message])
}
fn vault_read(&self, path: &str) -> std::result::Result<String, HostError> {
self.record("vault_read", &[path])
}
fn vault_write(&self, path: &str, content: &str) -> std::result::Result<String, HostError> {
self.record("vault_write", &[path, content])
}
fn vault_list(&self, glob: &str) -> std::result::Result<String, HostError> {
self.record("vault_list", &[glob])
}
fn search(&self, query: &str) -> std::result::Result<String, HostError> {
self.record("search", &[query])
}
fn links(&self, path: &str) -> std::result::Result<String, HostError> {
self.record("links", &[path])
}
fn backlinks(&self, path: &str) -> std::result::Result<String, HostError> {
self.record("backlinks", &[path])
}
fn active_note(&self) -> std::result::Result<String, HostError> {
self.record("active_note", &[])
}
fn insert_text(&self, text: &str) -> std::result::Result<String, HostError> {
self.record("insert_text", &[text])
}
fn notice(&self, text: &str) -> std::result::Result<String, HostError> {
self.record("notice", &[text])
}
fn now(&self) -> std::result::Result<String, HostError> {
self.record("now", &[])
}
}
/// Compiles a module from `tests/fixtures`.
#[must_use]
pub fn wasm(name: &str) -> Vec<u8> {
let text = match name {
"good" => include_str!("../fixtures/good.wat"),
"write" => include_str!("../fixtures/write.wat"),
"infinite_loop" => include_str!("../fixtures/infinite_loop.wat"),
"memory_bomb" => include_str!("../fixtures/memory_bomb.wat"),
"oob_pointer" => include_str!("../fixtures/oob_pointer.wat"),
"big_result" => include_str!("../fixtures/big_result.wat"),
"missing_export" => include_str!("../fixtures/missing_export.wat"),
"bad_abi" => include_str!("../fixtures/bad_abi.wat"),
"extra_import" => include_str!("../fixtures/extra_import.wat"),
"extra_ns_import" => include_str!("../fixtures/extra_ns_import.wat"),
"trap" => include_str!("../fixtures/trap.wat"),
"recursion" => include_str!("../fixtures/recursion.wat"),
"guest_error" => include_str!("../fixtures/guest_error.wat"),
"host_storm" => include_str!("../fixtures/host_storm.wat"),
"huge_host_answer" => include_str!("../fixtures/huge_host_answer.wat"),
other => panic!("no fixture named {other}"),
};
wat::parse_str(text).unwrap_or_else(|e| panic!("fixture {name} is not valid WAT: {e}"))
}
/// A manifest for plugin `id` asking for `permissions` and declaring `commands`.
pub fn manifest_for(id: &str, permissions: &[&str], commands: &[&str]) -> Manifest {
let list = |items: &[&str]| items.iter().map(|i| format!("\"{i}\"")).collect::<Vec<_>>().join(",");
let cmds = commands.iter().map(|c| format!(r#"{{"id":"{c}","name":"{c} label"}}"#)).collect::<Vec<_>>().join(",");
let json = format!(
r#"{{"id":"{id}","name":"Test plugin","version":"1.0.0","min_app_version":"0.1.0","permissions":[{}],"commands":[{}]}}"#,
list(permissions),
cmds
);
Manifest::from_json(&json).unwrap_or_else(|e| panic!("{e}"))
}
/// Loads `name` as plugin `id` with the default limits.
pub fn load(id: &str, name: &str, permissions: &[&str], commands: &[&str], host: Arc<StubHost>) -> Result<Plugin> {
Plugin::load(manifest_for(id, permissions, commands), &wasm(name), host, Limits::default())
}