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}