Skip to main content

nostr_double_ratchet_runtime/
file_storage.rs

1use crate::{Result, StorageAdapter};
2use std::collections::HashMap;
3use std::fs;
4use std::path::PathBuf;
5use std::sync::Mutex;
6
7pub struct FileStorageAdapter {
8    base_path: PathBuf,
9}
10
11impl FileStorageAdapter {
12    pub fn new(base_path: PathBuf) -> Result<Self> {
13        fs::create_dir_all(&base_path)
14            .map_err(|e| crate::Error::Storage(format!("Failed to create directory: {}", e)))?;
15        Ok(Self { base_path })
16    }
17
18    fn sanitize_key(key: &str) -> String {
19        key.replace(['/', '\\', ':'], "_")
20    }
21
22    fn key_to_path(&self, key: &str) -> PathBuf {
23        let sanitized = Self::sanitize_key(key);
24        self.base_path.join(format!("{}.json", sanitized))
25    }
26}
27
28impl StorageAdapter for FileStorageAdapter {
29    fn get(&self, key: &str) -> Result<Option<String>> {
30        let path = self.key_to_path(key);
31
32        if !path.exists() {
33            return Ok(None);
34        }
35
36        match fs::read_to_string(&path) {
37            Ok(contents) => Ok(Some(contents)),
38            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
39            Err(e) => Err(crate::Error::Storage(format!("Failed to read file: {}", e))),
40        }
41    }
42
43    fn put(&self, key: &str, value: String) -> Result<()> {
44        let path = self.key_to_path(key);
45
46        if let Some(parent) = path.parent() {
47            fs::create_dir_all(parent).map_err(|e| {
48                crate::Error::Storage(format!("Failed to create parent dir: {}", e))
49            })?;
50        }
51
52        // Atomic-ish write so other processes never observe a partially-written JSON blob.
53        // Use a unique temp file to avoid interleaving when multiple writers race.
54        let tmp_path = path.with_extension(format!("json.{}.tmp", uuid::Uuid::new_v4()));
55        fs::write(&tmp_path, value)
56            .map_err(|e| crate::Error::Storage(format!("Failed to write file: {}", e)))?;
57
58        #[cfg(windows)]
59        {
60            // `rename` on Windows won't overwrite an existing file.
61            if path.exists() {
62                fs::remove_file(&path).map_err(|e| {
63                    crate::Error::Storage(format!("Failed to replace existing file: {}", e))
64                })?;
65            }
66        }
67
68        fs::rename(&tmp_path, &path)
69            .map_err(|e| crate::Error::Storage(format!("Failed to write file: {}", e)))?;
70
71        Ok(())
72    }
73
74    fn del(&self, key: &str) -> Result<()> {
75        let path = self.key_to_path(key);
76
77        match fs::remove_file(&path) {
78            Ok(()) => Ok(()),
79            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
80            Err(e) => Err(crate::Error::Storage(format!(
81                "Failed to delete file: {}",
82                e
83            ))),
84        }
85    }
86
87    fn list(&self, prefix: &str) -> Result<Vec<String>> {
88        let mut keys = Vec::new();
89
90        // Prefix matching must use the same sanitization we apply to keys when we store them on
91        // disk; otherwise a logical key prefix like `user/` would never match a stored file name
92        // like `user_<hex>.json`.
93        let sanitized_prefix = FileStorageAdapter::sanitize_key(prefix);
94
95        let entries = fs::read_dir(&self.base_path)
96            .map_err(|e| crate::Error::Storage(format!("Failed to read directory: {}", e)))?;
97
98        for entry in entries {
99            let entry = entry
100                .map_err(|e| crate::Error::Storage(format!("Failed to read dir entry: {}", e)))?;
101
102            let file_name = entry.file_name();
103            let file_name_str = file_name.to_string_lossy();
104
105            if !file_name_str.ends_with(".json") {
106                continue;
107            }
108
109            let key = file_name_str
110                .strip_suffix(".json")
111                .unwrap_or(&file_name_str)
112                .to_string();
113
114            if prefix.is_empty() {
115                keys.push(key);
116                continue;
117            }
118
119            if key.starts_with(&sanitized_prefix) {
120                // Best-effort reconstruction of the original logical key by swapping the
121                // sanitized prefix for the caller-supplied prefix. This is correct for our current
122                // key scheme (the remainder does not contain path separators).
123                let remainder = key.strip_prefix(&sanitized_prefix).unwrap_or("");
124                keys.push(format!("{}{}", prefix, remainder));
125            }
126        }
127
128        Ok(keys)
129    }
130}
131
132pub struct DebouncedFileStorage {
133    adapter: FileStorageAdapter,
134    pending_writes: Mutex<HashMap<String, String>>,
135    last_flush: Mutex<std::time::Instant>,
136    flush_interval: std::time::Duration,
137}
138
139impl DebouncedFileStorage {
140    pub fn new(base_path: PathBuf, flush_interval_ms: u64) -> Result<Self> {
141        Ok(Self {
142            adapter: FileStorageAdapter::new(base_path)?,
143            pending_writes: Mutex::new(HashMap::new()),
144            last_flush: Mutex::new(std::time::Instant::now()),
145            flush_interval: std::time::Duration::from_millis(flush_interval_ms),
146        })
147    }
148
149    pub fn flush(&self) -> Result<()> {
150        let mut pending = self.pending_writes.lock().unwrap();
151        for (key, value) in pending.drain() {
152            self.adapter.put(&key, value)?;
153        }
154        *self.last_flush.lock().unwrap() = std::time::Instant::now();
155        Ok(())
156    }
157
158    fn maybe_flush(&self) -> Result<()> {
159        let last = *self.last_flush.lock().unwrap();
160        let pending_count = self.pending_writes.lock().unwrap().len();
161
162        if last.elapsed() >= self.flush_interval && pending_count > 0 {
163            self.flush()?;
164        }
165        Ok(())
166    }
167}
168
169impl StorageAdapter for DebouncedFileStorage {
170    fn get(&self, key: &str) -> Result<Option<String>> {
171        let pending = self.pending_writes.lock().unwrap();
172        if let Some(value) = pending.get(key) {
173            return Ok(Some(value.clone()));
174        }
175        drop(pending);
176        self.adapter.get(key)
177    }
178
179    fn put(&self, key: &str, value: String) -> Result<()> {
180        self.pending_writes
181            .lock()
182            .unwrap()
183            .insert(key.to_string(), value);
184        self.maybe_flush()
185    }
186
187    fn del(&self, key: &str) -> Result<()> {
188        self.pending_writes.lock().unwrap().remove(key);
189        self.adapter.del(key)
190    }
191
192    fn list(&self, prefix: &str) -> Result<Vec<String>> {
193        let mut keys = self.adapter.list(prefix)?;
194        let pending = self.pending_writes.lock().unwrap();
195
196        for key in pending.keys() {
197            if key.starts_with(prefix) && !keys.contains(key) {
198                keys.push(key.clone());
199            }
200        }
201
202        Ok(keys)
203    }
204}
205
206#[cfg(test)]
207mod tests {
208    use super::*;
209    use crate::StoredUserRecord;
210    use tempfile::TempDir;
211
212    #[test]
213    fn test_file_storage_adapter_basic() {
214        let temp_dir = TempDir::new().unwrap();
215        let adapter = FileStorageAdapter::new(temp_dir.path().to_path_buf()).unwrap();
216
217        assert!(adapter.get("test-key").unwrap().is_none());
218
219        adapter.put("test-key", "test-value".to_string()).unwrap();
220        assert_eq!(
221            adapter.get("test-key").unwrap(),
222            Some("test-value".to_string())
223        );
224
225        adapter.del("test-key").unwrap();
226        assert!(adapter.get("test-key").unwrap().is_none());
227    }
228
229    #[test]
230    fn test_file_storage_adapter_list() {
231        let temp_dir = TempDir::new().unwrap();
232        let adapter = FileStorageAdapter::new(temp_dir.path().to_path_buf()).unwrap();
233
234        adapter.put("user/alice", "data1".to_string()).unwrap();
235        adapter.put("user/bob", "data2".to_string()).unwrap();
236        adapter.put("invite/charlie", "data3".to_string()).unwrap();
237
238        let user_keys = adapter.list("user/").unwrap();
239        assert_eq!(user_keys.len(), 2);
240        assert!(user_keys.contains(&"user/alice".to_string()));
241        assert!(user_keys.contains(&"user/bob".to_string()));
242
243        let all_keys = adapter.list("").unwrap();
244        assert_eq!(all_keys.len(), 3);
245    }
246
247    #[test]
248    fn test_file_storage_adapter_json() {
249        let temp_dir = TempDir::new().unwrap();
250        let adapter = FileStorageAdapter::new(temp_dir.path().to_path_buf()).unwrap();
251
252        let user_record = StoredUserRecord {
253            user_id: "test-user".to_string(),
254            devices: vec![],
255            known_device_identities: vec![],
256        };
257
258        let json = serde_json::to_string(&user_record).unwrap();
259        adapter.put("user/test", json.clone()).unwrap();
260
261        let retrieved = adapter.get("user/test").unwrap().unwrap();
262        let parsed: StoredUserRecord = serde_json::from_str(&retrieved).unwrap();
263
264        assert_eq!(parsed.user_id, "test-user");
265    }
266
267    #[test]
268    fn test_debounced_storage() {
269        let temp_dir = TempDir::new().unwrap();
270        let storage = DebouncedFileStorage::new(temp_dir.path().to_path_buf(), 1000).unwrap();
271
272        storage.put("key1", "value1".to_string()).unwrap();
273
274        assert_eq!(storage.get("key1").unwrap(), Some("value1".to_string()));
275
276        assert!(storage.pending_writes.lock().unwrap().contains_key("key1"));
277
278        storage.flush().unwrap();
279
280        assert!(storage.pending_writes.lock().unwrap().is_empty());
281        assert_eq!(
282            storage.adapter.get("key1").unwrap(),
283            Some("value1".to_string())
284        );
285    }
286}