Files
notescraft/crates/plugins/src/host.rs
T
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

225 lines
7.5 KiB
Rust

//! The surface a plugin can reach, and what it costs to use it.
//!
//! The application implements [`HostApi`] once (vault, search, editor, notices, clock) and hands
//! it to every [`Plugin`](crate::Plugin). The host checks the plugin's [`Permission`]s *before*
//! calling a method, so a plugin that asks for something it was not granted gets
//! [`Error::PermissionDenied`](crate::Error::PermissionDenied) and the trait is never touched.
use std::fmt;
use crate::{Error, Manifest, Permission, Result};
/// A host call the module is allowed to import, all in module `ns` (SPEC 7.2).
pub const HOST_CALLS: &[&str] =
&["log", "vault_read", "vault_write", "vault_list", "search", "links", "backlinks", "active_note", "insert_text", "notice", "now"];
/// The permission a host call needs, or `None` when it is always allowed.
#[must_use]
pub fn required_permission(call: &str) -> Option<Permission> {
match call {
"vault_read" | "vault_list" | "search" | "links" | "backlinks" => Some(Permission::VaultRead),
"vault_write" => Some(Permission::VaultWrite),
"active_note" | "insert_text" => Some(Permission::Editor),
_ => None,
}
}
/// Checks `call` against the manifest's allow-list.
///
/// # Errors
///
/// [`Error::PermissionDenied`] when the manifest does not grant the permission `call` needs.
pub fn check_permission(manifest: &Manifest, call: &'static str) -> Result<()> {
match required_permission(call) {
Some(permission) if manifest.grants(permission) => Ok(()),
Some(_) => Err(Error::PermissionDenied { plugin: manifest.id.clone(), call }),
None => Ok(()),
}
}
/// Why a [`HostApi`] method failed. The plugin only ever sees a negative error code; the app
/// gets the message.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HostError {
message: String,
}
impl HostError {
/// Builds an error with `message`.
pub fn new(message: impl Into<String>) -> Self {
Self { message: message.into() }
}
/// The message, as given.
#[must_use]
pub fn message(&self) -> &str {
&self.message
}
}
impl fmt::Display for HostError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.message)
}
}
impl std::error::Error for HostError {}
impl From<String> for HostError {
fn from(message: String) -> Self {
Self::new(message)
}
}
impl From<&str> for HostError {
fn from(message: &str) -> Self {
Self::new(message)
}
}
/// Everything a plugin can ask the application to do.
///
/// Every method returns JSON text (or an empty string for calls with nothing to say). Paths are
/// vault-relative; the host owns the path guard, so a plugin never writes through `.obsidian/`
/// or outside the vault. Implementations must be cheap and must not panic: they run inside a
/// sandboxed call.
pub trait HostApi: Send + Sync {
/// `ns.log(level, msg)`: `level` is `trace`, `debug`, `info`, `warn` or `error`.
///
/// # Errors
///
/// [`HostError`] when the message could not be recorded.
fn log(&self, level: &str, message: &str) -> std::result::Result<String, HostError>;
/// `ns.vault_read(path)`: the note's text, or JSON `{"error": ...}`.
///
/// # Errors
///
/// [`HostError`] when the path is rejected or the note is unreadable.
fn vault_read(&self, path: &str) -> std::result::Result<String, HostError>;
/// `ns.vault_write(path, content)`: creates or replaces a note.
///
/// # Errors
///
/// [`HostError`] when the path is rejected or the vault is not writable.
fn vault_write(&self, path: &str, content: &str) -> std::result::Result<String, HostError>;
/// `ns.vault_list(glob)`: JSON array of vault-relative paths.
///
/// # Errors
///
/// [`HostError`] when the glob is malformed or the vault is unreadable.
fn vault_list(&self, glob: &str) -> std::result::Result<String, HostError>;
/// `ns.search(query)`: JSON array of hits.
///
/// # Errors
///
/// [`HostError`] when the query is malformed or the index is unavailable.
fn search(&self, query: &str) -> std::result::Result<String, HostError>;
/// `ns.links(path)`: JSON array of outgoing links.
///
/// # Errors
///
/// [`HostError`] when the path is rejected or the index is unavailable.
fn links(&self, path: &str) -> std::result::Result<String, HostError>;
/// `ns.backlinks(path)`: JSON array of notes linking here.
///
/// # Errors
///
/// [`HostError`] when the path is rejected or the index is unavailable.
fn backlinks(&self, path: &str) -> std::result::Result<String, HostError>;
/// `ns.active_note()`: JSON `{"path": ...}` for the focused note.
///
/// # Errors
///
/// [`HostError`] when there is no focused note.
fn active_note(&self) -> std::result::Result<String, HostError>;
/// `ns.insert_text(text)`: inserts at the cursor of the focused note.
///
/// # Errors
///
/// [`HostError`] when there is no focused note or the edit was refused.
fn insert_text(&self, text: &str) -> std::result::Result<String, HostError>;
/// `ns.notice(text)`: shows a toast.
///
/// # Errors
///
/// [`HostError`] when no notice could be shown.
fn notice(&self, text: &str) -> std::result::Result<String, HostError>;
/// `ns.now()`: JSON `{"unix_ms": ...}`; the only clock a plugin can see.
///
/// # Errors
///
/// [`HostError`] when the clock is unavailable.
fn now(&self) -> std::result::Result<String, HostError>;
}
#[cfg(test)]
mod tests {
use super::*;
fn manifest(permissions: &[Permission]) -> Manifest {
Manifest { id: "p".into(), permissions: permissions.to_vec(), ..sample() }
}
fn sample() -> Manifest {
Manifest {
id: "p".into(),
name: "P".into(),
version: "1".into(),
min_app_version: "1".into(),
description: String::new(),
author: String::new(),
permissions: Vec::new(),
commands: Vec::new(),
}
}
#[test]
fn every_host_call_has_a_permission_rule() {
for call in HOST_CALLS {
let _ = required_permission(call);
}
assert_eq!(HOST_CALLS.len(), 11);
}
#[test]
fn denied_without_the_permission() {
let m = manifest(&[]);
assert!(matches!(check_permission(&m, "vault_read"), Err(Error::PermissionDenied { .. })));
assert!(matches!(check_permission(&m, "insert_text"), Err(Error::PermissionDenied { .. })));
assert!(check_permission(&m, "log").is_ok());
assert!(check_permission(&m, "notice").is_ok());
assert!(check_permission(&m, "now").is_ok());
}
#[test]
fn granted_permissions_match_spec() {
let m = manifest(&[Permission::VaultRead]);
for call in ["vault_read", "vault_list", "search", "links", "backlinks"] {
assert!(check_permission(&m, call).is_ok(), "{call}");
}
assert!(check_permission(&m, "vault_write").is_err());
let m = manifest(&[Permission::VaultWrite, Permission::Editor]);
assert!(check_permission(&m, "vault_write").is_ok());
assert!(check_permission(&m, "insert_text").is_ok());
assert!(check_permission(&m, "active_note").is_ok());
}
#[test]
fn host_error_keeps_its_message() {
let e = HostError::from("no such note");
assert_eq!(e.message(), "no such note");
assert_eq!(e.to_string(), "no such note");
assert_eq!(HostError::new(String::from("x")), HostError::from("x"));
}
}