gemini_memory_rs/okf/
store.rs

1//! The byte-level store the OKF repository is projected onto.
2//!
3//! Separating "which files exist and what is in them" from "what a memory
4//! means" lets the repository be exercised without touching a disk, and lets
5//! production swap the filesystem for object storage without the repository
6//! noticing.
7
8use async_trait::async_trait;
9use std::collections::BTreeMap;
10use std::path::{Component, Path, PathBuf};
11
12use crate::core::MemoryError;
13
14/// A flat, path-addressed store of UTF-8 documents.
15#[async_trait]
16pub trait OkfStore: Send + Sync {
17    /// Read a document, or `None` when it does not exist.
18    async fn read(&self, path: &str) -> Result<Option<String>, MemoryError>;
19
20    /// Write a document, creating parents as needed.
21    async fn write(&self, path: &str, contents: &str) -> Result<(), MemoryError>;
22
23    /// Remove a document. Removing a missing document is not an error.
24    async fn remove(&self, path: &str) -> Result<(), MemoryError>;
25
26    /// List every document path under a prefix.
27    async fn list(&self, prefix: &str) -> Result<Vec<String>, MemoryError>;
28}
29
30/// An in-process store — the default for tests and ephemeral sessions.
31#[derive(Debug, Default)]
32pub struct MemoryStore {
33    files: parking_lot::RwLock<BTreeMap<String, String>>,
34}
35
36impl MemoryStore {
37    /// An empty store.
38    pub fn new() -> Self {
39        Self::default()
40    }
41
42    /// Every path currently held.
43    pub fn paths(&self) -> Vec<String> {
44        self.files.read().keys().cloned().collect()
45    }
46}
47
48#[async_trait]
49impl OkfStore for MemoryStore {
50    async fn read(&self, path: &str) -> Result<Option<String>, MemoryError> {
51        Ok(self.files.read().get(path).cloned())
52    }
53
54    async fn write(&self, path: &str, contents: &str) -> Result<(), MemoryError> {
55        reject_traversal(path)?;
56        self.files
57            .write()
58            .insert(path.to_string(), contents.to_string());
59        Ok(())
60    }
61
62    async fn remove(&self, path: &str) -> Result<(), MemoryError> {
63        self.files.write().remove(path);
64        Ok(())
65    }
66
67    async fn list(&self, prefix: &str) -> Result<Vec<String>, MemoryError> {
68        Ok(self
69            .files
70            .read()
71            .keys()
72            .filter(|k| k.starts_with(prefix))
73            .cloned()
74            .collect())
75    }
76}
77
78/// A filesystem-backed store rooted at a directory.
79///
80/// Every path is validated against traversal before it touches the filesystem —
81/// memory paths are derived from user and record identifiers, and identifiers
82/// must never be able to escape their own namespace.
83#[derive(Debug, Clone)]
84pub struct FsStore {
85    root: PathBuf,
86}
87
88impl FsStore {
89    /// Root the store at `root`.
90    pub fn new(root: impl Into<PathBuf>) -> Self {
91        Self { root: root.into() }
92    }
93
94    /// The directory this store writes under.
95    pub fn root(&self) -> &Path {
96        &self.root
97    }
98
99    fn resolve(&self, path: &str) -> Result<PathBuf, MemoryError> {
100        reject_traversal(path)?;
101        Ok(self.root.join(path))
102    }
103}
104
105#[async_trait]
106impl OkfStore for FsStore {
107    async fn read(&self, path: &str) -> Result<Option<String>, MemoryError> {
108        let full = self.resolve(path)?;
109        match tokio::fs::read_to_string(&full).await {
110            Ok(contents) => Ok(Some(contents)),
111            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
112            Err(e) => Err(MemoryError::Storage(format!("{}: {e}", full.display()))),
113        }
114    }
115
116    async fn write(&self, path: &str, contents: &str) -> Result<(), MemoryError> {
117        let full = self.resolve(path)?;
118        if let Some(parent) = full.parent() {
119            tokio::fs::create_dir_all(parent)
120                .await
121                .map_err(|e| MemoryError::Storage(format!("{}: {e}", parent.display())))?;
122        }
123        // Write-then-rename so a crash mid-write cannot leave a half-written
124        // canonical record behind.
125        let temp = full.with_extension("tmp");
126        tokio::fs::write(&temp, contents)
127            .await
128            .map_err(|e| MemoryError::Storage(format!("{}: {e}", temp.display())))?;
129        tokio::fs::rename(&temp, &full)
130            .await
131            .map_err(|e| MemoryError::Storage(format!("{}: {e}", full.display())))?;
132        Ok(())
133    }
134
135    async fn remove(&self, path: &str) -> Result<(), MemoryError> {
136        let full = self.resolve(path)?;
137        match tokio::fs::remove_file(&full).await {
138            Ok(()) => Ok(()),
139            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
140            Err(e) => Err(MemoryError::Storage(format!("{}: {e}", full.display()))),
141        }
142    }
143
144    async fn list(&self, prefix: &str) -> Result<Vec<String>, MemoryError> {
145        reject_traversal(prefix)?;
146        let mut out = Vec::new();
147        let mut stack = vec![self.root.clone()];
148        while let Some(dir) = stack.pop() {
149            let mut entries = match tokio::fs::read_dir(&dir).await {
150                Ok(entries) => entries,
151                Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
152                Err(e) => return Err(MemoryError::Storage(format!("{}: {e}", dir.display()))),
153            };
154            while let Some(entry) = entries
155                .next_entry()
156                .await
157                .map_err(|e| MemoryError::Storage(e.to_string()))?
158            {
159                let path = entry.path();
160                if path.is_dir() {
161                    stack.push(path);
162                    continue;
163                }
164                if let Ok(relative) = path.strip_prefix(&self.root) {
165                    let relative = relative.to_string_lossy().replace('\\', "/");
166                    if relative.starts_with(prefix) {
167                        out.push(relative);
168                    }
169                }
170            }
171        }
172        out.sort();
173        Ok(out)
174    }
175}
176
177/// Refuse absolute paths and any `..` component.
178fn reject_traversal(path: &str) -> Result<(), MemoryError> {
179    let candidate = Path::new(path);
180    if candidate.is_absolute() {
181        return Err(MemoryError::PolicyRefused(format!(
182            "absolute memory path `{path}` refused"
183        )));
184    }
185    for component in candidate.components() {
186        if matches!(component, Component::ParentDir | Component::RootDir) {
187            return Err(MemoryError::PolicyRefused(format!(
188                "memory path `{path}` escapes its namespace"
189            )));
190        }
191    }
192    Ok(())
193}
194
195#[cfg(test)]
196mod tests {
197    use super::*;
198
199    #[tokio::test]
200    async fn memory_store_round_trips_and_lists_by_prefix() {
201        let store = MemoryStore::new();
202        store.write("users/a/profile.md", "one").await.unwrap();
203        store.write("users/b/profile.md", "two").await.unwrap();
204
205        assert_eq!(
206            store.read("users/a/profile.md").await.unwrap().as_deref(),
207            Some("one")
208        );
209        assert_eq!(store.list("users/a/").await.unwrap().len(), 1);
210
211        store.remove("users/a/profile.md").await.unwrap();
212        assert!(store.read("users/a/profile.md").await.unwrap().is_none());
213        // Removing twice is not an error.
214        store.remove("users/a/profile.md").await.unwrap();
215    }
216
217    #[tokio::test]
218    async fn traversal_is_refused_before_it_reaches_the_filesystem() {
219        let store = MemoryStore::new();
220        let err = store.write("../../etc/passwd", "x").await.unwrap_err();
221        assert!(matches!(err, MemoryError::PolicyRefused(_)));
222        assert!(store.write("/etc/passwd", "x").await.is_err());
223    }
224
225    #[tokio::test]
226    async fn fs_store_round_trips_through_a_real_directory() {
227        let root = std::env::temp_dir().join(format!("okf-test-{}", uuid::Uuid::new_v4()));
228        let store = FsStore::new(&root);
229
230        store
231            .write("users/usr_1/preferences.md", "hello")
232            .await
233            .unwrap();
234        assert_eq!(
235            store
236                .read("users/usr_1/preferences.md")
237                .await
238                .unwrap()
239                .as_deref(),
240            Some("hello")
241        );
242        assert_eq!(
243            store.list("users/").await.unwrap(),
244            vec!["users/usr_1/preferences.md"]
245        );
246        assert!(
247            store
248                .read("users/usr_1/missing.md")
249                .await
250                .unwrap()
251                .is_none()
252        );
253
254        store.remove("users/usr_1/preferences.md").await.unwrap();
255        assert!(store.list("users/").await.unwrap().is_empty());
256
257        let _ = tokio::fs::remove_dir_all(&root).await;
258    }
259}