Skip to main content

turbovault_plugin_api/
storage.rs

1use std::sync::Arc;
2
3use async_trait::async_trait;
4
5use crate::{PluginError, PluginResult};
6
7/// Host implementation behind [`PluginStorage`].
8///
9/// The trait is public so plugin crates can provide in-memory fakes in their
10/// own tests. Keys reaching an implementation have already been validated by
11/// [`PluginStorage`], and namespacing is the host's responsibility.
12#[async_trait]
13pub trait PluginStore: Send + Sync {
14    /// Read a value, or `None` when the key was never written.
15    async fn get(&self, vault: &str, key: &str) -> PluginResult<Option<Vec<u8>>>;
16
17    /// Write a value, replacing any previous one.
18    async fn put(&self, vault: &str, key: &str, value: &[u8]) -> PluginResult<()>;
19
20    /// Remove a value. Removing a key that does not exist succeeds.
21    async fn delete(&self, vault: &str, key: &str) -> PluginResult<()>;
22
23    /// List keys beginning with `prefix`, sorted. An empty prefix lists all.
24    async fn list(&self, vault: &str, prefix: &str) -> PluginResult<Vec<String>>;
25}
26
27/// Durable, plugin-private, per-vault key/value storage.
28///
29/// A plugin that maintains derived state — an embedding index, a cached
30/// parse, a reconciliation watermark — needs somewhere to put it that is not
31/// the vault's notes. Writing derived state as notes would pollute the user's
32/// vault, the link graph, and the search corpus.
33///
34/// Isolation is structural rather than declared: the host namespaces every key
35/// under the calling plugin's own space, so a plugin cannot name another
36/// plugin's data, and there is no capability to grant. Storage lives beside
37/// the vault it describes, so it travels and is discarded with that vault.
38///
39/// Values are opaque bytes. Writes are atomic at the individual key: a
40/// concurrent reader sees either the previous value or the new one, never a
41/// partial write. There is no transaction across keys.
42#[derive(Clone)]
43pub struct PluginStorage {
44    store: Arc<dyn PluginStore>,
45}
46
47impl std::fmt::Debug for PluginStorage {
48    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
49        formatter
50            .debug_struct("PluginStorage")
51            .finish_non_exhaustive()
52    }
53}
54
55impl PluginStorage {
56    /// Wrap a host implementation.
57    pub fn new(store: Arc<dyn PluginStore>) -> Self {
58        Self { store }
59    }
60
61    /// Read a value, or `None` when the key was never written.
62    pub async fn get(&self, vault: &str, key: &str) -> PluginResult<Option<Vec<u8>>> {
63        self.store.get(vault, &validate_key(key)?).await
64    }
65
66    /// Write a value, replacing any previous one.
67    pub async fn put(&self, vault: &str, key: &str, value: &[u8]) -> PluginResult<()> {
68        self.store.put(vault, &validate_key(key)?, value).await
69    }
70
71    /// Remove a value. Removing a key that does not exist succeeds.
72    pub async fn delete(&self, vault: &str, key: &str) -> PluginResult<()> {
73        self.store.delete(vault, &validate_key(key)?).await
74    }
75
76    /// List keys beginning with `prefix`, sorted. An empty prefix lists all.
77    pub async fn list(&self, vault: &str, prefix: &str) -> PluginResult<Vec<String>> {
78        // A prefix is a filter, not a key, so it may be empty and need not
79        // name an existing entry — but it must not be able to walk out of the
80        // plugin's namespace either.
81        let prefix = if prefix.is_empty() {
82            String::new()
83        } else {
84            validate_key(prefix)?
85        };
86        self.store.list(vault, &prefix).await
87    }
88}
89
90/// Reject a key that could escape the plugin's namespace.
91///
92/// Keys map onto a directory tree, so `/` is allowed as a grouping separator
93/// while anything that could climb out of, or reach outside, the namespace is
94/// not.
95pub(crate) fn validate_key(key: &str) -> PluginResult<String> {
96    if key.is_empty() {
97        return Err(PluginError::invalid_input("storage key must not be empty"));
98    }
99    if key.len() > MAX_KEY_LEN {
100        return Err(PluginError::invalid_input(format!(
101            "storage key is {} bytes; the maximum is {MAX_KEY_LEN}",
102            key.len()
103        )));
104    }
105    if key.starts_with('/') || key.starts_with('\\') {
106        return Err(PluginError::invalid_input(format!(
107            "storage key {key:?} must be relative"
108        )));
109    }
110    for segment in key.split('/') {
111        if segment.is_empty() || segment == "." || segment == ".." {
112            return Err(PluginError::invalid_input(format!(
113                "storage key {key:?} must not contain empty or relative segments"
114            )));
115        }
116        if segment
117            .chars()
118            .any(|character| character == '\\' || character == '\0' || character.is_control())
119        {
120            return Err(PluginError::invalid_input(format!(
121                "storage key {key:?} contains an unsupported character"
122            )));
123        }
124    }
125    Ok(key.to_string())
126}
127
128/// Longest accepted storage key, chosen to stay well inside the path-length
129/// limits of every supported platform once the namespace prefix is added.
130const MAX_KEY_LEN: usize = 200;
131
132#[cfg(test)]
133mod tests {
134    use super::*;
135
136    #[test]
137    fn keys_may_group_with_slashes() {
138        for valid in ["index", "embeddings/v2/shard-0.bin", "watermark.json"] {
139            assert_eq!(validate_key(valid).expect("valid key"), valid);
140        }
141    }
142
143    #[test]
144    fn keys_cannot_escape_the_namespace() {
145        for rejected in [
146            "",
147            "/absolute",
148            "..",
149            "../sibling/data",
150            "nested/../../escape",
151            "trailing/",
152            "back\\slash",
153        ] {
154            let error = validate_key(rejected).expect_err("must reject");
155            assert_eq!(error.code, crate::PluginErrorCode::InvalidInput);
156        }
157        assert!(validate_key(&"k".repeat(MAX_KEY_LEN + 1)).is_err());
158    }
159}