a31eb59678
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
225 lines
7.5 KiB
Rust
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"));
|
|
}
|
|
}
|