Skip to main content

turbovault_plugin_api/
vault.rs

1use std::sync::Arc;
2
3use async_trait::async_trait;
4use serde::{Deserialize, Serialize};
5
6use crate::{PluginCapabilities, PluginError, PluginResult, WriteProvenance, validate_plugin_id};
7
8/// Public identity of a vault the host has registered.
9#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
10#[non_exhaustive]
11pub struct VaultDescriptor {
12    /// Host-configured vault name. Pass this to every [`VaultApi`] operation.
13    pub name: String,
14    /// Write substrate selected for this vault.
15    pub write_backend: String,
16}
17
18impl VaultDescriptor {
19    /// Construct a descriptor.
20    pub fn new(name: impl Into<String>, write_backend: impl Into<String>) -> Self {
21        Self {
22            name: name.into(),
23            write_backend: write_backend.into(),
24        }
25    }
26}
27
28/// A note plus the backend-native version token used for safe writes.
29#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
30#[non_exhaustive]
31pub struct NoteSnapshot {
32    /// Vault containing the note.
33    pub vault: String,
34    /// Vault-relative path using `/` separators.
35    pub path: String,
36    /// Complete markdown content.
37    pub content: String,
38    /// Opaque token accepted by [`WritePrecondition::Match`].
39    pub version: String,
40}
41
42impl NoteSnapshot {
43    /// Construct a snapshot.
44    pub fn new(
45        vault: impl Into<String>,
46        path: impl Into<String>,
47        content: impl Into<String>,
48        version: impl Into<String>,
49    ) -> Self {
50        Self {
51            vault: vault.into(),
52            path: path.into(),
53            content: content.into(),
54            version: version.into(),
55        }
56    }
57}
58
59/// Required optimistic-concurrency condition for a plugin write.
60///
61/// # Guarantees per backend
62///
63/// On a `git` vault both conditions are enforced inside the substrate's commit
64/// section against the tree being committed, so they are genuinely atomic — a
65/// racing write loses the ref CAS and the whole changeset aborts.
66///
67/// On a `direct` vault [`Self::CreateOnly`] is enforced by an exclusive file
68/// creation, so it is also atomic. [`Self::Match`] is a
69/// read-compare-then-write against the working tree: it reliably catches a
70/// change that happened before the comparison, but two writers that interleave
71/// inside that window can still both proceed. Callers needing a hard guarantee
72/// should use a git-backed vault.
73// Deliberately NOT `#[non_exhaustive]`, unlike the rest of this crate's public
74// types. This is a closed vocabulary that the host must implement completely:
75// a new precondition is a change to what "a safe write" means, and both sides
76// should be forced to acknowledge it rather than fall into a catch-all arm.
77#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
78#[serde(rename_all = "snake_case", tag = "kind", content = "version")]
79pub enum WritePrecondition {
80    /// Refuse if the target already exists.
81    CreateOnly,
82    /// Replace only the exact version previously returned by [`VaultApi::read_note`].
83    Match(String),
84}
85
86/// Safe full-note write request.
87///
88/// Construct with [`Self::new`]; the optional fields have builder setters. The
89/// type is `#[non_exhaustive]` so the contract can gain fields without breaking
90/// plugins that build requests through the constructor.
91#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
92#[non_exhaustive]
93pub struct WriteNoteRequest {
94    /// Vault to write to.
95    ///
96    /// Required, and checked by the host. TurboVault serves one active vault at
97    /// a time and that selection can change between a plugin's read and its
98    /// write; naming the vault turns that race into a refusal instead of a
99    /// write landing in the wrong vault.
100    pub vault: String,
101    /// Vault-relative note path.
102    pub path: String,
103    /// Complete replacement content.
104    pub content: String,
105    /// Mandatory create-or-CAS condition; blind overwrites are not exposed.
106    pub precondition: WritePrecondition,
107    /// Optional Git commit subject. Hosts may require it by policy.
108    pub commit_message: Option<String>,
109    /// Optional best-effort writer identity for hook correlation.
110    ///
111    /// This metadata is advisory and is not an authorization boundary. The
112    /// host separately stamps the calling plugin's namespace onto the event it
113    /// publishes, and that stamp is the trustworthy one.
114    pub provenance: Option<WriteProvenance>,
115}
116
117impl WriteNoteRequest {
118    /// Construct a write request for `vault`.
119    pub fn new(
120        vault: impl Into<String>,
121        path: impl Into<String>,
122        content: impl Into<String>,
123        precondition: WritePrecondition,
124    ) -> Self {
125        Self {
126            vault: vault.into(),
127            path: path.into(),
128            content: content.into(),
129            precondition,
130            commit_message: None,
131            provenance: None,
132        }
133    }
134
135    /// Set the Git commit subject.
136    pub fn with_commit_message(mut self, message: impl Into<String>) -> Self {
137        self.commit_message = Some(message.into());
138        self
139    }
140
141    /// Set advisory writer provenance.
142    pub fn with_provenance(mut self, provenance: WriteProvenance) -> Self {
143        self.provenance = Some(provenance);
144        self
145    }
146}
147
148/// Result of a successful plugin write.
149#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
150#[non_exhaustive]
151pub struct WriteReceipt {
152    /// Vault containing the note.
153    pub vault: String,
154    /// Vault-relative path that was written.
155    pub path: String,
156    /// New backend-native version token, read back from the stored note.
157    pub version: String,
158    /// Number of content bytes stored.
159    pub bytes: usize,
160}
161
162impl WriteReceipt {
163    /// Construct a receipt.
164    pub fn new(
165        vault: impl Into<String>,
166        path: impl Into<String>,
167        version: impl Into<String>,
168        bytes: usize,
169    ) -> Self {
170        Self {
171            vault: vault.into(),
172            path: path.into(),
173            version: version.into(),
174            bytes,
175        }
176    }
177}
178
179/// A note's identity and change-detection metadata, without its content.
180///
181/// This is what makes reconciliation affordable. A consumer maintaining
182/// derived state stores `(size_bytes, modified_ms)` alongside each entry and
183/// re-reads only the notes whose values moved — one stat per note instead of
184/// one full read per note, which is the difference between a few milliseconds
185/// and a full corpus scan on a large vault.
186#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
187#[non_exhaustive]
188pub struct NoteListing {
189    /// Vault-relative path using `/` separators.
190    pub path: String,
191    /// Size in bytes when the vault was scanned.
192    pub size_bytes: u64,
193    /// Last-modified time as Unix epoch milliseconds, when the platform
194    /// reports one.
195    ///
196    /// Treat this as a change hint, not a clock: filesystems vary in
197    /// resolution and a restored file can carry an older timestamp than the
198    /// content it replaced. Pair it with `size_bytes`, and fall back to a
199    /// version comparison when correctness matters more than the extra read.
200    pub modified_ms: Option<u64>,
201}
202
203impl NoteListing {
204    /// Construct a listing entry.
205    pub fn new(path: impl Into<String>, size_bytes: u64, modified_ms: Option<u64>) -> Self {
206        Self {
207            path: path.into(),
208            size_bytes,
209            modified_ms,
210        }
211    }
212
213    /// Whether this entry looks unchanged from a previously recorded one.
214    ///
215    /// A cheap pre-filter, not proof: equal size and timestamp are strong
216    /// evidence nothing happened, but a same-size edit within one timestamp
217    /// tick is invisible here. Consumers that cannot tolerate a missed change
218    /// should combine this with the event feed, which reports the write
219    /// regardless.
220    pub fn looks_unchanged_from(&self, previous: &Self) -> bool {
221        self.size_bytes == previous.size_bytes
222            && self.modified_ms.is_some()
223            && self.modified_ms == previous.modified_ms
224    }
225}
226
227/// Host implementation behind [`VaultApi`].
228///
229/// The trait is public so plugin crates can provide small fakes in their own
230/// tests. Production implementations remain owned by the TurboVault host.
231///
232/// Capability enforcement does NOT live here — [`VaultApi`] checks a plugin's
233/// declared capabilities before delegating, so every host implementation gets
234/// the same enforcement and no host can forget it.
235#[async_trait]
236pub trait VaultHost: Send + Sync {
237    /// Return the currently selected vault's identity.
238    async fn active_vault(&self) -> PluginResult<VaultDescriptor>;
239
240    /// List markdown note paths in `vault`.
241    async fn list_notes(&self, vault: &str) -> PluginResult<Vec<String>>;
242
243    /// List notes in `vault` with change-detection metadata.
244    async fn list_notes_detailed(&self, vault: &str) -> PluginResult<Vec<NoteListing>>;
245
246    /// Read a complete note and its opaque version from `vault`.
247    async fn read_note(&self, vault: &str, path: &str) -> PluginResult<NoteSnapshot>;
248
249    /// Read several notes in one call.
250    ///
251    /// Implementations resolve the vault once for the whole batch. A path that
252    /// cannot be read is omitted rather than failing the batch, so one deleted
253    /// note does not sink a reconciliation pass over thousands.
254    async fn read_notes(&self, vault: &str, paths: &[String]) -> PluginResult<Vec<NoteSnapshot>>;
255
256    /// Create or compare-and-swap a complete note.
257    async fn write_note(&self, request: WriteNoteRequest) -> PluginResult<WriteReceipt>;
258
259    /// Read one application-config file, bypassing the note-API path policy.
260    ///
261    /// [`VaultApi::read_config`] has already checked the path against the
262    /// calling plugin's declared capability, so implementations only enforce
263    /// the vault boundary (including symlink escapes) and read the bytes.
264    /// Return `Ok(None)` when the file does not exist.
265    async fn read_config(&self, vault: &str, relative_path: &str) -> PluginResult<Option<Vec<u8>>>;
266}
267
268/// The calling plugin's namespace and what it declared it needs.
269///
270/// Built by the host from the mounted plugin's validated descriptor; a plugin
271/// cannot fabricate one for itself.
272#[derive(Debug, Clone)]
273pub struct PluginIdentity {
274    id: String,
275    capabilities: PluginCapabilities,
276}
277
278impl PluginIdentity {
279    /// Bind a validated plugin namespace to its declared capabilities.
280    ///
281    /// Returns an error if the namespace or any declared capability is
282    /// invalid, so a malformed declaration stops the plugin from mounting
283    /// rather than failing later at the first call.
284    pub fn new(id: impl Into<String>, capabilities: PluginCapabilities) -> PluginResult<Self> {
285        let id = id.into();
286        validate_plugin_id(&id)?;
287        capabilities.validate()?;
288        Ok(Self { id, capabilities })
289    }
290
291    /// The plugin's MCP namespace.
292    pub fn id(&self) -> &str {
293        &self.id
294    }
295
296    /// What the plugin declared it needs.
297    pub fn capabilities(&self) -> &PluginCapabilities {
298        &self.capabilities
299    }
300}
301
302/// Cloneable, curated facade supplied to every plugin.
303///
304/// The host builds one of these PER PLUGIN, bound to that plugin's identity, so
305/// a capability granted to one plugin is not reachable from another.
306#[derive(Clone)]
307pub struct VaultApi {
308    host: Arc<dyn VaultHost>,
309    identity: Arc<PluginIdentity>,
310}
311
312impl std::fmt::Debug for VaultApi {
313    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
314        formatter
315            .debug_struct("VaultApi")
316            .field("plugin", &self.identity.id)
317            .finish_non_exhaustive()
318    }
319}
320
321impl VaultApi {
322    /// Wrap a host implementation for one specific plugin.
323    pub fn new(host: Arc<dyn VaultHost>, identity: PluginIdentity) -> Self {
324        Self {
325            host,
326            identity: Arc::new(identity),
327        }
328    }
329
330    /// The calling plugin's identity, as the host validated it.
331    pub fn identity(&self) -> &PluginIdentity {
332        &self.identity
333    }
334
335    /// Return the currently selected vault's identity.
336    ///
337    /// The selection can change between calls. Capture the name once and pass
338    /// it to subsequent operations rather than re-resolving per call.
339    pub async fn active_vault(&self) -> PluginResult<VaultDescriptor> {
340        self.host.active_vault().await
341    }
342
343    /// List markdown note paths in `vault`.
344    pub async fn list_notes(&self, vault: &str) -> PluginResult<Vec<String>> {
345        self.host.list_notes(vault).await
346    }
347
348    /// List notes in `vault` with change-detection metadata.
349    ///
350    /// The reconciliation half of the change contract: [`crate::HookBus`] is
351    /// the fast path for changes while a plugin is running, and this is how a
352    /// plugin catches up on everything the feed could not tell it about —
353    /// changes made while the process was down, and changes lost to
354    /// [`crate::HookRecvError::Lagged`]. Compare each entry against what you
355    /// stored and re-read only what moved.
356    pub async fn list_notes_detailed(&self, vault: &str) -> PluginResult<Vec<NoteListing>> {
357        self.host.list_notes_detailed(vault).await
358    }
359
360    /// Read a complete note and its opaque version from `vault`.
361    pub async fn read_note(&self, vault: &str, path: &str) -> PluginResult<NoteSnapshot> {
362        self.host.read_note(vault, path).await
363    }
364
365    /// Read several notes in one call.
366    ///
367    /// Prefer this to a loop over [`Self::read_note`] when reconciling: the
368    /// host resolves the vault once for the whole batch instead of once per
369    /// note. Paths that cannot be read are omitted, so a note deleted between
370    /// listing and reading does not fail the pass.
371    pub async fn read_notes(
372        &self,
373        vault: &str,
374        paths: &[String],
375    ) -> PluginResult<Vec<NoteSnapshot>> {
376        self.host.read_notes(vault, paths).await
377    }
378
379    /// Create or compare-and-swap a complete note.
380    pub async fn write_note(&self, request: WriteNoteRequest) -> PluginResult<WriteReceipt> {
381        self.host.write_note(request).await
382    }
383
384    /// Read one application-config file this plugin declared it needs.
385    ///
386    /// The note APIs cannot reach `.obsidian/` — that space holds code the
387    /// application executes, so it is not part of the note surface. A plugin
388    /// that genuinely needs to self-tune to app settings declares the exact
389    /// files it reads via [`PluginCapabilities::config_reads`], and this method
390    /// serves only those. Anything else is
391    /// [`PluginErrorCode::PermissionDenied`](crate::PluginErrorCode::PermissionDenied).
392    ///
393    /// Returns `Ok(None)` when a permitted file does not exist.
394    pub async fn read_config(&self, vault: &str, path: &str) -> PluginResult<Option<Vec<u8>>> {
395        let normalized = normalize_config_path(path)?;
396        if !self
397            .identity
398            .capabilities
399            .config_reads
400            .iter()
401            .any(|declared| declared == &normalized)
402        {
403            return Err(PluginError::permission_denied(format!(
404                "plugin {:?} did not declare {normalized:?} in config_reads",
405                self.identity.id
406            )));
407        }
408        self.host.read_config(vault, &normalized).await
409    }
410}
411
412/// Normalize and reject a config path before it is matched against a
413/// declaration.
414///
415/// Matching happens on the normalized form so that `.obsidian/./x.json` and
416/// `.obsidian\x.json` cannot slip past a declaration of `.obsidian/x.json`, and
417/// so a declaration can never be satisfied by a path that walks out of the
418/// config space.
419pub(crate) fn normalize_config_path(path: &str) -> PluginResult<String> {
420    let unified = path.replace('\\', "/");
421    if unified.is_empty() {
422        return Err(PluginError::invalid_input("config path must not be empty"));
423    }
424    if unified.starts_with('/') {
425        return Err(PluginError::invalid_input(format!(
426            "config path {path:?} must be vault-relative"
427        )));
428    }
429    let mut segments = Vec::new();
430    for segment in unified.split('/') {
431        match segment {
432            "" | "." => continue,
433            ".." => {
434                return Err(PluginError::invalid_input(format!(
435                    "config path {path:?} must not contain '..'"
436                )));
437            }
438            other => segments.push(other),
439        }
440    }
441    let normalized = segments.join("/");
442    if !normalized.starts_with(".obsidian/") {
443        return Err(PluginError::invalid_input(format!(
444            "config path {path:?} must live under '.obsidian/'"
445        )));
446    }
447    Ok(normalized)
448}
449
450#[cfg(test)]
451mod tests {
452    use super::*;
453
454    #[test]
455    fn config_paths_normalize_to_a_single_comparable_form() {
456        assert_eq!(
457            normalize_config_path(".obsidian/plugins/tasks/data.json").expect("plain path"),
458            ".obsidian/plugins/tasks/data.json"
459        );
460        assert_eq!(
461            normalize_config_path(".obsidian\\plugins\\tasks\\data.json").expect("windows path"),
462            ".obsidian/plugins/tasks/data.json"
463        );
464        assert_eq!(
465            normalize_config_path(".obsidian/./plugins//tasks/data.json").expect("noisy path"),
466            ".obsidian/plugins/tasks/data.json"
467        );
468    }
469
470    #[test]
471    fn config_paths_reject_escapes_and_foreign_space() {
472        for rejected in [
473            "",
474            "/etc/passwd",
475            ".obsidian/../.git/config",
476            "..",
477            "notes/note.md",
478            ".turbovault/audit/operations.jsonl",
479        ] {
480            let error = normalize_config_path(rejected).expect_err("must reject");
481            assert_eq!(error.code, crate::PluginErrorCode::InvalidInput);
482        }
483    }
484}