use std::sync::Arc;
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use crate::{PluginCapabilities, PluginError, PluginResult, WriteProvenance, validate_plugin_id};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct VaultDescriptor {
pub name: String,
pub write_backend: String,
}
impl VaultDescriptor {
pub fn new(name: impl Into<String>, write_backend: impl Into<String>) -> Self {
Self {
name: name.into(),
write_backend: write_backend.into(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct NoteSnapshot {
pub vault: String,
pub path: String,
pub content: String,
pub version: String,
}
impl NoteSnapshot {
pub fn new(
vault: impl Into<String>,
path: impl Into<String>,
content: impl Into<String>,
version: impl Into<String>,
) -> Self {
Self {
vault: vault.into(),
path: path.into(),
content: content.into(),
version: version.into(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case", tag = "kind", content = "version")]
pub enum WritePrecondition {
CreateOnly,
Match(String),
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct WriteNoteRequest {
pub vault: String,
pub path: String,
pub content: String,
pub precondition: WritePrecondition,
pub commit_message: Option<String>,
pub provenance: Option<WriteProvenance>,
}
impl WriteNoteRequest {
pub fn new(
vault: impl Into<String>,
path: impl Into<String>,
content: impl Into<String>,
precondition: WritePrecondition,
) -> Self {
Self {
vault: vault.into(),
path: path.into(),
content: content.into(),
precondition,
commit_message: None,
provenance: None,
}
}
pub fn with_commit_message(mut self, message: impl Into<String>) -> Self {
self.commit_message = Some(message.into());
self
}
pub fn with_provenance(mut self, provenance: WriteProvenance) -> Self {
self.provenance = Some(provenance);
self
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct WriteReceipt {
pub vault: String,
pub path: String,
pub version: String,
pub bytes: usize,
}
impl WriteReceipt {
pub fn new(
vault: impl Into<String>,
path: impl Into<String>,
version: impl Into<String>,
bytes: usize,
) -> Self {
Self {
vault: vault.into(),
path: path.into(),
version: version.into(),
bytes,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct NoteListing {
pub path: String,
pub size_bytes: u64,
pub modified_ms: Option<u64>,
}
impl NoteListing {
pub fn new(path: impl Into<String>, size_bytes: u64, modified_ms: Option<u64>) -> Self {
Self {
path: path.into(),
size_bytes,
modified_ms,
}
}
pub fn looks_unchanged_from(&self, previous: &Self) -> bool {
self.size_bytes == previous.size_bytes
&& self.modified_ms.is_some()
&& self.modified_ms == previous.modified_ms
}
}
#[async_trait]
pub trait VaultHost: Send + Sync {
async fn active_vault(&self) -> PluginResult<VaultDescriptor>;
async fn list_notes(&self, vault: &str) -> PluginResult<Vec<String>>;
async fn list_notes_detailed(&self, vault: &str) -> PluginResult<Vec<NoteListing>>;
async fn read_note(&self, vault: &str, path: &str) -> PluginResult<NoteSnapshot>;
async fn read_notes(&self, vault: &str, paths: &[String]) -> PluginResult<Vec<NoteSnapshot>>;
async fn write_note(&self, request: WriteNoteRequest) -> PluginResult<WriteReceipt>;
async fn read_config(&self, vault: &str, relative_path: &str) -> PluginResult<Option<Vec<u8>>>;
}
#[derive(Debug, Clone)]
pub struct PluginIdentity {
id: String,
capabilities: PluginCapabilities,
}
impl PluginIdentity {
pub fn new(id: impl Into<String>, capabilities: PluginCapabilities) -> PluginResult<Self> {
let id = id.into();
validate_plugin_id(&id)?;
capabilities.validate()?;
Ok(Self { id, capabilities })
}
pub fn id(&self) -> &str {
&self.id
}
pub fn capabilities(&self) -> &PluginCapabilities {
&self.capabilities
}
}
#[derive(Clone)]
pub struct VaultApi {
host: Arc<dyn VaultHost>,
identity: Arc<PluginIdentity>,
}
impl std::fmt::Debug for VaultApi {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("VaultApi")
.field("plugin", &self.identity.id)
.finish_non_exhaustive()
}
}
impl VaultApi {
pub fn new(host: Arc<dyn VaultHost>, identity: PluginIdentity) -> Self {
Self {
host,
identity: Arc::new(identity),
}
}
pub fn identity(&self) -> &PluginIdentity {
&self.identity
}
pub async fn active_vault(&self) -> PluginResult<VaultDescriptor> {
self.host.active_vault().await
}
pub async fn list_notes(&self, vault: &str) -> PluginResult<Vec<String>> {
self.host.list_notes(vault).await
}
pub async fn list_notes_detailed(&self, vault: &str) -> PluginResult<Vec<NoteListing>> {
self.host.list_notes_detailed(vault).await
}
pub async fn read_note(&self, vault: &str, path: &str) -> PluginResult<NoteSnapshot> {
self.host.read_note(vault, path).await
}
pub async fn read_notes(
&self,
vault: &str,
paths: &[String],
) -> PluginResult<Vec<NoteSnapshot>> {
self.host.read_notes(vault, paths).await
}
pub async fn write_note(&self, request: WriteNoteRequest) -> PluginResult<WriteReceipt> {
self.host.write_note(request).await
}
pub async fn read_config(&self, vault: &str, path: &str) -> PluginResult<Option<Vec<u8>>> {
let normalized = normalize_config_path(path)?;
if !self
.identity
.capabilities
.config_reads
.iter()
.any(|declared| declared == &normalized)
{
return Err(PluginError::permission_denied(format!(
"plugin {:?} did not declare {normalized:?} in config_reads",
self.identity.id
)));
}
self.host.read_config(vault, &normalized).await
}
}
pub(crate) fn normalize_config_path(path: &str) -> PluginResult<String> {
let unified = path.replace('\\', "/");
if unified.is_empty() {
return Err(PluginError::invalid_input("config path must not be empty"));
}
if unified.starts_with('/') {
return Err(PluginError::invalid_input(format!(
"config path {path:?} must be vault-relative"
)));
}
let mut segments = Vec::new();
for segment in unified.split('/') {
match segment {
"" | "." => continue,
".." => {
return Err(PluginError::invalid_input(format!(
"config path {path:?} must not contain '..'"
)));
}
other => segments.push(other),
}
}
let normalized = segments.join("/");
if !normalized.starts_with(".obsidian/") {
return Err(PluginError::invalid_input(format!(
"config path {path:?} must live under '.obsidian/'"
)));
}
Ok(normalized)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn config_paths_normalize_to_a_single_comparable_form() {
assert_eq!(
normalize_config_path(".obsidian/plugins/tasks/data.json").expect("plain path"),
".obsidian/plugins/tasks/data.json"
);
assert_eq!(
normalize_config_path(".obsidian\\plugins\\tasks\\data.json").expect("windows path"),
".obsidian/plugins/tasks/data.json"
);
assert_eq!(
normalize_config_path(".obsidian/./plugins//tasks/data.json").expect("noisy path"),
".obsidian/plugins/tasks/data.json"
);
}
#[test]
fn config_paths_reject_escapes_and_foreign_space() {
for rejected in [
"",
"/etc/passwd",
".obsidian/../.git/config",
"..",
"notes/note.md",
".turbovault/audit/operations.jsonl",
] {
let error = normalize_config_path(rejected).expect_err("must reject");
assert_eq!(error.code, crate::PluginErrorCode::InvalidInput);
}
}
}